Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

开始

leveldb是由Google两位大牛开发的单机KV存储系统,涉及到了skip list、内存KV table、LRU cache管理、table文件存储、operation log系统等。开始之前先来看看Leveldb的基本框架,几大关键组件

leveldb是一种基于operation log的文件系统,是Log-Structured-Merge Tree的典型实现。LSM源自Ousterhout和Rosenblum在1991年发表的经典论文<<The Design and Implementation of a Log-Structured File System >>。

由于采用了op log,它就可以把随机的磁盘写操作,变成了对op log的append操作,因此提高了IO效率,最新的数据则存储在内存memtable中。

op log文件大小超过限定值时,就定时做check point。Leveldb会生成新的Log文件和Memtable,后台调度会将Immutable Memtable的数据导出到磁盘,形成一个新的SSTable文件。SSTable就是由内存中的数据不断导出并进行Compaction操作后形成的,而且SSTable的所有文件是一种层级结构,第一层为Level 0,第二层为Level 1,依次类推,层级逐渐增高,这也是为何称之为LevelDb的原因。

1. 一些约定

先说下代码中的一些约定:

1.1 字节序

Leveldb对于数字的存储是little-endian的,在把int32或者int64转换为char*的函数中,是按照先低位再高位的顺序存放的,也就是little-endian的。

1.2 VarInt

把一个int32或者int64格式化到字符串中,除了上面说的little-endian字节序外,大部分还是变长存储的,也就是VarInt。对于VarInt,每byte的有效存储是7bit的,用最高的8bit位来表示是否结束,如果是1就表示后面还有一个byte的数字,否则表示结束。直接见Encode和Decode函数。

在操作log中使用的是Fixed存储格式。

1.3 字符比较

是基于unsigned char的,而非char。

2. 基本数据结构

别看是基本数据结构,有些也不是那么简单的,像LRU Cache管理和Skip list那都算是leveldb的核心数据结构。

2.1 Slice

Leveldb中的基本数据结构:

  1. 包括length和一个指向外部字节数组的指针。
  2. 和string一样,允许字符串中包含’\0’。

提供一些基本接口,可以把const char和string转换为Slice;把Slice转换为string,取得数据指针const char。

2.2 Status

Leveldb 中的返回状态,将错误号和错误信息封装成Status类,统一进行处理。并定义了几种具体的返回状态,如成功或者文件不存在等。

为了节省空间Status并没有用std::string来存储错误信息,而是将返回码(code), 错误信息message及长度打包存储于一个字符串数组中。

成功状态OK 是NULL state_,否则state_ 是一个包含如下信息的数组:

1
2
3
state_[0..3] == 消息message长度 
state_[4] == 消息code
state_[5..] ==消息message

2.3 Arena

Leveldb的简单的内存池,它所作的工作十分简单,申请内存时,将申请到的内存块放入std::vector blocks_中,在Arena的生命周期结束后,统一释放掉所有申请到的内存,内部结构如图2.3-1所示。

Arena主要提供了两个申请函数:其中一个直接分配内存,另一个可以申请对齐的内存空间。

Arena没有直接调用delete/free函数,而是由Arena的析构函数统一释放所有的内存。

应该说这是和leveldb特定的应用场景相关的,比如一个memtable使用一个Arena,当memtable被释放时,由Arena统一释放其内存。

2.4 Skip list

Skip list(跳跃表)是一种可以代替平衡树的数据结构。Skip lists应用概率保证平衡,平衡树采用严格的旋转(比如平衡二叉树有左旋右旋)来保证平衡,因此Skip list比较容易实现,而且相比平衡树有着较高的运行效率。

从概率上保持数据结构的平衡比显式的保持数据结构平衡要简单的多。对于大多数应用,用skip list要比用树更自然,算法也会相对简单。由于skip list比较简单,实现起来会比较容易,虽然和平衡树有着相同的时间复杂度(O(logn)),但是skip list的常数项相对小很多。skip list在空间上也比较节省。一个节点平均只需要1.333个指针(甚至更少),并且不需要存储保持平衡的变量。

如图2.4-1所示。

在Leveldb中,skip list是实现memtable的核心数据结构,memtable的KV数据都存储在skip list中。

2.5 Cache

Leveldb内部通过双向链表实现了一个标准版的LRUCache,先上个示意图,看看几个数据之间的关系,如图2.5-1。

Leveldb实现LRUCache的几个步骤

接下来说说Leveldb实现LRUCache的几个步骤,很直观明了。

S1

定义一个LRUHandle结构体,代表cache中的元素。它包含了几个主要的成员:

1
void* value; 

这个存储的是cache的数据;

1
void (*deleter)(const Slice&, void* value);

这个是数据从Cache中清除时执行的清理函数;

后面的三个成员事关LRUCache的数据的组织结构:

1
LRUHandle *next_hash;

指向节点在hash table链表中的下一个hash(key)相同的元素,在有碰撞时Leveldb采用的是链表法。最后一个节点的next_hash为NULL。

1
LRUHandle *next, *prev;

节点在双向链表中的前驱后继节点指针,所有的cache数据都是存储在一个双向list中,最前面的是最新加入的,每次新加入的位置都是head->next。所以每次剔除的规则就是剔除list tail。

S2

Leveldb自己实现了一个hash table:HandleTable,而不是使用系统提供的hash table。这个类就是基本的hash操作:Lookup、Insert和Delete

Hash table的作用是根据key快速查找元素是否在cache中,并返回LRUHandle节点指针,由此就能快速定位节点在hash表和双向链表中的位置。

它是通过LRUHandle的成员next_hash组织起来的。

HandleTable使用LRUHandle list_存储所有的hash节点,其实就是一个二维数组,**一维是不同的hash(key),另一维则是相同hash(key)的碰撞list。

每次当hash节点数超过当前一维数组的长度后,都会做Resize操作:

1
LRUHandle** new_list = new LRUHandle*[new_length];

然后复制list_到new_list中,并删除旧的list_。

S3

基于HandleTable和LRUHandle,实现了一个标准的LRUcache,并内置了mutex保护锁,是线程安全的。

其中存储所有数据的双向链表是LRUHandle lru_,这是一个list head;

Hash表则是HandleTable table_;

S4

ShardedLRUCache类,实际上到S3,一个标准的LRU Cache已经实现了,为何还要更近一步呢?答案就是速度!

为了多线程访问,尽可能快速,减少锁开销,ShardedLRUCache内部有16个LRUCache,查找Key时首先计算key属于哪一个分片,分片的计算方法是取32位hash值的高4位,然后在相应的LRUCache中进行查找,这样就大大减少了多线程的访问锁的开销。

1
LRUCache shard_[kNumShards]

它就是一个包装类,实现都在LRUCache类中。

2.6 其它

此外还有其它几个Random、Hash、CRC32、Histogram等,都在util文件夹下,不仔细分析了。

3.Int Coding

轻松一刻,前面约定中讲过Leveldb使用了很多VarInt型编码,典型的如后面将涉及到的各种key。其中的编码、解码函数分为VarInt和FixedInt两种。int32和int64操作都是类似的。

3.1 Eecode

首先是FixedInt编码,直接上代码,很简单明了。

1
2
3
4
5
6
7
8
9
10
11
void EncodeFixed32(char* buf, uint32_t value)
{
if (port::kLittleEndian) {
memcpy(buf, &value,sizeof(value));
} else {
buf[0] = value & 0xff;
buf[1] = (value >> 8)& 0xff;
buf[2] = (value >> 16)& 0xff;
buf[3] = (value >> 24)& 0xff;
}
}

下面是VarInt编码,int32和int64格式,代码如下,有效位是7bit的,因此把uint32按7bit分割,对unsigned char赋值时,超出0xFF会自动截断,因此直接*(ptr++) = v|B即可,不需要再把(v|B)与0xFF作&操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
char* EncodeVarint32(char* dst, uint32_t v)
{
unsigned char* ptr =reinterpret_cast<unsigned char*>(dst);
static const int B = 128;
if (v < (1<<7)) {
*(ptr++) = v;
} else if (v < (1<<14)){
*(ptr++) = v | B;
*(ptr++) = v>>7;
} else if (v < (1<<21)){
*(ptr++) = v | B;
*(ptr++) = (v>>7) | B;
*(ptr++) = v>>14;
} else if (v < (1<<28)){
*(ptr++) = v | B;
*(ptr++) = (v>>7) | B;
*(ptr++) = (v>>14) | B;
*(ptr++) = v>>21;
} else {
*(ptr++) = v | B;
*(ptr++) = (v>>7) | B;
*(ptr++) = (v>>14) | B;
*(ptr++) = (v>>21) | B;
*(ptr++) = v>>28;
}
return reinterpret_cast<char*>(ptr);
}

// 对于uint64,直接循环
char* EncodeVarint64(char* dst, uint64_t v) {
static const int B = 128;
unsigned char* ptr =reinterpret_cast<unsigned char*>(dst);
while (v >= B) {
*(ptr++) = (v & (B-1)) |B;
v >>= 7;
}
*(ptr++) =static_cast<unsigned char>(v);
returnreinterpret_cast<char*>(ptr);
}

3.2 Decode

Fixed Int的Decode,操作,代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
inline uint32_t DecodeFixed32(const char* ptr)
{
if (port::kLittleEndian) {
uint32_t result;
// gcc optimizes this to a plain load
memcpy(&result, ptr,sizeof(result));
return result;
} else {
return((static_cast<uint32_t>(static_cast<unsigned char>(ptr[0])))
|(static_cast<uint32_t>(static_cast<unsigned char>(ptr[1])) <<8)
| (static_cast<uint32_t>(static_cast<unsignedchar>(ptr[2])) << 16)
|(static_cast<uint32_t>(static_cast<unsigned char>(ptr[3])) <<24));
}
}

再来看看VarInt的解码,很简单,依次读取1byte,直到最高位为0的byte结束,取低7bit,作(<<7)移位操作组合成Int。看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const char* GetVarint32Ptr(const char* p,
const char* limit,
uint32_t* value)
{
if (p < limit) {
uint32_t result =*(reinterpret_cast<const unsigned char*>(p));
if ((result & 128) == 0) {
*value = result;
return p + 1;
}
}
return GetVarint32PtrFallback(p,limit, value);
}

const char* GetVarint32PtrFallback(const char* p,
const char* limit,
uint32_t* value)
{
uint32_t result = 0;
for (uint32_t shift = 0; shift<= 28 && p < limit; shift += 7) {
uint32_t byte =*(reinterpret_cast<const unsigned char*>(p));
p++;
if (byte & 128) { // More bytes are present
result |= ((byte & 127)<< shift);
} else {
result |= (byte <<shift);
*value = result;
returnreinterpret_cast<const char*>(p);
}
}
return NULL;
}

4. Memtable之一

Memtable是leveldb很重要的一块,leveldb的核心之一。我们肯定关注KV数据在Memtable中是如何组织的,秘密在Skip list中。

4.1 用途

在Leveldb中,所有内存中的KV数据都存储在Memtable中,物理disk则存储在SSTable中。在系统运行过程中,如果Memtable中的数据占用内存到达指定值(Options.write_buffer_size),则Leveldb就自动将Memtable转换为Memtable,并自动生成新的Memtable,也就是Copy-On-Write机制了。

Immutable Memtable则被新的线程Dump到磁盘中,Dump结束则该Immutable Memtable就可以释放了。因名知意,Immutable Memtable是只读的

所以可见,最新的数据都是存储在Memtable中的,Immutable Memtable和物理SSTable则是某个时点的数据。

为了防止系统down机导致内存数据Memtable或者Immutable Memtable丢失,leveldb自然也依赖于log机制来保证可靠性了。

Memtable提供了写入KV记录,删除以及读取KV记录的接口,但是事实上Memtable并不执行真正的删除操作,删除某个Key的Value在Memtable内是作为插入一条记录实施的,但是会打上一个Key的删除标记,真正的删除操作在后面的 Compaction过程中,lazy delete。

4.2 核心是Skip list

另外,Memtable中的KV对是根据Key排序的,leveldb在插入等操作时保证key的有序性。想想,前面看到的Skip list不正是合适的人选吗,因此Memtable的核心数据结构是一个Skip list,Memtable只是一个接口类。当然随之而来的一个问题就是Skip list是如何组织KV数据对的,在后面分析Memtable的插入、查询接口时我们将会看到答案。

4.3 接口说明

先来看看Memtable的接口:

1
2
3
4
5
6
7
8
9
10
11
12
void Ref() { ++refs_; }

void Unref();

Iterator* NewIterator();

void Add(SequenceNumber seq,
ValueType type,
const Slice& key,
const Slice& value);

bool Get(const LookupKey& key, std::string* value, Status* s);

首先Memtable是基于引用计数的机制,如果引用计数为0,则在Unref中删除自己,Ref和Unref就是干这个的。

  • NewIterator是返回一个迭代器,可以遍历访问table的内部数据,很好的设计思想,这种方式隐藏了table的内部实现。外部调用者必须保证使用Iterator访问Memtable的时候该Memtable是live的。
  • Add和Get是添加和获取记录的接口,没有Delete,还记得前面说过,memtable的delete实际上是插入一条type为kTypeDeletion的记录。

4.4 类图

先来看看Memtable相关的整体类层次吧,并不复杂,还是相当清晰的。见图。

4.5 Key结构

Memtable是一个KV存储结构,那么这个key肯定是个重点了,在分析接口实现之前,有必要仔细分析一下Memtable对key的使用。

这里面有5个key的概念,可能会让人混淆,下面就来一个一个的分析。

4.5.1 InternalKey & ParsedInternalKey & User Key

InternalKey是一个复合概念,是有几个部分组合成的一个key,ParsedInternalKey就是对InternalKey分拆后的结果,先来看看ParsedInternalKey的成员,这是一个struct:

1
2
3
4
5
Slice user_key;

SequenceNumber sequence;

ValueType type;

也就是说InternalKey是由User key + SequenceNumber + ValueType组合而成的,顺便先分析下几个Key相关的函数,它们是了解Internal Key和User Key的关键。

首先是InternalKey和ParsedInternalKey相互转换的两个函数,如下。

1
2
3
4
5
bool ParseInternalKey (const Slice& internal_key,
ParsedInternalKey* result);

void AppendInternalKey (std::string* result,
const ParsedInternalKey& key);

函数实现很简单,就是字符串的拼接与把字符串按字节拆分,代码略过。根据实现,容易得到InternalKey的格式为:

1
| User key (string) | sequence number (7 bytes) | value type (1 byte) |

由此还可知道sequence number大小是7 bytes,sequence number是所有基于op log系统的关键数据,它唯一指定了不同操作的时间顺序。

user key放到前面**的原因**是,这样对同一个user key的操作就可以按照sequence number顺序连续存放了,不同的user key是互不相干的,因此把它们的操作放在一起也没有什么意义。

另外用户可以为user key定制比较函数,系统默认是字母序的。

下面的两个函数是分别从InternalKey中拆分出User Key和Value Type的,非常直观,代码也附上吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
inline Slice ExtractUserKey(const Slice& internal_key)
{
assert(internal_key.size() >= 8);
return Slice(internal_key.data(), internal_key.size() - 8);
}

inline ValueType ExtractValueType(const Slice& internal_key)
{
assert(internal_key.size() >= 8);
const size_t n = internal_key.size();
uint64_t num = DecodeFixed64(internal_key.data() + n - 8);
unsigned char c = num & 0xff;
return static_cast<ValueType>(c);
}

4.5.2 LookupKey & Memtable Key

Memtable的查询接口传入的是LookupKey,它也是由User Key和Sequence Number组合而成的,从其构造函数:

1
LookupKey(const Slice& user_key, SequenceNumber s)

中分析出LookupKey的格式为:

1
| Size (int32变长)| User key (string) | sequence number (7 bytes) | value type (1 byte) |

两点:

  • 这里的Size是user key长度+8,也就是整个字符串长度了;
  • value type是kValueTypeForSeek,它等于kTypeValue。

由于LookupKey的size是变长存储的,因此它使用kstart_记录了user key string的起始地址,否则将不能正确的获取size和user key;

LookupKey导出了三个函数,可以分别从LookupKey得到Internal KeyMemtable KeyUser Key,如下:

1
2
3
4
5
6
7
8
// Return a key suitable for lookup in a MemTable.
Slice memtable_key() const { return Slice(start_, end_ - start_); }

// Return an internal key (suitable for passing to an internal iterator)
Slice internal_key() const { return Slice(kstart_, end_ - kstart_); }

// Return the user key
Slice user_key() const { return Slice(kstart_, end_ - kstart_ - 8); }

其中start_是LookupKey字符串的开始,end_是结束,kstart_是start_+4,也就是user key字符串的起始地址。

4.Memtable之2

4.6 Comparator

弄清楚了key,接下来就要看看key的使用了,先从Comparator开始分析。首先Comparator是一个抽象类,导出了几个接口。

其中Name()Compare()接口都很明了,另外的两个Find xxx接口都有什么功能呢,直接看程序注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Advanced functions: these are used to reduce the space requirements 
//for internal data structures like index blocks.
// 这两个函数:用于减少像index blocks这样的内部数据结构占用的空间
// 其中的*start和*key参数都是IN OUT的。
//If *start < limit, changes *start to a short string in [start,limit).
//Simple comparator implementations may return with *start unchanged,
//i.e., an implementation of this method that does nothing is correct.
// 这个函数的作用就是:如果*start < limit,就在[startlimit,)中找到一个
// 短字符串,并赋给*start返回
// 简单的comparator实现可能不改变*start,这也是正确的
virtual void FindShortestSeparator(std::string* start,
const Slice& limit) const = 0;
//Changes *key to a short string >= *key.
//Simple comparator implementations may return with *key unchanged,
//i.e., an implementation of this method that does nothing is correct.
//这个函数的作用就是:找一个>= *key的短字符串
//简单的comparator实现可能不改变*key,这也是正确的
virtual void FindShortSuccessor(std::string* key) const = 0;

其中的实现类有两个,一个是内置的BytewiseComparatorImpl,另一个是InternalKeyComparator。下面分别来分析。

4.6.1 BytewiseComparatorImpl

首先是重载的Name和比较函数,比较函数如其名,就是字符串比较,如下:

1
2
virtual const char* Name() const {return"leveldb.BytewiseComparator";}
virtual int Compare(const Slice& a, const Slice& b) const {return a.compare(b);}

再来看看Byte wise的comparator是如何实现FindShortestSeparator()的,没什么特别的,代码 + 注释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
virtual void FindShortestSeparator(std::string* start, 
onst Slice& limit) const
{
// 首先计算共同前缀字符串的长度
size_t min_length = std::min(start->size(), limit.size());
size_t diff_index = 0;
while ((diff_index < min_length) &&
((*start)[diff_index] == limit[diff_index]))
{
diff_index++;
}
if (diff_index >= min_length)
{
// 说明*start是limit的前缀,或者反之,此时不作修改,直接返回
}
else
{
// 尝试执行字符start[diff_index]++,
设置start长度为diff_index+1,并返回
// ++条件:字符< oxff 并且字符+1 < limit上该index的字符
uint8_t diff_byte = static_cast<uint8_t>((*start)[diff_index]);
if (diff_byte < static_cast<uint8_t>(0xff) &&
diff_byte + 1 < static_cast<uint8_t>(limit[diff_index]))
{
(*start)[diff_index]++;
start->resize(diff_index + 1);
assert(Compare(*start, limit) < 0);
}
}
}

最后是FindShortSuccessor(),这个更简单了,代码+注释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
virtual void FindShortSuccessor(std::string* key) const 
{
// 找到第一个可以++的字符,执行++后,截断字符串;
// 如果找不到说明*key的字符都是0xff啊,那就不作修改,直接返回
size_t n = key->size();
for (size_t i = 0; i < n; i++)
{
const uint8_t byte = (*key)[i];
if (byte != static_cast<uint8_t>(0xff))
{
(*key)[i] = byte + 1;
key->resize(i+1);
return;
}
}
}

Leveldb内建的基于Byte wise的comparator类就这么多内容了,下面再来看看InternalKeyComparator。

4.6.2 InternalKeyComparator

从上面对Internal Key的讨论可知,由于它是由user key和sequence number和value type组合而成的,因此它还需要user key的比较,所以InternalKeyComparator有一个Comparator user_comparator_成员,用于*user key的比较。

在leveldb中的名字为:”leveldb.InternalKeyComparator”,下面来看看比较函数:

1
Compare(const Slice& akey, const Slice& bkey)

代码很简单,其比较逻辑是:

  • S1 首先比较user key,基于用户设置的comparator,如果user key不相等就直接返回比较,否则执行进入S2
  • S2 取出8字节的sequence number | value type,如果akey的 > bkey的则返回-1,如果akey的<bkey的返回1相等返回0

由此可见其排序比较依据依次是:

  1. 首先根据user key按升序排列
  2. 然后根据sequence number按降序排列
  3. 最后根据value type按降序排列

虽然比较时value type并不重要,因为sequence number是唯一的,但是直接取出8byte的sequence number | value type,然后做比较更方便,不需要再次移位提取出7byte的sequence number,又何乐而不为呢。这也是把value type安排在低7byte的好处吧,排序的两个依据就是user key和sequence number

接下来就该看看其FindShortestSeparator()函数实现了,该函数取出Internal Key中的user key字段,根据user指定的comparator找到并替换start,如果start被替换了,就用新的start更新Internal Key,并使用最大的sequence number。否则保持不变。

函数声明:

1
void InternalKeyComparator::FindShortestSeparator(std::string* start, const Slice& limit) const;

函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 尝试更新user key,基于指定的user comparator
Slice user_start = ExtractUserKey(*start);
Slice user_limit = ExtractUserKey(limit);
std::string tmp(user_start.data(), user_start.size());
user_comparator_->FindShortestSeparator(&tmp, user_limit);
if(tmp.size() < user_start.size() && user_comparator_->Compare(user_start, tmp) < 0)
{
// user key在物理上长度变短了,但其逻辑值变大了.生产新的*start时,
// 使用最大的sequence number,以保证排在相同user key记录序列的第一个
PutFixed64(&tmp, PackSequenceAndType(kMaxSequenceNumber,
kValueTypeForSeek));
assert(this->Compare(*start, tmp) < 0);
assert(this->Compare(tmp, limit) < 0);
start->swap(tmp);
}

接下来是FindShortSuccessor(std::string* key)函数,该函数取出Internal Key中的user key字段,根据user指定的comparator找到并替换key,如果key被替换了,就用新的key更新Internal Key,并使用最大的sequence number。否则保持不变。实现逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Slice user_key = ExtractUserKey(*key);
// 尝试更新user key,基于指定的user comparator
std::string tmp(user_key.data(), user_key.size());
user_comparator_->FindShortSuccessor(&tmp);
if(tmp.size()<user_key.size() &&
user_comparator_->Compare(user_key, tmp)<0)
{
// user key在物理上长度变短了,但其逻辑值变大了.生产新的*start时,
// 使用最大的sequence number,以保证排在相同user key记录序列的第一个
PutFixed64(&tmp, PackSequenceAndType(kMaxSequenceNumber, kValueTypeForSeek));
assert(this->Compare(*key, tmp) < 0);
key->swap(tmp);
}

4.7 Memtable::Insert()

把相关的Key和Key Comparator都弄清楚后,是时候分析memtable本身了。首先是向memtable插入记录的接口,函数原型如下:

1
void Add(SequenceNumber seq, ValueType type, const Slice& key, const Slice& value);

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// KV entry字符串有下面4部分连接而成
//key_size : varint32 of internal_key.size()
//key bytes : char[internal_key.size()]
//value_size : varint32 of value.size()
// value bytes : char[value.size()]
size_t key_size = key.size();
size_t val_size = value.size();
size_t internal_key_size = key_size + 8;
const size_t encoded_len = VarintLength(internal_key_size) +
internal_key_size +
VarintLength(val_size) + val_size;
char* buf = arena_.Allocate(encoded_len);
char* p = EncodeVarint32(buf, internal_key_size);
memcpy(p, key.data(), key_size);
p += key_size;
EncodeFixed64(p, (s << 8) | type);
p += 8;
p = EncodeVarint32(p, val_size);
memcpy(p, value.data(), val_size);
assert((p + val_size) - buf == encoded_len);
able_.Insert(buf);

根据代码,我们可以分析出KV记录在skip list的存储格式等信息,首先总长度为:

1
VarInt(Internal Key size) len + internal key size + VarInt(value) len + value size

它们的相互衔接也就是KV的存储格式:

1
| VarInt(Internal Key size) len | internal key |VarInt(value) len |value|

其中前面说过:

1
2
internal key = |user key |sequence number |type |
Internal key size = key size + 8

4.8 Memtable::Get()

Memtable的查找接口,根据一个LookupKey找到响应的记录,函数声明:

1
bool MemTable::Get(const LookupKey& key, std::string* value, Status* s)

函数实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Slice memkey = key.memtable_key();
Table::Iterator iter(&table_);
iter.Seek(memkey.data());
// seek到value>= memkey.data()的第一个记录
if (iter.Valid())
{
// 这里不需要再检查sequence number了,因为Seek()已经跳过了所有
// 值更大的sequence number了
const char* entry = iter.key();
uint32_t key_length;
const char* key_ptr = GetVarint32Ptr(entry, entry+5,
&key_length);
// 比较user key是否相同,key_ptr开始的len(internal key) -8 byte是user key
if (comparator_.comparator.user_comparator()->Compare
(Slice(key_ptr, key_length - 8), key.user_key()) == 0)
{
// len(internal key)的后8byte是 |sequence number | value type|
const uint64_t tag = DecodeFixed64(key_ptr + key_length - 8);
switch (static_cast<ValueType>(tag & 0xff))
{
case kTypeValue:
{
// 只取出value
Slice v = GetLengthPrefixedSlice(key_ptr + key_length);
value->assign(v.data(), v.size());
return true;
}
case kTypeDeletion:
*s = Status::NotFound(Slice());
return true;
}
}
}
return false;

这段代码,主要就是一个Seek函数,根据传入的LookupmKey得到在emtable中存储的key,然后调用Skip list::Iterator的Seek函数查找。Seek直接调用Skip list的FindGreaterOrEqual(key)接口,返回大于等于key的Iterator。然后取出user key判断时候和传入的user key相同,如果相同取出value,如果记录的Value Type为kTypeDeletion,返回Status::NotFound(Slice())。

4.9 小结

Memtable到此就分析完毕了,本质上就是一个有序的Skip list,排序基于user key的sequence number,其排序比较依据依次是:

  1. 首先根据user key按升序排列
  2. 然后根据sequence number按降序排列
  3. 最后根据value type按降序排列(这个其实无关紧要)

5.操作Log 1

分析完KV在内存中的存储,接下来就是操作日志。所有的写操作都必须先成功的append到操作日志中,然后再更新内存memtable。这样做有两点

  1. 可以将随机的写IO变成append,极大的提高写磁盘速度;
  2. 防止在节点down机导致内存数据丢失,造成数据丢失,这对系统来说是个灾难。

在各种高效的存储系统中,这已经是口水技术了。

5.1 格式

在源码下的文档doc/log_format.txt中,作者详细描述了log格式

1
2
3
4
5
6
7
8
9
The log file contents are a sequence of 32KB blocks. 
The only exception is that the tail of thefile may contain a partial block.
Each block consists of a sequence of records:
block:= record* trailer?
record :=
checksum: uint32 // crc32c of type and data[] ; little-endian
length: uint16 // little-endian
type: uint8 // One of FULL,FIRST, MIDDLE, LAST
data: uint8[length]

A record never starts within the last six bytes of a block (since it won’tfit). Any leftover bytes here form thetrailer, which must consist entirely of zero bytes and must be skipped byreaders.

翻译过来就是:Leveldb把日志文件切分成了大小为32KB的连续block块,block由连续的log record组成,log record的格式为:

注意:CRC32, Length都是little-endian的。

Log Type有4种:FULL = 1、FIRST = 2、MIDDLE = 3、LAST = 4。FULL类型表明该log record包含了完整的user record;而user record可能内容很多,超过了block的可用大小,就需要分成几条log record,第一条类型为FIRST,中间的为MIDDLE,最后一条为LAST。也就是:

  1. FULL,说明该log record包含一个完整的user record;
  2. FIRST,说明是user record的第一条log record
  3. MIDDLE,说明是user record中间的log record
  4. LAST,说明是user record最后的一条log record

翻一下文档上的例子,考虑到如下序列的user records

  • A: length 1000
  • B: length 97270
  • C: length 8000

  • A作为FULL类型的record存储在第一个block中;

  • B将被拆分成3条log record,分别存储在第1、2、3个block中,这时block3还剩6byte,将被填充为0;
  • C将作为FULL类型的record存储在block 4中。

由于一条logrecord长度最短为7,如果一个block的剩余空间<=6byte,那么将被填充为\空字**符串,另外长度为7的log record是不包括任何用户数据的**。

5.2 写日志

写比读简单,而且写入决定了读,所以从写开始分析。有意思的是在写文件时,Leveldb使用了内存映射文件,内存映射文件的读写效率比普通文件要高。其中涉及到的类层次比较简单,如图:

注意Write类的成员type_crc_数组,这里存放的为Record Type预先计算的CRC32值,因为Record Type是固定的几种,为了效率。Writer类只有一个接口,就是AddRecord(),传入Slice参数,下面来看函数实现。首先取出slice的字符串指针和长度,初始化begin=true,表明是第一条log record

1
2
3
const char* ptr = slice.data();
size_t left = slice.size();
bool begin = true;

然后进入一个while循环,直到写入出错,或者成功写入全部数据

首先查看当前block是否小于7,如果小于7则补位,并重置block偏移

1
2
dest_->Append(Slice("\x00\x00\x00\x00\x00\x00",leftover));
block_offset_ = 0;

计算block剩余大小,以及本次log record可写入数据长度

1
2
const size_t avail =kBlockSize - block_offset_ - kHeaderSize;
const size_t fragment_length = (left <avail) ? left : avail

根据两个值,判断log type

1
2
3
4
5
6
RecordType type;
const bool end = (left ==fragment_length); // 两者相等,表明写
if (begin && end) type = kFullType;
else if (begin) type = kFirstType;
else if (end) type = kLastType;
else type = kMiddleType;

调用EmitPhysicalRecord函数,append日志;并更新指针、剩余长度和begin标记

1
2
3
4
s = EmitPhysicalRecord(type, ptr,fragment_length);
ptr += fragment_length;
left -= fragment_length;
begin = false;

接下来看看EmitPhysicalRecord函数,这是实际写入的地方,涉及到log的存储格式。函数声明为:

1
StatusWriter::EmitPhysicalRecord(RecordType t, const char* ptr, size_t n)

参数ptr为用户record数据,参数n为record长度,不包含log header。

计算header,并Append到log文件,共7byte格式为:

1
2
3
4
5
6
7
8
9
| CRC32 (4 byte) | payload length lower + high (2 byte) |   type (1byte)|
char buf[kHeaderSize];
buf[4] = static_cast<char>(n& 0xff);
buf[5] =static_cast<char>(n >> 8);
buf[6] =static_cast<char>(t); // 计算record type和payload的CRC校验值
uint32_t crc = crc32c::Extend(type_crc_[t], ptr, n);
crc = crc32c::Mask(crc); // 空间调整
EncodeFixed32(buf, crc);
dest_->Append(Slice(buf,kHeaderSize));

写入payload,并Flush,更新block的当前偏移

1
2
3
s =dest_->Append(Slice(ptr, n));
s = dest_->Flush();
block_offset_ += kHeaderSize +n;

以上就是写日志的逻辑,很直观。

5.3 读日志

日志读取显然比写入要复杂,要检查checksum,检查是否有损坏等等,处理各种错误。

5.3.1 类层次

Reader主要用到了两个接口,一个是汇报错误的Reporter,另一个是log文件读取类SequentialFile

Reporter的接口只有一个

1
void Corruption(size_t bytes,const Status& status);

SequentialFile有两个接口:

1
2
Status Read(size_t n, Slice* result, char* scratch);
Status Skip(uint64_t n);

说明下,Read接口有一个result参数传递结果就行了,为何还有一个scratch呢,这个就和Slice相关了。它的字符串指针是传入的外部char*指针,自己并不负责内存的管理与分配。因此Read接口需要调用者提供一个字符串指针,实际存放字符串的地方。

Reader类有几个成员变量,需要注意:

1
2
3
4
5
6
bool eof_;          
// 上次Read()返回长度< kBlockSize,暗示到了文件结尾EOF
uint64_t last_record_offset_; // 函数ReadRecord返回的上一个record的偏移
uint64_t end_of_buffer_offset_;// 当前的读取偏移
uint64_t const initial_offset_;// 偏移,从哪里开始读取第一条record
Slice buffer_; // 读取的内容

5.3.2日志读取流程

Reader只有一个接口,那就是ReadRecord,下面来分析下这个函数。

S1

根据initial offset跳转到调用者指定的位置,开始读取日志文件。跳转就是直接调用SequentialFile的Seek接口。
另外,需要先调整调用者传入的initialoffset参数,调整和跳转逻辑在SkipToInitialBlock函数中。

1
2
3
4
if (last_record_offset_ <initial_offset_) 
{ // 当前偏移 < 指定的偏移,需要Seek
if (!SkipToInitialBlock()) return false;
}

下面的代码是SkipToInitialBlock函数调整read offset的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
// 计算在block内的偏移位置,并圆整到开始读取block的起始位置
size_t offset_in_block =initial_offset_ % kBlockSize;
uint64_t block_start_location =initial_offset_ - offset_in_block;
// 如果偏移在最后的6byte里,肯定不是一条完整的记录,跳到下一个block
if (offset_in_block >kBlockSize - 6)
{
offset_in_block = 0;
block_start_location +=kBlockSize;
}
end_of_buffer_offset_ =block_start_location;
// 设置读取偏移
if (block_start_location > 0) file_->Skip(block_start_location); // 跳转

首先计算出在block内的偏移位置,然后圆整到要读取block的起始位置。开始读取日志的时候都要保证读取的是完整的block,这就是调整的目的

同时成员变量end_of_buffer_offset_记录了这个值,在后续读取中会用到。

S2在开始while循环前首先初始化几个标记:

1
2
3
// 当前是否在fragment内,也就是遇到了FIRST 类型的record
bool in_fragmented_record = false;
uint64_t prospective_record_offset = 0; // 我们正在读取的逻辑record的偏移
S3

进入到while(true)循环,直到读取到KLastType或者KFullType的record,或者到了文件结尾。从日志文件读取完整的record是ReadPhysicalRecord函数完成的。

读取出现错误时,并不会退出循环,而是汇报错误,继续执行,直到成功读取一条user record,或者遇到文件结尾。

S3.1 从文件读取record

1
2
uint64_t physical_record_offset = end_of_buffer_offset_ -buffer_.size();
const unsigned int record_type = ReadPhysicalRecord(&fragment);

physical_record_offset存储的是当前正在读取的record的偏移值。接下来根据不同的record_type类型,分别处理,一共有7种情况:

S3.2 FULL type(kFullType),表明是一条完整的log record,成功返回读取的user record数据。另外需要对早期版本做些work around,早期的Leveldb会在block的结尾生产一条空的kFirstType log record。

1
2
3
4
5
6
7
8
9
10
11
if (in_fragmented_record) 
{
if (scratch->empty())in_fragmented_record = false;
else ReportCorruption(scratch->size(),"partial record without end(1)");
}

prospective_record_offset= physical_record_offset;
scratch->clear(); // 清空scratch,读取成功不需要返回scratch数据
*record = fragment;
last_record_offset_ =prospective_record_offset; // 更新last record offset
return true;

S3.3 FIRST type(kFirstType),表明是一系列logrecord(fragment)的第一个record。同样需要对早期版本做work around。

把数据读取到scratch中,直到成功读取了LAST类型的log record,才把数据返回到result中,继续下次的读取循环。

如果再次遇到FIRSTor FULL类型的log record,如果scratch不为空,就说明日志文件有错误。

1
2
3
4
5
6
7
8
9
10
11
if (in_fragmented_record) 
{
if (scratch->empty())in_fragmented_record = false;
else ReportCorruption(scratch->size(),"partial record without end(2)");
}

prospective_record_offset =physical_record_offset;
scratch->assign(fragment.data(), fragment.size());
//赋值给scratch
in_fragmented_record =true;
// 设置fragment标记为true

S3.4 MIDDLE type(kMiddleType),这个处理很简单,如果不是在fragment中,报告错误,否则直接append到scratch中就可以了。

1
2
3
4
5
6
if (!in_fragmented_record)
{
ReportCorruption(fragment.size(),
"missing start of fragmentedrecord(1)");
}
else {scratch->append(fragment.data(),fragment.size());}

S3.5 LAST type(kLastType),说明是一系列log record(fragment)中的最后一条。如果不在fragment中,报告错误。

1
2
3
4
5
6
7
8
9
10
11
12
if (!in_fragmented_record) 
{
ReportCorruption(fragment.size(),
"missing start of fragmentedrecord(2)");
}
else
{
scratch->append(fragment.data(), fragment.size());
*record = Slice(*scratch);
last_record_offset_ =prospective_record_offset;
return true;
}

至此,4种正常的log record type已经处理完成,下面3种情况是其它的错误处理,类型声明在Logger类中:

1
2
3
4
5
6
7
8
9
enum
{
kEof = kMaxRecordType + 1, // 遇到文件结尾
// 非法的record,当前有3中情况会返回bad record:
// * CRC校验失败 (ReadPhysicalRecord reports adrop)
// * 长度为0 (No drop is reported)
// * 在指定的initial_offset之外 (No drop is reported)
kBadRecord = kMaxRecordType +2
};

S3.6 遇到文件结尾kEof,返回false。不返回任何结果。

1
2
3
4
5
6
if (in_fragmented_record) 
{
ReportCorruption(scratch->size(), "partial record withoutend(3)");
scratch->clear();
}
return false;

S3.7 非法的record(kBadRecord),如果在fragment中,则报告错误。

1
2
3
4
5
6
if (in_fragmented_record)
{
ReportCorruption(scratch->size(), "error in middle ofrecord");
in_fragmented_record = false;
scratch->clear();
}

S3.8 缺省分支,遇到非法的record 类型,报告错误,清空scratch。

1
2
3
ReportCorruption(…, "unknownrecord type %u", record_type);
in_fragmented_record = false; // 重置fragment标记
scratch->clear();// 清空scratch

上面就是ReadRecord的全部逻辑,解释起来还有些费力。

5.3.3 从log文件读取record

就是前面讲过的ReadPhysicalRecord函数,它调用SequentialFile的Read接口,从文件读取数据。

该函数开始就进入了一个while(true)循环,其目的是为了读取到一个完整的record。读取的内容存放在成员变量buffer_中。这样的逻辑有些奇怪,实际上,完全不需要一个while(true)循环的。

函数基本逻辑如下:

S1

如果buffer_小于block header大小kHeaderSize,进入如下的几个分支:

S1.1 如果eof_为false,表明还没有到文件结尾,清空buffer,并读取数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
buffer_.clear(); 
// 因为上次肯定读取了一个完整的record
Status status =file_->Read(kBlockSize, &buffer_, backing_store_);
end_of_buffer_offset_ +=buffer_.size();
// 更新buffer读取偏移值
if (!status.ok())
{
// 读取失败,设置eof_为true,报告错误并返回kEof
buffer_.clear();
ReportDrop(kBlockSize,status);
eof_ = true;
return kEof;
}
else if (buffer_.size()< kBlockSize)
{
eof_ = true; // 实际读取字节<指定(Block Size),表明到了文件结尾
}
continue; // 继续下次循环

S1.2 如果eof_为true并且buffer为空,表明已经到了文件结尾,正常结束,返回kEof。

S1.3 否则,也就是eof_为true,buffer不为空,说明文件结尾包含了一个不完整的record,报告错误,返回kEof。

1
2
3
4
size_t drop_size =buffer_.size();
buffer_.clear();
ReportCorruption(drop_size,"truncated record at end of file");
return kEof;
S2 进入到这里表明上次循环中的Read读取到了一个完整的log record,continue后的第二次循环判断buffer_.size() >= kHeaderSize将执行到此处。

解析出log record的header部分,判断长度是否一致。

根据log的格式,前4byte是crc32。后面就是length和type,解析如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const char* header = buffer_.data();
const uint32_t length = ((header[4])& 0xff) | ((header[5]&0xff)<<8)
const uint32_t type = header[6];
if (kHeaderSize + length >buffer_.size())
{
// 长度超出了,汇报错误
size_t drop_size =buffer_.size();
buffer_.clear();
ReportCorruption(drop_size,"bad record length");
return kBadRecord; // 返回kBadRecord
}
if (type == kZeroType&& length == 0)
{
// 对于Zero Type类型,不汇报错误
buffer_.clear();
return kBadRecord; // 依然返回kBadRecord
}
S3 校验CRC32,如果校验出错,则汇报错误,并返回kBadRecord。
S4 如果record的开始位置在initial offset之前,则跳过,并返回kBadRecord,否则返回record数据和type。
1
2
3
4
5
6
7
8
9
buffer_.remove_prefix(kHeaderSize+ length);
if (end_of_buffer_offset_ -buffer_.size() - kHeaderSize -
length < initial_offset_)
{
result->clear();
return kBadRecord;
}
*result = Slice(header +kHeaderSize, length);
return type;

从log文件读取record的逻辑就是这样的。至此,读日志的逻辑也完成了。接下来将进入磁盘存储的sstable部分。

6. SSTable之一

SSTable是Leveldb的核心之一,是表数据最终在磁盘上的物理存储。也是体量比较大的模块。

6.1 SSTable的文件组织

作者在文档doc/table_format.txt中描述了表的逻辑结构,如图6.1-1所示。逻辑上可分为两大块,数据存储区Data Block,以及各种Meta信息。

  1. 文件中的k/v对是有序存储的,并且被划分到连续排列的Data Block里面,这些Data Block从文件头开始顺序存储,Data Block的存储格式代码在block_builder.cc中;

  2. 紧跟在Data Block之后的是Meta Block,其格式代码也在block_builder.cc中;Meta Block存储的是Filter信息,比如Bloom过滤器,用于快速定位key是否在data block中。

  3. MetaIndex Block是对Meta Block的索引,它只有一条记录,key是meta index的名字(也就是Filter的名字),value为指向meta index的BlockHandle;BlockHandle是一个结构体,成员offset_是Block在文件中的偏移,成员size_是block的大小;

  4. Index block是对Data Block的索引,对于其中的每个记录,其key >=Data Block最后一条记录的key,同时<其后Data Block的第一条记录的key;value是指向data index的BlockHandle;

  1. Footer,文件的最后,大小固定,其格式如图6.1-2所示。

  • 成员metaindex_handle指出了meta index block的起始位置和大小;
  • 成员index_handle指出了index block的起始地址和大小;

这两个字段都是BlockHandle对象,可以理解为索引的索引,通过Footer可以直接定位到metaindex和index block。再后面是一个填充区和魔数(0xdb4775248b80fb57)。

6.2 Block存储格式

6.2.1 Block的逻辑存储

Data Block是具体的k/v数据对存储区域,此外还有存储meta的metaIndex Block,存储data block索引信息的Index Block等等,他们都是以Block的方式存储的。来看看Block是如何组织的。每个Block有三部分构成:block data, type, crc32,如图6.2-1所示。

类型type指明使用的是哪种压缩方式,当前支持none和snappy压缩。

虽然block有好几种,但是Block Data都是有序的k/v对,因此写入、读取BlockData的接口都是统一的,对于Block Data的管理也都是相同的。

对Block的写入、读取将在创建、读取sstable时分析,知道了格式之后,其读取写入代码都是很直观的。

由于sstable对数据的存储格式都是Block,因此在分析sstable的读取和写入逻辑之前,我们先来分析下Leveldb对Block Data的管理。

Leveldb对Block Data的管理是读写分离的,读取后的遍历查询操作由Block类实现,BlockData的构建则由BlockBuilder类实现。

6.2.2 重启点-restartpoint

BlockBuilder对key的存储是前缀压缩的,对于有序的字符串来讲,这能极大的减少存储空间。但是却增加了查找的时间复杂度,为了兼顾查找效率,每隔K个key,leveldb就不使用前缀压缩,而是存储整个key,这就是重启点(restartpoint)。

在构建Block时,有参数Options::block_restart_interval定每隔几个key就直接存储一个重启点key。

Block在结尾记录所有重启点的偏移,可以二分查找指定的key。Value直接存储在key的后面,无压缩。

对于一个k/v对,其在block中的存储格式为:

  • 共享前缀长度 shared_bytes: varint32
  • 前缀之后的字符串长度 unshared_bytes: varint32
  • 值的长度 value_length: varint32
  • 前缀之后的字符串 key_delta: char[unshared_bytes]
  • 值 value: char[value_length]

对于重启点,shared_bytes= 0

Block的结尾段格式是:

  • restarts: uint32[num_restarts]
  • num_restarts: uint32 // 重启点个数

元素restarts[i]存储的是block的第i个重启点的偏移。很明显第一个k/v对,总是第一个重启点,也就是restarts[0] = 0;

图给出了block的存储示意图。

总体来看Block可分为k/v存储区和后面的重启点存储区两部分,其中k/v的存储格式如前面所讲,可看做4部分:

前缀压缩的key长度信息 + value长度 + key前缀之后的字符串+ value

最后一个4byte为重启点的个数。

对Block的存储格式了解之后,对Block的构建和读取代码分析就是很直观的事情了。见下面的分析。

6.3 Block的构建与读取

6.3.1 BlockBuilder的接口

首先从Block的构建开始,这就是BlockBuilder类,来看下BlockBuilder的函数接口,一共有5个:

1
2
3
4
5
6
7
8
9
10
11
12
13
void Reset(); // 重设内容,通常在Finish之后调用已构建新的block

//添加k/v,要求:Reset()之后没有调用过Finish();Key > 任何已加入的key

void Add(const Slice& key,const Slice& value);

// 结束构建block,并返回指向block内容的指针

Slice Finish();// 返回Slice的生存周期:Builder的生存周期,or直到Reset()被调用

size_t CurrentSizeEstimate()const; // 返回正在构建block的未压缩大小—估计值

bool empty() const { returnbuffer_.empty();} // 没有entry则返回true

主要成员变量如下:

1
2
3
4
std::string            buffer_;    // block的内容
std::vector<uint32_t> restarts_; // 重启点-后面会分析到
int counter_; // 重启后生成的entry数
std::string last_key_; // 记录最后添加的key

6.3.2 BlockBuilder::Add()

调用Add函数向当前Block中新加入一个k/v对{key, value}。函数处理逻辑如下:

S1

保证新加入的key > 已加入的任何一个key;

1
2
3
4
5
assert(!finished_);  

assert(counter_ <= options_->block_restart_interval);

assert(buffer_.empty() || options_->comparator->Compare(key,last_key_piece) > 0);

S2

如果计数器counter < opions->block_restart_interval,则使用前缀算法压缩key,否则就把key作为一个重启点,无压缩存储;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Slice last_key_piece(last_key_);

if (counter_ < options_->block_restart_interval) { //前缀压缩
// 计算key与last_key_的公共前缀
const size_t min_length= std::min(last_key_piece.size(), key.size());
while ((shared < min_length)&& (last_key_piece[shared] == key[shared])) {
shared++;

} else { // 新的重启点
restarts_.push_back(buffer_.size());
counter_ = 0;
}

Slice last_key_piece(last_key_);

if (counter_ < options_->block_restart_interval) { //前缀压缩
// 计算key与last_key_的公共前缀
const size_t min_length= std::min(last_key_piece.size(), key.size());
while ((shared < min_length)&& (last_key_piece[shared] == key[shared]))
shared++;
} else { // 新的重启点
restarts_.push_back(buffer_.size());
counter_ = 0;
}
S3

根据上面的数据格式存储k/v对,追加到buffer中,并更新block状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
const size_t non_shared = key.size() - shared; // key前缀之后的字符串长度
// append"<shared><non_shared><value_size>" 到buffer_
PutVarint32(&buffer_, shared);
PutVarint32(&buffer_, non_shared);
PutVarint32(&buffer_, value.size());
// 其后是前缀之后的字符串 + value
buffer_.append(key.data() + shared, non_shared);
buffer_.append(value.data(), value.size());
// 更新状态 ,last_key_ = key及计数器counter_
last_key_.resize(shared); // 连一个string的赋值都要照顾到,使内存copy最小化
last_key_.append(key.data() + shared, non_shared);
assert(Slice(last_key_) == key);
counter_++;

6.3.3 BlockBuilder::Finish()

调用该函数完成Block的构建,很简单,压入重启点信息,并返回buffer_,设置结束标记finished_:

1
2
3
4
5
6
7
for (size_t i = 0; i < restarts_.size(); i++) {  // 重启点  
PutFixed32(&buffer_, restarts_[i]);
}

PutFixed32(&buffer_, restarts_.size()); // 重启点数量
finished_ = true;
return Slice(buffer_);

6.3.4 BlockBuilder::Reset() & 大小

还有Reset和CurrentSizeEstimate两个函数,Reset复位函数,清空各个信息;函数CurrentSizeEstimate返回block的预计大小,从函数实现来看,应该在调用Finish之前调用该函数。

1
2
3
4
5
6
7
8
9
10
11
void BlockBuilder::Reset() {  
buffer_.clear(); restarts_.clear(); last_key_.clear();
restarts_.push_back(0); // 第一个重启点位置总是 0
counter_ = 0;
finished_ = false;
}

size_t BlockBuilder::CurrentSizeEstimate () const {
// buffer大小 +重启点数组长度 + 重启点长度(uint32)
return (buffer_.size() + restarts_.size() * sizeof(uint32_t) + sizeof(uint32_t));
}

Block的构建就这些内容了,下面开始分析Block的读取,就是类Block。

6.3.5 Block类接口

对Block的读取是由类Block完成的,先来看看其函数接口和关键成员变量。

Block只有两个函数接口,通过Iterator对象,调用者就可以遍历访问Block的存储的k/v对了;以及几个成员变量,如下:

1
2
3
4
5
6
7
size_t size() const { returnsize_; }
Iterator* NewIterator(constComparator* comparator);

const char* data_; // block数据指针
size_t size_; // block数据大小
uint32_t restart_offset_; // 重启点数组在data_中的偏移
bool owned_; //data_[]是否是Block拥有的

6.3.6 Block初始化

Block的构造函数接受一个BlockContents对象contents初始化,BlockContents是一个有3个成员的结构体。

  • data = Slice();
  • cachable = false; // 无cache
  • heap_allocated = false; // 非heap分配

根据contents为成员赋值

1
data_ = contents.data.data(), size_ =contents.data.size(),owned_ = contents.heap_allocated;

然后从data中解析出重启点数组,如果数据太小,或者重启点计算出错,就设置size_=0,表明该block data解析失败。

1
2
3
4
5
6
if (size_ < sizeof(uint32_t)){
size_ = 0; // 出错了
} else {
restart_offset_ = size_ - (1 +NumRestarts()) * sizeof(uint32_t);
if (restart_offset_ > size_- sizeof(uint32_t)) size_ = 0;
}

NumRestarts()函数就是从最后的uint32解析出重启点的个数,并返回:

1
return DecodeFixed32(data_ +size_ - sizeof(uint32_t))

6.3.7 Block::Iter

这是一个用以遍历Block内部数据的内部类,它继承了Iterator接口。函数NewIterator返回Block::Iter对象:

1
return new Iter(cmp, data_,restart_offset_, num_restarts);

下面我们就分析Iter的实现

主要成员变量有:

1
2
3
4
5
6
const Comparator* constcomparator_; // key比较器
const char* const data_; // block内容
uint32_t const restarts_; // 重启点(uint32数组)在data中的偏移
uint32_t const num_restarts_; // 重启点个数
uint32_t current_; // 当前entry在data中的偏移. >= restarts_表明非法
uint32_t restart_index_; // current_所在的重启点的index

下面来看看对Iterator接口的实现,简单函数略过。

首先是Next()函数,直接调用private函数ParseNextKey()跳到下一个k/v对,函数实现如下:

S1

跳到下一个entry,其位置紧邻在当前value_之后。如果已经是最后一个entry了,返回false,标记current_为invalid。

1
2
3
4
5
6
7
8
current_ = NextEntryOffset(); // (value_.data() + value_.size()) - data_
const char* p = data_ +current_;
const char* limit = data_ +restarts_; // Restarts come right after data
if (p >= limit) { // entry到头了,标记为invalid.
current_ = restarts_;
restart_index_ =num_restarts_;
return false;
}

S2

解析出entry,解析出错则设置错误状态,记录错误并返回false。解析成功则根据信息组成key和value,并更新重启点index。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint32_t shared, non_shared,value_length;
p = DecodeEntry(p, limit,&shared, &non_shared, &value_length);
if (p == NULL || key_.size()< shared) {
CorruptionError();
return false;
} else { // 成功
key_.resize(shared);
key_.append(p, non_shared);
value_ = Slice(p +non_shared, value_length);
while (restart_index_ + 1< num_restarts_ && GetRestartPoint(restart_index_ + 1) < current_) {
++restart_index_; //更新重启点index
}
return true;
}
  • 函数DecodeEntry从字符串[p, limit)解析出key的前缀长度、key前缀之后的字符串长度和value的长度这三个vint32值,代码很简单。
  • 函数CorruptionError将current_和restart_index_都设置为invalid状态,并在status中设置错误状态。
  • 函数GetRestartPoint从data中读取指定restart index的偏移值restart[index],并返回:
1
DecodeFixed32(data_ + restarts_ +index * sizeof(uint32_t);

接下来看看Prev函数,Previous操作分为两步:首先回到current_之前的重启点,然后再向后直到current_,实现如下:

S1

首先向前回跳到在current_前面的那个重启点,并定位到重启点的k/v对开始位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const uint32_t original =current_;

while (GetRestartPoint(restart_index_)>= original) {
// 到第一个entry了,标记invalid状态
if (restart_index_ == 0) {
current_ = restarts_;
restart_index_ =num_restarts_;
return;
}
restart_index_--;
}

//根据restart index定位到重启点的k/v对
SeekToRestartPoint(restart_index_);

S2

第二步,从重启点位置开始向后遍历,直到遇到original前面的那个k/v对。

1
do {} while (ParseNextKey() &&NextEntryOffset() < original);

说说上面遇到的SeekToRestartPoint函数,它只是设置了几个有限的状态,其它值将在函数ParseNextKey()中设置。感觉这有点tricky,这里的value_并不是k/v对的value,而只是一个指向k/v对起始位置的0长度指针,这样后面的ParseNextKey函数将会取出重启点的k/v值。

1
2
3
4
5
6
7
8
void SeekToRestartPoint(uint32_tindex) {
key_.clear();
restart_index_ = index;
// ParseNextKey()会设置current_;
//ParseNextKey()从value_结尾开始, 因此需要相应的设置value_
uint32_t offset =GetRestartPoint(index);
value_ = Slice(data_ + offset,0); // value长度设置为0,字符串指针是data_+offset
}

SeekToFirst/Last,这两个函数都很简单,借助于前面的SeekToResartPoint函数就可以完成。

1
2
3
4
5
6
7
8
9
virtual void SeekToFirst() {
SeekToRestartPoint(0);
ParseNextKey();
}

virtual void SeekToLast() {
SeekToRestartPoint(num_restarts_ - 1);
while (ParseNextKey()&& NextEntryOffset() < restarts_) {} //Keep skipping
}

最后一个Seek函数,跳到指定的target(Slice),函数逻辑如下:

S1

二分查找,找到key < target的最后一个重启点,典型的二分查找算法,代码就不再贴了。

S2

找到后,跳转到重启点,其索引由left指定,这是前面二分查找到的结果。如前面所分析的,value_指向重启点的地址,而size_指定为0,这样ParseNextKey函数将会取出重启点的k/v值。

1
SeekToRestartPoint(left);
S3

自重启点线性向下,直到遇到key>= target的k/v对。

1
2
3
4
while (true) {
if (!ParseNextKey()) return;
if (Compare(key_, target)>= 0) return;
}

上面就是Block::Iter的全部实现逻辑,这样Block的创建和读取遍历都已经分析完毕。

6.4 创建sstable文件

了解了sstable文件的存储格式,以及Data Block的组织,下面就可以分析如何创建sstable文件了。相关代码在table_builder.h/.cc以及block_builder.h/.cc(构建Block)中。

6.4.1 TableBuilder类

构建sstable文件的类是TableBuilder,该类提供了几个有限的方法可以用来添加k/v对,Flush到文件中等等,它依赖于BlockBuilder来构建Block。

TableBuilder的几个接口说明下:

  1. void Add(const Slice& key, const Slice& value),向当前正在构建的表添加新的{key, value}对,要求根据Option指定的Comparator,key必须位于所有前面添加的key之后;
  2. void Flush(),将当前缓存的k/v全部flush到文件中,一个高级方法,大部分的client不需要直接调用该方法;
  3. void Finish(),结束表的构建,该方法被调用后,将不再会使用传入的WritableFile;
  4. void Abandon(),结束表的构建,并丢弃当前缓存的内容,该方法被调用后,将不再会使用传入的WritableFile;【只是设置closed为true,无其他操作
  5. 一旦Finish()/Abandon()方法被调用,将不能再次执行Flush或者Add操作。

下面来看看涉及到的类。

其中WritableFile和op log一样,使用的都是内存映射文件。Options是一些调用者可设置的选项。

TableBuilder只有一个成员变量Rep* rep_,实际上Rep结构体的成员就是TableBuilder所有的成员变量;这样做的目的可能是为了隐藏其内部细节。Rep的定义也是在.cc文件中,对外是透明的。

简单解释下成员的含义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Options options;              // data block的选项
Options index_block_options; // index block的选项
WritableFile* file; // sstable文件
uint64_t offset;
// 要写入data block在sstable文件中的偏移,初始0
Status status; //当前状态-初始ok
BlockBuilder data_block; //当前操作的data block
BlockBuilder index_block; // sstable的index block
std::string last_key; //当前data block最后的k/v对的key
int64_t num_entries; //当前data block的个数,初始0
bool closed; //调用了Finish() or Abandon(),初始false
FilterBlockBuilder*filter_block;
//根据filter数据快速定位key是否在block中
bool pending_index_entry; //见下面的Add函数,初始false
BlockHandle pending_handle; //添加到index block的data block的信息
std::string compressed_output;//压缩后的data block,临时存储,写入后即被清空

Filter block是存储的过滤器信息,它会存储{key, 对应data block在sstable的偏移值},不一定完全精确的,以快速定位给定key是否在data block中。

下面分析如何向sstable中添加k/v对,创建并持久化sstable。其它函数都比较简单,略过。另外对于Abandon,简单设置closed=true即返回。

6.4.2 添加k/v对

这是通过方法Add(constSlice& key, const Slice& value)完成的,没有返回值。下面分析下函数的逻辑:

S1 首先保证文件没有close,也就是没有调用过Finish/Abandon,以及保证当前status是ok的;如果当前有缓存的kv对,保证新加入的key是最大的。

1
2
3
4
5
6
7
Rep* r = rep_;
assert(!r->closed);
if (!ok()) return;
if (r->num_entries > 0)
{
assert(r->options.comparator->Compare(key, Slice(r->last_key))> 0);
}

S2 如果标记r->pending_index_entry为true,表明遇到下一个data block的第一个k/v,根据key调整r->last_key,这是通过Comparator的FindShortestSeparator完成的。

1
2
3
4
5
6
7
8
9
if (r->pending_index_entry) 
{
assert(r->data_block.empty());
r->options.comparator->FindShortestSeparator(&r->last_key,key);
std::string handle_encoding;
r->pending_handle.EncodeTo(&handle_encoding);
r->index_block.Add(r->last_key, Slice(handle_encoding));
r->pending_index_entry =false;
}

接下来将pending_handle加入到index block中{r->last_key, r->pending_handle’sstring}。最后将r->pending_index_entry设置为false。

值得讲讲pending_index_entry这个标记的意义,见代码注释:

直到遇到下一个databock的第一个key时,我们才为上一个datablock生成index entry,这样的好处是:可以为index使用较短的key;比如上一个data block最后一个k/v的key是”the quick brown fox”,其后继data block的第一个key是”the who”,我们就可以用一个较短的字符串“the r”作为上一个data block的index block entry的key。

简而言之,就是在开始下一个datablock时,Leveldb才将上一个data block加入到index block中。标记pending_index_entry就是干这个用的,对应data block的index entry信息就保存在(BlockHandle)pending_handle。

S3 如果filter_block不为空,就把key加入到filter_block中。

1
2
3
4
if (r->filter_block != NULL) 
{
r->filter_block->AddKey(key);
}

S4 设置r->last_key = key,将(key, value)添加到r->data_block中,并更新entry数。

1
2
3
r->last_key.assign(key.data(), key.size());
r->num_entries++;
r->data_block.Add(key,value);

S5 如果data block的个数超过限制,就立刻Flush到文件中。

1
2
const size_testimated_block_size = r->data_block.CurrentSizeEstimate();
if (estimated_block_size >=r->options.block_size) Flush();

6.4.3 Flush文件

该函数逻辑比较简单,直接见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Rep* r = rep_;
assert(!r->closed); // 首先保证未关闭,且状态ok
if (!ok()) return;
if (r->data_block.empty())return; // data block是空的
// 保证pending_index_entry为false,即data block的Add已经完成
assert(!r->pending_index_entry);
// 写入data block,并设置其index entry信息—BlockHandle对象
WriteBlock(&r->data_block, &r->pending_handle);
//写入成功,则Flush文件,并设置r->pending_index_entry为true,
//以根据下一个data block的first key调整index entry的key—即r->last_key
if (ok())
{
r->pending_index_entry =true;
r->status =r->file->Flush();
}
if (r->filter_block != NULL)
{
//将data block在sstable中的便宜加入到filter block中
r->filter_block->StartBlock(r->offset);
// 并指明开始新的data block
}

6.4.4 WriteBlock函数

在Flush文件时,会调用WriteBlock函数将data block写入到文件中,该函数同时还设置data block的index entry信息。原型为:

1
void WriteBlock(BlockBuilder* block, BlockHandle* handle)

该函数做些预处理工作,序列化要写入的data block,根据需要压缩数据,真正的写入逻辑是在WriteRawBlock函数中。下面分析该函数的处理逻辑。

S1 获得block的序列化数据Slice,根据配置参数决定是否压缩,以及根据压缩格式压缩数据内容。对于Snappy压缩,如果压缩率太低<12.5%,还是作为未压缩内容存储。

BlockBuilder的Finish()函数将data block的数据序列化成一个Slice

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Rep* r = rep_;
Slice raw = block->Finish();
// 获得data block的序列化字符串
Slice block_contents;
CompressionType type =r->options.compression;
switch (type)
{
case kNoCompression: block_contents= raw; break; // 不压缩
case kSnappyCompression:
{
// snappy压缩格式
std::string* compressed =&r->compressed_output;
if(port::Snappy_Compress(raw.data(), raw.size(), compressed) &&
compressed->size()< raw.size() - (raw.size() / 8u))
{
block_contents =*compressed;
}
else
{
// 如果不支持Snappy,或者压缩率低于12.5%,依然当作不压缩存储
block_contents = raw;
type = kNoCompression;
}
break;
}
}

S2 将data内容写入到文件,并重置block成初始化状态,清空compressedoutput。

1
2
3
WriteRawBlock(block_contents,type, handle);  
r->compressed_output.clear();
block->Reset();

6.4.5 WriteRawBlock函数

在WriteBlock把准备工作都做好后,就可以写入到sstable文件中了。来看函数原型:

1
void WriteRawBlock(const Slice& data, CompressionType, BlockHandle*handle);

函数逻辑很简单,见代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Rep* r = rep_;
handle->set_offset(r->offset);
// 为index设置data block的handle信息
handle->set_size(block_contents.size());
r->status =r->file->Append(block_contents); // 写入data block内容
if (r->status.ok())
{
// 写入1byte的type和4bytes的crc32
chartrailer[kBlockTrailerSize];
trailer[0] = type;
uint32_t crc = crc32c::Value(block_contents.data(),
block_contents.size());
crc = crc32c::Extend(crc, trailer, 1); // Extend crc tocover block type
EncodeFixed32(trailer+1, crc32c::Mask(crc));
r->status =r->file->Append(Slice(trailer, kBlockTrailerSize));
if (r->status.ok())
{
// 写入成功更新offset-下一个data block的写入偏移
r->offset +=block_contents.size() + kBlockTrailerSize;
}
}

6.4.6 Finish函数

调用Finish函数,表明调用者将所有已经添加的k/v对持久化到sstable,并关闭sstable文件。

该函数逻辑很清晰,可分为5部分

S1 首先调用Flush,写入最后的一块data block,然后设置关闭标志closed=true。表明该sstable已经关闭,不能再添加k/v对。

1
2
3
4
5
Rep* r = rep_;
Flush();
assert(!r->closed);
r->closed = true;
BlockHandle filter_block_handle,metaindex_block_handle, index_block_handle;

S2 写入filter block到文件中。

1
2
3
4
if (ok() &&r->filter_block != NULL) 
{
WriteRawBlock(r->filter_block->Finish(), kNoCompression,&filter_block_handle);
}

S3 写入meta index block到文件中。

如果filterblock不为NULL,则加入从”filter.Name”到filter data位置的映射。通过meta index block,可以根据filter名字快速定位到filter的数据区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (ok()) 
{
BlockBuildermeta_index_block(&r->options);
if (r->filter_block !=NULL)
{
//加入从"filter.Name"到filter data位置的映射
std::string key ="filter.";
key.append(r->options.filter_policy->Name());
std::string handle_encoding;
filter_block_handle.EncodeTo(&handle_encoding);
meta_index_block.Add(key,handle_encoding);
}
// TODO(postrelease): Add stats and other metablocks
WriteBlock(&meta_index_block, &metaindex_block_handle);
}

S4 写入index block,如果成功Flush过data block,那么需要为最后一块data block设置index block,并加入到index block中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (ok()) 
{
if (r->pending_index_entry)
{
// Flush时会被设置为true
r->options.comparator->FindShortSuccessor(&r->last_key);
std::string handle_encoding;
r->pending_handle.EncodeTo(&handle_encoding);
r->index_block.Add(r->last_key, Slice(handle_encoding));
// 加入到index block中
r->pending_index_entry =false;
}
WriteBlock(&r->index_block, &index_block_handle);
}

S5 写入Footer。

1
2
3
4
5
6
7
8
9
10
11
12
13
if (ok()) 
{
Footer footer;
footer.set_metaindex_handle(metaindex_block_handle);
footer.set_index_handle(index_block_handle);
std::string footer_encoding;
footer.EncodeTo(&footer_encoding);
r->status =r->file->Append(footer_encoding);
if (r->status.ok())
{
r->offset +=footer_encoding.size();
}
}

整个写入流程就分析完了,对于Datablock和Filter Block的操作将在Data block和Filter Block中单独分析,下面的读取相同。

6.5 读取sstable文件

6.5.1 类层次

Sstable文件的读取逻辑在类Table中,其中涉及到的类还是比较多的,如图6.5-1所示。

img

Table类导出的函数只有3个,先从这三个导出函数开始分析。其中涉及到的类(包括上图中为画出的)都会一一遇到,然后再一一拆解。

本节分析sstable的打开逻辑,后面再分析key的查找与数据遍历。

6.5.2 Table::Open()

打开一个sstable文件,函数声明为:

1
2
static Status Open(const Options& options, RandomAccessFile* file,
uint64_tfile_size, Table** table);

这是Table类的一个静态函数,如果操作成功,指针*table指向新打开的表,否则返回错误。

要打开的文件和大小分别由参数file和file_size指定;option是一些选项;

下面就分析下函数逻辑:

S1

首先从文件的结尾读取Footer,并Decode到Footer对象中,如果文件长度小于Footer的长度,则报错。Footer的decode很简单,就是根据前面的Footer结构,解析并判断magic number是否正确,解析出meta index和index block的偏移和长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
*table = NULL;
if (size <Footer::kEncodedLength)
{
// 文件太短
returnStatus::InvalidArgument("file is too short to be an sstable");
}
charfooter_space[Footer::kEncodedLength]; // Footer大小是固定的
Slice footer_input;
Status s = file->Read(size -Footer::kEncodedLength, Footer::kEncodedLength,
&footer_input, footer_space);
if (!s.ok()) return s;
Footer footer;
s =footer.DecodeFrom(&footer_input);
if (!s.ok()) return s;
S2

解析出了Footer,我们就可以读取index block和meta index了,首先读取index block。

1
2
3
4
5
6
7
8
9
10
BlockContents contents;
Block* index_block = NULL;
if (s.ok())
{
s = ReadBlock(file, ReadOptions(),footer.index_handle(), &contents);
if (s.ok())
{
index_block = newBlock(contents);
}
}

这是通过调用ReadBlock完成的,下面会分析这个函数。

S3

已经成功读取了footer和index block,此时table已经可以响应请求了。构建table对象,并读取metaindex数据构建filter policy。如果option打开了cache,还要为table创建cache。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (s.ok())
{
// 已成功读取footer和index block: 可以响应请求了
Rep* rep = new Table::Rep;
rep->options = options;
rep->file = file;
rep->metaindex_handle =footer.metaindex_handle();
rep->index_block =index_block;
rep->cache_id =(options.block_cache ? options.block_cache->NewId() : 0);
rep->filter_data = rep->filter= NULL;
*table = new Table(rep);
(*table)->ReadMeta(footer);
// 调用ReadMeta读取metaindex
}
else
{
if (index_block) deleteindex_block;
}

到这里,Table的打开操作就已经为完成了。下面来分析上面用到的ReadBlock()和ReadMeta()函数

6.5.3 ReadBlock()

前面讲过block的格式,以及Block的写入(TableBuilder::WriteRawBlock),现在我们可以轻松的分析Block的读取操作了。

这是一个全局函数,声明为:

1
2
Status ReadBlock(RandomAccessFile* file, const ReadOptions& options, 
const BlockHandle&handle, BlockContents* result);

下面来分析实现逻辑:

S1

初始化结果result,BlockContents是一个有3个成员的结构体。

1
2
3
result->data = Slice();
result->cachable = false; // 无cache
result->heap_allocated =false; // 非heap分配
S2

根据handle指定的偏移和大小,读取block内容,type和crc32值,其中常量kBlockTrailerSize=5= 1byte的type和4bytes的crc32。

1
2
Status s = file->Read(handle.offset(),handle.size() + kBlockTrailerSize,
&contents, buf);
S3

如果option要校验CRC32,则计算content + type的CRC32并校验。

S4

最后根据type指定的存储类型,如果是非压缩的,则直接取数据赋给result,否则先解压,把解压结果赋给result,目前支持的是snappy压缩。

另外,文件的Read接口返回的Slice结果,其data指针可能没有使用我们传入的buf,如果没有,那么释放Slice的data指针就是我们的事情,否则就是文件来管理的。

1
2
3
4
5
6
7
8
9
10
11
12
13
if (data != buf) 
{
// 文件自己管理,cacheable等标记设置为false
delete[] buf;
result->data =Slice(data, n);
result->heap_allocated= result->cachable =false;
}
else
{
// 读取者自己管理,标记设置为true
result->data =Slice(buf, n);
result->heap_allocated= result->cachable = true;
}

对于压缩存储,解压后的字符串存储需要读取者自行分配的,所以标记都是true

6.5.4 Table::ReadMeta()

解决完了Block的读取,接下来就是meta的读取了。函数声明为:

1
void Table::ReadMeta(const Footer& footer)

函数逻辑并不复杂。

S1

首先调用ReadBlock读取meta的内容

1
2
3
4
5
6
7
8
if(rep_->options.filter_policy == NULL) return; 
// 不需要metadata
ReadOptions opt;
BlockContents contents;
if (!ReadBlock(rep_->file,opt, footer.metaindex_handle(), &contents).ok())
{
return; // 失败了也没报错,因为没有meta信息也没关系
}
S2

根据读取的content构建Block,找到指定的filter;如果找到了就调用ReadFilter构建filter对象。Block的分析留在后面。

1
2
3
4
5
6
7
8
Block* meta = newBlock(contents);
Iterator* iter =meta->NewIterator(BytewiseComparator());
std::string key ="filter.";
key.append(rep_->options.filter_policy->Name());
iter->Seek(key);
if (iter->Valid() &&iter->key() == Slice(key)) ReadFilter(iter->value());
delete iter;
delete meta;

6.5.5 Table::ReadFilter()

根据指定的偏移和大小,读取filter,函数声明:

1
void ReadFilter(const Slice& filter_handle_value);

简单分析下函数逻辑:

S1

从传入的filter_handle_value Decode出BlockHandle,这是filter的偏移和大小;

1
2
BlockHandle filter_handle;
filter_handle.DecodeFrom(&filter_handle_value);
S2

根据解析出的位置读取filter内容,ReadBlock。如果block的heap_allocated为true,表明需要自行释放内存,因此要把指针保存在filter_data中。最后根据读取的data创建FilterBlockReader对象。

1
2
3
4
5
6
ReadOptions opt;
BlockContents block;
ReadBlock(rep_->file, opt,filter_handle, &block);
if (block.heap_allocated)rep_->filter_data = block.data.data();
// 需要自行释放内存
rep_->filter = newFilterBlockReader(rep_->options.filter_policy, block.data);

以上就是sstable文件的读取操作,不算复杂。

6.6 遍历Table

6.6.1 遍历接口

Table导出了一个返回Iterator的接口,通过Iterator对象,调用者就可以遍历Table的内容,它简单的返回了一个TwoLevelIterator对象。见函数实现:

1
2
3
4
5
6
7
8
9
10
11
Iterator* NewIterator(const ReadOptions&options) const;  
{
return NewTwoLevelIterator(rep_->index_block->NewIterator(rep_->options.comparator),
&Table::BlockReader,const_cast<Table*>(this), options);
}
// 函数NewTwoLevelIterator创建了一个TwoLevelIterator对象:
Iterator* NewTwoLevelIterator(Iterator* index_iter,BlockFunction block_function,
void* arg, constReadOptions& options)
{
return newTwoLevelIterator(index_iter, block_function, arg, options);
}

这里有一个函数指针BlockFunction,类型为:

1
typedef Iterator* (*BlockFunction)(void*, const ReadOptions&, constSlice&);

为什么叫TwoLevelIterator呢,下面就来看看。

6.6.2 TwoLevelIterator

它也是Iterator的子类,之所以叫two level应该是不仅可以迭代其中存储的对象,它还接受了一个函数BlockFunction,可以遍历存储的对象,可见它是专门为Table定制的。
我们已经知道各种Block的存储格式都是相同的,但是各自block data存储的k/v互不相同,于是我们就需要一个途径,能够在使用同一个方式遍历不同的block时,又能解析这些k/v。这就是BlockFunction,它又返回了一个针对block data的Iterator。Block和block data存储的k/v对的key是统一的。
先来看类的主要成员变量:

1
2
3
4
5
6
7
8
9
BlockFunction block_function_; // block操作函数  
void* arg_; // BlockFunction的自定义参数
const ReadOptions options_; // BlockFunction的read option参数
Status status_; // 当前状态
IteratorWrapper index_iter_; // 遍历block的迭代器
IteratorWrapper data_iter_; // May be NULL-遍历block data的迭代器
// 如果data_iter_ != NULL,data_block_handle_保存的是传递给
// block_function_的index value,以用来创建data_iter_
std::string data_block_handle_;

下面分析一下对于Iterator几个接口的实现。

S1

对于其Key和Value接口都是返回的data_iter_对应的key和value:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
virtual bool Valid() const 
{
return data_iter_.Valid();
}

virtual Slice key() const
{
assert(Valid());
return data_iter_.key();
}

virtual Slice value() const
{
assert(Valid());
return data_iter_.value();
}

S2 在分析Seek系函数之前,有必要先了解下面这几个函数的用途。

1
2
3
4
5
void InitDataBlock();  
void SetDataIterator(Iterator*data_iter);
//设置date_iter_ = data_iter
voidSkipEmptyDataBlocksForward();
voidSkipEmptyDataBlocksBackward();
S2.1

首先是InitDataBlock(),它是根据index_iter来初始化data_iter,当定位到新的block时,需要更新data Iterator,指向该block中k/v对的合适位置,函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (!index_iter_.Valid()) SetDataIterator(NULL);
// index_iter非法
else
{
Slice handle =index_iter_.value();
if (data_iter_.iter() != NULL&& handle.compare(data_block_handle_) == 0)
{
//data_iter已经在该block data上了,无须改变
}
else
{
// 根据handle数据定位data iter
Iterator* iter =(*block_function_)(arg_, options_, handle);
data_block_handle_.assign(handle.data(), handle.size());
SetDataIterator(iter);
}
}
S2.2

SkipEmptyDataBlocksForward,向前跳过空的datablock,函数实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
while (data_iter_.iter() == NULL|| !data_iter_.Valid()) 
{
// 跳到下一个block
if (!index_iter_.Valid())
{
// 如果index iter非法,设置data iteration为NULL
SetDataIterator(NULL);
return;
}
index_iter_.Next();
InitDataBlock();
if (data_iter_.iter() != NULL)data_iter_.SeekToFirst();
// 跳转到开始
}
S2.3

SkipEmptyDataBlocksBackward,向后跳过空的datablock,函数实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
while (data_iter_.iter() == NULL|| !data_iter_.Valid()) 
{
// 跳到前一个block
if (!index_iter_.Valid())
{
// 如果index iter非法,设置data iteration为NULL
SetDataIterator(NULL);
return;
}
index_iter_.Prev();
InitDataBlock();
if (data_iter_.iter() != NULL)data_iter_.SeekToLast();
// 跳转到开始
}
S3

了解了几个跳转的辅助函数,再来看Seek系接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void TwoLevelIterator::Seek(const Slice& target) 
{
index_iter_.Seek(target);
InitDataBlock();
// 根据index iter设置data iter
if (data_iter_.iter() != NULL)data_iter_.Seek(target);
// 调整data iter跳转到target
SkipEmptyDataBlocksForward();
// 调整iter,跳过空的block
}

void TwoLevelIterator::SeekToFirst()
{
index_iter_.SeekToFirst();
InitDataBlock(); // 根据index iter设置data iter
if (data_iter_.iter() != NULL)data_iter_.SeekToFirst();
SkipEmptyDataBlocksForward(); // 调整iter,跳过空的block
}

void TwoLevelIterator::SeekToLast()
{
index_iter_.SeekToLast();
InitDataBlock(); // 根据index iter设置data iter
if (data_iter_.iter() != NULL)data_iter_.SeekToLast();
SkipEmptyDataBlocksBackward();// 调整iter,跳过空的block
}

void TwoLevelIterator::Next()
{
assert(Valid());
data_iter_.Next();
SkipEmptyDataBlocksForward(); // 调整iter,跳过空的block
}

void TwoLevelIterator::Prev()
{
assert(Valid());
data_iter_.Prev();
SkipEmptyDataBlocksBackward();// 调整iter,跳过空的block
}

6.6.3 BlockReader()

上面传递给twolevel Iterator的函数是Table::BlockReader函数,声明如下:

1
2
static Iterator* Table::BlockReader(void* arg, const ReadOptions&options,
constSlice& index_value);

它根据参数指明的blockdata,返回一个iterator对象,调用者就可以通过这个iterator对象遍历blockdata存储的k/v对,这其中用到了LRUCache
函数实现逻辑如下:

S1

从参数中解析出BlockHandle对象,其中arg就是Table对象,index_value存储的是BlockHandle对象,读取Block的索引。

1
2
3
4
5
6
Table* table =reinterpret_cast<Table*>(arg);  
Block* block = NULL;
Cache::Handle* cache_handle =NULL;
BlockHandle handle;
Slice input = index_value;
Status s =handle.DecodeFrom(&input);
S2

根据block handle,首先尝试从cache中直接取出block,不在cache中则调用ReadBlock从文件读取,读取成功后,根据option尝试将block加入到LRU cache中。并在Insert的时候注册了释放函数DeleteCachedBlock。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Cache* block_cache =table->rep_->options.block_cache;  
BlockContents contents;
if (block_cache != NULL)
{
char cache_key_buffer[16];
// cache key的格式为table.cache_id + offset
EncodeFixed64(cache_key_buffer, table->rep_->cache_id);
EncodeFixed64(cache_key_buffer+8, handle.offset());
Slice key(cache_key_buffer,sizeof(cache_key_buffer));
cache_handle =block_cache->Lookup(key); // 尝试从LRU cache中查找
if (cache_handle != NULL)
{
// 找到则直接取值
block =reinterpret_cast<Block*>(block_cache->Value(cache_handle));
}
else
{
// 否则直接从文件读取
s =ReadBlock(table->rep_->file,
options, handle, &contents);
if (s.ok())
{
block = new Block(contents);
if (contents.cachable&& options.fill_cache)
// 尝试加到cache中
cache_handle =block_cache->Insert(key, block,block->size(), &DeleteCachedBlock);
}
}
}
else
{
s =ReadBlock(table->rep_->file, options, handle, &contents);
if (s.ok()) block = newBlock(contents);
}
S3

如果读取到了block,调用Block::NewIterator接口创建Iterator,如果cache handle为NULL,则注册DeleteBlock,否则注册ReleaseBlock,事后清理。

1
2
3
4
5
6
7
8
Iterator* iter;  
if (block != NULL)
{
iter =block->NewIterator(table->rep_->options.comparator);
if (cache_handle == NULL) iter->RegisterCleanup(&DeleteBlock,block, NULL);
else iter->RegisterCleanup(&ReleaseBlock,block_cache, cache_handle);
}
else iter = NewErrorIterator(s);

处理结束,最后返回iter。这里简单列下这几个静态函数,都很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void DeleteBlock(void* arg, void* ignored) 
{
deletereinterpret_cast<Block*>(arg);
}

static void DeleteCachedBlock(const Slice& key, void* value)
{
Block* block =reinterpret_cast<Block*>(value);
delete block;
}

static void ReleaseBlock(void* arg, void* h)
{
Cache* cache =reinterpret_cast<Cache*>(arg);
Cache::Handle* handle =reinterpret_cast<Cache::Handle*>(h);
cache->Release(handle);
}

6.7 定位key

这里并不是精确的定位,而是在Table中找到第一个>=指定key的k/v对,然后返回其value在sstable文件中的偏移。也是Table类的一个接口:

1
uint64_t ApproximateOffsetOf(const Slice& key) const;

函数实现比较简单:

S1

调用Block::Iter的Seek函数定位

1
2
3
Iterator* index_iter=rep_->index_block->NewIterator(rep_->options.comparator);  
index_iter->Seek(key);
uint64_t result;
S2

如果index_iter是合法的值,并且Decode成功,返回结果offset。

1
2
3
BlockHandle handle;  
handle.DecodeFrom(&index_iter->value());
result = handle.offset();
S3

其它情况,设置result为rep_->metaindex_handle.offset(),metaindex的偏移在文件结尾附近。

6.8 获取Key—InternalGet()

InternalGet,这是为TableCache开的一个口子。这是一个private函数,声明为:

1
2
Status Table::InternalGet(const ReadOptions& options, constSlice& k,
void*arg, void (*saver)(void*, const Slice&, const Slice&))

其中又有函数指针,在找到数据后,就调用传入的函数指针saver执行调用者的自定义处理逻辑,并且TableCache可能会做缓存。
函数逻辑如下:

S1

首先根据传入的key定位数据,这需要indexblock的Iterator。

1
2
Iterator* iiter =rep_->index_block->NewIterator(rep_->options.comparator);  
iiter->Seek(k);
S2

如果key是合法的,取出其filter指针,如果使用了filter,则检查key是否存在,这可以快速判断,提升效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Status s;  
Slice handle_value =iiter->value();
FilterBlockReader* filter = rep_->filter;
BlockHandle handle;
if (filter != NULL && handle.DecodeFrom(&handle_value).ok() && !filter->KeyMayMatch(handle.offset(),k))
{
// key不存在
}
else
{
// 否则就要读取block,并查找其k/v对
Slice handle = iiter->value();
Iterator* block_iter =BlockReader(this, options, iiter->value());
block_iter->Seek(k);
if (block_iter->Valid())(*saver)(arg, block_iter->key(), block_iter->value());
s = block_iter->status();
delete block_iter;
}
S3

最后返回结果,删除临时变量。

1
2
3
if (s.ok()) s =iiter->status();  
delete iiter;
return s;

随着有关sstable文件读取的结束,sstable的源码也就分析完了,其中我们还遗漏了一些功课要做,那就是Filter和TableCache部分。

7.TableCache

7.1 TableCache简介

TableCache缓存的是Table对象,每个DB一个,它内部使用一个LRUCache缓存所有的table对象,实际上其内容是文件编号{file number, TableAndFile}TableAndFile是一个拥有2个变量的结构体:RandomAccessFile和Table*;

TableCache类的主要成员变量有:

1
2
3
Env* const env_;            // 用来操作文件  
const std::string dbname_; // db名
Cache* cache_; // LRUCache

三个函数接口,其中的参数@file_number是文件编号,@file_size是文件大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Evict(uint64_tfile_number);  
// 该函数用以清除指定文件所有cache的entry,
//函数实现很简单,就是根据file number清除cache对象。
EncodeFixed64(buf,file_number); cache_->Erase(Slice(buf, sizeof(buf)));
Iterator* NewIterator(constReadOptions& options, uint64_t file_number,
uint64_t file_size, Table**tableptr = NULL);
//该函数为指定的file返回一个iterator(对应的文件长度必须是"file_size"字节).
//如果tableptr不是NULL,那么tableptr保存的是底层的Table指针。
//返回的tableptr是cache拥有的,不能被删除,生命周期同返回的iterator

Status Get(constReadOptions& options,
uint64_t file_number,uint64_t file_size,
const Slice& k,void* arg,
void(*handle_result)(void*, const Slice&, const Slice&));

// 这是一个查找函数,如果在指定文件中seek 到internal key "k" 找到一个entry,
//就调用 (*handle_result)(arg,found_key, found_value).

7.2 TableCache::Get()

先来看看Get接口,只有几行代码:

1
2
3
4
5
6
7
8
9
Cache::Handle* handle = NULL;  
Status s =FindTable(file_number, file_size, &handle);
if (s.ok())
{
Table* t =reinterpret_cast<TableAndFile*>(cache_->Value(handle))->table;
s = t->InternalGet(options,k, arg, saver);
cache_->Release(handle);
}
return s;

首先根据file_number找到Table的cache对象,如果找到了就调用Table::InternalGet,对查找结果的处理在调用者传入的saver回调函数中。
Cache在Lookup找到cache对象后,如果不再使用需要调用Release减引用计数。这个见Cache的接口说明。

7.3 TableCache遍历

函数NewIterator(),返回一个可以遍历Table对象的Iterator指针,函数逻辑:

S1

初始化tableptr,调用FindTable,返回cache对象

1
2
3
4
if (tableptr != NULL) *tableptr =NULL;  
Cache::Handle* handle = NULL;
Status s =FindTable(file_number, file_size, &handle);
if (!s.ok()) returnNewErrorIterator(s);

S2

从cache对象中取出Table对象指针,调用其NewIterator返回Iterator对象,并为Iterator注册一个cleanup函数。

1
2
3
4
5
Table* table =reinterpret_cast<TableAndFile*>(cache_->Value(handle))->table;  
Iterator* result =table->NewIterator(options);
result->RegisterCleanup(&UnrefEntry, cache_, handle);
if (tableptr != NULL) *tableptr= table;
return result;

7.4 TableCache::FindTable()

前面的遍历和Get函数都依赖于FindTable这个私有函数完成对cache的查找,下面就来看看该函数的逻辑。函数声明为:

1
2
Status FindTable(uint64_t file_number, uint64_t file_size,
Cache::Handle** handle)

函数流程为:

S1

首先根据file number从cache中查找table,找到就直接返回成功。

1
2
3
4
char buf[sizeof(file_number)];  
EncodeFixed64(buf, file_number);
Slice key(buf, sizeof(buf));
*handle = cache_->Lookup(key);

S2

如果没有找到,说明table不在cache中,则根据file number和db name打开一个RadomAccessFile。Table文件格式为:..sst。如果文件打开成功,则调用Table::Open读取sstable文件。

1
2
3
4
5
std::string fname =TableFileName(dbname_, file_number);  
RandomAccessFile* file = NULL;
Table* table = NULL;
s =env_->NewRandomAccessFile(fname, &file);
if (s.ok()) s =Table::Open(*options_, file, file_size, &table);

S3

如果Table::Open成功则,插入到Cache中。

1
2
TableAndFile* tf = newTableAndFile(table, file);  
*handle = cache_->Insert(key,tf, 1, &DeleteEntry);

如果失败,则删除file,直接返回失败,失败的结果是不会cache的。

7.5 辅助函数

有点啰嗦,不过还是写一下吧。其中一个是为LRUCache注册的删除函数DeleteEntry

1
2
3
4
5
6
7
static void DeleteEntry(const Slice& key, void* value) 
{
TableAndFile* tf =reinterpret_cast<TableAndFile*>(value);
delete tf->table;
delete tf->file;
delete tf;
}

另外一个是为Iterator注册的清除函数UnrefEntry

1
2
3
4
5
6
static void UnrefEntry(void* arg1, void* arg2) 
{
Cache* cache =reinterpret_cast<Cache*>(arg1);
Cache::Handle* h =reinterpret_cast<Cache::Handle*>(arg2);
cache->Release(h);
}

8.FilterPolicy&Bloom之1

8.1 FilterPolicy

因名知意,FilterPolicy是用于key过滤的,可以快速的排除不存在的key。前面介绍Table的时候,在Table::InternalGet函数中有过一面之缘。
FilterPolicy有3个接口:

1
2
3
4
5
virtual const char* Name() const = 0; 
// 返回filter的名字
virtual void CreateFilter(const Slice* keys,
int n, std::string* dst)const = 0;
virtual bool KeyMayMatch(const Slice& key, const Slice& filter)const = 0;

CreateFilter接口,它根据指定的参数创建过滤器,并将结果append到dst中,注意:不能修改dst的原始内容,只做append。
参数@keys[0,n-1]包含依据用户提供的comparator排序的key列表—可重复,并把根据这些key创建的filter追加到@*dst中。

KeyMayMatch,参数@filter包含了调用CreateFilter函数append的数据,如果key在传递函数CreateFilter的key列表中,则必须返回true

注意:它不需要精确,也就是即使key不在前面传递的key列表中,也可以返回true,但是如果key在列表中,就必须返回true

8.2InternalFilterPolicy

这是一个简单的FilterPolicy的wrapper,以方便的把FilterPolicy应用在InternalKey上,InternalKey是Leveldb内部使用的key,这些前面都讲过。它所做的就是从InternalKey拆分得到user key,然后在user key上做FilterPolicy的操作。
它有一个成员:

1
constFilterPolicy* const user_policy_;

Name()返回的是user_policy_->Name()

1
2
3
4
5
6
7
8
9
10
11
bool InternalFilterPolicy::KeyMayMatch(const Slice& key, constSlice& f) const 
{
returnuser_policy_->KeyMayMatch(ExtractUserKey(key), f);
}
void InternalFilterPolicy::CreateFilter(const Slice* keys,
int n,std::string* dst) const
{
Slice* mkey =const_cast<Slice*>(keys);
for (int i = 0; i < n; i++)mkey[i] = ExtractUserKey(keys[i]);
user_policy_->CreateFilter(keys, n, dst);
}

8.3 BloomFilter

8.3.1 基本理论

Bloom Filter实际上是一种hash算法,数学之美系列有专门介绍。它是由巴顿.布隆于一九七零年提出的,它实际上是一个很长的二进制向量和一系列随机映射函数

Bloom Filter将元素映射到一个长度为m的bit向量上的一个bit,当这个bit是1时,就表示这个元素在集合内。使用hash的缺点就是元素很多时可能有冲突,为了减少误判,就使用k个hash函数计算出k个bit,只要有一个bit为0,就说明元素肯定不在集合内。下面的图8.3-1是一个示意图。

在leveldb的实现中,Name()返回”leveldb.BuiltinBloomFilter”,因此metaindex block中的key就是filter.leveldb.BuiltinBloomFilter。Leveldb使用了double hashing来模拟多个hash函数,当然这里不是用来解决冲突的。

和线性再探测(linearprobing)一样,Double hashing从一个hash值开始,重复向前迭代,直到解决冲突或者搜索完hash表。不同的是,double hashing使用的是另外一个hash函数,而不是固定的步长。

给定两个独立的hash函数h1和h2,对于hash表T和值k,第i次迭代计算出的位置就是:h(i, k) = (h1(k) + i*h2(k)) mod |T|。对此,Leveldb选择的hash函数是:

1
2
Gi(x)=H1(x)+iH2(x)
H2(x)=(H1(x)>>17) | (H1(x)<<15)

H1是一个基本的hash函数,H2是由H1循环右移得到的,Gi(x)就是第i次循环得到的hash值。在bloom_filter的数据的最后一个字节存放的是k_的值,k_实际上就是G(x)的个数,也就是计算时采用的hash函数个数

8.3.2 BloomFilter参数

这里先来说下其两个成员变量:bits_per_key_key_;其实这就是Bloom Hashing的两个关键参数。变量k_实际上就是模拟的hash函数的个数;

关于变量bits_per_key_,对于n个key,其hash table的大小就是bits_per_key_。它的值越大,发生冲突的概率就越低,那么bloom hashing误判的概率就越低。因此这是一个时间空间的trade-off

对于hash(key),在平均意义上,发生冲突的概率就是1 / bits_per_key_。它们在构造函数中根据传入的参数bits_per_key初始化

1
2
3
4
bits_per_key_ = bits_per_key;  
k_ =static_cast<size_t>(bits_per_key * 0.69); // 0.69 =~ ln(2)
if (k_ < 1) k_ = 1;
if (k_ > 30) k_ = 30;

模拟hash函数的个数k_取值为bits_per_key_*ln(2),为何不是0.5或者0.4了,可能是什么理论推导的结果吧,不了解了。

8.3.3 建立BloomFilter

了解了上面的理论,再来看leveldb对Bloom Fil**ter的实现就轻松多了,先来看Bloom Filter的构建。这就是FilterPolicy::CreateFilter接口的实现**:

1
void CreateFilter(const Slice* keys, int n, std::string* dst) const

下面分析其实现代码,大概有如下几个步骤:

S1

首先根据key个数分配filter空间,并圆整到8byte。

1
2
3
4
5
6
7
size_t bits = n * bits_per_key_;  
if (bits < 64) bits = 64;
// 如果n太小FP会很高,限定filter的最小长度
size_t bytes = (bits + 7) / 8; // 圆整到8byte
bits = bytes * 8; // bit计算的空间大小
const size_t init_size =dst->size();
dst->resize(init_size +bytes, 0); // 分配空间
S2

在filter最后的字节位压入hash函数个数

1
2
dst->push_back(static_cast<char>(k_));
// Remember # of probes in filter
S3

对于每个key,使用double-hashing生产一系列的hash值h(K_个),设置bits array的第h位=1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
char* array =&(*dst)[init_size];  
for (size_t i = 0; i < n;i++)
{
// double-hashing,分析参见[Kirsch,Mitzenmacher 2006]
uint32_t h =BloomHash(keys[i]);
// h1函数
const uint32_t delta = (h>> 17) | (h << 15);
// h2函数、由h1 Rotate right 17 bits
for (size_t j = 0; j < k_; j++)
{
// double-hashing生产k_个的hash值
const uint32_t bitpos = h% bits;
// 在bits array上设置第bitpos位
array[bitpos/8] |= (1<< (bitpos % 8));
h += delta;
}
}

Bloom Filter的创建就完成了。

8.3.4 查找BloomFilter

在指定的filer中查找key是否存在,这就是bloom filter的查找函数:
bool KeyMayMatch(const Slice& key, const Slice& bloom_filter),函数逻辑如下:

S1

准备工作,并做些基本判断。

1
2
3
4
5
6
7
8
const size_t len =bloom_filter.size();  
if (len < 2) return false;
const char* array = bloom_filter.data();
const size_t bits = (len - 1)* 8;
const size_t k = array[len-1];
// 使用filter的k,而不是k_,这样更灵活
if (k > 30) return true;
// 为短bloom filter保留,当前认为直接match
S2

计算key的hash值,重复计算阶段的步骤,循环计算k个hash值,只要有一个结果对应的bit位为0,就认为不匹配,否则认为匹配。

1
2
3
4
5
6
7
8
9
10
uint32_t h = BloomHash(key);  
const uint32_t delta = (h>> 17) | (h << 15); // Rotate right 17 bits
for (size_t j = 0; j < k;j++)
{
const uint32_t bitpos = h %bits;
if ((array[bitpos/8] &(1 << (bitpos % 8))) == 0) return false;
// notmatch
h += delta;
}
return true; // match

8.4 Filter Block格式

Filter Block也就是前面sstable中的meta block,位于data block之后。

如果打开db时指定了FilterPolicy,那么每个创建的table都会保存一个filter block,table中的metaindex就包含一条从”filter.到filter block的BlockHandle的映射,其中””是filter policy的Name()函数返回的string

Filter block存储了一连串的filter值,其中第i个filter保存的是block b中所有的key通过FilterPolicy::CreateFilter()计算得到的结果,block b在sstable文件中的偏移满足[ i*base … (i+1)*base-1 ]

当前base是2KB,举个例子,如果block X和Y在sstable的起始位置都在[0KB, 2KB-1]中,X和Y中的所有key调用FilterPolicy::CreateFilter()的计算结果都将生产到同一个filter中,而且该filter是filter block的第一个filter。

Filter block也是一个block,其格式遵从block的基本格式:|block data| type | crc32|。其中block dat的格式如图8.4-1所示。

8.5 构建FilterBlock

8.5.1 FilterBlockBuilder

了解了filter机制,现在来看看filter block的构建,这就是类FilterBlockBuilder。它为指定的table构建所有的filter,结果是一个string字符串,并作为一个block存放在table中。它有三个函数接口:

1
2
3
4
5
6
7
8
// 开始构建新的filter block,TableBuilder在构造函数和Flush中调用  
void StartBlock(uint64_tblock_offset);

// 添加key,TableBuilder每次向data block中加入key时调用
void AddKey(const Slice&key);

// 结束构建,TableBuilder在结束对table的构建时调用
Slice Finish();

FilterBlockBuilder的构建顺序必须满足如下范式:(StartBlock AddKey*)* Finish,显然这和前面讲过的BlockBuilder有所不同。
其成员变量有:

1
2
3
4
5
6
const FilterPolicy* policy_; // filter类型,构造函数参数指定  
std::string keys_; //Flattened key contents
std::vector<size_t> start_; // 各key在keys_中的位置
std::string result_; // 当前计算出的filter data
std::vector<uint32_t>filter_offsets_; // 各个filter在result_中的位置
std::vector<Slice> tmp_keys_;// policy_->CreateFilter()参数

前面说过base是2KB,这对应两个常量kFilterBase =11, kFilterBase =(1<<kFilterBaseLg);其实从后面的实现来看tmp_keys_完全不必作为成员变量,直接作为函数GenerateFilter()的栈变量就可以。下面就分别分析三个函数接口。

8.5.2 FilterBlockBuilder::StartBlock()

它根据参数block_offset计算出filter index,然后循环调用GenerateFilter生产新的Filter。

1
2
3
uint64_t filter_index =(block_offset / kFilterBase);  
assert(filter_index >=filter_offsets_.size());
while (filter_index >filter_offsets_.size()) GenerateFilter();

我们来到GenerateFilter这个函数,看看它的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//S1 如果filter中key个数为0,则直接压入result_.size()并返回  
const size_t num_keys =start_.size();
if (num_keys == 0) { // there are no keys for this filter
filter_offsets_.push_back(result_.size()); //result_.size()应该是0
return;
}

//S2 从key创建临时key list,根据key的序列字符串kyes_和各key在keys_
//中的开始位置start_依次提取出key。

start_.push_back(keys_.size()); // Simplify lengthcomputation
tmp_keys_.resize(num_keys);
for (size_t i = 0; i < num_keys; i++) {
const char* base =keys_.data() + start_[i]; // 开始指针
size_t length = start_[i+1] -start_[i]; // 长度
tmp_keys_[i] = Slice(base,length);

}

//S3 为当前的key集合生产filter,并append到result_

filter_offsets_.push_back(result_.size());
policy_->CreateFilter(&tmp_keys_[0], num_keys, &result_);

//S4 清空,重置状态

tmp_keys_.clear();
keys_.clear();
start_.clear();

8.5.3 FilterBlockBuilder::AddKey()

这个接口很简单,就是把key添加到key_中,并在start_中记录位置。

1
2
3
Slice k = key;  
start_.push_back(keys_.size());
keys_.append(k.data(),k.size());

8.5.4 FilterBlockBuilder::Finish()

调用这个函数说明整个table的data block已经构建完了,可以生产最终的filter block了,在TableBuilder::Finish函数中被调用,向sstable写入meta block。函数逻辑为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//S1 如果start_数字不空,把为的key列表生产filter  

if (!start_.empty()) GenerateFilter();

//S2 从0开始顺序存储各filter的偏移值,见filter block data的数据格式。

const uint32_t array_offset =result_.size();
for (size_t i = 0; i < filter_offsets_.size();i++) {
PutFixed32(&result_,filter_offsets_[i]);
}

//S3 最后是filter个数,和shift常量(11),并返回结果
PutFixed32(&result_,array_offset);
result_.push_back(kFilterBaseLg); // Save encoding parameter in result
return Slice(result_);

8.5.5 简单示例

让我们根据TableBuilder对FilterBlockBuilder接口的调用范式:
(StartBlock AddKey) Finish以及上面的函数实现,结合一个简单例子看看leveldb是如何为data block创建filter block(也就是meta block)的。
考虑两个datablock,在sstable的范围分别是:Block 1 [0, 7KB-1], Block 2 [7KB, 14.1KB]

  • S1 首先TableBuilder为Block 1调用FilterBlockBuilder::StartBlock(0),该函数直接返回;
  • S2 然后依次向Block 1加入k/v,其中会调用FilterBlockBuilder::AddKey,FilterBlockBuilder记录这些key。
  • S3 下一次TableBuilder添加k/v时,例行检查发现Block 1的大小超过设置,则执行Flush操作,Flush操作在写入Block 1后,开始准备Block 2并更新block offset=7KB,最后调用FilterBlockBuilder::StartBlock(7KB),开始为Block 2构建Filter。
  • S4 在FilterBlockBuilder::StartBlock(7KB)中,计算出filter index = 3,触发3次GenerateFilter函数,为Block 1添加的那些key列表创建filter,其中第2、3次循环创建的是空filter。
    • 在StartBlock(7KB)时会向filter的偏移数组filter_offsets_压入两个包含空key set的元素,filter_offsets_[1]和filter_offsets_[2],它们的值都等于7KB-1。
  • S5 Block 2构建结束,TableBuilder调用Finish结束table的构建,这会再次触发Flush操作,在写入Block 2后,为Block 2的key创建filter。
  • 这里如果Block 1的范围是[0, 1.8KB-1],Block 2从1.8KB开始,那么Block 2将会和Block 1共用一个filter,它们的filter都被生成到filter 0中。
    • 当然在TableBuilder构建表时,Block的大小是根据参数配置的,也是基本均匀的。

8.6 读取FilterBlock

8.6.1 FilterBlockReader

FilterBlock的读取操作在FilterBlockReader类中,它的主要功能是根据传入的FilterPolicy和filter,进行key的匹配查找。
它有如下的几个成员变量:

1
2
3
4
5
const FilterPolicy* policy_; // filter策略  
const char* data_; // filter data指针 (at block-start)
const char* offset_; // offset array的开始地址 (at block-end)
size_t num_; // offsetarray元素个数
size_t base_lg_; // 还记得kFilterBaseLg吗

Filter策略和filter block内容都由构造函数传入。一个接口函数,就是key的批判查找:

1
bool KeyMayMatch(uint64_t block_offset, const Slice& key);

8.6.2 构造

在构造函数中,根据存储格式解析出偏移数组开始指针、个数等信息。

1
2
3
4
5
6
7
8
9
10
11
12
FilterBlockReader::FilterBlockReader(const FilterPolicy* policy, 
constSlice& contents)
: policy_(policy),data_(NULL), offset_(NULL), num_(0), base_lg_(0) {

size_t n = contents.size();
if (n < 5) return; // 1 byte forbase_lg_ and 4 for start of offset array
base_lg_ = contents[n-1]; // 最后1byte存的是base
uint32_t last_word =DecodeFixed32(contents.data() + n - 5); //偏移数组的位置
if (last_word > n - 5)return;
data_ = contents.data();
offset_ = data_ + last_word; // 偏移数组开始指针
num_ = (n - 5 - last_word) / 4; // 计算出filter个数

8.6.3 查找

查找函数传入两个参数

  • @block_offset是查找data block在sstable中的偏移,Filter根据此偏移计算filter的编号;
  • @key是查找的key。

声明如下:bool FilterBlockReader::KeyMayMatch(uint64_t block_offset, constSlice& key)

首先计算出filterindex,根据index解析出filter的range,如果是合法的range,就从data_中取出filter,调用policy_做key的匹配查询。函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint64_t index = block_offset>> base_lg_; // 计算出filter index  

if (index < num_) {
// 解析出filter的range
uint32_t start =DecodeFixed32(offset_ + index*4);
uint32_t limit =DecodeFixed32(offset_ + index*4 + 4);
if (start <= limit&& limit <= (offset_ - data_)) {
Slice filter = Slice(data_ +start, limit - start); // 根据range得到filter
returnpolicy_->KeyMayMatch(key, filter);
} else if (start == limit) {
return false; // 空filter不匹配任何key
}
}
return true; // 当匹配处理

至此,FilterPolicy和Bloom就分析完了。

9 LevelDB框架之1

到此为止,基本上Leveldb的主要功能组件都已经分析完了,下面就是把它们组合在一起,形成一个高性能的k/v存储系统。这就是leveldb::DB类。

这里先看一下LevelDB的导出接口和涉及的类,后面将依次以接口分析的方式展开。而实际上leveldb::DB只是一个接口类,真正的实现和框架类是DBImpl这个类,正是它集合了上面的各种组件。此外,还有Leveldb对版本的控制,执行版本控制的是VersionVersionSet类。在leveldb的源码中,DBImpl和VersionSet是两个庞然大物,体量基本算是最大的。对于这两个类的分析,也会分散在打开、销毁和快照等等这些功能中,很难在一个地方集中分析。

作者在文档impl.html中描述了leveldb的实现,其中包括文件组织compactionrecovery等等。下面的9.1和9.2基本都是翻译子impl.html文档。在进入框架代码之前,先来了解下leveldb的文件组织和管理。

9.1 DB文件管理

9.1.1 文件类型

对于一个数据库Level包含如下的6种文件:

1/[0-9]+.log:db操作日志
这就是前面分析过的操作日志,log文件包含了最新的db更新,每个更新都以append的方式追加到文件结尾。当log文件达到预定大小时(缺省大约4MB),leveldb就把它转换为一个有序表(如下-2),并创建一个新的log文件。
当前的log文件在内存中的存在形式就是memtable,每次read操作都会访问memtable,以保证read读取到的是最新的数据。

2/[0-9]+.sst:db的sstable文件
这两个就是前面分析过的静态sstable文件,sstable存储了以key排序的元素。每个元素或者是key对应的value,或者是key的删除标记(删除标记可以掩盖更老sstable文件中过期的value)。

Leveldbsstable文件通过level的方式组织起来,从log文件中生成的sstable被放在level 0。当level 0的sstable文件个数超过设置(当前为4个)时,leveldb就把所有的level 0文件,以及有重合的level 1文件merge起来,组织成一个新的level 1文件(每个level 1文件大小为2MB)。

Level 0的SSTable文件(后缀为.sst)和Level>1的文件相比有特殊性:这个层级内的.sst文件,两个文件可能存在key重叠。对于Level>0,同层sstable文件的key不会重叠。考虑level>0,level中的文件的总大小超过10^level MB时(如level=1是10MB,level=2是100MB),那么level中的一个文件,以及所有level+1中和它有重叠的文件,会被merge到level+1层的一系列新文件。Merge操作的作用是将更新从低一级level迁移到最高级,只使用批量读写(最小化seek操作,提高效率)。

3/MANIFEST-[0-9]+:DB元信息文件
它记录的是leveldb的元信息,比如DB使用的Comparator名,以及各SSTable文件的管理信息:如Level层数、文件名、最小key和最大key等等。

4/CURRENT:记录当前正在使用的Manifest文件
它的内容就是当前的manifest文件名;因为在LevleDb的运行过程中,随着Compaction的进行,新的SSTable文件被产生,老的文件被废弃。并生成新的Manifest文件来记载sstable的变动,而CURRENT则用来记录我们关心的Manifest文件。

当db被重新打开时,leveldb总是生产一个新的manifest文件。Manifest文件使用log的格式,对服务状态的改变(新加或删除的文件)都会追加到该log中。

上面的log文件、sst文件、清单文件,末尾都带着序列号,其序号都是单调递增的(随着next_file_number从1开始递增),以保证不和之前的文件名重复。

5/log:系统的运行日志,记录系统的运行信息或者错误日志。

6/dbtmp:临时数据库文件,repair时临时生成的。
这里就涉及到几个关键的number计数器,log文件编号,下一个文件(sstable、log和manifest)编号,sequence。
所有正在使用的文件编号,包括log、sstable和manifest都应该小于下一个文件编号计数器。

9.1.2 Level 0

当操作log超过一定大小时(缺省是1MB),执行如下操作:

  • S1 创建新的memtable和log文件,并重导向新的更新到新memtable和log中;
  • S2 在后台:
  • S2.1 将前一个memtable的内容dump到sstable文件;
  • S2.2 丢弃前一个memtable;
  • S2.3 删除旧的log文件和memtable
  • S2.4 把创建的sstable文件放到level 0

9.2 Compaction

level L的总文件大小查过限制时,我们就在后台执行compaction操作。Compaction操作从level L中选择一个文件f,以及选择中所有和f有重叠的文件。如果某个level (L+1)的文件ff只是和f部分重合,compaction依然选择ff的完整内容作为输入,在compaction后f和ff都会被丢弃。

另外:因为level 0有些特殊(同层文件可能有重合),从level 0到level 1的compaction就需要特殊对待:level 0的compaction可能会选择多个level 0文件,如果它们之间有重叠。

Compaction将选择的文件内容merge起来,并生成到一系列的level (L+1)文件中,如果输出文件超过设置(2MB),就切换到新的。当输出文件的key范围太大以至于和超过10个level (L+2)文件有重合时,也会切换。后一个规则确保了level (L+1)的文件不会和过多的level (L+2)文件有重合,其后的level (L+1) compaction不会选择过多的level (L+2)文件。

老的文件会被丢弃,新创建的文件将加入到server状态中。

Compaction操作在key空间中循环执行,详细讲一点就是,对于每个level,我们记录上次compaction的ending key。Level的下一次compaction将选择ending key之后的第一个文件(如果这样的文件不存在,将会跳到key空间的开始)。

Compaction会忽略被写覆盖的值,如果更高一层的level没有文件的范围包含了这个key,key的删除标记也会被忽略。

9.2.1 时间

Level 0的compaction最多从level 0读取4个1MB的文件,以及所有的level 1文件(10MB),也就是我们将读取14MB,并写入14BM。

Level > 0的compaction,从level L选择一个2MB的文件,最坏情况下,将会和levelL+1的12个文件有重合(10:level L+1的总文件大小是level L的10倍;边界的2:level L的文件范围通常不会和level L+1的文件对齐)。因此Compaction将会读26MB,写26MB。对于100MB/s的磁盘IO来讲,compaction将最坏需要0.5秒。

如果磁盘IO更低,比如10MB/s,那么compaction就需要更长的时间5秒。如果user以10MB/s的速度写入,我们可能生成很多level 0文件(50个来装载5*10MB的数据)。这将会严重影响读取效率,因为需要merge更多的文件。

  • 解决方法1:为了降低该问题,我们可能想增加log切换的阈值,缺点就是,log文件越大,对应的memtable文件就越大,这需要更多的内存。
  • 解决方法2:当level 0文件太多时,人工降低写入速度。
  • 解决方法3:降低merge的开销,如把level 0文件都无压缩的存放在cache中。

9.2.2 文件数

对于更高的level我们可以创建更大的文件,而不是2MB,代价就是更多突发性的compaction。或者,我们可以考虑分区,把文件放存放多目录中。
在2011年2月4号,作者做了一个实验,在ext3文件系统中打开100KB的文件,结果表明可以不需要分区。

  • 文件数 文件打开ms
  • 1000 9
  • 10000 10
  • 100000 16

9.3 Recovery & GC

9.3.1 Recovery

Db恢复的步骤:

  • S1 首先从CURRENT读取最后提交的MANIFEST
  • S2 读取MANIFEST内容
  • S3 清除过期文件
  • S4 这里可以打开所有的sstable文件,但是更好的方案是lazy open
  • S5 把log转换为新的level 0sstable
  • S6 将新写操作导向到新的log文件,从恢复的序号开始

9.3.2 GC

垃圾回收,每次compaction和recovery之后都会有文件被废弃,成为垃圾文件。GC就是删除这些文件的,它在每次compaction和recovery完成之后被调用。

9.4 版本控制

当执行一次compaction后,Leveldb将在当前版本基础上创建一个新版本,当前版本就变成了历史版本。还有,如果你创建了一个Iterator,那么该Iterator所依附的版本将不会被leveldb删除。

在leveldb中,Version就代表了一个版本,它包括当前磁盘及内存中的所有文件信息。在所有的version中,只有一个是CURRENTVersionSet是所有Version的集合,这是个version的管理机构。

前面讲过的VersionEdit记录了Version之间的变化,相当于delta增量,表示又增加了多少文件,删除了文件。也就是说:Version0 + VersionEdit —> Version1

每次文件有变动时,leveldb就把变动记录到一个VersionEdit变量中,然后通过VersionEdit把变动应用到current version上,并把current version的快照,也就是db元信息保存到MANIFEST文件中。

另外,MANIFEST文件组织是以VersionEdit的形式写入的,它本身是一个log文件格式,采用log::Writer/Reader的方式读写,一个VersionEdit就是一条log record。

9.4.1 VersionSet

和DBImpl一样,下面就初识一下Version和VersionSet。先来看看Version的成员:

1
2
3
4
5
6
7
8
std::vector<FileMetaData*>files_[config::kNumLevels]; // sstable文件列表  
// Next fileto compact based on seek stats. 下一个要compact的文件
FileMetaData* file_to_compact_;
int file_to_compact_level_;
// 下一个应该compact的level和compaction分数.
// 分数 < 1 说明compaction并不紧迫. 这些字段在Finalize()中初始化
double compaction_score_;
int compaction_level_;

可见一个Version就是一个sstable文件集合,以及它管理的compact状态。Version通过Version prev和next指针构成了一个Version双向循环链表,表头指针则在VersionSet中(初始都指向自己)。
下面是VersionSet的成员。可见它除了通过Version管理所有的sstable文件外,还关心manifest文件信息,以及控制log文件等编号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//=== 第一组,直接来自于DBImple,构造函数传入  
Env* const env_; // 操作系统封装
const std::string dbname_;
const Options* const options_;
TableCache* const table_cache_; // table cache
const InternalKeyComparatoricmp_;

//=== 第二组,db元信息相关
uint64_t next_file_number_; // log文件编号
uint64_t manifest_file_number_; // manifest文件编号
uint64_t last_sequence_;
uint64_t log_number_; // log编号
uint64_t prev_log_number_; // 0 or backingstore for memtable being compacted

//=== 第三组,menifest文件相关
WritableFile* descriptor_file_;
log::Writer* descriptor_log_;

//=== 第四组,版本管理
Version dummy_versions_; // versions双向链表head.
Version* current_; // ==dummy_versions_.prev_
// level下一次compaction的开始key,空字符串或者合法的InternalKey
std::stringcompact_pointer_[config::kNumLevels];

关于版本控制大概了解其Version和VersionEdit的功能和管理范围,详细的函数操作在后面再慢慢揭开。

9.4.2 VersionEdit

LevelDB中对Manifest的Decode/Encode是通过类VersionEdit完成的,Menifest文件保存了LevelDB的管理元信息。VersionEdit这个名字起的蛮有意思,每一次compaction,都好比是生成了一个新的DB版本,对应的Menifest则保存着这个版本的DB元信息。VersionEdit并不操作文件,只是为Manifest文件读写准备好数据、从读取的数据中解析出DB元信息。
VersionEdit有两个作用:

  1. 当版本间有增量变动时,VersionEdit记录了这种变动;
  2. 写入到MANIFEST时,先将current version的db元信息保存到一个VersionEdit中,然后在组织成一个log record写入文件;

了解了VersionEdit的作用,来看看这个类导出的函数接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void Clear(); // 清空信息  
void Setxxx(); // 一系列的Set函数,设置信息

// 添加sstable文件信息,要求:DB元信息还没有写入磁盘Manifest文件
// @level:.sst文件层次;@file 文件编号-用作文件名 @size 文件大小
// @smallest, @largest:sst文件包含k/v对的最大最小key
void AddFile(int level, uint64_t file, uint64_t file_size,
constInternalKey& smallest, const InternalKey& largest);
void DeleteFile(int level, uint64_t file); // 从指定的level删除文件
void EncodeTo(std::string* dst) const; // 将信息Encode到一个string中
Status DecodeFrom(const Slice& src); // 从Slice中Decode出DB元信息

//===================下面是成员变量,由此可大概窥得DB元信息的内容。
typedef std::set< std::pair<int, uint64_t> > DeletedFileSet;
std::string comparator_; // key comparator名字
uint64_t log_number_; // 日志编号
uint64_t prev_log_number_; // 前一个日志编号
uint64_t next_file_number_; // 下一个文件编号
SequenceNumber last_sequence_; // 上一个seq
bool has_comparator_; // 是否有comparator
bool has_log_number_;// 是否有log_number_
bool has_prev_log_number_;// 是否有prev_log_number_
bool has_next_file_number_;// 是否有next_file_number_
bool has_last_sequence_;// 是否有last_sequence_
std::vector< std::pair<int, InternalKey> >compact_pointers_; // compact点
DeletedFileSet deleted_files_; // 删除文件集合
std::vector< std::pair<int, FileMetaData> > new_files_; // 新文件集合

Set系列的函数都很简单,就是根据参数设置相应的信息。
AddFile函数就是根据参数生产一个FileMetaData对象,把sstable文件信息添加到new_files_数组中。
DeleteFile函数则是把参数指定的文件添加到deleted_files中;
SetCompactPointer函数把{level, key}指定的compact点加入到compact_pointers_中。
执行序列化和发序列化的是DecodeEncode函数,根据这些代码,我们可以了解Manifest文件的存储格式。序列化函数逻辑都很直观,不详细说了。

9.4.3 Manifest文件格式

前面说过Manifest文件记录了leveldb的管理元信息,这些元信息到底都包含哪些内容呢?下面就来一一列示。
首先是使用的coparator名、log编号、前一个log编号、下一个文件编号、上一个序列号。这些都是日志、sstable文件使用到的重要信息,这些字段不一定必然存在。

Leveldb在写入每个字段之前,都会先写入一个varint型数字来标记后面的字段类型。在读取时,先读取此字段,根据类型解析后面的信息。一共有9种类型:

1
2
3
kComparator = 1, kLogNumber = 2, kNextFileNumber = 3, kLastSequence = 4,
kCompactPointer = 5, kDeletedFile = 6, kNewFile = 7, kPrevLogNumber = 9
// 8 was used for large value refs

其中8另有它用。

其次是compact点,可能有多个,写入格式为{kCompactPointer, level, internal key}。其后是删除文件,可能有多个,格式为{kDeletedFile, level, file number}。最后是新文件,可能有多个,格式为{kNewFile, level, file number, file size, min key, max key}

对于版本间变动它是新加的文件集合,对于MANIFEST快照是该版本包含的所有sstable文件集合。

其中的数字都是varint存储格式,string都是以varint指明其长度,后面跟实际的字符串内容。

9.5 DB接口

9.5.1 接口函数

除了DB类, leveldb还导出了C语言风格的接口:接口和实现在c.h&c.cc,它其实是对leveldb::DB的一层封装。DB是一个持久化的有序map{key, value},它是线程安全的。DB只是一个虚基类,下面来看看其接口:

首先是一个静态函数,打开一个db,成功返回OK,打开的db指针保存在dbptr中,用完后,调用者需要调用`delete dbptr`删除之。

1
static Status Open(const Options& options, const std::string&name, DB** dbptr);

下面几个是纯虚函数,最后还有两个全局函数,为何不像Open一样作为静态函数呢。
注:在几个更新接口中,可考虑设置options.sync = true。另外,虽然是纯虚函数,但是leveldb还是提供了缺省的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 设置db项{key, value}  
virtual Status Put(const WriteOptions& options, const Slice&key, const Slice& value) = 0;
// 在db中删除"key",key不存在依然返回成功
virtual Status Delete(const WriteOptions& options, const Slice&key) = 0;
// 更新操作
virtual Status Write(const WriteOptions& options, WriteBatch*updates) = 0;
// 获取操作,如果db中有”key”项则返回结果,没有就返回Status::IsNotFound()
virtual Status Get(const ReadOptions& options, const Slice& key,std::string* value) = 0;
// 返回heap分配的iterator,访问db的内容,返回的iterator的位置是invalid的
// 在使用之前,调用者必须先调用Seek。
virtual Iterator* NewIterator(const ReadOptions& options) = 0;
// 返回当前db状态的handle,和handle一起创建的Iterator看到的都是
// 当前db状态的稳定快照。不再使用时,应该调用ReleaseSnapshot(result)
virtual const Snapshot* GetSnapshot() = 0;

// 释放获取的db快照
virtual voidReleaseSnapshot(const Snapshot* snapshot) = 0;

// 借此方法DB实现可以展现它们的属性状态. 如果"property" 是合法的,
// 设置"*value"为属性的当前状态值并返回true,否则返回false.
// 合法属性名包括:
//
// >"leveldb.num-files-at-level<N>"– 返回level <N>的文件个数,
// <N> 是level 数的ASCII 值 (e.g. "0").
// >"leveldb.stats" – 返回描述db内部操作统计的多行string
// >"leveldb.sstables" – 返回一个多行string,描述构成db内容的所有sstable
virtual bool GetProperty(constSlice& property, std::string* value) = 0;

//"sizes[i]"保存的是"[range[i].start.. range[i].limit)"中的key使用的文件空间.
// 注:返回的是文件系统的使用空间大概值,
// 如果用户数据以10倍压缩,那么返回值就是对应用户数据的1/10
// 结果可能不包含最近写入的数据大小.
virtual voidGetApproximateSizes(const Range* range, int n, uint64_t* sizes) = 0;

// Compactkey范围[*begin,*end]的底层存储,删除和被覆盖的版本将会被抛弃
// 数据会被重新组织,以减少访问开销
// 注:那些不了解底层实现的用户不应该调用该方法。
//begin==NULL被当作db中所有key之前的key.
//end==NULL被当作db中所有key之后的key.
// 所以下面的调用将会compact整个db:
// db->CompactRange(NULL, NULL);
virtual void CompactRange(constSlice* begin, const Slice* end) = 0;

// 最后是两个全局函数--删除和修复DB
// 要小心,该方法将删除指定db的所有内容
Status DestroyDB(const std::string& name, const Options&options);
// 如果db不能打开了,你可能调用该方法尝试纠正尽可能多的数据
// 可能会丢失数据,所以调用时要小心
Status RepairDB(const std::string& dbname, const Options&options);

9.5.2 类图

这里又会设计到几个功能类,如图9.5-1所示。此外还有前面我们讲过的几大组件:操作日志的读写类、内存MemTable类、InternalFilterPolicy类、Internal Key比较类、以及sstable的读取构建类。如图9.5-2所示。

这里涉及的类很多,snapshot是内存快照,Version和VersionSet类。

9.6 DBImpl类

在向下继续之前,有必要先了解下DBImpl这个具体的实现类。主要是它的成员变量,这说明了它都利用了哪些组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
  //== 第一组,他们在构造函数中初始化后将不再改变。其中,InternalKeyComparator和InternalFilterPolicy已经分别在Memtable和FilterPolicy中分析过。  
Env* const env_; // 环境,封装了系统相关的文件操作、线程等等
const InternalKeyComparatorinternal_comparator_; // key comparator
const InternalFilterPolicyinternal_filter_policy_; // filter policy
const Options options_; //options_.comparator == &internal_comparator_
bool owns_info_log_;
bool owns_cache_;
const std::string dbname_;

//== 第二组,只有两个。
TableCache* table_cache_; // Table cache,线程安全的
FileLock* db_lock_;// 锁db文件,persistent state,直到leveldb进程结束

//== 第三组,被mutex_包含的状态和成员
port::Mutex mutex_; // 互斥锁
port::AtomicPointershutting_down_;
port::CondVar bg_cv_; // 在background work结束时激发
MemTable* mem_;
MemTable* imm_; // Memtablebeing compacted
port::AtomicPointerhas_imm_; // BGthread 用来检查是否是非NULL的imm_

// 这三个是log相关的
WritableFile* logfile_; // log文件
uint64_t logfile_number_; // log文件编号
log::Writer* log_; // log writer

//== 第四组,没有规律
std::deque<Writer*>writers_; // writers队列.
WriteBatch* tmp_batch_;
SnapshotList snapshots_; //snapshot列表

// Setof table files to protect from deletion because they are
// part ofongoing compactions.
std::set<uint64_t>pending_outputs_; // 待copact的文件列表,保护以防误删
bool bg_compaction_scheduled_; // 是否有后台compaction在调度或者运行?
Status bg_error_; // paranoid mode下是否有后台错误?
ManualCompaction*manual_compaction_; // 手动compaction信息
CompactionStatsstats_[config::kNumLevels]; // compaction状态
VersionSet* versions_; // 多版本DB文件,又一个庞然大物

10.Version分析之一

先来分析leveldb对单版本的sstable文件管理,主要集中在Version类中。前面的10.4节已经说明了Version类的功能和成员,这里分析其函数接口和代码实现。
Version不会修改其管理的sstable文件,只有读取操作。

10.1 Version接口

先来看看Version类的接口函数,接下来再一一分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 追加一系列iterator到 @*iters中,
//将在merge到一起时生成该Version的内容

// 要求: Version已经保存了(见VersionSet::SaveTo)

void AddIterators(constReadOptions&,
std::vector<Iterator*>* iters);


// 给定@key查找value,如果找到保存在@*val并返回OK。
// 否则返回non-OK,设置@ *stats.
// 要求:没有hold lock

struct GetStats {
FileMetaData* seek_file;
int seek_file_level;
};

Status Get(constReadOptions&, const LookupKey& key,
std::string* val,GetStats* stats);

// 把@stats加入到当前状态中,如果需要触发新的compaction返回true

// 要求:hold lock
bool UpdateStats(constGetStats& stats);
void GetOverlappingInputs(intlevel,
const InternalKey*begin, // NULL 指在所有key之前
const InternalKey* end, // NULL指在所有key之后
std::vector<FileMetaData*>* inputs);

// 如果指定level中的某些文件和[*smallest_user_key,*largest_user_key]
//有重合就返回true。

// @smallest_user_key==NULL表示比DB中所有key都小的key.
// @largest_user_key==NULL表示比DB中所有key都大的key.

bool OverlapInLevel(int level,const Slice*smallest_user_key,
const Slice* largest_user_key);

// 返回我们应该在哪个level上放置新的memtable compaction,
// 该compaction覆盖了范围[smallest_user_key,largest_user_key].

int PickLevelForMemTableOutput(const Slice& smallest_user_key,
const Slice& largest_user_key);

// 指定level的sstable个数
int NumFiles(int level) const {return files_[level].size();

10.2 Version::AddIterators()

该函数最终在DB::NewIterators()接口中被调用,调用层次为:
DBImpl::NewIterator()->DBImpl::NewInternalIterator()->Version::AddIterators()

函数功能是为该Version中的所有sstable都创建一个Two Level Iterator,以遍历sstable的内容。

  • 对于level=0级别的sstable文件,直接通过TableCache::NewIterator()接口创建,这会直接载入sstable文件到内存cache中。
  • 对于level>0级别的sstable文件,通过函数NewTwoLevelIterator()创建一个TwoLevelIterator,这就使用了lazy open的机制。

下面来分析函数代码:

S1

对于level=0级别的sstable文件,直接装入cache,level0的sstable文件可能有重合,需要merge。

1
2
3
4
for (size_t i = 0; i <files_[0].size(); i++) {  
iters->push_back(vset_->table_cache_->NewIterator(// versionset::table_cache_
options,files_[0][i]->number, files_[0][i]->file_size));
}

S2

对于level>0级别的sstable文件,lazy open机制,它们不会有重叠。

1
2
3
for (int ll = 1; ll <config::kNumLevels; ll++) {  
if(!files_[ll].empty()) iters->push_back(NewConcatenatingIterator(options,level));
}

函数NewConcatenatingIterator()直接返回一个TwoLevelIterator对象:

1
2
return NewTwoLevelIterator(new LevelFileNumIterator(vset_->icmp_,&files_[level]),
&GetFileIterator,vset_->table_cache_, options);
  • 其第一级iterator是一个LevelFileNumIterator
  • 第二级的迭代函数是GetFileIterator

下面就来分别分析之。
GetFileIterator是一个静态函数,很简单,直接返回TableCache::NewIterator()。函数声明为:

1
2
3
4
5
6
7
8
9
static Iterator* GetFileIterator(void* arg,const ReadOptions& options, constSlice& file_value)
TableCache* cache =reinterpret_cast<TableCache*>(arg);
if (file_value.size() != 16) { // 错误
return NewErrorIterator(Status::Corruption("xxx"));
} else {
return cache->NewIterator(options,
DecodeFixed64(file_value.data()), // filenumber
DecodeFixed64(file_value.data() + 8)); // filesize
}

这里的file_value是取自于LevelFileNumIterator的value,它的value()函数把file number和size以Fixed 8byte的方式压缩成一个Slice对象并返回。

10.3 Version::LevelFileNumIterator类

这也是一个继承者Iterator的子类,一个内部Iterator。

给定一个version/level对,生成该level内的文件信息。

对于给定的entry

  • key()返回的是文件中所包含的最大的key;
  • value()返回的是|file number(8 bytes)|file size(8 bytes)|串;
  • 它的构造函数接受两个参数:InternalKeyComparator&,用于key的比较;
  • vector*,指向version的所有sstable文件列表。
1
2
3
LevelFileNumIterator(const InternalKeyComparator& icmp,
const std::vector<FileMetaData*>* flist)
: icmp_(icmp), flist_(flist),index_(flist->size()) {} // Marks as invalid

来看看其接口实现。

Valid函数、SeekToxx和Next/Prev函数都很简单,毕竟容器是一个vector。Seek函数调用了FindFile,这个函数后面会分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
virtual void Seek(constSlice& target) { index_ = FindFile(icmp_, *flist_, target);}  
virtual void SeekToFirst() {index_ = 0; }
virtual void SeekToLast() {index_ = flist_->empty() ? 0 : flist_->size() - 1;}
virtual void Next() {
assert(Valid());
index_++;
}

virtual void Prev() {
assert(Valid());
if (index_ == 0) index_ =flist_->size(); // Marks as invalid
else index_--;
}

Slice key() const {
assert(Valid());
return(*flist_)[index_]->largest.Encode(); // 返回当前sstable包含的largest key
}

Slice value() const { // 根据|number|size|的格式Fixed int压缩
assert(Valid());
EncodeFixed64(value_buf_,(*flist_)[index_]->number);
EncodeFixed64(value_buf_+8,(*flist_)[index_]->file_size);
return Slice(value_buf_,sizeof(value_buf_));
}

来看FindFile,这其实是一个二分查找函数,因为传入的sstable文件列表是有序的,因此可以使用二分查找算法。就不再列出代码了。

10.4 Version::Get()

查找函数,直接在DBImpl::Get()中被调用,函数原型为:

1
Status Version::Get(const ReadOptions& options, constLookupKey& k, std::string* value, GetStats* stats)

如果本次Get不止seek了一个文件(仅会发生在level 0的情况),就将搜索的第一个文件保存在stats中。如果stat有数据返回,表明本次读取在搜索到包含key的sstable文件之前,还做了其它无谓的搜索。这个结果将用在UpdateStats()中。
这个函数逻辑还是有些复杂的,来看看代码。

S1

首先,取得必要的信息,初始化几个临时变量

1
2
3
4
5
6
7
8
9
10
Slice ikey = k.internal_key();  
Slice user_key = k.user_key();
const Comparator* ucmp =vset_->icmp_.user_comparator();
Status s;
stats->seek_file = NULL;
stats->seek_file_level = -1;
FileMetaData* last_file_read =NULL; // 在找到>1个文件时,读取时记录上一个
int last_file_read_level = -1; // 这仅发生在level 0的情况下
std::vector<FileMetaData*>tmp;
FileMetaData* tmp2;

S2

从0开始遍历所有的level,依次查找。因为entry不会跨越level,因此如果在某个level中找到了entry,那么就无需在后面的level中查找了。

1
2
3
4
5
for (int level = 0; level < config::kNumLevels; level++) {  
size_t num_files = files_[level].size();
if (num_files == 0) continue; // 本层没有文件,则直接跳过
// 取得level下的所有sstable文件列表,搜索本层
FileMetaData* const* files = &files_[level][0];

后面的所有逻辑都在for循环体中。

S3

遍历level下的sstable文件列表,搜索,注意对于level=0和>0的sstable文件的处理,由于level 0文件之间的key可能有重叠,因此处理逻辑有别于>0的level。

S3.1

对于level 0,文件可能有重叠,找到所有和user_key有重叠的文件,然后根据时间顺序从最新的文件依次处理。

1
2
3
4
5
6
7
8
9
10
11
12
tmp.reserve(num_files);  

for (uint32_t i = 0; i <num_files; i++) { // 遍历level 0下的所有sstable文件
FileMetaData* f =files[i];
if(ucmp->Compare(user_key, f->smallest.user_key()) >= 0 &&
ucmp->Compare(user_key, f->largest.user_key()) <= 0)
tmp.push_back(f); // sstable文件有user_key有重叠
}

if (tmp.empty()) continue;
std::sort(tmp.begin(),tmp.end(), NewestFirst); // 排序
files = &tmp[0]; num_files= tmp.size();// 指向tmp指针和大小
S3.2

对于level>0,leveldb保证sstable文件之间不会有重叠,所以处理逻辑有别于level 0,直接根据ikey定位到sstable文件即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
//二分查找,找到第一个largest key >=ikey的file index  
uint32_t index =FindFile(vset_->icmp_, files_[level], ikey);
if (index >= num_files) { // 未找到,文件不存在
files = NULL; num_files = 0;
} else {
tmp2 = files[index];
if(ucmp->Compare(user_key, tmp2->smallest.user_key()) < 0) {
// 找到的文件其所有key都大于user_key,等于文件不存在
files = NULL; num_files = 0;
} else {
files = &tmp2; num_files = 1;
}
}

S4

遍历找到的文件,存在files中,其个数为num_files。

1
for (uint32_t i = 0; i <num_files; ++i) {

后面的逻辑都在这一层循环中,只要在某个文件中找到了k/v对,就跳出for循环。

S4.1

如果本次读取不止搜索了一个文件,记录之,这仅会发生在level 0的情况下。

1
2
3
4
5
6
7
8
9
if(last_file_read != NULL && stats->seek_file == NULL) {  
// 本次读取不止seek了一个文件,记录第一个
stats->seek_file =last_file_read;
stats->seek_file_level= last_file_read_level;
}

FileMetaData* f = files[i];
last_file_read = f; // 记录本次读取的level和file
last_file_read_level =level;
S4.2

调用TableCache::Get()尝试获取{ikey, value},如果返回OK则进入,否则直接返回,传递的回调函数是SaveValue()。

1
2
3
4
5
6
7
8
Saver saver; // 初始化saver  
saver.state = kNotFound;
saver.ucmp = ucmp;
saver.user_key = user_key;
saver.value = value;
s = vset_->table_cache_->Get(options,f->number, f->file_size,
ikey, &saver, SaveValue);
if (!s.ok()) return s;
S4.3

根据saver的状态判断,如果是Not Found则向下搜索下一个更早的sstable文件,其它值则返回。

1
2
3
4
5
6
7
8
9
10
switch (saver.state) {  
case kNotFound: break; // 继续搜索下一个更早的sstable文件
case kFound: return s; // 找到
case kDeleted: // 已删除
s =Status::NotFound(Slice()); // 为了效率,使用空的错误字符串
return s;
case kCorrupt: // 数据损坏
s =Status::Corruption("corrupted key for ", user_key);
return s;
}

以上就是Version::Get()的代码逻辑,如果level 0的sstable文件太多的话,会影响读取速度,这也是为什么进行compaction的原因。
另外,还有一个传递给TableCache::Get()的saver函数,下面就来简单分析下。这是一个静态函数:static void SaveValue(void* arg,const Slice& ikey, const Slice& v)。它内部使用了结构体Saver:

1
2
3
4
5
6
struct Saver {
SaverState state;
const Comparator* ucmp; // user key比较器
Slice user_key;
std::string* value;
};

函数SaveValue的逻辑很简单。首先解析Table传入的InternalKey,然后根据指定的Comparator判断user key是否是要查找的user key。如果是并且type是kTypeValue,则设置到Saver::value中,并*返回kFound,否则返回kDeleted。代码如下:

1
2
3
4
5
6
7
8
9
Saver* s =reinterpret_cast<Saver*>(arg);  
ParsedInternalKey parsed_key; // 解析ikey到ParsedInternalKey
if (!ParseInternalKey(ikey,&parsed_key)) s->state = kCorrupt; // 解析失败
else {
if(s->ucmp->Compare(parsed_key.user_key, s->user_key) == 0) { // 比较user key
s->state =(parsed_key.type == kTypeValue) ? kFound : kDeleted;
if (s->state == kFound) s->value->assign(v.data(), v.size()); // 找到,保存结果
}
}

下面要分析的几个函数,或多或少都和compaction相关。

10.5 Version::UpdateStats()

Get操作直接搜寻memtable没有命中时,就需要调用Version::Get()函数从磁盘load数据文件并查找。如果此次Get不止seek了一个文件,就记录第一个文件到stat并返回。其后leveldb就会调用UpdateStats(stat)

Stat表明在指定key range查找key时,都要先seek此文件,才能在后续的sstable文件中找到key

该函数是将stat记录的sstable文件的allowed_seeks减1,减到0就执行compaction。也就是说如果文件被seek的次数超过了限制,表明读取效率已经很低,需要执行compaction了。所以说allowed_seeks是对compaction流程的有一个优化。

函数声明:boolVersion::UpdateStats(const GetStats& stats)函数逻辑很简单:

1
2
3
4
5
6
7
8
9
10
FileMetaData* f =stats.seek_file;  
if (f != NULL) {
f->allowed_seeks--;
if (f->allowed_seeks <=0 && file_to_compact_ == NULL) {
file_to_compact_ = f;
file_to_compact_level_ =stats.seek_file_level;
return true;
}
}
return false;

变量allowed_seeks的值在sstable文件加入到version时确定,也就是后面将遇到的VersionSet::Builder::Apply()函数。

10.6 Version::GetOverlappingInputs()

它在指定level中找出和[begin, end]有重合的sstable文件,函数声明为:

1
2
void Version::GetOverlappingInputs(int level,
const InternalKey* begin, constInternalKey* end, std::vector<FileMetaData*>* inputs);

要注意的是,对于level0,由于文件可能有重合,其处理具有特殊性。当在level 0中找到有sstable文件和[begin, end]重合时,会相应的将begin/end扩展到文件的min key/max key,然后重新开始搜索。了解了功能,下面分析函数实现代码,逻辑还是很直观的。

S1 首先根据参数初始化查找变量。

1
2
3
4
5
inputs->clear();  
Slice user_begin, user_end;
if (begin != NULL) user_begin =begin->user_key();
if (end != NULL) user_end = end->user_key();
const Comparator* user_cmp =vset_->icmp_.user_comparator();

S2 遍历该层的sstable文件,比较sstable的{minkey,max key}和传入的[begin, end],如果有重合就记录文件到@inputs中,需要对level 0做特殊处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
for (size_t i = 0; i <files_[level].size(); ) {  
FileMetaData* f =files_[level][i++];
const Slice file_start =f->smallest.user_key();
const Slice file_limit =f->largest.user_key();
if (begin != NULL &&user_cmp->Compare(file_limit, user_begin) < 0) {
//"f" 中的k/v全部在指定范围之前; 跳过
} else if (end != NULL&& user_cmp->Compare(file_start, user_end) > 0) {
//"f" 中的k/v全部在指定范围之后; 跳过
} else {
inputs->push_back(f); // 有重合,记录
if (level == 0) {
// 对于level 0,sstable文件可能相互有重叠,所以要检查新加的文件
// 是否范围更大,如果是则扩展范围重新开始搜索
if (begin != NULL&& user_cmp->Compare(file_start, user_begin) < 0) {
user_begin = file_start;
inputs->clear();
i = 0;
} else if (end != NULL&& user_cmp->Compare(file_limit, user_end) > 0) {
user_end = file_limit;
inputs->clear();
i = 0;
}
}
}
}

10.7 Version::OverlapInLevel()

检查是否和指定level的文件有重合,该函数直接调用了SomeFileOverlapsRange(),这两个函数的声明为:

1
2
3
4
5
6
7
8
9
10
11
bool Version::OverlapInLevel(int level,const Slice*smallest_user_key, 
const Slice* largest_user_key){
return SomeFileOverlapsRange(vset_->icmp_,(level > 0), files_[level],
smallest_user_key, largest_user_key);
}

bool SomeFileOverlapsRange(const InternalKeyComparator& icmp,
bool disjoint_sorted_files,
const std::vector<FileMetaData*>& files,const
Slice*smallest_user_key,
const Slice* largest_user_key);

所以下面直接分析SomeFileOverlapsRange()函数的逻辑,代码很直观。
disjoint_sorted_files=true,表明文件集合是互不相交、有序的,对于乱序的、可能有交集的文件集合,需要逐个查找,找到有重合的就返回true;对于有序、互不相交的文件集合,直接执行二分查找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// S1 乱序、可能相交的文件集合,依次查找  
for (size_t i = 0; i < files.size(); i++) {
const FileMetaData* f =files[i];
if(AfterFile(ucmp,smallest_user_key, f) ||
BeforeFile(ucmp, largest_user_key, f)){
} else
return true; // 有重合
}

// S2 有序&互不相交,直接二分查找
uint32_t index = 0;
if (smallest_user_key != NULL) {
// Findthe earliest possible internal key smallest_user_key
InternalKeysmall(*smallest_user_key, kMaxSequenceNumber,kValueTypeForSeek);
index = FindFile(icmp, files,small.Encode());
}
if (index >= files.size())
// 不存在比smallest_user_key小的key
return false;
//保证在largest_user_key之后
return !BeforeFile(ucmp,largest_user_key, files[index]);

上面的逻辑使用到了AfterFile()BeforeFile()两个辅助函数,都很简单。

1
2
3
4
5
6
7
8
9
static bool AfterFile(const Comparator* ucmp,  
const Slice* user_key, constFileMetaData* f) {
return (user_key!=NULL&& ucmp->Compare(*user_key, f->largest.user_key())>0);
}

static bool BeforeFile(const Comparator* ucmp,
constSlice* user_key, const FileMetaData* f) {
return (user_key!=NULL&& ucmp->Compare(*user_key, f->smallest.user_key())<0);
}

10.8 Version::PickLevelForMemTableOutput()

函数返回我们应该在哪个level上放置新的memtable compaction,这个compaction覆盖了范围[smallest_user_key,largest_user_key]
该函数的调用链为:

1
DBImpl::RecoverLogFile/DBImpl::CompactMemTable -> DBImpl:: WriteLevel0Table->Version::PickLevelForMemTableOutput;

函数声明如下:

1
int Version::PickLevelForMemTableOutput(const Slice& smallest_user_key, constSlice& largest_user_key);

如果level 0没有找到重合就向下一层找,最大查找层次为kMaxMemCompactLevel = 2。如果在level 0or1找到了重合,就返回level 0。否则查找level 2,如果level 2有重合就返回level 1,否则返回level 2。
函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int level = 0;  
//level 0无重合
if (!OverlapInLevel(0,&smallest_user_key, &largest_user_key)) {
// 如果下一层没有重叠,就压到下一层,
// andthe #bytes overlapping in the level after that are limited.
InternalKeystart(smallest_user_key, kMaxSequenceNumber, kValueTypeForSeek);
InternalKeylimit(largest_user_key, 0, static_cast<ValueType>(0));
std::vector<FileMetaData*> overlaps;
while (level <config::kMaxMemCompactLevel) {
if (OverlapInLevel(level +1, &smallest_user_key, &largest_user_key))
break; // 检查level + 1层,有重叠就跳出循环
GetOverlappingInputs(level +2, &start, &limit, &overlaps); // 没理解这个调用
const int64_t sum =TotalFileSize(overlaps);
if (sum >kMaxGrandParentOverlapBytes) break;
level++;
}
}
return level;

这个函数在整个compaction逻辑中的作用在分析DBImpl时再来结合整个流程分析,现在只需要了解它找到一个level存放新的compaction就行了。如果返回level = 0,表明在level 0或者1和指定的range有重叠;如果返回1,表明在level2和指定的range有重叠;否则就返回2(kMaxMemCompactLevel)。也就是说在compactmemtable的时候,写入的sstable文件不一定总是在level 0,如果比较顺利,没有重合的,它可能会写到level1或者level2中。

10.9 小结

Version是管理某个版本的所有sstable的类,就其导出接口而言,无非是遍历sstable,查找k/v。以及为compaction做些事情,给定range,检查重叠情况。
而它不会修改它管理的sstable这些文件,对这些文件而言它是只读操作接口。

11 VersionSet分析

Version之后就是VersionSet,它并不是Version的简单集合,还肩负了不少的处理逻辑。这里的分析不涉及到compaction相关的部分,这部分会单独分析。包括log等各种编号计数器,compaction点的管理等等。

11.1 VersionSet接口

1 首先是构造函数,VersionSet会使用到TableCache,这个是调用者传入的。TableCache用于Get k/v操作。

1
2
VersionSet(const std::string& dbname, const Options* options,
TableCache*table_cache, const InternalKeyComparator*);

VersionSet的构造函数很简单,除了根据参数初始化,还有两个地方值得注意:

  • N1 next_file_number_从2开始;
  • N2 创建新的Version并加入到Version链表中,并设置CURRENT=新创建version;
  • 其它的数字初始化为0,指针初始化为NULL。

2 恢复函数,从磁盘恢复最后保存的元信息

1
Status Recover();

3 标记指定的文件编号已经被使用了

1
void MarkFileNumberUsed(uint64_t number);

逻辑很简单,就是根据编号更新文件编号计数器:

1
2
if (next_file_number_ <= number) 
next_file_number_ = number + 1;

4 在current version上应用指定的VersionEdit,生成新的MANIFEST信息,保存到磁盘上,并用作current version。
要求:没有其它线程并发调用;要用于mu;

1
Status LogAndApply(VersionEdit* edit, port::Mutex* mu)EXCLUSIVE_LOCKS_REQUIRED(mu);

5 对于@v中的@key,返回db中的大概位置

1
uint64_t ApproximateOffsetOf(Version* v, const InternalKey& key);

6 其它一些简单接口,信息获取或者设置,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//返回current version
Version* current() const {
return current_;
}

// 当前的MANIFEST文件号
uint64_t ManifestFileNumber() const {
return manifest_file_number_;
}

// 分配并返回新的文件编号
uint64_t NewFileNumber() {
return next_file_number_++;
}

// 返回当前log文件编号
uint64_t LogNumber() const {
return log_number_;
}

// 返回正在compact的log文件编号,如果没有返回0
uint64_t PrevLogNumber() const {
return prev_log_number_;
}

// 获取、设置last sequence,set时不能后退
uint64_t LastSequence() const {
return last_sequence_;
}

void SetLastSequence(uint64_t s) {
assert(s >=last_sequence_);
last_sequence_ = s;
}

// 返回指定level中所有sstable文件大小的和
int64_t NumLevelBytes(int level) const;

// 返回指定level的文件个数
int NumLevelFiles(int level) const;

// 重用@file_number,限制很严格:@file_number必须是最后分配的那个
// 要求: @file_number是NewFileNumber()返回的.

void ReuseFileNumber(uint64_t file_number) {
if (next_file_number_ ==file_number + 1) next_file_number_ = file_number;
}

// 对于所有level>0,遍历文件,找到和下一层文件的重叠数据的最大值(in bytes)
// 这个就是Version:: GetOverlappingInputs()函数的简单应用
int64_t MaxNextLevelOverlappingBytes();

// 获取函数,把所有version的所有level的文件加入到@live中
void AddLiveFiles(std::set<uint64_t>* live);

// 返回一个可读的单行信息——每个level的文件数,保存在*scratch中
struct LevelSummaryStorage {char buffer[100]; };
const char* LevelSummary(LevelSummaryStorage* scratch) const;

下面就来分析这两个接口RecoverLogAndApply以及ApproximateOffsetOf

11.2 VersionSet::Builder类

Builder是一个内部辅助类,其主要作用是:

  1. 把一个MANIFEST记录的元信息应用到版本管理器VersionSet中;
  2. 把当前的版本状态设置到一个Version对象中。

11.2.1 成员与构造

Builder的vset_与base_都是调用者传入的,此外它还为FileMetaData定义了一个比较类BySmallestKey,首先依照文件的min key,小的在前;如果min key相等则file number小的在前。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedefstd::set<FileMetaData*, BySmallestKey> FileSet;  
// 这个是记录添加和删除的文件
struct LevelState {
std::set<uint64_t>deleted_files;
// 保证添加文件的顺序是有效定义的
FileSet* added_files;
};
VersionSet* vset_;
Version* base_;
LevelStatelevels_[config::kNumLevels];

// 其接口有3个:
void Apply(VersionEdit* edit);
void SaveTo(Version* v);
void MaybeAddFile(Version* v, int level, FileMetaData* f);

构造函数执行简单的初始化操作,在析构时,遍历检查LevelState::added_files,如果文件引用计数为0,则删除文件。

11.2.2 Apply()

函数声明:voidApply(VersionEdit* edit),该函数将edit中的修改应用到当前状态中。注意除了compaction点直接修改了vset_,其它删除和新加文件的变动只是先存储在Builder自己的成员变量中,在调用SaveTo(v)函数时才施加到v上。

S1 把edit记录的compaction点应用到当前状态

1
edit->compact_pointers_ => vset_->compact_pointer_

S2 把edit记录的已删除文件应用到当前状态

1
edit->deleted_files_ => levels_[level].deleted_files

S3把edit记录的新加文件应用到当前状态,这里会初始化文件的allowed_seeks值,以在文件被无谓seek指定次数后自动执行compaction,这里作者阐述了其设置规则。

1
2
3
4
5
6
7
8
9
for (size_t i = 0; i < edit->new_files_.size(); i++) {  
const int level =edit->new_files_[i].first;
FileMetaData* f = newFileMetaData(edit->new_files_[i].second);
f->refs = 1;
f->allowed_seeks = (f->file_size /16384); // 16KB-见下面
if (f->allowed_seeks <100) f->allowed_seeks = 100;
levels_[level].deleted_files.erase(f->number); // 以防万一
levels_[level].added_files->insert(f);
}

值allowed_seeks事关compaction的优化,其计算依据如下,首先假设:

  • 1 一次seek时间为10ms
  • 2 写入10MB数据的时间为10ms(100MB/s)
  • 3 compact 1MB的数据需要执行25MB的IO
    • 从本层读取1MB
    • 从下一层读取10-12MB(文件的key range边界可能是非对齐的)
    • 向下一层写入10-12MB

这意味这25次seek的代价等同于compact 1MB的数据,也就是一次seek花费的时间大约相当于compact 40KB的数据。基于保守的角度考虑,对于每16KB的数据,我们允许它在触发compaction之前能做一次seek。

11.2.3 MaybeAddFile()

函数声明:

1
voidMaybeAddFile(Version* v, int level, FileMetaData* f);

该函数尝试将f加入到levels_[level]文件set中。要满足两个条件:

  1. 文件不能被删除,也就是不能在levels_[level].deleted_files集合中;
  2. 保证文件之间的key是连续的,即基于比较器vset_->icmp_,f的min key要大于levels_[level]集合中最后一个文件的max key;

11.2.4 SaveTo()

把当前的状态存储到v中返回,函数声明:

1
void SaveTo(Version* v);

函数逻辑:For循环遍历所有的level[0, config::kNumLevels-1],把新加的文件和已存在的文件merge在一起,丢弃已删除的文件,结果保存在v中。对于level> 0,还要确保集合中的文件没有重合。

S1 merge流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 原文件集合
conststd::vector<FileMetaData*>& base_files = base_->files_[level];
std::vector<FileMetaData*>::const_iterator base_iter =base_files.begin();
std::vector<FileMetaData*>::const_iterator base_end =base_files.end();
const FileSet* added =levels_[level].added_files;
v->files_[level].reserve(base_files.size()+ added->size());
for (FileSet::const_iteratoradded_iter = added->begin();
added_iter !=added->end(); ++added_iter) {
//加入base_中小于added_iter的那些文件

for(std::vector<FileMetaData*>::const_iterator bpos = std::upper_bound(base_iter,base_end,*added_iter, cmp);
base_iter != bpos;++base_iter) {
// base_iter逐次向后移到
MaybeAddFile(v, level,*base_iter);
}
// 加入added_iter
MaybeAddFile(v, level,*added_iter);
}

// 添加base_剩余的那些文件
for (; base_iter != base_end;++base_iter)
MaybeAddFile(v, level, *base_iter);

对象cmp就是前面定义的比较仿函数BySmallestKey对象。

S2 检查流程,保证level>0的文件集合无重叠,基于vset_->icmp_,确保文件i-1的max key < 文件i的min key。

11.3 Recover()

对于VersionSet而言,Recover就是根据CURRENT指定的MANIFEST,读取db元信息。这是9.3介绍的Recovery流程的开始部分。

11.3.1 函数流程

下面就来分析其具体逻辑。

S1 读取CURRENT文件,获得最新的MANIFEST文件名,根据文件名打开MANIFEST文件。CURRENT文件以\n结尾,读取后需要trim下。

1
2
3
4
5
std::string current; // MANIFEST文件名  
ReadFileToString(env_, CurrentFileName(dbname_), ¤t);
std::string dscname = dbname_ + "/" + current;
SequentialFile* file;
env_->NewSequentialFile(dscname, &file);

S2 读取MANIFEST内容,MANIFEST是以log的方式写入的,因此这里调用的是log::Reader来读取。然后调用VersionEdit::DecodeFrom,从内容解析出VersionEdit对象,并将VersionEdit记录的改动应用到versionset中。读取MANIFEST中的log number, prev log number, nextfile number, last sequence。

1
2
3
4
5
6
7
8
9
10
11
12
Builder builder(this, current_);  
while (reader.ReadRecord(&record, &scratch) && s.ok()) {
VersionEdit edit;
s = edit.DecodeFrom(record);
if (s.ok())builder.Apply(&edit);
// log number, file number, …逐个判断
if (edit.has_log_number_) {
log_number =edit.log_number_;
have_log_number = true;
}
… …
}

S3 将读取到的log number, prev log number标记为已使用。

1
2
MarkFileNumberUsed(prev_log_number);
MarkFileNumberUsed(log_number);

S4 最后,如果一切顺利就创建新的Version,并应用读取的几个number。

1
2
3
4
5
6
7
8
9
10
11
12
if (s.ok()) {  
Version* v = newVersion(this);
builder.SaveTo(v);
// 安装恢复的version
Finalize(v);
AppendVersion(v);
manifest_file_number_ =next_file;
next_file_number_ = next_file+ 1;
last_sequence_ = last_sequence;
log_number_ = log_number;
prev_log_number_ =prev_log_number;
}

Finalize(v)AppendVersion(v)用来安装并使用version v,在AppendVersion函数中会将current version设置为v。下面就来分别分析这两个函数。

11.3.2 Finalize()

函数声明:

1
void Finalize(Version*v);

该函数依照规则为下次的compaction计算出最适用的level,对于level 0和>0需要分别对待,逻辑如下。

S1 对于level 0以文件个数计算,kL0_CompactionTrigger默认配置为4。

1
score =v->files_[level].size()/static_cast<double>(config::kL0_CompactionTrigger);

S2 对于level>0,根据level内的文件总大小计算

1
2
const uint64_t level_bytes = TotalFileSize(v->files_[level]);
score = static_cast<double>(level_bytes) /MaxBytesForLevel(level);

S3 最后把计算结果保存到v的两个成员compaction_level_和compaction_score_中。

其中函数MaxBytesForLevel根据level返回其本层文件总大小的预定最大值。
计算规则为:1048576.0* level^10
这里就有一个问题,为何level0和其它level计算方法不同,原因如下,这也是leveldb为compaction所做的另一个优化。

  1. 对于较大的写缓存(write-buffer),做太多的level 0 compaction并不好
  2. 每次read操作都要merge level 0的所有文件,因此我们不希望level 0有太多的小文件存在(比如写缓存太小,或者压缩比较高,或者覆盖/删除较多导致小文件太多)。
  3. 看起来这里的写缓存应该就是配置的操作log大小。

11.3.3 AppendVersion()

函数声明:

1
void AppendVersion(Version*v);

把v加入到versionset中,并设置为current version。并对老的current version执行Uref()。在双向循环链表中的位置在dummy_versions_之前。

11.4 LogAndApply()

函数声明:

1
Status LogAndApply(VersionEdit*edit, port::Mutex* mu)

前面接口小节中讲过其功能:在currentversion上应用指定的VersionEdit,生成新的MANIFEST信息,保存到磁盘上,并用作current version,故为Log And Apply。
参数edit也会被函数修改。

11.4.1 函数流程

下面就来具体分析函数代码。

S1 为edit设置log number等4个计数器。

1
2
3
4
5
6
7
8
if (edit->has_log_number_) {
assert(edit->log_number_ >= log_number_);
assert(edit->log_number_ < next_file_number_);
}
else edit->SetLogNumber(log_number_);
if (!edit->has_prev_log_number_) edit->SetPrevLogNumber(prev_log_number_);
edit->SetNextFile(next_file_number_);
edit->SetLastSequence(last_sequence_);

要保证edit自己的log number是比较大的那个,否则就是致命错误。保证edit的log number小于next file number,否则就是致命错误-见9.1小节。

S2 创建一个新的Version v,并把新的edit变动保存到v中。

1
2
3
4
5
6
7
Version* v = new Version(this);
{
Builder builder(this, current_);
builder.Apply(edit);
builder.SaveTo(v);
}
Finalize(v); //如前分析,只是为v计算执行compaction的最佳level

S3 如果MANIFEST文件指针不存在,就创建并初始化一个新的MANIFEST文件。这只会发生在第一次打开数据库时。这个MANIFEST文件保存了current version的快照。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::string new_manifest_file;
Status s;
if (descriptor_log_ == NULL) {
// 这里不需要unlock *mu因为我们只会在第一次调用LogAndApply时
// 才走到这里(打开数据库时).
assert(descriptor_file_ == NULL); // 文件指针和log::Writer都应该是NULL
new_manifest_file = DescriptorFileName(dbname_, manifest_file_number_);
edit->SetNextFile(next_file_number_);
s = env_->NewWritableFile(new_manifest_file, &descriptor_file_);
if (s.ok()) {
descriptor_log_ = new log::Writer(descriptor_file_);
s = WriteSnapshot(descriptor_log_); // 写入快照
}
}

S4 向MANIFEST写入一条新的log,记录current version的信息。在文件写操作时unlock锁,写入完成后,再重新lock,以防止浪费在长时间的IO操作上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mu->Unlock();
if (s.ok()) {
std::string record;
edit->EncodeTo(&record);// 序列化current version信息
s = descriptor_log_->AddRecord(record); // append到MANIFEST log中
if (s.ok()) s = descriptor_file_->Sync();
if (!s.ok()) {
Log(options_->info_log, "MANIFEST write: %s\n", s.ToString().c_str());
if (ManifestContains(record)) { // 返回出错,其实确实写成功了
Log(options_->info_log, "MANIFEST contains log record despiteerror ");
s = Status::OK();
}
}
}
//如果刚才创建了一个MANIFEST文件,通过写一个指向它的CURRENT文件
//安装它;不需要再次检查MANIFEST是否出错,因为如果出错后面会删除它
if (s.ok() && !new_manifest_file.empty()) {
s = SetCurrentFile(env_, dbname_, manifest_file_number_);
}
mu->Lock();

S5 安装这个新的version

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (s.ok()) { // 安装这个version  
AppendVersion(v);
log_number_ = edit->log_number_;
prev_log_number_ = edit->prev_log_number_;
}
else { // 失败了,删除
delete v;
if (!new_manifest_file.empty()) {
delete descriptor_log_;
delete descriptor_file_;
descriptor_log_ = descriptor_file_ = NULL;
env_->DeleteFile(new_manifest_file);
}
}

流程的S4中,函数会检查MANIFEST文件是否已经有了这条record,那么什么时候会有呢?

主函数使用到了几个新的辅助函数WriteSnapshot,ManifestContains和SetCurrentFile,下面就来分析。

11.4.2 WriteSnapshot()

函数声明:

1
Status WriteSnapshot(log::Writer*log)

把currentversion保存到*log中,信息包括comparator名字、compaction点和各级sstable文件,函数逻辑很直观。

  • S1 首先声明一个新的VersionEdit edit
  • S2 设置comparator:edit.SetComparatorName(icmp_.user_comparator()->Name());
  • S3 遍历所有level,根据compact_pointer_[level],设置compaction点:
    • edit.SetCompactPointer(level, key);
  • S4 遍历所有level,根据current_->files_,设置sstable文件集合:edit.AddFile(level, xxx)
  • S5 根据序列化并append到log(MANIFEST文件)中;
1
2
3
std::string record;
edit.EncodeTo(&record);
returnlog->AddRecord(record);

11.4.3 ManifestContains()

函数声明:

1
bool ManifestContains(conststd::string& record)

如果当前MANIFEST包含指定的record就返回true,来看看函数逻辑。

  • S1 根据当前的manifest_file_number_文件编号打开文件,创建SequentialFile对象
  • S2 根据创建的SequentialFile对象创建log::Reader,以读取文件
  • S3 调用log::Reader的ReadRecord依次读取record,如果和指定的record相同,就返回true,没有相同的record就返回false

SetCurrentFile很简单,就是根据指定manifest文件编号,构造出MANIFEST文件名,并写入到CURRENT即可。

11.5 ApproximateOffsetOf()

函数声明:

1
uint64_tApproximateOffsetOf(Version* v, const InternalKey& ikey)

在指定的version中查找指定key的大概位置。假设version中有n个sstable文件,并且落在了地i个sstable的key空间内,那么返回的位置 = sstable1文件大小+sstable2文件大小 + … + sstable (i-1)文件大小 + key在sstable i中的大概偏移。

可分为两段逻辑。

  • 首先直接和sstable的max key作比较,如果key > max key,直接跳过该文件,还记得sstable文件是有序排列的。
    • 对于level >0的文件集合而言,如果如果key < sstable文件的min key,则直接跳出循环,因为后续的sstable的min key肯定大于key。
  • key在sstable i中的大概偏移使用的是Table:: ApproximateOffsetOf(target)接口,前面分析过,它返回的是Table中>= target的key的位置。

VersionSet的相关函数暂时分析到这里,compaction部分后需单独分析。

12 DB的打开

先分析LevelDB是如何打开db的,万物始于创建。在打开流程中有几个辅助函数:DBImpl(),DBImpl::Recover, DBImpl::DeleteObsoleteFiles, DBImpl::RecoverLogFile, DBImpl::MaybeScheduleCompaction

12.1 DB::Open()

打开一个db,进行PUT、GET操作,就是前面的静态函数DB::Open的工作。如果操作成功,它就返回一个db指针。前面说过DB就是一个接口类,其具体实现在DBImp类中,这是一个DB的子类。
函数声明为:

1
Status DB::Open(const Options& options, const std::string&dbname, DB** dbptr);

分解来看,Open()函数主要有以下5个执行步骤。

  • S1 创建DBImpl对象,其后进入DBImpl::Recover()函数执行S2和S3。
  • S2 从已存在的db文件恢复db数据,根据CURRENT记录的MANIFEST文件读取db元信息;这通过调用VersionSet::Recover()完成。
  • S3 然后过滤出那些最近的更新log,前一个版本可能新加了这些log,但并没有记录在MANIFEST中。然后依次根据时间顺序,调用DBImpl::RecoverLogFile()从旧到新回放这些操作log。回放log时可能会修改db元信息,比如dump了新的level 0文件,因此它将返回一个VersionEdit对象,记录db元信息的变动。
  • S4 如果DBImpl::Recover()返回成功,就执行VersionSet::LogAndApply()应用VersionEdit,并保存当前的DB信息到新的MANIFEST文件中。
  • S5 最后删除一些过期文件,并检查是否需要执行compaction,如果需要,就启动后台线程执行。

下面就来具体分析Open函数的代码,在Open函数中涉及到上面的3个流程。

S1 首先创建DBImpl对象,锁定并试图做Recover操作。Recover操作用来处理创建flag,比如存在就返回失败等等,尝试从已存在的sstable文件恢复db。并返回db元信息的变动信息,一个VersionEdit对象。

1
2
3
4
DBImpl* impl = newDBImpl(options, dbname);  
impl->mutex_.Lock(); // 锁db
VersionEdit edit;
Status s =impl->Recover(&edit); // 处理flag&恢复:create_if_missing,error_if_exists

S2 如果Recover返回成功,则调用VersionSet取得新的log文件编号——实际上是在当前基础上+1,准备新的log文件。如果log文件创建成功,则根据log文件创建log::Writer。然后执行VersionSet::LogAndApply,根据edit记录的增量变动生成新的current version,并写入MANIFEST文件。

函数NewFileNumber(){returnnext_file_number_++;},直接返回next_file_number_

1
2
3
4
5
6
7
8
9
10
uint64_t new_log_number = impl->versions_->NewFileNumber();
WritableFile* lfile;
s = options.env->NewWritableFile(LogFileName(dbname, new_log_number), &lfile);
if (s.ok()) {
edit.SetLogNumber(new_log_number);
impl->logfile_ = lfile;
impl->logfile_number_ = new_log_number;
impl->log_ = newlog::Writer(lfile);
s = impl->versions_->LogAndApply(&edit, &impl->mutex_);
}

S3 如果VersionSet::LogAndApply返回成功,则删除过期文件,检查是否需要执行compaction,最终返回创建的DBImpl对象。

1
2
3
4
5
6
7
if (s.ok()) {
impl->DeleteObsoleteFiles();
impl->MaybeScheduleCompaction();
}
impl->mutex_.Unlock();
if (s.ok()) *dbptr = impl;
return s;

以上就是DB::Open的主题逻辑。

12.2 DBImpl::DBImpl()

构造函数做的都是初始化操作,

1
DBImpl::DBImpl(const Options& options, const std::string&dbname)

首先是初始化列表中,直接根据参数赋值,或者直接初始化。Comparator和filter policy都是参数传入的。在传递option时会首先将option中的参数合法化,logfile_number_初始化为0,指针初始化为NULL。
创建MemTable,并增加引用计数,创建WriteBatch。

1
2
3
4
5
6
7
8
mem_(newMemTable(internal_comparator_)),
tmp_batch_(new WriteBatch),
mem_->Ref();
// 然后在函数体中,创建TableCache和VersionSet。
// 为其他预留10个文件,其余的都给TableCache.
const int table_cache_size = options.max_open_files - 10;
table_cache_ = newTableCache(dbname_, &options_, table_cache_size);
versions_ = newVersionSet(dbname_, &options_, table_cache_, &internal_comparator_);

12.3 DBImp::NewDB()

当外部在调用DB::Open()时设置了option指定如果db不存在就创建,如果db不存在leveldb就会调用函数创建新的db。判断db是否存在的依据是<db name>/CURRENT文件是否存在。其逻辑很简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// S1首先生产DB元信息,设置comparator名,以及log文件编号、文件编号,以及seq no。  
VersionEdit new_db;
new_db.SetComparatorName(user_comparator()->Name());
new_db.SetLogNumber(0);
new_db.SetNextFile(2);
new_db.SetLastSequence(0);
// S2 生产MANIFEST文件,将db元信息写入MANIFEST文件。
const std::string manifest = DescriptorFileName(dbname_, 1);
WritableFile* file;
Status s = env_->NewWritableFile(manifest, &file);
if (!s.ok()) return s;
{
log::Writer log(file);
std::string record;
new_db.EncodeTo(&record);
s = log.AddRecord(record);
if (s.ok()) s = file->Close();
}
delete file;
// S3 如果成功,就把MANIFEST文件名写入到CURRENT文件中
if (s.ok())
s = SetCurrentFile(env_, dbname_, 1);
else
env_->DeleteFile(manifest);
return s;

这就是创建新DB的逻辑,很简单。

12.4 DBImpl::Recover()

函数声明为:

1
StatusDBImpl::Recover(VersionEdit* edit)

如果调用成功则设置VersionEdit。Recover的基本功能是:首先是处理创建flag,比如存在就返回失败等等;然后是尝试从已存在的sstable文件恢复db;最后如果发现有大于原信息记录的log编号的log文件,则需要回放log,更新db数据。回放期间db可能会dump新的level 0文件,因此需要把db元信息的变动记录到edit中返回。函数逻辑如下:

S1 创建目录,目录以db name命名,忽略任何创建错误,然后尝试获取db name/LOCK文件锁,失败则返回。

1
2
3
env_->CreateDir(dbname_);
Status s = env_->LockFile(LockFileName(dbname_), &db_lock_);
if (!s.ok()) return s;

S2 根据CURRENT文件是否存在,以及option参数执行检查。

  • 如果文件不存在 & create_is_missing=true,则调用函数NewDB()创建;否则报错。
  • 如果文件存在 & error_if_exists=true,则报错。

S3 调用VersionSet的Recover()函数,就是从文件中恢复数据。如果出错则打开失败,成功则向下执行S4。

1
s = versions_->Recover();

S4尝试从所有比manifest文件中记录的log要新的log文件中恢复(前一个版本可能会添加新的log文件,却没有记录在manifest中)。另外,函数PrevLogNumber()已经不再用了,仅为了兼容老版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//  S4.1 这里先找出所有满足条件的log文件:比manifest文件记录的log编号更新。  
SequenceNumber max_sequence(0);
const uint64_t min_log = versions_->LogNumber();
const uint64_t prev_log = versions_->PrevLogNumber();
std::vector<std::string>filenames;
s = env_->GetChildren(dbname_, &filenames); // 列出目录内的所有文件
uint64_t number;
FileType type;
std::vector<uint64_t>logs;
for (size_t i = 0; i < filenames.size(); i++) { // 检查log文件是否比min log更新
if (ParseFileName(filenames[i], &number, &type) && type == kLogFile
&& ((number >= min_log) || (number == prev_log))) {
logs.push_back(number);
}
}
// S4.2 找到log文件后,首先排序,保证按照生成顺序,依次回放log。并把DB元信息的变动(sstable文件的变动)追加到edit中返回。
std::sort(logs.begin(), logs.end());
for (size_t i = 0; i < logs.size(); i++) {
s = RecoverLogFile(logs[i], edit, &max_sequence);
// 前一版可能在生成该log编号后没有记录在MANIFEST中,
//所以这里我们手动更新VersionSet中的文件编号计数器
versions_->MarkFileNumberUsed(logs[i]);
}
// S4.3 更新VersionSet的sequence
if (s.ok()) {
if (versions_->LastSequence() < max_sequence)
versions_->SetLastSequence(max_sequence);
}

上面就是Recover的执行流程。

12.5 DBImpl::DeleteObsoleteFiles()

这个是垃圾回收函数,如前所述,每次compaction和recovery之后都会有文件被废弃。DeleteObsoleteFiles就是删除这些垃圾文件的,它在每次compaction和recovery完成之后被调用。

其调用点包括:DBImpl::CompactMemTable,DBImpl::BackgroundCompaction, 以及DB::Open的recovery步骤之后。它会删除所有过期的log文件,没有被任何level引用到、或不是正在执行的compaction的output的sstable文件。该函数没有参数,其代码逻辑也很直观,就是列出db的所有文件,对不同类型的文件分别判断,如果是过期文件,就删除之,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// S1 首先,确保不会删除pending文件,将versionset正在使用的所有文件加入到live中。  
std::set<uint64_t> live = pending_outputs_;
versions_->AddLiveFiles(&live); //该函数其后分析
// S2 列举db的所有文件
std::vector<std::string>filenames;
env_->GetChildren(dbname_, &filenames);
// S3 遍历所有列举的文件,根据文件类型,分别处理;
uint64_t number;
FileType type;
for (size_t i = 0; i < filenames.size(); i++) {
if (ParseFileName(filenames[i], &number, &type)) {
bool keep = true; //false表明是过期文件
// S3.1 kLogFile,log文件,根据log编号判断是否过期
keep = ((number >= versions_->LogNumber()) ||
(number == versions_->PrevLogNumber()));
// S3.2 kDescriptorFile,MANIFEST文件,根据versionset记录的编号判断
keep = (number >= versions_->ManifestFileNumber());
// S3.3 kTableFile,sstable文件,只要在live中就不能删除
// S3.4 kTempFile,如果是正在写的文件,只要在live中就不能删除
keep = (live.find(number) != live.end());
// S3.5 kCurrentFile,kDBLockFile, kInfoLogFile,不能删除
keep = true;
// S3.6 如果keep为false,表明需要删除文件,如果是table还要从cache中删除
if (!keep) {
if (type == kTableFile) table_cache_->Evict(number);
Log(options_.info_log, "Delete type=%d #%lld\n", type, number);
env_->DeleteFile(dbname_ + "/" + filenames[i]);
}
}
}

这就是删除过期文件的逻辑,其中调用到了VersionSet::AddLiveFiles函数,保证不会删除active的文件。

函数DbImpl::MaybeScheduleCompaction()放在Compaction一节分析,基本逻辑就是如果需要compaction,就启动后台线程执行compaction操作。

12.6 DBImpl::RecoverLogFile()

函数声明:

1
StatusRecoverLogFile(uint64_t log_number, VersionEdit* edit,SequenceNumber* max_sequence)

参数说明:

  • @log_number是指定的log文件编号
  • @edit记录db元信息的变化——sstable文件变动
  • @max_sequence 返回max{log记录的最大序号, *max_sequence}

该函数打开指定的log文件,回放日志。期间可能会执行compaction,生产新的level 0sstable文件,记录文件变动到edit中。它声明了一个局部类LogReporter以打印错误日志,没什么好说的,下面来看代码逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// S1 打开log文件返回SequentialFile*file,出错就返回,否则向下执行S2。  
// S2 根据log文件句柄file创建log::Reader,准备读取log。
log::Reader reader(file, &reporter, true/*checksum*/, 0/*initial_offset*/);
// S3 依次读取所有的log记录,并插入到新生成的memtable中。这里使用到了批量更新接口WriteBatch,具体后面再分析。
std::string scratch;
Slice record;
WriteBatch batch;
MemTable* mem = NULL;
while (reader.ReadRecord(&record, &scratch) && status.ok()) { // 读取全部log
if (record.size() < 12) { // log数据错误,不满足最小长度12
reporter.Corruption(record.size(), Status::Corruption("log recordtoo small"));
continue;
}
WriteBatchInternal::SetContents(&batch, record); // log内容设置到WriteBatch中
if (mem == NULL) { // 创建memtable
mem = new MemTable(internal_comparator_);
mem->Ref();
}
status = WriteBatchInternal::InsertInto(&batch, mem); // 插入到memtable中
MaybeIgnoreError(&status);
if (!status.ok()) break;
const SequenceNumber last_seq =
WriteBatchInternal::Sequence(&batch) + WriteBatchInternal::Count(&batch) - 1;
if (last_seq > *max_sequence) *max_sequence = last_seq; // 更新max sequence
// 如果mem的内存超过设置值,则执行compaction,如果compaction出,
// 立刻返回错误,DB::Open失败
if (mem->ApproximateMemoryUsage() > options_.write_buffer_size) {
status = WriteLevel0Table(mem, edit, NULL);
if (!status.ok()) break;
mem->Unref(); // 释放当前memtable
mem = NULL;
}
}
// S4 扫尾工作,如果mem != NULL,说明还需要dump到新的sstable文件中。
if (status.ok() && mem != NULL) {// 如果compaction出错,立刻返回错误
status = WriteLevel0Table(mem, edit, NULL);
}
if (mem != NULL)mem->Unref();
delete file;
return status;

把MemTabledump到sstable是函数WriteLevel0Table的工作,其实这是compaction的一部分,准备放在compaction一节来分析。

13 DB的关闭&销毁

13.1 DB关闭

外部调用者通过DB::Open()获取一个DB*对象,如果要关闭打开的DB* db对象,则直接delete db即可,这会调用到DBImpl的析构函数。析构依次执行如下的5个逻辑:

  • S1 等待后台compaction任务结束
  • S2 释放db文件锁,/lock文件
  • S3 删除VersionSet对象,并释放MemTable对象
  • S4 删除log相关以及TableCache对象
  • S5 删除options的block_cache以及info_log对象

13.2 DB销毁

函数声明:

1
StatusDestroyDB(const std::string& dbname, const Options& options)

该函数会删除掉db的数据内容,要谨慎使用。函数逻辑为:

  • S1 获取dbname目录的文件列表到filenames中,如果为空则直接返回,否则进入S2。
  • S2 锁文件<dbname>/lock,如果锁成功就执行S3
  • S3 遍历filenames文件列表,过滤掉lock文件,依次调用DeleteFile删除。
  • S4 释放lock文件,并删除之,然后删除文件夹。

Destory就执行完了,如果删除文件出现错误,记录之,依然继续删除下一个。最后返回错误代码。

14 DB的查询与遍历

分析完如何打开和关闭db,本章就继续分析如何从db中根据key查询value,以及遍历整个db

14.1 Get()

函数声明:StatusGet(const ReadOptions& options, const Slice& key, std::string* value)

从DB中查询key 对应的value,参数@options指定读取操作的选项,典型的如snapshot号,从指定的快照中读取。快照本质上就是一个sequence号,后面将单独在快照一章中分析。下面就来分析下函数逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// S1 锁mutex,防止并发,如果指定option则尝试获取snapshot;然后增加MemTable的引用值。  
MutexLock l(&mutex_);
SequenceNumber snapshot;
if (options.snapshot != NULL)
snapshot = reinterpret_cast<const SnapshotImpl*>(options.snapshot)->number_;
else snapshot = versions_->LastSequence(); // 取当前版本的最后Sequence
MemTable *mem = mem_, *imm = imm_;
Version* current = versions_->current();
mem->Ref();
if (imm != NULL) imm->Ref();
current->Ref();
// S2 从sstable文件和MemTable中读取时,释放锁mutex;之后再次锁mutex。
bool have_stat_update = false;
Version::GetStats stats;
{
mutex_.Unlock();
// 先从memtable中查询,再从immutable memtable中查询
LookupKey lkey(key, snapshot);
if (mem->Get(lkey, value, &s)) {
}
else if (imm != NULL && imm->Get(lkey, value, &s)) {
}
else { // 需要从sstable文件中查询
s = current->Get(options, lkey, value, &stats);
have_stat_update = true; // 记录之,用于compaction
}
mutex_.Lock();
}
// S3 如果是从sstable文件查询出来的,检查是否需要做compaction。最后把MemTable的引用计数减1。
if (have_stat_update &¤t->UpdateStats(stats)) {
MaybeScheduleCompaction();
}
mem->Unref();
if (imm != NULL)imm->Unref();
current->Unref();

查询是比较简单的操作,UpdateStats在前面Version一节已经分析过。

14.2 NewIterator()

函数声明:Iterator*NewIterator(const ReadOptions& options)。通过该函数生产了一个Iterator对象,调用这就可以基于该对象遍历db内容了。函数很简单,调用两个函数创建了一个二级*Iterator

1
2
3
4
5
6
7
8
Iterator* DBImpl::NewIterator(const ReadOptions& options) {
SequenceNumber latest_snapshot;
Iterator* internal_iter = NewInternalIterator(options, &latest_snapshot);
returnNewDBIterator(&dbname_, env_, user_comparator(), internal_iter,
(options.snapshot != NULL
? reinterpret_cast<constSnapshotImpl*>(options.snapshot)->number_
: latest_snapshot));
}

其中,函数NewDBIterator直接返回了一个DBIter指针

1
2
3
4
5
Iterator* NewDBIterator(const std::string* dbname, Env* env,
const Comparator*user_key_comparator, Iterator* internal_iter,
const SequenceNumber& sequence) {
return new DBIter(dbname, env, user_key_comparator, internal_iter, sequence);
}

函数NewInternalIterator有一些处理逻辑,就是收集所有能用到的iterator,生产一个Merging Iterator。这包括MemTable,Immutable MemTable,以及各sstable。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Iterator* DBImpl::NewInternalIterator(const ReadOptions& options,
SequenceNumber*latest_snapshot) {
IterState* cleanup = newIterState;
mutex_.Lock();
// 根据last sequence设置lastest snapshot,并收集所有的子iterator
*latest_snapshot = versions_->LastSequence();
std::vector<Iterator*>list;
list.push_back(mem_->NewIterator()); // >memtable
mem_->Ref();
if (imm_ != NULL) {
list.push_back(imm_->NewIterator()); // >immutablememtable
imm_->Ref();
}
versions_->current()->AddIterators(options, &list); // >current的所有sstable
Iterator* internal_iter = NewMergingIterator(&internal_comparator_, &list[0], list.size());
versions_->current()->Ref();
// 注册清理机制
cleanup->mu = &mutex_;
cleanup->mem = mem_;
cleanup->imm = imm_;
cleanup->version = versions_->current();
internal_iter->RegisterCleanup(CleanupIteratorState, cleanup, NULL);
mutex_.Unlock();
return internal_iter;
}

这个清理函数CleanupIteratorState是很简单的,对注册的对象做一下Unref操作即可。

1
2
3
4
5
6
7
8
9
static void CleanupIteratorState(void* arg1, void* arg2) {
IterState* state = reinterpret_cast<IterState*>(arg1);
state->mu->Lock();
state->mem->Unref();
if (state->imm != NULL)state->imm->Unref();
state->version->Unref();
state->mu->Unlock();
delete state;
}

可见对于db的遍历依赖于DBIter和Merging Iterator这两个迭代器,它们都是Iterator接口的实现子类。

14.3 MergingIterator

MergingIterator是一个合并迭代器,它内部使用了一组自Iterator,保存在其成员数组children_中。如上面的函数NewInternalIterator,包括memtable,immutable memtable,以及各sstable文件;它所做的就是根据调用者指定的key和sequence,从这些Iterator中找到合适的记录。

在分析其Iterator接口之前,先来看看两个辅助函数FindSmallest和FindLargest。FindSmallest从0开始向后遍历内部Iterator数组,找到key最小的Iterator,并设置到current_;FindLargest从最后一个向前遍历内部Iterator数组,找到key最大的Iterator,并设置到current_;

MergingIterator还定义了两个移动方向:kForward,向前移动;kReverse,向后移动。

14.3.1 Get系接口

下面就把其接口拖出来一个一个分析,首先是简单接口,key和value都是返回current_的值,current_是当前seek到的Iterator位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
virtual Slice key() const {
assert(Valid());
return current_->key();
}

virtual Slice value() const {
assert(Valid());
return current_->value();
}

virtual Status status() const {
Status status;
for (int i = 0; i < n_; i++) { // 只有所有内部Iterator都ok时,才返回ok
status = children_[i].status();
if (!status.ok()) break;
}
return status;
}

14.3.2 Seek系接口

然后是几个seek系的函数,也比较简单,都是依次调用内部Iterator的seek系函数。然后做merge,对于Seek和SeekToFirst都调用FindSmallest;对于SeekToLast调用FindLargest。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
virtual void SeekToFirst() {
for (int i = 0; i < n_; i++) children_[i].SeekToFirst();
FindSmallest();
direction_ = kForward;
}

virtual void SeekToLast() {
for (int i = 0; i < n_; i++) children_[i].SeekToLast();
FindLargest();
direction_ = kReverse;
}

virtual void Seek(constSlice& target) {
for (int i = 0; i < n_; i++) children_[i].Seek(target);
FindSmallest();
direction_ = kForward;
}

14.3.3 逐步移动

最后就是Next和Prev函数,完成迭代遍历。这可能会有点绕。下面分别来说明。

首先,在Next移动时,如果当前direction不是kForward的,也就是上一次调用了Prev或者SeekToLast函数,就需要先调整除current之外的所有iterator,为什么要做这种调整呢?啰嗦一点,考虑如下的场景,如图14.3-1所示。

当前direction为kReverse,并且有:Current = memtable Iterator。各Iterator位置为:{memtable, stable 0, sstable1} ={ key3:1:1, key2:3:1, key2:1:1},这符合prev操作的largest key要求。

注:需要说明下,对于每个update操作,leveldb都会赋予一个全局唯一的sequence号,且是递增的。例子中的sequence号可理解为每个key的相对值,后面也是如此。

接下来我们来分析Prev移动的操作。

  • 第一次Prev,current(memtable iterator)移动到key1:3:0上,3者中最大者变成sstable0;因此current修改为sstable0;
  • 第二次Prev,current(sstable0 Iterator)移动到key1:2:1上,3者中最大者变成sstable1;因此current修改为sstable1:
  • 此时各Iterator的位置为{memtable, sstable 0, sstable1} = { key1:3:0, key1:2:1, key2:2:1},并且current=sstable1。
  • 接下来再调用Next,显然当前Key()为key2:2:1,综合考虑3个iterator,两次Next()的调用结果应该是key2:1:1和key3:1:1。而memtable和sstable0指向的key却是key1:3:0和key1:2:1,这时就需要调整memtable和sstable0了,使他们都定位到Key()之后,也就是key3:1:1和key2:3:1上。

然后current(current1)Next移动到key2:1:1上。这就是Next时的调整逻辑,同理,对于Prev也有相同的调整逻辑。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
virtual void Next() {
assert(Valid());
// 确保所有的子Iterator都定位在key()之后.
// 如果我们在正向移动,对于除current_外的所有子Iterator这点已然成立
// 因为current_是最小的子Iterator,并且key() = current_->key()。
// 否则,我们需要明确设置其它的子Iterator
if (direction_ != kForward) {
for (int i = 0; i < n_; i++) { // 把所有current之外的Iterator定位到key()之后
IteratorWrapper* child = &children_[i];
if (child != current_) {
child->Seek(key());
if (child->Valid() && comparator_->Compare(key(), child->key()) == 0)
child->Next(); // key等于current_->key()的,再向后移动一位
}
}
direction_ = kForward;
}
// current也向后移一位,然后再查找key最小的Iterator
current_->Next();
FindSmallest();
}

virtual void Prev() {
assert(Valid());
// 确保所有的子Iterator都定位在key()之前.
// 如果我们在逆向移动,对于除current_外的所有子Iterator这点已然成立
// 因为current_是最大的,并且key() = current_->key()
// 否则,我们需要明确设置其它的子Iterator
if (direction_ != kReverse) {
for (int i = 0; i < n_; i++) {
IteratorWrapper* child = &children_[i];
if (child != current_) {
child->Seek(key());
if (child->Valid()) {
// child位于>=key()的第一个entry上,prev移动一位到<key()
child->Prev();
}
else { // child所有的entry都 < key(),直接seek到last即可
child->SeekToLast();
}
}
}
direction_ = kReverse;
}
//current也向前移一位,然后再查找key最大的Iterator
current_->Prev();
FindLargest();
}

这就是MergingIterator的全部代码逻辑了,每次Next或者Prev移动时,都要重新遍历所有的子Iterator以找到key最小或最大的Iterator作为current_。这就是merge的语义所在了。
但是它没有考虑到删除标记等问题,因此直接使用MergingIterator是不能正确的遍历DB的,这些问题留待给DBIter来解决。

14.4 DBIter

Leveldb数据库的MemTablesstable文件的存储格式都是(user key, seq, type) => uservalue。DBIter把同一个userkey在DB中的多条记录合并为一条,综合考虑了userkey的序号、删除标记、和写覆盖等等因素。

从前面函数NewIterator的代码还能看到,DBIter内部使用了MergingIterator,在调用MergingItertor的系列seek函数后,DBIter还要处理key的删除标记。否则,遍历时会把已删除的key列举出来。

DBIter还定义了两个移动方向,默认是kForward:

  1. kForward,向前移动,代码保证此时DBIter的内部迭代器刚好定位在this->key(),this->value()这条记录上;
  2. kReverse,向后移动,代码保证此时DBIter的内部迭代器刚好定位在所有key=this->key()的entry之前。

其成员变量savedkey和saved value保存的是KReverse方向移动时的k/v对,每次seek系调用之后,其值都会跟随iter_而改变。

DBIter的代码开始读来感觉有些绕,主要就是它要处理删除标记,而且其底层的MergingIterator,对于同一个key会有多个不同sequence的entry。导致其Next/Prev操作比较复杂,要考虑到上一次移动的影响,跳过删除标记和重复的key。

DBIter必须导出Iterator定义的几个接口,下面就拖出来挨个分析。

14.4.1 Get系接口

首先是几个简单接口,获取key、value和status的:

1
2
3
4
5
6
7
8
9
10
11
//kForward直接取iter_->value(),否则取saved value
virtual Slice value() const {
assert(valid_);
return (direction_ == kForward) ? iter_->value() : saved_value_;
}

virtual Status status() const {
if (status_.ok())
returniter_->status();
return status_;
}

14.4.2 辅助函数

在分析seek系函数之前,先来理解两个重要的辅助函数:FindNextUserEntryFindPrevUserEntry的功能和逻辑。其功能就是循环跳过下一个/前一个delete的记录,直到遇到kValueType的记录。

先来看看,函数声明为:void DBIter::FindNextUserEntry(bool skipping, std::string* skip)

  • 参数@skipping表明是否要跳过sequence更小的entry;
  • 参数@skip临时存储空间,保存seek时要跳过的key;

在进入FindNextUserEntry时,iter_刚好定位在this->key(), this->value()这条记录上。下面来看函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
virtual Slice key() const { //kForward直接取iter_->key(),否则取saved key  
assert(valid_);
return (direction_ == kForward) ? ExtractUserKey(iter_->key()) : saved_key_;
}

// 循环直到找到合适的entry,direction必须是kForward
assert(iter_->Valid());
assert(direction_ == kForward);
do {
ParsedInternalKey ikey;
// 确保iter_->key()的sequence <= 遍历指定的sequence
if (ParseKey(&ikey) && ikey.sequence <= sequence_) {
switch (ikey.type) {
case kTypeDeletion:
//对于该key,跳过后面遇到的所有entry,它们被这次删除覆盖了
//保存key到skip中,并设置skipping=true
SaveKey(ikey.user_key, skip);
skipping = true;
break;
case kTypeValue:
if (skipping &&
user_comparator_->Compare(ikey.user_key, *skip) <= 0) {
// 这是一个被删除覆盖的entry,或者user key比指定的key小,跳过
}
else { // 找到,清空saved key并返回,iter_已定位到正确的entry
valid_ = true;
saved_key_.clear();
return;
}
break;
}
}
iter_->Next(); // 继续检查下一个entry
} while (iter_->Valid());
// 到这里表明已经找到最后了,没有符合的entry
saved_key_.clear();
valid_ = false;

FindNextUserKey移动方向是kForward,DBIter在向kForward移动时,借用了saved key作为临时缓存。FindNextUserKey确保定位到的entry的sequence不会大于指定的sequence,并跳过被删除标记覆盖的旧记录。

接下来是FindPrevUserKey,函数声明为:void DBIter::FindPrevUserEntry(),在进入FindPrevUserEntry时,iter_刚好位于saved key对应的所有记录之前。源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
assert(direction_ == kReverse); // 确保是kReverse方向  
ValueType value_type =kTypeDeletion; //后面的循环至少执行一次Prev操作
if (iter_->Valid()) {
do { // 循环
// 确保iter_->key()的sequence <= 遍历指定的sequence
ParsedInternalKey ikey;
if (ParseKey(&ikey)&& ikey.sequence <= sequence_) {
if ((value_type !=kTypeDeletion) &&
user_comparator_->Compare(ikey.user_key, saved_key_) < 0) {
break; // 我们遇到了前一个key的一个未被删除的entry,跳出循环
// 此时Key()将返回saved_key,saved key非空;
}
//根据类型,如果是Deletion则清空saved key和saved value
//否则,把iter_的user key和value赋给saved key和saved value
value_type = ikey.type;
if (value_type ==kTypeDeletion) {
saved_key_.clear();
ClearSavedValue();
} else {
Slice raw_value =iter_->value();
if(saved_value_.capacity() > raw_value.size() + 1048576) {
std::string empty;
swap(empty,saved_value_);
}
SaveKey(ExtractUserKey(iter_->key()), &saved_key_);
saved_value_.assign(raw_value.data(), raw_value.size());
}
}
iter_->Prev(); // 前一个
} while (iter_->Valid());
}
if (value_type == kTypeDeletion){ // 表明遍历结束了,将direction设置为kForward
valid_ = false;
saved_key_.clear();
ClearSavedValue();
direction_ = kForward;
} else {
valid_ = true;
}

函数FindPrevUserKey根据指定的sequence,依次检查前一个entry,直到遇到user key小于saved key,并且类型不是Delete的entry。如果entry的类型是Delete,就清空saved key和saved value,这样在依次遍历前一个entry的循环中,只要类型不是Delete,就是要找的entry。这就是Prev的语义。

14.4.3 Seek系函数

了解了这两个重要的辅助函数,可以分析几个Seek接口了,它们需要借助于上面的这两个函数来跳过被delete的记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void DBIter::Seek(const Slice& target) {  
direction_ = kForward; // 向前seek
// 清空saved value和saved key,并根据target设置saved key
ClearSavedValue();
saved_key_.clear();
AppendInternalKey( // kValueTypeForSeek(1) > kDeleteType(0)
&saved_key_,ParsedInternalKey(target, sequence_, kValueTypeForSeek));
iter_->Seek(saved_key_); // iter seek到saved key
//可以定位到合法的iter,还需要跳过Delete的entry
if (iter_->Valid()) FindNextUserEntry(false,&saved_key_);
else valid_ = false;
}

void DBIter::SeekToFirst() {
direction_ = kForward; // 向前seek
// 清空saved value,首先iter_->SeekToFirst,然后跳过Delete的entry
ClearSavedValue();
iter_->SeekToFirst();
if (iter_->Valid()) FindNextUserEntry(false,&saved_key_ /*临时存储*/);
else valid_ = false;
}

void DBIter::SeekToLast() { // 更简单
direction_ = kReverse;
ClearSavedValue();
iter_->SeekToLast();
FindPrevUserEntry();
}

14.4.4 Prev()和Next()

Next和Prev接口,相对复杂一些。和底层的merging iterator不同,DBIter的Prev和Next步进是以key为单位的,而mergingiterator是以一个record为单位的。所以在调用merging Iterator做Prev和Next迭代时,必须循环直到key发生改变。

假设指定读取的sequence为2,当前iter在key4:2:1上,direction为kForward。此时调用Prev(),此图显示了Prev操作执行的5个步骤:

  • S1 首先因为direction为kForward,先调整iter到key3:1:1上。此图也说明了调整的理由,key4:2:1前面还有key4:3:1。然后进入FindPrevUserEntry函数,执行S2到S4。
  • S2 跳到key3:2:0上时,这是一个删除标记,清空saved key(其中保存的是key3:1:1)。
  • S3 循环继续,跳到key2:1:1上,此时key2:1:1 > saved key,设置saved key为key2:1:1,并继续循环。
  • S4 循环继续,跳到key2:2:1上,此时key2:2:1 > saved key,设置saved key为key2:2:1,并继续循环。
  • S5 跳到Key1:1:1上,因为key1:1:1 < saved key,跳出循环。

最终状态iter_位置在key1:1:1上,而saved key保存的则是key2:2:1上,这也就是Prev应该定位到的值。也就是说在Prev操作下,iter_的位置并不是真正的key位置。这就是前面Get系函数中,在direction为kReverse时,返回saved key/value的原因。

同理,在Next时,如果direction是kReverse,根据上面的Prev可以发现,此时iter刚好是saved key的前一个entry。执行iter->Next()就跳到了saved key的dentry范围的sequence最大的那个entry。在前面的例子中,在Prev后执行Next,那么iter首先跳转到key2:3:1上,然后再调用FindNextUserEntry循环,使iter定位在key2:2:1上。

下面首先来分析Next的实现。如果direction是kReverse,表明上一次做的是kReverse跳转,这种情况下,iter_位于key是this->key()的所有entry之前,我们需要先把iter_跳转到this->key()对应的entries范围内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void DBIter::Next() {  
assert(valid_);
if (direction_ == kReverse) { //需要预处理,并更改direction=kForward
direction_ = kForward;
// iter_刚好在this->key()的所有entry之前,所以先跳转到this->key()
// 的entries范围之内,然后再做常规的skip
if (!iter_->Valid()) iter_->SeekToFirst();
else iter_->Next();
if (!iter_->Valid()) {
valid_ = false;
saved_key_.clear();
return;
}
}
// 把saved_key_ 用作skip的临时存储空间
std::string* skip =&saved_key_;
SaveKey(ExtractUserKey(iter_->key()), skip);// 设置skip为iter_->key()的user key
FindNextUserEntry(true, skip);
}

接下来是Prev(),其实和Next()逻辑相似,但方向相反。

如果direction是kForward,表明上一次是做的是kForward跳转,这种情况下,iter_指向当前的entry,我们需要调整iter,使其指向到前一个key,iter的位置是这个key所有record序列的最后一个,也就是sequence最小的那个record。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void DBIter::Prev() {  
assert(valid_);
if (direction_ == kForward) { //需要预处理,并更改direction
// iter_指向当前的entry,向后扫描直到key发生改变,然后我们可以做
//常规的reverse扫描
assert(iter_->Valid()); // iter_必须合法,并把saved key设置为iter_->key()
SaveKey(ExtractUserKey(iter_->key()), &saved_key_);
while (true) {
iter_->Prev();
if (!iter_->Valid()) { // 到头了,直接返回
valid_ = false;
saved_key_.clear();
ClearSavedValue();
return;
}
if (user_comparator_->Compare(ExtractUserKey(iter_->key()),
saved_key_) < 0) {
break; // key变化就跳出循环,此时iter_刚好位于saved key对应的所有entry之前
}
}
direction_ = kReverse;
}
FindPrevUserEntry();
}

14.5 小结

查询操作并不复杂,只需要根据seq找到最新的记录即可。知道leveldb的遍历会比较复杂,不过也没想到会这么复杂。这主要是得益于sstable 0的重合性,以及memtable和sstable文件的重合性。

C++代码优化策略总结

C++ 有一些热点代码是性能“惯犯”,其中包括函数调用、内存分配和循环。下面是一份改善 C++ 程序性能的方法的总结。

用好的编译器并用好编译器

关于如何选择 C++ 编译器的一条最重要的建议,是使用支持 C++11 的编译器。C++11 实现了右值引用(rvalue reference)和移动语义(move semantics),可以省去许多在以前的C++ 版本中无法避免的复制操作,

有时,用好的编译器也意味着用好编译器。默认情况下,许多编译器都不会进行任何优化,因为如果不进行优化,编译器就可以稍微缩短一点编译时间。当关闭优化选项时,调试也会变得更加简单,因为程序的执行流程与源代码完全一致。优化选项可能会将代码移出循环、移除一些函数调用和完全移除一些变量。仅仅是打开函数内联优化选项就可以显著地提升 C++ 程序的性能,因为编写许多小的成员函数去访问各个类的成员变量是一种优秀的 C++ 编码风格。

使用更好的算法

选择一个最优算法对性能优化的效果最大。

使用更好的库

C++ 编译器提供的标准 C++ 模板库和运行时库必须是可维护的、全面的和非常健壮的。Boost Project(http://www.boost.org)和 Google Code(https://code.google.com)等公开了很多可供使用的库,其中有一些用于 I/O、窗口、处理字符串和并发的库。它们虽然不是标准库的替代品,却可以帮助我们改善性能和加入新的特性。这些库在设计上的权衡与标准库不同,从而获得了处理速度上的提升。

减少内存分配和复制

减少对内存管理器的调用是一种非常有效的优化手段,以至于开发人员只要掌握了这一个技巧就可以变为成功的性能优化人员。绝大多数 C++ 语言特性的性能开销最多只是几个指令,但是每次调用内存管理器的开销却是数千个指令。

对缓存复制函数的一次调用也可能消耗数千个 CPU 周期。因此,很明显减少复制是一种提高代码运行速度的优化方式。大量复制的发生都与内存分配有关,所以修改一处往往也会消灭另一处。其他可能会发生复制的热点代码是构造函数和赋值运算符以及输入输出。

移除计算

除了内存分配和函数调用外,单条 C++ 语句的性能开销通常都很小。但是如果在循环中执行 100 万次这条语句,或是每次程序处理事件时都执行这条语句,那么这就是个大问题了。绝大多数程序都会有一个或多个主要的事件处理循环和一个或多个处理字符的函数。找出并优化这些循环几乎总是可以让性能优化硕果累累。

使用更好的数据结构

选择最合适的数据结构对性能有着深刻的影响,因为插入、迭代、排序和检索元素的算法的运行时开销取决于数据结构。除此之外,不同的数据结构在使用内存管理器的方式上也有所不同。另一个原因是数据结构可能有也可能没有优秀的缓存本地化。

提高并发性

现代计算机都可以使用多个处理核心来执行指令。如果一项工作被分给几个处理器执行,那么它可以更快地执行完毕。伴随并发执行而来的是用于同步并发线程让它们可以共享数据的工具。有人可以用好这些工具,有人则用不好。

优化内存管理

内存管理器作为 C++ 运行时库中的一部分,管理着动态内存分配。它在许多 C++ 程序中都会被频繁地执行。C++ 确实为内存管理提供了丰富的 API。

小结

本书将帮助开发人员识别和利用以下优化机会来改善代码性能。

  • 使用更好的编译器,打开编译选项
  • 使用最优算法
  • 使用更好的库并用好库
  • 减少内存分配
  • 减少复制
  • 移除计算
  • 使用最优数据结构
  • 提高并发
  • 优化内存管理

影响优化的计算机行为

C++所相信的计算机谎言

有一个与其他任何有效的内存地址都不同的特殊的地址,叫作nullptr。整数 0 会被转换为nullptr,尽管在地址 0 上不需要nullptr。有一个概念上的执行地址指向正在被执行的源代码语句。C++ 知道计算机远比这个简单模型要复杂。它在这台闪闪发亮的机器下提供了一些快
速功能。

  • C++ 程序只需要表现得好像语句是按照顺序执行的。C++ 编译器和计算机自身只要能够确保每次计算的含义都不会改变,就可以改变执行顺序使程序运行得更快。
  • 自 C++11 开始, C++ 不再认为只有一个执行地址。C++ 标准库现在支持启动和终止线程以及同步线程间的内存访问。在C++11之前,程序员对C++编译器隐瞒了他们的线程,有时候这会导致难以调试。
  • 某些内存地址可能是设备寄存器,而不是普通内存。这些地址的值可能会在同一个线程对该地址的两次连续读的间隔发生变化,这表示硬件发生了变化。在 C++ 中用volatile 关键字定义这些地址。声明一个volatile变量会要求编译器在每次使用该变量时都获取它的一份新的副本,而不用通过将该变量的值保存在一个寄存器中并复用它来优化程序。另外,也可以声明指向volatile内存的指针。
  • C++11 提供了一个名为std::atomic<>的特性,可以让内存在一段短暂的时间内表现得仿佛是字节的简单线性存储一样,这样可以远离所有现代处理器的复杂性,包括多线程执行、多层高速缓存等。

计算机的真相

内存很慢

通往主内存的接口是限制执行速度的瓶颈。这个瓶颈甚至有一个名字,叫冯 - 诺伊曼瓶颈。多个活动会争夺对内存总线的访问。处理器会不断地读取包含下一条需要执行的指令的内存。高速缓存控制器会将数据内存块保存至高速缓存中,刷新已写的缓存行。DRAM 控制器还会“偷用”周期刷新内存中的动态 RAM 基本存储单元的电荷。多核处理器的核心数量足以确保内存总线的通信数据量是饱和的。数据从主内存读取至某个核心的实际速率大概是每字 20 至 80 纳秒(ns)。

根据摩尔定律,每年处理器核心的数量都会增加。但是这也无法让连接主内存的接口变快。这些核心只能等待访问内存的机会。上述对性能的隐式限制被称为内存墙(memory wall)。

内存访问并非以字节为单位

当 C++ 获取一个多字节类型的数据,比如一个 int、 double 或者指针时,构成数据的字节可能跨越了两个物理内存字。这种访问被称为非对齐的内存访问(unaligned memory access)。此处优化的意义在于,一次非对齐的内存访问的时间相当于这些字节在同一个字中时的两倍,因为需要读取两个字。C++ 编译器会帮助我们对齐结构体,使每个字段的起始字节地址都是该字段的大小的倍数。

某些内存访问会比其他的更慢

为了进一步补偿主内存的缓慢速度,许多计算机中都有高速缓存(cache memory),一种非常接近处理器的快速的、临时的存储,来加快对那些使用最频繁的内存字的访问速度。高速缓存层次中每一层的速度大约是它下面一层的 10 倍。

当执行单元需要获取不在高速缓存中的数据时,有一些当前处于高速缓存中的数据必须被舍弃以换取足够的空余空间。通常,选择放弃的数据都是最近很少被使用的数据。这一点与性能优化有着紧密的关系,因为这意味着访问那些被频繁地访问过的存储位置的速度会比访问不那么频繁地被访问的存储位置更快。读取一个不在高速缓存中的字节甚至会导致许多临近的字节也都被缓存起来

就 C++ 而言,这表示一个包含循环处理的代码块的执行速度可能会更快。这是因为组成循环处理的指令会被频繁地执行,而且互相紧挨着,因此更容易留在高速缓存中。一段包含函数调用或是含有 if 语句导致执行发生跳转的代码则会执行得较慢,因为代码中各个独立的部分不会那么频繁地被执行,也不是那么紧邻着。

内存字分为大端和小端

处理器可以一次从内存中读取一字节的数据,但是更多时候都会读取由几个连续的字节组成的一个数字。例如,在微软的 Visual C++ 中,读取 int 值时会读取 4 字节。如果 int 值 0x01234567 存储在地址 1000~1003 中,而且首先存储小端,那么在地址 1000 中存储的是 0x01,在地址 1003 中存储的是 0x67。反之,如果首先存储大端,那么在地址 1000 中存储的是 0x67, 0x01 被存储在地址 1003 中。从首字节地址读取最高有效位的计算机被称为大端计算机, 小端计算机则会首先读取最低有效位。

内存容量是有限的

想让高速缓存更快是非常昂贵的。一台台式计算机或是手机中可能会有数吉字节的主内存,但是只有几百万字节的高速缓存。通常,程序和它们的数据不会被存储在高速缓存中。

高速缓存和虚拟内存带来的一个影响是,由于高速缓存的存在,在进行性能测试时,一个函数运行于整个程序的上下文中时的执行速度可能是运行于测试套件中时的万分之一。当运行于整个程序的上下文中时,函数和它的数据不太可能存储至缓存中,而在测试套件的上下文中,它们则通常会被缓存起来。这个影响放大了减少内存或磁盘使用量带来的优化收益,而减小代码体积的优化收益则没有任何变化。

第二个影响则是,如果一个大程序访问许多离散的内存地址,那么可能没有足够的高速缓存来保存程序刚刚使用的数据。这会导致一种性能衰退,称为页抖动(page thrashing)。

指令执行缓慢

处理器中包含一条指令“流水线”,它支持并发执行指令。指令在流水线中被解码、获取参数、执行计算,最后保存处理结果。处理器的性能越强大,这条流水线就越复杂。它会将指令分解为若干阶段,这样就可以并发地处理更多的指令。如果指令 B 需要指令 A 的计算结果,那么在计算出指令 A 的处理结果前是无法执行指令 B的计算的。这会导致在指令执行过程中发生流水线停滞(pipeline stall)。

计算机难以作决定

跳转指令或跳转子例程指令会将执行地址变为一个新的值。在执行跳转指令一段时间后,执行地址才会被更新。在这之前是无法从内存中读取“下一条”指令并将其放入到流水线中的。新的执行地址中的内存字不太可能会存储在高速缓存中。在更新执行地址和加载新的“下一条”指令到流水线中的过程中,会发生流水线停滞。

在执行了一个条件分支指令后,执行可能会走向两个方向:下一条指令或者分支目标地址中的指令。最终会走向哪个方向取决于之前的某些计算的结果。这时,流水线会发生停滞,直至与这些计算结果相关的全部指令都执行完毕,而且还会继续停滞一段时间,直至决定一下条指令的地址并取得下一条指令为止。

程序执行中的多个流

当许多程序一齐开始运行,互相竞争内存和磁盘时。为了性能调优,如果一个程序必须在启动时执行或是在负载高峰期时执行,那么在测量性能时也必须带上负载。

如果操作系统正在将一个线程切换至同一个程序的另外一个线程,这表示要为即将暂停的线程保存处理器中的寄存器,然后为即将被继续执行的线程加载之前保存过的寄存器。现代处理器中的寄存器包含数百字节的数据。当新线程继续执行时,它的数据可能并不在高速缓存中,所以当加载新的上下文到高速缓存中时,会有一个缓慢的初始化阶段。因此,切换线程上下文的成本很高。

当操作系统从一个程序切换至另外一个程序时,这个过程的开销会更加昂贵。所有脏的高速缓存页面都必须被刷新至物理内存中。所有的处理器寄存器都需要被保存。然后,内存管理器中的“物理地址到虚拟地址”的内存页寄存器也需要被保存。接着,新线程的“物理地址到虚拟地址”的内存页寄存器和处理器寄存器被载入。最后就可以继续执行了。但是这时高速缓存是空的,因此在高速缓存被填充满之前,还有一段缓慢且需要激烈地竞争内存的初始化阶段。

当执行单元写值时,这个值会首先进入高速缓存内存。不过最终,这个值将被写入至主内存中,这样其他所有的执行单元就都可以看见这个值了。但是,这些执行单元在访问主内存时存在着竞争,所以可能在执行单元改变了一个值,然后又执行几百个指令后,主内存中的值才会被更新。

因此,如果一台计算机有多个执行单元,那么一个执行单元可能需要在很长一段时间后才能看见另一个执行单元所写的数据被反映至主内存中,而且主内存发生改变的顺序可能与指令的执行顺序不一样。受到不可预测的时间因素的干扰,执行单元看到的共享内存字中的值可能是旧的,也可能是被更新后的值。这时,必须使用特殊的同步指令来确保运行于不同执行单元间的线程看到的内存中的值是一致的。对优化而言,这意味着访问线程间的
共享数据比访问非共享数据要慢得多。

调用操作系统的开销是昂贵的

操作系统内核需要能够访问所有程序的内存,这样程序就可以通过系统调用访问操作系统。有些操作系统还允许程序发送访问共享内存的请求。许多系统调用的发生方式和共享内存的分布方式是多样和神秘的。对优化而言,这意味着系统调用的开销是昂贵的,是单线程程序中的函数调用开销的数百倍。

C++也会说谎

并非所有语句的性能开销都相同

一个赋值语句,如BigInstance i = OtherObject;会复制整个对象的结构。更值得注意的是,这类赋值语句会调用BigInstance的构造函数,而其中可能隐藏了不确定的复杂性。当一个表达式被传递给一个函数的形参时,也会调用构造函数。当函数返回值时也是一样的。对优化而言,这一点的意义是某些语句隐藏了大量的计算,但从这些语句的外表上看不出它的性能开销会有多大。

语句并非按顺序执行

C++ 程序表现得仿佛它们是按顺序执行的,完全遵守了 C++ 流程控制语句的控制。上句话中的含糊其辞的“仿佛”正是许多编译器进行优化的基础,也是现代计算机硬件的许多技巧的基础。

当然,在底层,编译器能够而且有时也确实会对语句进行重新排序以改善性能。但是编译器知道在测试一个变量或是将其赋值给另外一个变量之前,必须先确定它包含了所有的最新计算结果。现代处理器也可能会选择乱序执行指令,不过它们包含了可以确保在随后读取同一个内存地址之前,一定会先向该地址写入值的逻辑。

并发会让情况变得复杂。C++ 程序在编译时不知道是否会有其他线程并发运行。C++ 编译器不知道哪个变量会在线程间共享。当程序中包含共享数据的并发线程时,编译器对语句的重排序和延迟写入主内存会导致计算结果与按顺序执行语句的计算结果不同。开发人员必须向多线程程序中显式地加入同步代码来确保可预测的行为的一致性。当并发线程共享数据时,同步代码降低了并发量。

小结

  • 在处理器中,访问内存的性能开销远比其他操作的性能开销大。
  • 非对齐访问所需的时间是所有字节都在同一个字中时的两倍。
  • 访问频繁使用的内存地址的速度比访问非频繁使用的内存地址的速度快。
  • 访问相邻地址的内存的速度比访问互相远隔的地址的内存快。
  • 由于高速缓存的存在,一个函数运行于整个程序的上下文中时的执行速度可能比运行于测试套件中时更慢。
  • 访问线程间共享的数据比访问非共享的数据要慢很多。
  • 计算比做决定快。
  • 每个程序都会与其他程序竞争计算机资源。
  • 如果一个程序必须在启动时执行或是在负载高峰期时执行,那么在测量性能时必须加载负载。
  • 每一次赋值、函数参数的初始化和函数返回值都会调用一次构造函数,这个函数可能隐藏了大量的未知代码。
  • 有些语句隐藏了大量的计算。从语句的外表上看不出语句的性能开销会有多大。
  • 当并发线程共享数据时,同步代码降低了并发量。

测量性能

用计算机测量时间

自 Windows 98(可能更早)以来,微软的 C 运行时提供了 ANSI C 函数clock_t clock()。该函数会返回一个有符号形式的时标计数器。常量CLOCKS_PER_SEC指定了每秒钟的时标的次数。返回值为 -1 表示clock()不可用。clock()会基于交流电源的周期性中断记录时标。clock()在 Windows 上的实现方式与 ANSI 所规定的不同。

自奔腾体系结构后,英特尔提供了一个叫作时间戳计数器(Time Stamp Counter, TSC)的硬件寄存器。TSC 是一个从处理器时钟中计算时标数的 64 位寄存器。RDTSC 指令可以非常快地访问该寄存器。

评估代码开销来找出热点代码

评估独立的C++语句的开销

访问内存的时间开销远比执行其他指令的开销大。执行一条指令所花费的时间大致包含从内存中读取指令的每个字节所需要的时间,加上读取指令的输入数据所需的时间,再加上写指令结果的时间。相比之下,隐藏于内存访问时间之下的解码和执行指令的时间就显得微不足道了。读写数据的开销可以近似地看作所有级别的微处理器上的执行指令的相对开销。

有一条有效的规则能够帮助我们评估一条 C++ 语句的开销有多大,那就是计算该语句对内存的读写次数。例如,有一条语句a = b + c;,其中 a、 b 和 c 都是整数, b 和 c 的值必须从内存中读取,而且它们的和必须写入至内存中的位置 a。因此,这条语句的开销是三次内存访问。

再比如,r = *p + a[i];这条语句访问内存的次数如下:一次访问用于读取 i,一次读取a[i],一次读取 p,一次读取*p所指向的数据,一次将结果写入至r。也就是说总共进行了 5 次访问。

评估循环的开销

由于每条 C++ 语句都只会进行几次内存访问,通常情况下热点代码都不会是一条单独的语句,除非受其他因素的作用,让其频繁地执行。这些因素之一就是该语句出现在了循环中。这样,合计开销就是该语句的开销乘以该语句被执行的次数了。

如果你很幸运,可能会偶然找到这样的代码。分析器可能会指出一条单独的语句被执行了100 万次,或者其他的热点函数包含以下这样的循环:

1
2
3
4
5
6
7
for (int i=1; i<1000000; ++i) {
do_something_expensive();
if (mostly_true) {
do_more_stuff();
even_more();
}
}

这个循环中的语句很明显会被执行 100 万次,因此它是热点语句。看起来你需要花点精力去优化。

评估嵌套循环中的循环次数

当一个循环被嵌套在另一个循环里面的时候,代码块的循环次数是内层循环的次数乘以外层循环的次数。例如:

1
2
3
4
5
for (int i=0; i<100; ++i) {
for (int j=0; j<50; ++j) {
fiddle(a[i][j]);
}
}

在这里,代码块的循环次数是 100*50=5000。

嵌套循环可能并非一眼就能看出来。如果一个循环调用了一个函数,而这个函数中又包含了另外一个循环,那么内层循环就是嵌套循环。有时在外层循环中重复地调用函数的开销也是可以消除的。内存循环可能被嵌入在标准库函数中,特别是处理字符串或字符的 I/O 函数。如果这些函数被重复调用的次数非常多,那么可能值得去重新实现标准函数库中的函数来回避调用开销。

评估循环次数为变量的循环的开销

不是所有循环中的循环次数都是很明确的。许多循环处理会不断重复直至满足某个条件为止,比如有些循环会重复地处理字符,直至找到空格为止;还有些循环则会重复地处理数字,直到遇到非数字为止。

识别出隐式循环

响应事件的程序(例如 Windows UI 程序)在最外层都会有一个隐式循环。这个循环甚至在程序中是看不到的,因为它被隐藏在了框架中。如果这个框架以最大速率接收事件的话,那么每当事件处理器取得程序控制权,或是在事件分发前,抑或是在事件分发过程中都会被执行的代码,以及最频繁地被分发的事件中的代码都可能是热点代码。

识别假循环

不是所有的 while 或者 do 语句都是循环语句。我就曾经遇到过使用 do 语句帮助控制流程的代码。下面这个“循环”只会被执行一次。当它遇到while(0)后就会退出:

1
2
3
4
5
6
do {
if (!operation1())
break;
if (!operation2(x,y,z))
break;
} while(0);

小结

  • 必须测量性能。
  • 做出可测试的预测并记录预测。
  • 记录代码修改。
  • 如果每次都记录了实验内容,那么就可以快速地重复实验。
  • 一个程序会花费 90% 的运行时间去执行 10% 的代码。
  • 只有正确且精确的测量才是准确的测量。
  • 分辨率不是准确性。
  • 只进行有明显效果的性能改善,开发人员就无需担忧方法论的问题。
  • 计算一条 C++ 语句对内存的读写次数,可以估算出一条 C++ 语句的性能开销。

优化字符串的使用:案例研究

为什么字符串很麻烦

字符串是动态分配的

字符串之所以使用起来很方便,是因为它们会为了保存内容而自动增长。为了实现这种灵活性,字符串被设计为动态分配的。相比于 C++ 的大多数其他特性,动态分配内存耗时耗力。因此无论如何,字符串都是性能优化热点。当一个字符串变量超出了其定义范围或是被赋予了一个新的值后,动态分配的存储空间会被自动释放。与下面这段代码展示的需要为动态分配的 C 风格的字符数组手动释放内存相比,这样无疑方便了许多。

1
2
3
4
char* p = (char*) malloc(7);
strcpy(p, "string");
...
free(p);

尽管如此,但字符串内部的字符缓冲区的大小仍然是固定的。任何会使字符串变长的操作,如在字符串后面再添加一个字符或是字符串,都可能会使字符串的长度超出它内部的缓冲区的大小。当发生这种情况时,操作会从内存管理器中获取一块新的缓冲区,并将字符串复制到新的缓冲区中。

有些字符串的实现方式所申请的字符缓冲区的大小是需要存储的字符数的两倍。这样,在下一次申请新的字符缓冲区之前,字符串的容量足够允许它增长一倍。下一次某个操作需要增长字符串时,现有的缓冲区足够存储新的内容,可以避免申请新的缓冲区。

字符串就是值

在赋值语句和表达式中,字符串的行为与值是一样的。可以将一个新值赋予给一个变量,但是改变这个变量并不会改变这个值。例如:

1
2
3
4
int i, j;
i = 3; // i的值是3
j = i; // j的值也是3
i = 5; // i的值现在是5,但是j的值仍然是3

将一个字符串赋值给另一个字符串的工作方式是一样的,就仿佛每个字符串变量都拥有一份它们所保存的内容的私有副本一样:

1
2
3
4
std::string s1, s2;
s1 = "hot"; // s1是"hot"
s2 = s1; // s2是"hot"
s1[0] = 'n'; // s2仍然是"hot",但s1变为了"not"

由于字符串就是值,因此字符串表达式的结果也是值。如果你使用s1 = s2 + s3 + s4;这条语句连接字符串,那么s2 + s3的结果会被保存在一个新分配的临时字符串中。连接s4后的结果则会被保存在另一个临时字符串中。这个值将会取代s1之前的值。接着,为第一个临时字符串和s1之前的值动态分配的内存将会被释放。这会导致多次调用内存管理器。

字符串会进行大量复制

每个字符串变量必须表现得好像它们拥有一份自己的私有副本一样。实现这种行为的最简单的方式是当创建字符串、赋值或是将其作为参数传递给函数的时候进行一次复制。如果字符串是以这种方式实现的,那么赋值和参数传递的开销将会变得很大,但是变值函数(mutating function)和非
常量引用的开销却很小。

有一种被称为“写时复制”(copy on write)的著名的编程惯用法,它可以让对象与值具有同样的表现,但是会使复制的开销变得非常大。在 C++ 文献中,它被简称为 COW。在 COW 的字符串中,动态分配的内存可以在字符串间共享。每个字符串都可以通过引用计数知道它们是否使用了共享内存。当一个字符串被赋值给另一个字符串时,所进行的处理只有复制指针以及增加引用计数。任何会改变字符串值的操作都会首先检查是否只有一个指针指向该字符串的内存。如果多个字符串都指向该内存空间,所有的变值操作(任何可能会改变字符串值的操作)都会在改变字符串值之前先分配新的内存空间并复制字符串:

1
2
3
4
5
COWstring s1, s2;
s1 = "hot"; // s1是"hot"
s2 = s1; // s2是"hot" (s1和s2指向相同的内存)
s1[0] = 'n';// s1会在改变它的内容之前将当前内存空间中的内容复制一份
// s2仍然是"hot",但s1变为了"not"

如果以写时复制方式实现字符串,那么赋值和参数传递操作的开销很小,但是一旦字符串被共享了,非常量引用以及任何变值函数的调用都需要昂贵的分配和复制操作。在并发代码中,写时复制字符串的开销同样很大。每次变值函数和非常量引用都要访问引用计数器。当引用计数器被多个线程访问时,每个线程都必须使用一个特殊的指令从主内存中得到引用计数的副本,以确保没有其他线程改变这个值。

第一次尝试优化字符串

假设通过分析一个大型程序揭示出了remove_ctrl()函数的执行时间在程序整体执行时间中所占的比例非常大。这个函数的功能是从一个由 ASCII 字符组成的字符串中移除控制字符。看起来它似乎很无辜,但是出于多种原因,这种写法的函数确实性能非常糟糕。

1
2
3
4
5
6
7
8
std::string remove_ctrl(std::string s) {
std::string result;
for (int i=0; i<s.length(); ++i) {
if(s[i] >= 0x20)
result = result + s[i];
}
return result;
}

remove_ctrl()在循环中对通过参数接收到的字符串 s 的每个字符进行处理。循环中的代码就是导致这个函数成为热点的原因。字符串连接运算符的开销是很大的。它会调用内存管理器去构建一个新的临时字符串对象来保存连接后的字符串。如果传递给remove_ctrl()的参数是一个由可打印的字符组成的字符串,那么remove_ctrl()几乎会为 s 中的每个字符都构建一个临时字符串对象。对于一个由 100 个字符组成的字符串而言,这会调用 100 次内存管理器来为临时字符串分配内存,调用 100 次内存管理器来释放内存。

除了分配临时字符串来保存连接运算的结果外,将字符串连接表达式赋值给 result 时可能还会分配额外的字符串。当然,这取决于字符串是如何实现的。

  • 如果字符串是以写时复制惯用法实现的,那么赋值运算符将会执行一次高效的指针复制并增加引用计数。
  • 如果字符串是以非共享缓冲区的方式实现的,那么赋值运算符必须复制临时字符串的内容。如果实现是原生的,或者 result 的缓冲区没有足够的容量,那么赋值运算符还必须分配一块新的缓冲区用于复制连接结果。这会导致 100 次复制操作和 100 次额外的内存分配。
  • 如果编译器实现了 C++11 风格的右值引用和移动语义,那么连接表达式的结果是一个右值,这表示编译器可以调用 result 的移动构造函数,而无需调用复制构造函数。因此,程序将会执行一次高效的指针复制。

每次执行连接运算时还会将之前处理过的所有字符复制到临时字符串中。如果参数字符串有 n 个字符,那么remove_ctrl()会复制 O(n^2) 个字符。所有这些内存分配和复制都会导致性能变差。

因为remove_ctrl()是一个小且独立的函数,所以我们可以构建一个测试套件,通过反复地调用该函数来测量通过优化到底能将该函数的性能提升多少。

使用复合赋值操作避免临时字符串

我首先通过移除内存分配和复制操作来优化remove_ctrl()。第 5 行中会产生很多临时字符串对象的连接表达式被替换为了复合赋值操作符+=

1
2
3
4
5
6
7
8
std::string remove_ctrl_mutating(std::string s) {
std::string result;
for (int i=0; i<s.length(); ++i) {
if(s[i] >= 0x20)
result += s[i];
}
return result;
}

这个小的改动却带来了很大的性能提升。这次改善源于移除了所有为了分配临时字符串对象来保存连接结果而对内存管理器的调用,以及相关的复制和删除临时字符串的操作。赋值时的分配和复制操作也可以被移除,不过这取决于字符串的实现方式。

通过预留存储空间减少内存的重新分配

remove_ctrl_mutating()函数仍然会执行一个导致 result 变长的操作。如果std::string是以这种规则实现的,那么对于一个含有 100 个字符的字符串来说,重新分配内存的次数可能会多达 8 次。

假设字符串中绝大多数都是可打印的字符,只有几个是需要被移除的控制字符,那么参数字符串 s 的长度几乎等于结果字符串的最终长度。使用reserve()不仅移除了字符串缓冲区的重新分配,还改善了函数所读取的数据的缓存局部性(cache locality),因此我们从中得到了更好的改善效果。

1
2
3
4
5
6
7
8
9
std::string remove_ctrl_reserve(std::string s) {
std::string result;
result.reserve(s.length());
for (int i=0; i<s.length(); ++i) {
if (s[i] >= 0x20)
result += s[i];
}
return result;
}

移除了几处内存分配后,程序性能得到了明显的提升。

消除对参数字符串的复制

如果实参是一个变量,那么将会调用形参的构造函数,这会导致一次内存分配和复制。remove_ctrl_ref_args()是改善后的永远不会复制 s 的函数。由于该函数不会修改 s,因此没有理由去复制一份 s。取而代之的是,remove_ctrl_ref_args()会给s 一个常量引用作为参数。这省去了另外一次内存分配。由于内存分配是昂贵的,所以哪怕只是一次内存分配,也值得从程序中移除。

1
2
3
4
5
6
7
8
9
std::string remove_ctrl_ref_args(std::string const& s) {
std::string result;
result.reserve(s.length());
for (int i=0; i<s.length(); ++i) {
if (s[i] >= 0x20)
result += s[i];
}
return result;
}

改善后相比remove_ctrl_reserve()性能下降了 8%。这次修改本应该能够省去一次内存分配。原因可能是并没有真正省去这次内存分配,或是将s 从字符串修改为字符串引用后导致其他相关因素抵消了节省内存分配带来的性能提升。引用变量是作为指针实现的。因在,当在remove_ctrl_ref_args()中每次出现 s 时,程序都会解引指针,而在remove_ctrl_reserve()中则不会发生解引。

使用迭代器消除指针解引

解决方法是在字符串上使用迭代器。字符串迭代器是指向字符缓冲区的简单指针。与在循环中不使用迭代器的代码相比,这样可以节省两次解引操作。remove_ctrl_ref_args_it()使用了迭代器。

1
2
3
4
5
6
7
8
9
std::string remove_ctrl_ref_args_it(std::string const& s) {
std::string result;
result.reserve(s.length());
for (auto it=s.begin(),end=s.end(); it != end; ++it) {
if (*it >= 0x20)
result += *it;
}
return result;
}

在所有这些函数中,使用迭代器都比不使用迭代器要快。在remove_ctrl_ref_args_it()中还包含另一个优化点,那就是用于控制 for 循环的s.end()的值会在循环初始化时被缓存起来。

消除对返回的字符串的复制

remove_ctrl()函数的初始版本是通过值返回处理结果的。C++ 会调用复制构造函数将处理结果设置到调用上下文中。虽然只要可能的话,编译器是可以省去(即简单地移除)调用复制构造函数的,但是如果我们想要确保不会发生复制,那么有几种选择。其中一种选择是将字符串作为输出参数返回,这种方法适用于所有的 C++ 版本以及字符串的所有实现方式。这也是编译器在省去调用复制构造函数时确实会进行的处理。

1
2
3
4
5
6
7
8
9
10
11
void remove_ctrl_ref_result_it (
std::string& result,
std::string const& s)
{
result.clear();
result.reserve(s.length());
for (auto it=s.begin(),end=s.end(); it != end; ++it) {
if (*it >= 0x20)
result += *it;
}
}

当程序调用remove_ctrl_ref_result_it()时,一个指向字符串变量的引用会被传递给形参result。如果result引用的字符串变量是空的,那么调用reserve()将分配足够的内存空间用于保存字符。如果程序之前使用过这个字符串变量,例如程序循环地调用了remove_ctrl_ref_result_it(),那么它的缓冲区可能已经足够大了,这种情况下可能无需分配新的内存空间。当函数返回时,调用方的字符串变量将会接收返回值,无需进行复制。

remove_ctrl_ref_result_it()的优点在于多数情况下它都可以移除所有的内存分配。remove_ctrl_ref_result_it()的性能测量结果是每次调用花费 1.02 微秒,比修改之前的版本快了大约 2%。

用字符数组代替字符串

相比std::string, C 风格的字符串函数更难以使用,但是它们却能带来显著的性能提升。在局部存储区(即函数调用栈)中往往有足够的空间可以静态地声明大型临时缓冲区。当函数退出时,这些缓冲区将会被回收,而产生的运行时开销则微不足道。

1
2
3
4
5
6
7
void remove_ctrl_cstrings(char* destp, char const* srcp, size_t size) {
for (size_t i=0; i<size; ++i) {
if (srcp[i] >= 0x20)
*destp++ = srcp[i];
}
*destp = 0;
}

第二次尝试优化字符串

使用更好的算法

一种优化选择是尝试改进算法。初始版本的remove_ctrl()使用了一种简单的算法,一次将一个字符复制到结果字符串中。这个不幸的选择导致了最差的内存分配行为。在初始设计的基础上,通过将整个子字符串移动至结果字符串中改善了函数性能。这个改动可以减少内存分配和复制操作的次数。remove_ctrl_block()中展示的另外一种优化选择是缓存参数字符串的长度,以减少外层 for 循环中结束条件语句的性能开销。

1
2
3
4
5
6
7
8
9
10
11
std::string remove_ctrl_block(std::string s) {
std::string result;
for (size_t b=0, i=b, e=s.length(); b < e; b = i+1) {
for (i=b; i<e; ++i) {
if (s[i] < 0x20)
break;
}
result = result + s.substr(b,i-b);
}
return result;
}

这个函数与以前一样,可以通过使用复合赋值运算符替换字符串连接运算符来改善其性能,但是substr()仍然生成临时字符串。由于这个函数将字符添加到了result的末尾,开发人员可以通过重载std::stringappend()成员函数来复制子字符串,且无需创建临时字符串。

1
2
3
4
5
6
7
8
9
10
11
std::string remove_ctrl_block_append(std::string s) {
std::string result;
result.reserve(s.length());
for (size_t b=0,i=b; b < s.length(); b = i+1) {
for (i=b; i<s.length(); ++i) {
if (s[i] < 0x20) break;
}
result.append(s, b, i-b);
}
return result;
}

另外一种改善性能的方法是,通过使用std::stringerase()成员函数移除控制字符来改变字符串。

1
2
3
4
5
6
7
std::string remove_ctrl_erase(std::string s) {
for (size_t i = 0; i < s.length();)
if (s[i] < 0x20)
s.erase(i,1);
else ++i;
return s;
}

这种算法的优势在于,由于 s 在不断地变短,除了返回值时会发生内存分配外,其他情况下都不会再发生内存分配。

使用更好的字符串库

定义std::string的行为是一种妥协,它是经过很长一段时间以后从各种设计思想中演变出来的。

  • 与其他标准库容器一样,std::string提供了用于访问字符串中单个字符的迭代器。
  • 与 C 风格的字符串一样,std::string提供了类似数组索引的符号,可以使用运算符[]访问它的元素。std::string还提供了一种用于获取指向以空字符结尾的 C 风格字符串的指针的机制。
  • 与 BASIC 字符串类似,std::string有一个连接运算符和可以赋予字符串值语义(value semantics)的返回值的函数。
  • std::string提供的操作非常有限,有些开发人员会感觉受到了限制。

希望std::string与 C 风格的字符数组一样高效,这个需求推动着字符串的实现朝着在紧邻的内存中表现字符串的方向前进。C++ 标准要求迭代器能够随机访问,而且禁止写时复制语义。

使用std::stringstream避免值语义

C++ 已经有几种字符串实现方式了:模板化的、支持迭代器访问的、可变长度的std::string字符串;简单的、基于迭代器的std::vector<char>;老式的、 C 风格的以空字符结尾的、固定长度的字符数组。

C++ 中还有另外一种字符串。std::stringstream之于字符串,就如同std::ostream之于输出文件。std::stringstream类以一种不同的方式封装了一块动态大小的缓冲区(事实上,通常就是一个std::string),数据可以被添加至这个实体中。std::stringstream是一个很好的例子,它展示了如何在类似的实现的顶层使用不同的API 来提高代码性能。

1
2
3
4
5
6
std::stringstream s;
for (int i=0; i<10; ++i) {
s.clear();
s << "The square of " << i << " is " << i*i << std::endl;
log(s.str());
}

这段代码展示了几个优化代码的技巧。由于 s 被修改为了一个实体,这个很长的插入表达式不会创建任何会临时字符串,因此不会发生内存分配和复制操作。另外一个故意的改动是将 s 声明了在循环外。这样, s 内部的缓存将会被复用。第一次循环时,随着字符被添加至对象中,可能会重新分配几次缓冲区,但是在接下来的迭代中就不太可能会重新分配缓冲区了。相比之下,如果将 s 定义在循环内部,每次循环时都会分配一块空的缓冲区,而且当使用插入运算符添加字符时,还有可能重新分配缓冲区。

如果std::stringstream是用std::string实现的,那么它在性能上永远不能胜过std::string。它的优点在于可以防止某些降低程序性能的编程实践。

string_view可以解决std::string的某些问题。它包含一个指向字符串数据的无主指针和一个表示字符串长度的值,所以它可以表示为std::string或字面字符串的子字符串。与std::string的返回值的成员函数相比,它的substringtrim等操作更高效。std::string_view可能会被加入到 C++14 中。有些编译器现在已经实现了std::experimental::string_viewstring_viewstd::string的接口几乎相同。

std::string的问题在于指针是无主的。程序员必须确保每个string_view的生命周期都不会比它所指向的std::string的生命周期长。

使用更好的内存分配器

每个std::string的内部都是一个动态分配的字符数组。std::string看上去像是下面这样的通用模板的一种特化:

1
2
3
4
5
6
7
namespace std {
template < class charT,
class traits = char_traits<charT>,
class Alloc = allocator<charT>
> class basic_string;
typedef basic_string<char> string;
};

第三个模板参数Alloc定义了一个分配器——一个访问 C++ 内存管理器的专用接口。默认情况下,Allocstd::allocator,它会调用::operator new()::operator delete()——两个全局的 C++ 内存分配器函数。

::operator new()::operator delete()以及分配器对象的行为。现在,我只能告诉读者,::operator new()::operator delete()会做一项非常复杂和困难的工作,为各种动态变量分配存储空间。它们需要为大大小小的对象以及单线程和多线程程序工作。为了实现良好的通用性,它们在设计上做出了一些妥协。有时,选择一种更加特化的分配器可能会更好。因此,我们可以指定默认分配器以外的为std::string定制的分配器作为Alloc

这是一个极其简单的分配器,来展示可以获得怎样的性能提升。这个分配器可以管理几个固定大小的内存块。首先为使用这种分配器的字符串创建了一个typedef。接着,修改初始的、非常低效的remove_ctrl()来使用这种特殊的字符串。以下使用简单的、管理固定大小内存块的分配器的原始版本的remove_ctrl()

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef std::basic_string<
char,
std::char_traits<char>,
block_allocator<char, 10>> fixed_block_string;

fixed_block_string remove_ctrl_fixed_block(std::string s) {
fixed_block_string result;
for (size_t i=0; i<s.length(); ++i) {
if (s[i] >= 0x20)
result = result + s[i];
}
return result;
}

remove_ctrl_fixed_block()比初始版本快。

消除字符串转换

现代世界的复杂性之一是不止有一种字符串。通常,字符串函数只适用于对相同类型的字符串进行比较、赋值或是作为运算对象和参数,因此,程序员必须将一种类型的字符串转换为另外一种类型。任何时候,涉及复制字符和动态分配内存的转换都是优化性能的机会。转换函数库自身也可以被优化。更重要的是,大型程序的良好设计是可以限制这种转换的。

将C字符串转换为std::string

从以空字符结尾的字符串到std::string的无谓转换,是浪费计算机 CPU 周期的原因之一。例如:

1
2
3
std::string MyClass::Name() const {
return "MyClass";
}

这个函数必须将字符串常量MyClass转换为一个std::string,分配内存和复制字符到std::string中。C++ 会自动地进行这次转换,因为在std::string中有一个参数为char*的构造函数。

转换为std::string是无谓的。std::string有一个参数为char*的构造函数,因此当Name()的返回值被赋值给一个字符串或是作为参数传递给另外一个函数时,会自动进行转换。上面的函数可以简单地写为:

1
2
3
char const* MyClass::Name() const {
return "MyClass";
}

这会将返回值的转换推迟至它真正被使用的时候。当它被使用时,通常不需要转换:

1
2
3
char const* p = myInstance->Name(); // 没有转换
std::string s = myInstance->Name(); // 转换为'std::string'
std::cout << myInstance->Name(); // 没有转换

小结

  • 由于字符串是动态分配内存的,因此它们的性能开销非常大。它们在表达式中的行为与值类似,它们的实现方式中需要大量的复制。
  • 将字符串作为对象而非值可以降低内存分配和复制的频率。
  • 为字符串预留内存空间可以减少内存分配的开销。
  • 将指向字符串的常量引用传递给函数与传递值的结果几乎一样,但是更加高效。
  • 将函数的结果通过输出参数作为引用返回给调用方会复用实参的存储空间,这可能比分配新的存储空间更高效。
  • 即使只是有时候会减少内存分配的开销,仍然是一种优化。
  • 有时候,换一种不同的算法会更容易优化或是本身就更高效。
  • 标准库中的类是为通用用途而实现的,它们很简单。它们并不需要特别高效,也没有为某些特殊用途而进行优化。

优化算法

优化模式

本节收集了一些用于改善性能的通用技巧。它们非常实用。

预计算

预计算是一种常用的技巧,通过在程序执行至热点代码之前,先提前进行计算来达到从热点代码中移除计算的目的。预计算有多种不同的形式,既可以将计算从热点代码移至程序中不那么热点的部分,也可以移动至程序链接时、编译时和设计时。通常,越早进行计算越好。以下是预计算的几个例子。

  • C++ 编译器会使用编译器内建的相关性规则和运算符优先级,对常量表达式的值自动地进行预计算。编译器对上例中的 sec_per_day 的值进行预计算是没有问题的。
  • 编译器会在编译时评估调用模板函数时所用到的参数。如果参数是常量的话,编译器会生成高效代码。
  • 当设计人员可以观察到,例如,当在一段程序的上下文中,“周末”的概念总是两天,那么他可以在编写程序的时候预计算这个常量。

延迟计算

延迟计算的目的在于将计算推迟至更接近真正需要进行计算的地方。延迟计算带来了一些好处。如果没有必要在某个函数中的所有执行路径(if-then-else 逻辑的所有分支)上都进行计算,那就只在需要结果的路径上进行计算。以下是延迟计算的例子。

  • 两段构建(two-part construction)
    • 当实例能够被静态地构建时,经常会缺少构建对象所需的信息。在构建对象时,我们并不是一气呵成,而是仅在构造函数中编写建立空对象的最低限度的代码。稍后,程序再调用该对象的初始化成员函数来完成构建。将初始化推迟至有足够的额外数据时,意味着被构建的对象总是高效的、扁平的数据结构。
  • 写时复制
    • 写时复制是指当一个对象被复制时,并不复制它的动态成员变量,而是让两个实例共享动态变量。只在其中某个实例要修改该变量时,才会真正进行复制。

批量处理

批量处理的目标是收集多份工作,然后一起处理它们。批量处理可以用来移除重复的函数调用或是每次只处理一个项目时会发生的其他计算。当有更高效的算法可以处理所有输入数据时,也可以使用批量处理将计算推迟至有更多的计算资源可用时。举例如下。

  • 缓存输出是批量处理的一个典型例子。输出字符会一直被缓存,直至缓存满了或是程序遇到行尾(EOL)符或是文件末尾(EOF)符。相比于为每个字符都调用输出例程,将整个缓存传递给输出例程节省了性能开销。
  • 将一个未排序的数组转换为堆的最优方法是通过批量处理使用更高效算法的一个例子。将 n 个元素一个一个地插入到堆中的时间开销是 O(n log2n),而一次性构建整个堆的开销则只有 O(n)。
  • 多线程的任务队列是通过批量处理高效地利用计算资源的一个例子。
  • 在后台保存或更新是使用批量处理的一个例子。

缓存

缓存指的是通过保存和复用昂贵计算的结果来减少计算量的方法。这样可以避免在每次需要计算结果时都重新进行计算。举例如下。

  • 就像用于解引数组元素的计算一样,编译器也会缓存短小的、重复的代码块的结果。当编译器发现了像a[i][j] = a[i][j] + c;这样的语句后会保存数组表达式,然后生成一段像这样的代码:auto p = &a[i][j]; *p = *p + c;
  • 在每次需要知道 C 风格的字符串的长度时,都必须计算字符的数量,而std::string则会缓存字符串的长度,不会在每次需要时都进行计算。
  • 线程池缓存了那些创建开销很大的线程。

特化

特化与泛化相对。特化的目的在于移除在某种情况下不需要执行的昂贵的计算。通过移除那些导致计算变得昂贵的特性可以简化操作或是数据结构,但是在特定情况下,这是没有必要的。可以通过放松问题的限制或是对实现附加限制来实现这一点,例如,使动态变为静态,限制不受限制的条件,等等。举例如下。

  • 模板函数std::swap()的默认实现可能会复制它的参数。不过,开发人员可以基于对数据结构内部的了解提供一种更高效的特化实现。(当参数类型实现了移动构造函数时,C++11 版本的std::swap()会使用移动语义提高效率。)
    -std::string可以动态地改变长度,容纳不定长度字符的字符串。它提供了许多操作来操纵字符串。如果只需要比较固定的字符串,那么使用 C 风格的数组或是指向字面字符串的指针以及一个比较函数会更加高效。

提高处理量

提高处理量的目标是减少重复操作的迭代次数,削减重复操作带来的开销。这些策略如下。

  • 向操作系统请求大量输入数据或是或发送大量输出数据,来减少为少量内存块或是独立的数据项调用内核而产生的开销。
  • 在移动缓存或是清除缓存时,不要以字节为单位,而要以字或是长字为单位。这项优化仅在两块内存对齐至相同大小的边界时才能改善性能。
  • 以字或是长字来比较字符串。这项优化仅适用于大端计算机,不适用于小端的 x86 架构计算机。
  • 在唤醒线程时执行更多的工作。在唤醒线程后,不要只让处理器执行一个工作单元后就放弃它,应当让它处理多个工作单元。这样可以节省重复唤醒线程的开销。
  • 不要在每次循环中都执行维护任务,而应当每循环10次或是100次再执行一次维护任务。

提示

使用提示来减少计算量,可以达到减少单个操作的开销的目的。

例如,std::map中有一个重载的insert()成员函数,它有一个表示最优插入位置的可选参数。最优提示可以使插入操作的时间开销变为 O(1),而不使用最优提示时的时间开销则是 O(log2n)。

优化期待路径

在有多个 else-if 分支的 if-then-else 代码块中,如果条件语句的编写顺序是随机的,那么每次执行经过 if-then-else 代码块时,都有大约一半的条件语句会被测试。如果有一种情况的发生几率是 95%,而且首先对它进行条件测试,那么在 95% 的情况下都只会执行一次测试。

散列法

大型数据结构或长字符串会被一种算法处理为一个称为散列值的整数值。通过比较两个输入数据的散列值,可以高效地判断出它们是否相等。如果散列值不同,那么这两个数据绝对不相等。如果散列值相等,那么输入数据可能相等。散列法可与双重检查一起使用,以优化条件判断处理的性能。通常,输入数据的散列值都会被缓存起来,这样就无需重复地计算散列值。

双重检查

双重检查是指首先使用一种开销不大的检查来排除部分可能性,然后在必要时再使用一个开销很大的检查来测试剩余的可能性。举例如下。

  • 双重检查常与缓存同时使用。当处理器需要某个值时,首先会去检查该值是否在缓存中,如果不在,则从内存中获取该值或是通过一项开销大的计算来得到该值。
  • 当比较两个字符串是否相等时,通常需要对字符串中的字符逐一进行比较。不过,首先比较这两个字符串的长度可以很快地排除它们不相等的情况。
  • 双重检查可以用于散列法中。首先比较两个输入数据的散列值,可以高效地判断它们是否不相等。如果散列值不同,那么它们肯定不相等。

优化动态分配内存的变量

C++变量回顾

变量的存储期

每个变量都有它的存储期,也称为生命周期。只有在这段时间内,变量所占用的存储空间或者内存字节中的值才是有意义的。为变量分配内存的开销取决于存储期。

具有静态存储期的变量被分配在编译器预留的内存空间中。在程序编译时,编译器会为每个静态变量分配一个固定位置和固定大小的内存空间。静态变量的内存空间在程序的整个生命周期内都会被一直保留。所有的全局静态变量都会在程序执行进入main()前被构建,在退出main()之后被销毁。在函数内声明的静态变量则会在“程序执行第一次进入函数前”被构建,这表示它可能会和全局静态变量同时被构建,也可能直到第一次调用该函数时才会被构建。C++ 为全局静态变量指定了构建和销毁的顺序,因此开发人员可以准确地知道它们的生命周期。

为静态变量创建存储空间是没有运行时开销的。不过,我们无法再利用这段存储空间。因此,静态变量适用于那些在整个程序的生命周期内都会被使用数据。在命名空间作用域内定义的变量以及被声明为 static 或是 extern 的变量具有静态存储期。

自 C++11 开始, 程序可以声明具有线程局部存储期的变量。自 C++11 开始,用thread_local存储类型指示符关键字声明的变量具有线程局部存储期。

具有自动存储期的变量被分配在编译器在函数调用栈上预留的内存空间中。在编译时,编译器会计算出距离栈指针的偏移量,自动变量会以该偏移量为起点,占用一段固定大小的内存,但是自动变量的绝对地址直到程序执行进入变量的作用域内才会确定下来。在程序执行于大括号括起来的代码块内的这段时间,自动变量是一直存在的。当程序运行至声明自动变量的位置时,会构建自动变量;当程序离开大括号括起来的代码块时,自动变量将会被析构。

与静态变量一样,为自动变量分配存储空间不会发生运行时开销。但与静态变量不同的是,自动变量每次可以占用的总的存储空间是有限的。当递归不收敛或是发生深度函数嵌套调用导致自动变量占用的存储空间大小超出这个最大值时,会发生栈溢出,导致程序会突然终止。自动变量适合于那些只在代码块附近被使用的对象。

具有动态存储期的变量被保存在程序请求的内存中。程序会调用内存管理器,即 C++运行时系统函数和代表程序管理内存的数据结构的集合。程序会在 new 表达式中显式地为动态变量请求存储空间并构建动态变量,这可能会发生在程序中的任何一处地方。稍后,程序在 delete 表达式中显式地析构动态变量,并将变量所占用的内存返回给内存管理器。

与静态变量不同的是,动态变量的地址是在运行时确定的。不同于静态变量、线程局部变量和自动变量的是,数组的声明语法被扩展了,这样可以在运行时通过一个(非常量)表达式来指定动态数组变量的最高维度。在 C++ 中,这是唯一一种在编译时变量所占用的内存大小不固定的情况。

变量的所有权

C++ 变量的另一个重要概念是所有权。变量的所有者决定了变量什么时候会被创建,什么时候会被析构。有时,存储期会决定变量什么时候会被创建,什么时候会被析构,但所有权是另外一个单独的概念,而且是对优化动态变量而言非常重要的概念。下面是一些指导原则。

  • 全局所有权
    • 具有静态存储期的变量整体上被程序所有。程序会在进入main()前构建它们,并在从main()返回后销毁它们。
  • 词法作用域所有权
    • 具有自动存储期的变量被一段由大括号括起来的代码块构成的词法作用域所拥有。在程序进入词法作用域时会被构建,在程序退出词法作用域时会被销毁。
  • 成员所有权
    • 类和结构体的成员变量由定义它们的类实例所有。当类的实例被构建时,它们会被类的构造函数构建;当类的实例被销毁时,它们也会随之被销毁。
  • 动态变量所有权
    • 动态变量没有预定义的所有者。取而代之, new 表达式创建动态变量并返回一个必须由程序显式管理的指针。动态变量必须在最后一个指向它的指针被销毁之前,通过delete 表达式返回给内存管理器销毁。

C++动态变量API回顾

使用智能指针实现动态变量所有权的自动化

我们可以设计一个仅仅用于拥有动态变量的简单的类。除了构造和销毁动态变量外,还让这个类实现operator->()运算符和operator*()运算符。这样的类称为智能指针,因为不仅它的行为几乎与 C 风格的指针一样,当它被销毁时还能够销毁它所指向的动态对象。C++ 提供了一个称为std::unique_ptr<T>的智能指针模板来维护 T 类型的动态变量的所有权。相比于自己编写代码实现的智能指针,unique_ptr被编译后产生的代码更加高效。

动态变量所有权的自动化

智能指针会通过耦合动态变量的生命周期与拥有该动态变量的智能指针的生命周期,来实现动态变量所有权的自动化。动态变量将会被正确地销毁,其所占用的内存也会被自动地释放,这些取决于指针的声明。

  • 当程序执行超出智能指针实例所属的作用域时,具有自动存储期的智能指针实例会删除它所拥有的动态变量。
  • 声明为类的成员函数的智能指针实例在类被销毁时会删除它所拥有的动态变量。由于类的析构规则决定了在类的析构函数执行后,所有成员变量都会被销毁,因此没有必要再显式地在析构函数中编写销毁动态变量的代码。智能指针会被 C++ 的内建机制所删除。
  • 当线程正常终止时(通常不包括操作系统终止线程的情况),具有线程局部存储期的智能指针实例会删除它所拥有的动态变量。
  • 当程序结束时,具有静态存储期的智能指针实例会删除它所拥有的动态变量。

在通常情况下维护一个所有者,在特殊情况下使用std::unique_ptr维护所有权,这样可以更加容易地判断一个动态变量是否指向一块有效的内存地址,以及当不再需要它时它是否会被正确地返回给内存管理器。

共享动态变量的所有权的开销更大

C++ 标准库模板std::shared_ptr<T>提供了一个智能指针,可以在所有权被共享时管理被共享的所有权的。shared_ptr的实例包含一个指向动态变量的指针和另一个指向含有引用计数的动态对象的指针。当一个动态变量被赋值给shared_ptr时,赋值运算符会创建引用计数对象并将引用计数设置为 1。当一个shared_ptr被赋值给另一个shared_ptr时,引用计数会增加。当shared_ptr被销毁后,析构函数会减小引用计数;如果此时引用计数变为了 0,还会删除动态变量。

由于在引用计数上会发生性能开销昂贵的原子性的加减运算,因此shared_ptr可以工作于多线程程序中。std::shared_ptr也因此比 C 风格指针和std::unique_ptr的开销更大。

std::auto_ptr与容器类

在 C++11 之前, 有一个称为std::auto_ptr<T>的智能指针,它也能够管理动态变量未共享的所有权。auto_ptrunique_ptr在许多方面十分相似。C++11 之前的绝大多数标准库容器都会通过复制构造函数将它们的值类型复制到容器内部的存储空间中,因此auto_ptr无法被用作值类型。

减少动态变量的使用

使用静态数据结构

std::stringstd::vectorstd::mapstd::list是 C++ 程序员几乎每天必用的容器。只要使用得当,它们的效率还是比较高的。但它们并非是唯一选择。当向容器中添加新的元素时,std::stringstd::vector偶尔会重新分配它们的存储空间。std::mapstd::list会为每个新添加的元素分配一个新节点。有时,这种开销非常昂贵。

用std::array替代std::vector

std::vector允许程序定义任意类型的动态大小的数组。如果在编译时能够知道数组的大小,或是最大的大小,那么可以使用与std::vector具有类似接口,但数组大小固定且不会调用内存管理器的std::arraystd::array支持复制构造,且提供了标准库风格的随机访问迭代器和下标运算符[]size()函数会返回数组的固定大小。

在栈上创建大块缓冲区

担心可能会发生局部数组溢出的谨慎的开发人员,可以先检查参数字符串或是数组的长度,如果发现参数长度大于局部数组变量的长度了,那么就使用动态构建的数组。为什么这种复制的速度比使用std::string等动态数据结构要快呢?其中一个原因是变值操作通常会复制输入数据。另一个原因则是,相比于为中间结果分配动态存储空间的开销,在桌面级硬件上复制上千字节的开销更小。

静态地创建链式数据结构

可以使用静态初始化的方式构建具有链式数据结构的数据。在这个例子中,这棵树是一棵二分查找树,节点在一个广度优先(breadth-first)顺序的数组中,其中第一个节点是根节点:

1
2
3
4
5
6
7
8
9
10
11
12
struct treenode {
char const* name;
treenode* left;
treenode* right;
} tree[] = {
{ "D", &tree[1], &tree[2] }
{ "B", &tree[3], &tree[4] },
{ "F", &tree[5], nullptr},
{ "A", nullptr, nullptr},
{ "C", nullptr, nullptr},
{ "E", nullptr, nullptr},
};

这段代码之所以能够正常工作,是因为数组元素的地址是常量表达式。我们可以使用这种标记法定义任何链式结构,但是这种初始化方法难以记住,所以在构建这种结构时很容易出现编码错误。

另外一种静态地创建链式结构的方法是为结构中的每个元素都初始化一个变量。这种方式非常容易记忆,但是它的缺点是必须特别声明前向引用(就像下面示例代码中从第四个节点前向引用到第一个节点这样)。声明这种结构的最自然的方法(第一个节点、第二个节点、第三个节点、第四个节点的顺序)需要将这四个变量都声明为 extern。之所以我在下面的代码片段中反过来定义它们,是因为这样可以使得大多数引用都指向已经定义的变量:

1
2
3
4
5
6
7
8
9
struct cyclenode {
char const* name;
cyclenode* next;
}
extern cyclenode first; // 前向引用
cyclenode fourth = { "4", &first );
cyclenode third = { "3", &fourth };
cyclenode second = { "2", &third };
cyclenode first = { "1", &second };

在数组中创建二叉树

在数组中构建二叉树,然后不在节点中保存子节点的链接,而是利用节点的数组索引来计算子节点的数组索引。如果节点的索引是 i,那么它的两个子节点的索引分别是 2i 和 2i+1。这种方法带来的另外一个好处是,能够很快地知道父节点的索引是 i/2。由于这些乘法和除法运算在代码中可以实现为左位移和右位移,因此即使在处理能力非常差的处理器上这些计算也不会太慢。

以数组方式实现的树中的节点需要一种机制来判断它们的左节点和右节点是否有效,或是它们是否等于空指针。如果树是左平衡的,那么用一个整数值保存第一个无效节点的数组索引就足够了。

这些特性——能够计算子节点和父节点的能力以及左平衡树所表现出的高效——使得对于堆数据结构而言,在数组中构建树是一种高效的实现方法。

对于平衡二叉树而言,数组形式的树可能会比链式树低效。有些平衡算法保存一棵有 n 个节点的树可能需要 2n 长度的数组。而且,一次平衡操作需要复制节点到不同的数组位置中,而不仅仅是更新指针。在更加小型的处理器上,对有很多节点的树进行处理时,这种复制操作的开销可能非常大。但是,如果节点的大小小于三个指针时,数组形式的树可能会在性能上领先。

用环形缓冲区替代双端队列

std::dequestd::list经常被用于 FIFO(first-in-first-out,先进先出)缓冲区,以至于在标准库中有一个称为std::queue的容器适配器。其实,还可以在环形缓冲区上实现双端队列。环形缓冲区是一个数组型的数据结构,其中,队列的首尾两端由两个数组索引对数组的长度取模给定。环形缓冲区与双端队列有着相似的特性,包括都有时间开销为常量时间的push_back()pop_front()以及随机访问迭代器。不过,只要缓冲区消费者跟得上缓冲区生产者,环形缓冲区就无需重新分配内存。

使用std::make_shared替代new表达式

std::shared_ptr这样的共享指针实际上了包含了两个指针,一个指针指向std::shared_ptr所指向的对象;另一个指针指向一个动态变量,该变量保存了被所有指向该对象的std::shared_ptr所共享的引用计数。因此,下面这条语句:

1
std::shared_ptr<MyClass> p(new MyClass("hello", 123));

会调用两次内存管理器:第一次用于创建MyClass的实例,第二次用于创建被隐藏起来的引用计数对象。

在 C++11 之前,分配引用计数器的开销是添加侵入式引用计数作为 MyClass 的基类,然后创建一个自定义的智能指针使用该侵入式引用计数。

1
custom_shared_ptr<RCClass> p(new RCClass("Goodbye", 999));

C++ 标准库的编写者在了解到了开发人员的这种痛苦后,编写了一个称为std::make_shared()的模板函数,这个函数可以分配一块独立的内存来同时保存引用计数和MyClass的一个实例。std::shared_ptr还有一个删除器函数,它知道被共享的指针是以这两种方式中的哪一种被创建的。make_shared()的使用方法很简单:

1
std::shared_ptr<MyClass> p = std::make_shared<MyClass>("hello", 123);

也可以使用更简单的 C++11 风格的声明:

1
auto p = std::make_shared<MyClass>("hello", 123);

不要无谓地共享所有权

多个std::shared_ptr实例可以共享一个动态变量的所有权。当各个指针的生命周期会不可预测地发生重叠时,shared_ptr非常有用。但它的开销也很昂贵。增加和减少shared_ptr中的引用计数并不是执行一个简单的增量指令,而是使用完整的内存屏障进行一次非常昂贵的原子性增加操作,这样shared_ptr才能工作于多线程程序中。

如果一个shared_ptr的生命周期完全地包含了另一个shared_ptr的生命周期,那么第二个shared_ptr的开销是无谓的。下面的代码描述了一种经常发生的场景:

1
2
3
4
void fiddle(std::shared_ptr<Foo> f);
...
shared_ptr<Foo> myFoo = make_shared<Foo>();
fiddle(myFoo);

myFoo拥有动态变量的实例Foo。当程序调用fiddle()时,会创建第二个指向动态FOO实例的链接,并增加shared_ptr的引用计数。当fiddle()返回时,shared_ptr参数会释放它对动态FOO实例的所有权,但调用方仍然拥有指针。这次调用的最小开销是一次无谓的原子性增加和减小操作,而且这两次操作都带有完整的内存屏障。在一次函数调用过程中,这种开销微不足道。将传递给fiddle()的参数改为一个普通指针可以避免这种开销:

1
2
3
4
5
void fiddle(Foo* f);
...
shared_ptr<Foo> myFoo = make_shared<Foo>();
...
fiddle(myFoo.get());

使用“主指针”拥有动态变量

经常出现的一种情况是,一个单独的数据结构在它的整个生命周期内拥有动态变量。指向动态变量的引用或是指针可能会被传递给函数和被函数返回,或是被赋值给变量,等等。但是在这些引用中,没有哪个的寿命比“主引用”长。如果存在主引用,那么我们可以使用std::unique_ptr高效地实现它。然后,我们可以在函数调用过程中,用普通的 C 风格的指针或是 C++ 引用来引用该对象。如果在程序中贯彻了这种方针,那么普通指针和引用就会被记录为“无主”指针。

减少动态变量的重新分配

预分配动态变量以防止重新分配

随着在std::string或是std::vector上数据的增加,它内部的动态分配的存储空间终究会枯竭。下一个添加操作会导致需要分配更大的存储空间,以及将旧的数据复制到新存储空间中。对内存管理器的调用以及复制操作将会多次访问内存并消耗很多指令。诚然,添加操作的时间开销是 O(1),但是比例常量(即常量时间是多少毫秒)可能会非常大。

string 和vector都有成员函数reserve(size_t n),调用该函数会告诉 string 或是vector请确保至少有存储 n 个元素的空间。如果可以事先计算或是预估出这个大小,那么调用reserve()为 string 或是vector预留足够的内部存储空间,可以避免出现它们到达增长极限后需要重新分配存储空间的情况:

1
2
3
4
5
std::string errmsg;
errmsg.reserve(100); // 下面这些字符串连接操作中只会发生一次内存分配
errmsg += "Error 1234: variable ";
errmsg += varname;
errmsg += " was used before set. Undefined behavior.";

调用reserve()如同是对 string 或是vector的一种提示。与分配最差情况下的静态缓存不同的是,即使过小地估计了预留的容量,惩罚也不过是额外的重新分配。而即使过大地估计了预留的容量,只要 string 或是vector会在短暂地使用后被销毁,就都没有问题。在使用reserve()预分配 string 或是vector后,还可以使用std::string或是std::vectorshrink_to_fit()成员函数将未使用的空间返回给内存管理器。

在循环外创建动态变量

在下面这段代码中,循环虽小,问题却大。这段程序会将 namelist 中的每个文件中的每行都添加到std::string类型的变量 config 中,接着再从 config 中抽出一小部分数据。问题出在每次循环中都会创建 config,并且在每次循环内部,随着 config 的不断增大,都会重新分配内存。接着,在循环末尾离开了它的作用域后, config 将会被销毁,它的存储空间会被返回给内存管理器:

1
2
3
4
5
for (auto& filename : namelist) {
std::string config;
ReadFileXML(filename, config);
ProcessXML(config);
}

提高这段循环的性能的一种方法是将 config 的声明移至循环外部。在每次循环中,都先清除该变量。不过,clear()函数并不会释放 config 内部的动态缓冲区,它只是将config 的内容的长度设置为 0。从第二次循环开始,只要接下来的文件没有比第一次循环中使用的文件大很多,就不会重新分配内存:

1
2
3
4
5
6
std::string config;
for (auto& filename : namelist) {
config.clear();
ReadFileXML(filename, config);
ProcessXML(config);
}

移除无谓的复制

在 C++ 中,存在着看似简单,但其实并不高效的赋值语句。如果 a 和 b 都是BigClass类的实例,那么赋值语句a = b;会调用BigClass的赋值运算符成员函数。赋值运算符可以只是简单地将 b 的字段全部复制到 a 中去。但是问题在于这个函数可能会做任何C++ 函数都会做的事情。BigClass可能有很多字段需要复制。如果BigClass中有动态变量,复制它们可能会引发对调用内存管理器的调用。如果BigClass中有一个保存有数百万元素的std::map或是一个保存有数百万字符的字符数组,那么赋值语句的开销会非常大。

在 C++ 中,如果Foo是一个类,初始化声明Foo a = b;可能会调用一个称为复制构造函数的成员函数。复制构造函数和赋值运算符是两个紧密相关的成员函数,它们所做的事情几乎相同:将一个类实例中的字段复制到另一个类实例中去。而且与赋值运算符一样,复制构造函数的开销是没有上限的。

复制可能会发生于以下任何一种情况下:

  • 初始化(调用构造函数)
  • 赋值(调用赋值运算符)
  • 函数参数(每个参数表达式都会被移动构造函数或复制构造函数复制到形参中)
  • 函数返回(调用移动构造函数或复制构造函数,甚至可能会调用两次)
  • 插入一个元素到标准库容器中(会调用移动构造函数或复制构造函数复制元素)
  • 插入一个元素到vector中(如果需要重新为vector分配内存,那么所有的元素都会通过移动构造函数或复制构造函数复制到新的vector中)

在类定义中禁止不希望发生的复制

许多具有实体行为的对象都会有一些状态。如果程序不经意地将实体复制到了一个会检查该实体状态的函数中,虽然在功能上是没有问题的,但是运行时开销会非常大。

如果复制类实例过于昂贵或是不希望这么做,那么一种可以有效地避免发生这种昂贵开销的方法就是禁止复制。将复制构造函数和赋值运算符的可见性声明为 private 可以防止它们被外部调用。既然它们无法被调用,那么也就不需要任何定义,只需要声明就足够了。例如:

1
2
3
4
5
6
7
8
// 在C++11之前禁止复制的方法
class BigClass {
private:
BigClass(BigClass const&);
BigClass& operator=(BigClass const&);
public:
...
};

在 C++11 中,我们可以在复制构造函数和赋值运算符后面加上delete关键字来达到这个目的。将带有delete关键字的复制构造函数的可见性设为 public 更好,因为在这种情况下调用复制构造函数的话,编译器会报告出明确的错误消息:

1
2
3
4
5
6
7
// 在C++11中禁止复制的方法
class BigClass {
public:
BigClass(BigClass const&) = delete;
BigClass& operator=(BigClass const&) = delete;
...
};

任何企图对以这种方式声明的类的实例赋值——或通过传值方式传递给函数,或通过传值方式返回,或是将它用作标准库容器的值时——都会导致发生编译错误。

但是还可以用指向该类的指针和引用来赋值或初始化变量,或是在函数中传递和返回指向该类实例的引用或指针。从性能优化的角度看,使用指针或引用进行赋值和参数传递,或是返回指针或引用更加高效,因为指针或引用是存储在寄存器中的。

移除函数调用上的复制

当程序调用函数时,会计算每个参数表达式,并以相对应的参数表达式的值作为初始化器创建每个形参。“创建”意味着会调用形参的构造函数。当形参是诸如 int、 double 或是char*等基本类型时,由于基本类型的构造函数是概念上的而非实际的函数,因此程序只会简单地将值复制到形参的存储空间中。

但是当形参是某个类的实例时,程序将调用这个类的复制构造函数之一来初始化实例。通常情况下,复制构造函数都是一个实际的函数。请考虑以下示例代码:

1
2
3
4
5
6
int Sum(std::list<int> v) {
int sum = 0;
for (auto it : v)
sum += *it;
return sum;
}

当调用这里展示的Sum()函数时,实参是一个链表:例如,int total = Sum(MyList);。形参v也是一个链表。v是通过一个接收链表作为参数的构造函数创建的。它就是复制构造函数。std::list的复制构造函数会为链表中所有的元素创建一份副本。如果MyList总是只有几个元素,那么这个开销尽管没有必要,但是依然可以忍受。但是随着MyList变大,这个开销将会降低程序性能。如果它有 1000 个元素,那么内存管理器会被调用 1000 次。在函数最后,形参v会超出它的作用域,其中的 1000 个元素也会被逐一返回给不会再被使用的链表。

为了避免这种开销,我们可以将形参定义为带有平凡(trivial)构造函数的类型。为了将类实例传递给函数,指针和引用具有普通构造函数。例如,在之前的例子中,我们可以将v定义为std::list<int> const&。接着,该引用会被指向实参的引用初始化,而不会使用复制构造函数初始化类的实例。引用通常被实现为指针。

当一个类和标准库容器一起使用时,通过复制构造函数创建它的实例可能会调用内存管理器来复制它内部的数据,而传递指向类实例的引用可以改善程序性能。通过引用访问实例也会产生开销:每次访问实例时,都必须解引实现该引用的指针。如果函数很大,而且在函数体中多次使用了参数值,那么连续地解引引用的开销会超过所节省下来的复制开销,导致性能改善收益递减。但是对于小型函数,除了特别小的类以外,通过引用传递参数总是能获得更好的性能。

引用参数的行为与值参数并不完全相同。引用参数在函数内部发生改变会导致它所引用的实例也发生改变,但是值参数在函数内部发生改变却不会对函数外的值造成任何影响。将引用参数声明为常量引用可以防止不小心修改所引用的实例。

引用参数还会引入别名,这会导致不曾预料到的影响。也就是说,如果函数签名是:

1
void func(Foo& a, Foo& b);

函数调用func(x,x);引入了一个别名。如果func()更新了 a,那么你会发现 b 突然也被更新了。

移除函数返回上的复制

如果函数返回一个值,那么这个值会被复制构造到一个未命名的与函数返回值类型相同的临时变量中。对于 long、 double 或指针等基本类型会进行默认的复制构造,而当变量是类时,复制构造通常都会发生实际的函数调用。类越大越复杂,复制构造函数的时间开销也越大。下面来看一个例子:

1
2
3
4
5
6
7
std::vector<int> scalar_product(std::vector<int> const& v, int c) {
std::vector<int> result;
result.reserve(v.size());
for (auto val : v)
result.push_back(val * c);
return result;
}

在有些情况下,通过返回引用而不是返回已经创建的返回值,可以避免发生复制构造开销。不幸的是,如果在函数内计算出返回值后,将其赋值给了一个具有自动存储期的变量,那么这个技巧将无法适用。当函数返回后,这个变量将超出它的作用域,导致悬挂引用将会指向一块堆内存尾部的未知字节,而且该区域通常都会很快被其他数据覆盖。更糟糕的是,函数计算返回结果是很普遍的情况,所以多数函数都会返回值,而非引用。

就像返回值的复制构造的开销并不算太糟糕,调用方常常会像auto res =scalar_product(argarray, 10);这样将函数返回值赋值给一个变量。因此,除了在函数内部调用复制构造外,在调用方还会调用复制构造函数或赋值运算符。C++ 编译器找到了一种移除额外的复制构造函数调用的方法。这种优化方法被称为复制省略(copy elision)或是返回值优化(return value optimization,RVO)。函数必须返回一个局部对象。编译器必须能够确定在所有的控制路径上返回的都是相同的对象。返回对象的类型必须与所声明的函数返回值的类型相同。最简单的情况是,如果一个函数非常短小,并且只有一条控制路径,那么编译器进行 RVO 的可能性非常大。如果函数比较庞大,或是控制路径有很多分支,那么编译器将难以确定是否可以进行 RVO。当然,各种编译器的分析能力也是不同的。

有一种方法可以移除函数内部的类实例的构造以及从函数返回时发生的两次复制构造(或是等价于复制构造函数的赋值运算符)。这需要开发人员手动编码实现,所以其结果肯定比寄希望于编译器在给定的情况下进行 RVO 要好。这种方法就是不用 return 语句返回值,而是在函数内更新引用参数,然后通过输出参数返回该引用参数:

1
2
3
4
5
6
7
8
9
void scalar_product(
std::vector<int> const& v,
int c,
vector<int>& result) {
result.clear();
result.reserve(v.size());
for (auto val : v)
result.push_back(val * c);
}

这里,我们在函数参数列表中加入了一个称为 result 的输出参数。这种机制有以下几个优点。

  • 当函数被调用时,该对象已经被构建。有时,该对象必须被清除或是重新初始化,但是这些操作不太可能比构造操作的开销更大。
  • 在函数内被更新的对象无需在 return 语句中被复制到未命名的临时变量中。
  • 由于实际数据通过参数返回了,因此函数的返回类型可以是 void,也可以用来返回状态或是错误信息。
  • 由于在函数中被更新的对象已经与调用方中的某个名字绑定在了一起,因此当函数返回时不再需要复制或是赋值。

当在程序中多次调用一个函数时,许多数据结构(如字符串、矢量和散列表)都有一个可复用的动态分配的骨干数组。有时,函数的结果必须保存在调用方中,但是这种开销永远不会比当函数通过值返回类的实例时调用复制构造函数的开销大。

这种机制会产生额外的运行时开销,例如额外的参数开销吗?其实并不会。编译器在处理返回实例的函数时,会将其转换为一种带有额外参数的形式。这个额外的参数是一个引用,它指向为用于保存函数所返回的未命名的临时变量的未初始化的存储空间。在 C++ 中有一种情况只能通过值返回对象:运算符函数。当开发人员在编写矩阵计算函数时,如果希望使用通用的运算符A = B * C;,就无法使用引用参数。在实现运算符函数时必须格外小心,确保它们会使用 RVO 和移动语义,这样才能实现最高效率。

免复制库

当需要填充的缓冲区、结构体或其他数据结构是函数参数时,传递引用穿越多层库调用的开销很小。这种模式出现在了许多性能需求严格的函数库中。例如, C++ 标准库istream::read()成员函数的签名如下:

1
istream& read(char* s, streamsize n);

这个函数会读取 n 个字节的内容到 s 所指向的存储空间中。这段缓冲区是一个输出参数,因此要读取的数据不会被复制到新分配的存储空间中。由于 s 是一个参数,istream::read()可以将返回值用于其他用途。在本例中,函数将this指针作为引用返回。但是istream::read()自身并不会从操作系统内核获取数据。它会调用另外一个函数。在某些实现方式下,它可能会调用 C 的库函数fread()fread()的函数签名如下:

1
size_t fread(void* ptr, size_t size, size_t nmemb, FILE* stream);

fread()会读取size*nmemb个字节的数据并将它们存储在ptr所指向的存储空间中。fread()中的ptr参数与read()中的s相同。但是fread()并不是调用链的终点。在 Linux 上,fread()会调用标准 Unix 函数read()

实现写时复制惯用法

写时复制(copy on write, COW)用于高效地复制那些含有复制开销昂贵的动态变量的类实例。通常来说,当一个带有动态变量的对象被复制时,也必须复制该动态变量。这种复制被称为深复制。通过复制指针,而不是复制指针指向的变量得到包含无主指针的对象的副本,这种复制被称为浅复制。

写时复制的核心思想是,在其中一个副本被修改之前,一个对象的两个副本一直都是相同的。因此,直到其中一个实例或另外一个实例被修改,两个实例能够共享那些指向复制开销昂贵的字段的指针。写时复制首先会进行一次“浅复制”,然后将深复制推迟至对象的某个元素发生改变时。

在现代 C++ 的 COW 的实现方式中,任何引用动态变量的类成员都是用如std::shared_ptr这样的具有共享所有权的智能指针实现的。类的构造函数复制具有共享所有权的指针,将动态变量的一份新的复制的创建延迟到任何一份复制想要修改该动态变量时。作用于类上的任何变值操作都会在真正改变类之前先检查共享指针的引用计数。引用计数值大于 1 表明所有权被共享了,那么这个操作会为对象创建一份新的副本,用指向新副本的共享指针交换之前的共享指针成员,并释放旧的副本和减小引用计数。由于已经确保了动态变量没有被共享,现在可以进行变值操作了。

在 COW 类中使用 std::make_shared() 构建动态变量是非常重要的。否则,使用共享指针会发生额外的调用内存管理器来获取引用计数对象的开销。如果在类中有许多动态变量,那么这个开销与简单地将动态变量复制到新的存储空间中并赋值给一个(非共享的)智能指针的开销无异。因此,除非要复制很多份副本,或者变值运算符通常不会被调用,否则COW 惯用法可能不会发挥什么作用。

切割数据结构

切割(slice)是一种编程惯用法,它指的是一个变量指向另外一个变量的一部分。被切割的对象通常都是小的、容易复制的对象,将其内容复制至子数组或子字符串中而分配存储空间的开销不大。如果被分割的数据结构为被共享的指针所有,那么切割是完全安全的。

实现移动语义

移动语义解决了之前版本的 C++ 中反复出现的问题,例子如下。

  • 将一个对象赋值给一个变量时,会导致其内部的内容被复制。而在这之后,原来的对象立即被销毁了。
  • 开发人员希望将一个实体赋值给一个变量。在这个对象中,赋值语句中的“复制”操作是未定义的,因为这个对象具有唯一的识别符。

以上这两种情况对std::vector等动态容器有很大影响,因为伴随着容器中元素数量的增加,容器内部的存储空间必须被重新分配。第一种情况会导致重新分配容器的开销比实际所需更大。第二种情况则会导致 auto_ptr 等实体无法被存储在容器中。

问题的起因在于,复制构造函数和赋值运算符执行的复制操作对于基本类型和无主指针没有问题,但是对于实体则没有意义。拥有这种类型的成员变量的类可以被保存在 C 风格的数组中,但是无法被保存在std::vector等动态容器中。

移动语义的移动部分

为了实现移动语义, C++ 编译器需要能够识别一个变量在什么时候只是临时值。这样的实例是没有名字的。例如,函数返回的对象或 new 表达式的结果就没有名字。不可能会有其他引用指向该对象。该对象可以被初始化、赋值给一个变量或是作为表达式或函数的参数。但是接下来它会立即被销毁。这样的无名值被称为右值,因为它与赋值语句右侧的表达式的结果类似。相反, 左值是指通过变量命名的值。在语句 y = 2x + 1; 中,表达式2x + 1 的结果是一个右值,它是一个没有名字的临时值。等号左侧的变量是一个左值, y是它的名字。

当一个对象是右值时,它的内容可以被转换为左值。所需做的就是保持右值为有效状态,这样它的析构函数就可以正常工作了。C++ 的类型系统被扩展了,它能够从函数调用上的左值中识别出右值。如果 T 是一个类型,那么声明T&&就是指向 T 的右值引用——也就是说,一个指向类型 T 的右值的引用。函数重载的解析规则也被扩展了,这样当右值是一个实参时,优先右值引用重载;而当左值是实参时,则需要左值引用重载。

代码清单 6-3 是一个包含唯一实体的简单的类。编译器会为这个类自动地生成移动构造函数和移动赋值运算符。如果类的成员定义了移动操作,这些移动运算符就会对这些成员进行一次移动操作;如果没有,则进行一次复制操作。这等同于对每个类成员都执行this->member = std::move(rhs.member)

1
2
3
4
5
6
7
8
9
10
11
12
class Foo {
std::unique_ptr<int> value_;
public:
...
Foo(Foo&& rhs) {
value_ = rhs.release();
}
Foo(Foo const& rhs) : value_(nullptr) {
if (rhs.value_)
value_ = std::make_unique<int*>(*rhs.value_);
}
};

实际上,编译器只会在当程序没有指定复制构造函数、赋值运算符或是析构函数,而且父类或是任何类成员都没有禁用移动运算符的简单情况下,才会自动生成移动构造函数和移动赋值运算符。这条规则是有意义的,因为这些特殊的函数定义的出现暗示可能需要一些特殊的东西(而不是“成员逐一移动”)。

移动语义的微妙之处

移动语义并非黑科技。这种特性太重要了,而且标准库的实现人员确实做得很棒,让它在语义上与复制构造函数十分相似。但是我认为,可以说移动语义是非常微妙的。这是 C++中必须谨慎使用的特性之一,如果你对它足够了解,你的程序就可以获得极大的性能提升。

移动实例至std::vector

如果你希望你的对象在std::vector中能够高效地移动,那么仅仅编写移动构造函数和移动赋值运算符是不够的。开发人员必须将移动构造函数和移动赋值运算符声明为noexcept。这很有必要,因为std::vector提供了强异常安全保证(strong exception safety guarantee):当一个 vetcor 执行某个操作时,如果发生了异常,那么该 vetcor 的状态会与执行操作之前一样。复制构造函数并不会改变源对象。移动构造函数则会销毁它。任何在移动构造函数中发生的异常都会与强异常安全保证相冲突。

如果没有将移动构造函数和移动赋值运算符声明为 noexcept,std::vector会使用比较低效的复制构造函数。当发生这种情况时,编译器可能不会给出警告,代码仍然可以正常运行,不过会变慢。noexcept 是一种强承诺。使用 noexcept 意味着不会调用内存管理器、 I/O 或是其他任何可能会抛出异常的函数。同时,它也意味着必须忍受所有异常,因为没有任何办法报告在程序中发生了异常。

右值引用参数是左值

当一个函数接收一个右值引用作为参数时,它会使用右值引用来构建形参。因为形参是有名字的,所以尽管它构建于一个右值引用,它仍然是一个左值。幸运的是,开发人员可以显式地将左值转换为右值引用。如代码所示,标准库提供了漂亮的<utility>中的模板函数std::move()来完成这项任务。

1
2
3
4
5
6
7
8
9
std::string MoveExample(std::string&& s) {
std::string tmp(std::move(s));
// 注意!现在s是空的
return tmp;
}
...
std::string s1 = "hello";
std::string s2 = "everyone";
std::string s3 = MoveExample(s1 + s2);

在代码中,调用MoveExample(s1 + s2)会导致通过右值引用构建 s,这意味着实参被移动到了 s 中。调用std::move(s)会创建一个指向 s 的内容的右值引用。由于右值引用是std::move()的返回值,因此它没有名字。右值引用会初始化tmp,调用std::string的移动构造函数。此时, s 已经不再指向MoveExample()的实参字符串。它可能是一个空字符串。当返回tmp的时候,从概念上讲,tmp的值会被复制到匿名返回值中,接着 tmp 会被删除。

MoveExample()的匿名返回值会被复制构造到s3中。不过,实际上,在这种情况下编译器能够进行 RVO,这样参数s会被直接移动到s3的存储空间中。通常, RVO 比移动更高效。下面是一个使用了std::move()的移动语义版本的std::swap()

1
2
3
4
5
6
template <typename T> void std::swap(T& a, T& b) {
{
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}

只要 T 实现了移动语义,这个函数就会执行三次移动,且不会进重新分配。否则,它会进行复制构造。

不要返回右值引用

移动语义的另外一个微妙之处在于不应当定义函数返回右值引用。直觉上,返回右值引用是合理的。在像x = foo(y)这样的函数调用中,返回右值引用会高效地将返回值从未命名的临时变量中复制到赋值目标 x 中。

但是实际上,返回右值引用会妨碍返回值优化,即允许编译器向函数传递一个指向目标的引用作为隐藏参数,来移除从未命名的临时变量到目标的复制。返回右值引用会执行两次移动操作,而一旦使用了返回值优化,返回一个值则只会执行一次移动操作。因此,只要可以使用 RVO,无论是返回语句中的实参还是函数的返回类型,都不应当使用右值引用。

移动父类和类成员

正如代码所示,要想为一个类实现移动语义,你必须为所有的父类和类成员也实现移动语义。否则,父类和类成员将会被复制,而不会被移动。

1
2
3
4
5
6
7
8
9
10
11
12
class Base {...};
class Derived : Base {
...
std::unique_ptr<Foo> member_;
Bar* barmember_;
};
Derived::Derived(Derived&& rhs)
: Base(std::move(rhs)),
member_(std::move(rhs.member_)),
barmember_(nullptr) {
std::swap(this->barmember_, rhs.barmember_);
}

代码展示了一个编写移动构造函数的微妙之处。假设 Base 有移动构造函数,那么它只有在通过调用std::move()将左值rhs转换为右值引用后才会被调用。同样,只有当rhs.member_被转换为右值引用后才会调用std::unique_ptr的移动构造函数。而对于普通指针barmember_或其他任何没有定义移动构造函数的对象,std::swap()实现了一个类似移动的操作。

在实现移动赋值运算符时,std::swap()可能会引起麻烦。麻烦在于this可能会指向一个已经分配了内存的对象。std::swap()不会销毁那些不再需要的内存。它会将它们保存在rhs中,直至rhs被销毁前这块内存都无法被重新利用。如果在一个类成员中有一个含有100 万个字符的字符串或是包含一张 100 万个元素的表,这可能会是一个潜在的大问题。

在这种情况下,最好先显式地复制barmember_指针,然后在rhs中删除它,以防止rhs的析构函数删除释放它:

1
2
3
4
5
6
void Derived::operator=(Derived&& rhs) {
Base::operator=(std::move(rhs));
delete(this->barmember_);
this->barmember_ = rhs.barmember_;
rhs.barmember_ = nullptr;
}

扁平数据结构

当一个数据结构中的元素被存储在连续的存储空间中时,我们称这个数据结构为扁平的。相比于通过指针链接在一起的数据结构,扁平数据结构具有显著的性能优势。

  • 相比于通过指针链接在一起的数据结构,创建扁平数据结构实例时调用内存管理器的开销更小。有些数据结构(如 list、 deque、 map、 unordered_map)会创建许多动态变量,而其他数据结构(如 vector)则较少。
  • std::arraystd::vector等扁平数据结构所需的内存比 list、 map、 unordered_map 等基于节点的数据结构少,因为在基于节点的数据结构中存在着链接指针的开销。即使所消耗的总字节数没有问题,紧凑的数据结构仍然有助于改善缓存局部性。扁平数据结构在局部缓存性上的优势使得它们更加高效。
  • 以前常常需要用到的技巧,诸如用智能指针组成vector或是 map 来存储不可复制的对象,在 C++11 中的移动语义出现后已经不再需要了。移动语义移除了在分配智能指针和它所指向的对象的存储空间时产生的显著的运行时开销。

小结

  • 在 C++ 程序中,乱用动态分配内存的变量是最大的“性能杀手”。当发生性能问题时,new 不再是你的朋友。
  • 只要知道了如何减少对内存管理器的调用,开发人员就能够成为一个高效的性能优化专家。
  • 通过提供::operator new()运算符和::operator delete()运算符,可以整体地改变程序分配内存的方式。
  • 通过替换malloc()free()可以整体地改变程序管理内存的方式。
  • 智能指针实现了动态变量所有权的自动化。
  • 共享了所有权的动态变量更加昂贵。
  • 静态地创建类实例。
  • 静态地创建类成员并且在有必要时采用两段初始化。
  • 让主指针来拥有动态变量,使用无主指针替代共享所有权。
  • 编写通过输出参数返回值的免复制函数。
  • 实现移动语义。
  • 扁平数据结构更好。

优化热点语句

循环中的语句开销是语句各自的开销乘以它们被重复执行的次数。热点循环必须由开发人员自己找出来。

频繁被调用的函数:函数的开销是函数自身的开销乘以它被执行的次数。分析器可以直接指出热点函数。

从循环中移除代码

一个循环是由两部分组成的:一段被重复执行的控制语句和一个确定需要进行多少次循环的控制分支。通常情况下,移除 C++ 语句中的计算指的是移除循环中的控制语句的计算。不过在循环中,控制分支也有额外的优化机会,因为从某种意义上说,它产生了额外的开销。

1
2
3
4
5
char s[] = "This string has many space (0x20) chars. ";
...
for (size_t i = 0; i < strlen(s); ++i)
if (s[i] == ' ')
s[i] = '*';

这段代码对字符串中的每个字符都会判断循环条件i < strlen(s)是否成立 1。调用strlen()的开销是昂贵的,遍历参数字符串对它的字符计数使得这个算法的开销从O(n)变为了O(n^2)

缓存循环结束条件值

我们可以通过在进入循环时预计算并缓存循环结束条件值,即调用开销昂贵的strlen()的返回值,来提高程序性能。

1
2
3
for (size_t i = 0, len = strlen(s); i < len; ++i)
if (s[i] == ' ')
s[i] = '*';

由于strlen()的开销实在是太大了,因此修改后的效果非常明显。

使用更高效的循环语句

粗略地讲, for 循环会被编译为如下代码:

1
2
3
4
5
6
初始化表达式 ;
L1: if ( ! 循环条件 ) goto L2;
语句 ;
继续表达式 ;
goto L1;
L2:

for 循环必须执行两次 jump 指令:一次是当循环条件为 false 时;另一次则是在计算了继续表达式之后。这些 jump 指令可能会降低执行速度。C++ 还有一种使用不那么广泛的、称为 do 的更简单的循环形式,do 循环会被编译为如下代码:

1
2
L1: 控制语句
if ( 循环条件 ) goto L1;

因此,将一个 for 循环简化为 do 循环通常可以提高循环处理的速度。

用递减替代递增

缓存循环结束条件的另一种方法是用递减替代递增,将循环结束条件缓存在循环索引变量中。许多循环都有一种结束条件判断起来比其他结束条件更高效。例如,循环中,一种结束条件是常量 0,而另外一种则是调用开销昂贵的strlen()函数。

1
2
3
for (int i = (int)strlen(s)-1; i >= 0; --i)
if (s[i] == ' ')
s[i] = '*';

归纳变量 i 的类型从无符号的size_t变为了有符号的 int。for 循环的结束条件是i >= 0。如果 i 是无符号的,从定义上说,它总是大于或等于 0,那么循环就永远无法结束。在采用递减方式时,这是一个非常典型的错误。

从循环中移除不变性代码

当代码不依赖于循环的归纳变量时,它就具有循环不变性。现代编译器非常善于找出在循环中被重复计算的具有循环不变性的代码,然后将计算移动至循环外部来改善程序性能。开发人员通常没有必要重写这段代码,因为编译器已经替我们找出了具有循环不变性的代码并重写了循环。

当在循环中有语句调用了函数时,编译器可能无法确定函数的返回值是否依赖于循环中的某些变量。被调用的函数可能很复杂,或是函数体包含在另外一个编译器看不到的编译单元中。这时,开发人员必须自己找出具有循环不变性的函数调用并将它们从循环中移除。

从循环中移除无谓的函数调用

一次函数调用可能会执行大量的指令。如果函数具有循环不变性(loop-invariant),那么将它移除到循环外有助于改善性能。在代码清单 7-1 中,strlen()具有循环不变性,因此可以将它到移动到循环外部。

从循环中移除隐含的函数调用

普通的函数调用很容易识别,它们有函数名,在圆括号中有参数表达式列表。C++ 代码还可能会隐式地调用函数,而没有这种很明显的调用语句。当一个变量是以下类型之一时就可能会发生这种情况:

  • 声明一个类实例(调用构造函数)
  • 初始化一个类实例(调用构造函数)
  • 赋值给一个类实例(调用赋值运算符)
  • 涉及类实例的计算表达式(调用运算符成员函数)
  • 退出作用域(调用在作用域中声明的类实例的析构函数)
  • 函数参数(每个参数表达式都会被复制构造到它的形参中)
  • 函数返回一个类的实例(调用复制构造函数,可能是两次)
  • 向标准库容器中插入元素(元素会被移动构造或复制构造)
  • 向矢量中插入元素(如果矢量重新分配了内存,那么所有的元素都需要被移动构造或是复制构造)

这些函数调用被隐藏起来了。你从表面上看不出带有名字和参数列表的函数调用。它们看起来更像赋值和声明。我们很容易误以为这里没有发生函数调用。

如果将函数签名从通过值传递实参修改为传递指向类的引用或指针,有时候可以在进行隐式函数调用时移除形参构建。如果将函数签名修改为通过输出参数返回指向类实例的引用或指针时,可以在进行隐式函数调用时移除函数返回值的复制。

如果赋值语句和初始化声明具有循环不变性,那么我们可以将它们移动到循环外部。有时,即使需要每次都将变量传递到循环中,你也可以将声明移动到循环外部,并在每次循环中都执行一次开销较小的函数调用。例如,std::string是一个含有动态分配内存的字符数组的类。在以下代码中:

1
2
3
4
5
for (...) {
std::string s("<p>");
...
s += "</p>";
}

在 for 循环中声明 s 的开销是昂贵的。在循环语句块的反大括号的位置将会调用 s 的析构函数,而析构函数会释放为 s 动态分配的内存,因此当下一次进入循环时,一定会重新分配内存。这段代码可以被优化为:

1
2
3
4
5
6
7
std::string s;
for (...) {
s.clear();
s += "<p>";
...
s += "</p>";
}

现在,不会再在每次循环中都调用 s 的析构函数了。这不仅仅是在每次循环中都节省了一次函数调用,同时还带来了其他效果——由于 s 内部的动态数组会被复用,因此当向 s 中添加字符时,可能会移除一次对内存管理器的调用。

将循环放入函数以减少调用开销

如果程序要遍历字符串、数组或是其他数据结构,并会在每次迭代中都调用一个函数,那么可以通过一种称为循环倒置(loop inversion)的技巧来提高程序性能。循环倒置是指将在循环中调用函数变为在函数中进行循环。这需要改变函数的接口,不再接收一条元素作为参数,而是接收整个数据结构作为参数。按照这种方式修改后,如果数据结构中包含 n 条元素,那么可以节省 n-1 次函数调用。我们来看一个非常简单的例子。下面这个函数的功能是用点(“.”)替代非打印字符:

1
2
3
4
5
# include <ctype>
void replace_nonprinting(char& c) {
if (!isprint(c))
c = '.';
}

当想替换一个字符串中所有的非打印字符时,可以在程序中循环中调用replace_nonprinting()

1
2
for (unsigned i = 0, e = str.size(); i < e; ++i)
replace_nonprinting(str[i]);

如果编译器无法对replace_nonprinting()内联展开,那么当需要处理的字符串是“Ring the carriage bell\x07\x07!!”时,它会调用这个函数 26 次。

库的设计者可以重载replace_nonprinting()函数来处理整个字符串:

1
2
3
4
5
void replace_nonprinting(std::string& str) {
for (unsigned i = 0, e = str.size(); i < e; ++i)
if (!isprint(str[i]))
c = '.';
}

现在,循环在函数内部了,这样可以节省 n-1 次对replace_nonprinting()的调用。请注意,必须将replace_nonprinting()的实现代码复制到新的重载函数中。仅仅在新的重载函数的循环中调用之前的函数是没有效果的。下面的版本实际上只是在循环中调用了之前的函数:

1
2
3
4
void replace_nonprinting(std::string& str) {
for (unsigned i = 0, e = str.size(); i < e; ++i)
replace_nonprinting(str[i]);
}

从函数中移除代码

与循环一样,函数也包含两部分:一部分是由一段代码组成的函数体,另一部分是由参数列表和返回值类型组成的函数头。与优化循环一样,这两部分也可以独立优化。尽管执行函数体的开销可能会非常大,但是调用函数的开销与调用大多数 C++ 语句的开销一样,是非常小的。不过,当函数被多次调用时,累积的开销可能会变得巨大,因此减少这种开销非常重要。

函数调用的开销

函数是编程中最古老和最重要的抽象概念。程序员先定义一个函数,接着就可以在代码中的其他地方调用这个函数。每次调用时,计算机都会在执行代码中保存它的位置,将控制权交给函数体,接着会返回到函数调用后的下一条语句,高效地将函数体插入到指令执行流中。

这种便利性可不是免费的。每次程序调用一个函数时,都会发生类似下面这样的处理(依赖于处理器体系结构和优化器设置)。

  1. 执行代码将一个栈帧推入到调用栈中来保存函数的参数和局部变量。
  2. 计算每个参数表达式并复制到栈帧中。
  3. 执行地址被复制到栈帧中并生成返回地址。
  4. 执行代码将执行地址更新为函数体的第一条语句(而不是函数调用后的下一条语句)。
  5. 执行函数体中的指令。
  6. 返回地址被从栈帧中复制到指令地址中,将控制权交给函数调用后的语句。
  7. 栈帧被从栈中弹出。

不过,关于函数开销也有一些好消息。带有函数的程序通常都会比带有被内联展开的大型函数的程序更加紧凑。这有利于提高缓存和虚拟内存的性能。而且,函数调用与非函数调用的其他开销都相同,这使得提高会被频繁地调用的函数的性能成为了一种有效的优化手段。

函数调用的基本开销

有许多细节问题都会降低 C++ 中函数调用的速度,这些问题也构成了函数调用优化的基础。

函数参数:除了计算参数表达式的开销外,复制每个参数的值到栈中也会发生开销。如果只有几个小型的参数,那么可能可以很高效地将它们传递到寄存器中;但是如果有很多参数,那么至少其中一部分需要通过栈传递。

成员函数调用(与函数调用):每个成员函数都有一个额外的隐藏参数:一个指向 this 类实例的指针,而成员函数正是通过它被调用的。这个指针必须被写入到调用栈上的内存中或是保存在寄存器中。

调用和返回:调用和返回对程序的功能没有任何影响。我们可以通过用函数体替代函数调用来移除这些开销。的确,当函数很小且在函数被调用之前已经定义了函数时,许多编译器都会试图内联函数体。如果不能内联函数,调用和返回就会产生开销。调用函数要求执行地址被写入到栈帧中来生成返回地址。

虚函数的开销

每个带有虚成员函数的实例都有一个无名指针指向一张称为虚函数表(vtable) 的表,这张表指向类中可见的每个虚函数签名所关联的函数体。虚函数表指针通常都是类实例的第一个字段,这样解引时的开销更小。

由于虚函数调用会从多个函数体中选择一个执行,调用虚函数的代码会解引指向类实例的指针,来获得指向虚函数表的指针。这段代码会为虚函数表加上索引(也就是说,代码会在虚函数表上加上一段小的整数偏移量并解引该地址)来得到函数的执行地址。因此,实际上这里会为所有的虚函数调用额外地加载两次非连续的内存,每次都会增加高速缓存未命中的几率和发生流水线停顿的几率。虚函数的另一个问题是编译器难以内联它们。编译器只有在它能同时访问函数体和构造实例的代码(这样编译器才能决定调用虚函数的哪个函数体)时才能内联它们。

继承中的成员函数调用

继承类中定义的虚成员函数如果继承关系最顶端的基类没有虚成员函数,那么代码必须要给 this 类实例指针加上一个偏移量,来得到继承类的虚函数表,接着会遍历虚函数表来获取函数执行地址。这些代码会包含更多的指令字节,而且这些指令通常都比较慢,因为它们会进行额外的计算。这种开销在小型嵌入式处理器上非常显著,但是在桌面级处理器上,指令级别的并发掩盖了大部分这种额外的开销。

为了组成虚多重继承类的实例的指针,代码必须解引类实例中的表,来确定要得到指向虚多重继承类的实例的指针时需要加在类实例指针上的偏移量。如前所述,当被调用的函数是虚函数时,这里也会产生额外的间接开销。

函数指针的开销

C++ 允许在程序中定义指向函数的指针。程序员可以通过函数指针显式地选择一个具有特定签名(由参数列表和返回类型组成)的非成员函数。当函数指针被解引后,这个函数将会在运行时会被调用。通过将一个函数赋值给函数指针,程序可以显式地通过函数指针选择要调用的函数。代码必须解引指针来获取函数的执行地址。编译器也不太可能会内联这些函数。

成员函数指针声明同时指定了函数签名和解释函数调用的上下文中的类。程序通过将函数赋值给函数指针,显式地选择通过成员函数指针调用哪个函数。成员函数指针有多种表现形式,一个成员函数只能有一种表现形式。它必须足够通用才能够在以上列举的各种复杂的场景下调用任意的成员函数。我们有理由认为一个成员函数指针会出现最差情况的性能。

函数调用开销总结

因此, C 风格的不带参数的 void 函数的调用开销是最小的。如果能够内联它的话,就没有开销;即使不能内联,开销也仅仅是两次内存读取加上两次程序执行的非局部转移。

如果基类没有虚函数,而虚函数在多重虚拟继承的继承类中,那么这是最坏的情况。不过幸运的是,这种情况非常罕见。在这种情况下,代码必须解引类实例中的函数表来确定加到类实例指针上的偏移量,构成虚拟多重继承函数的实例的指针,接着解引该实例来获取虚函数表,最后索引虚函数表得到函数执行地址。

简短地声明内联函数

那些函数体在类定义中的函数会被隐式地声明为内联函数。通过将在类定义外部定义的函数声明为存储类内联,也可以明确地将它们声明为内联函数。此外,如果函数定义出现在它们在某个编译单元中第一次被使用之前,那么编译器还可能会自己选择内联较短的函数。

当编译器内联一个函数时,那么它还有可能会改善代码,包括移除调用和返回语句。内联是一种通过在编译时进行计算来移除多余计算的改善性能的手段。

在使用之前定义函数

在第一次调用函数之前定义函数(提供函数体)给了编译器优化函数调用的机会。当编译器编译对某个函数的调用时发现该函数已经被定义了,那么编译器能够自主选择内联这次函数调用。如果编译器能够同时找到函数体,以及实例化那些发生虚函数调用的类变量、指针或是引用的代码,那么这也同样适用于虚函数。

放弃不使用的接口

由于纯虚函数没有函数体,因此 C++ 不允许实例化接口基类。继承类可以通过重写(定义)接口基类中的所有纯虚函来实现接口。C++ 中接口惯用法的优点在于,继承类必须实现接口中声明的所有函数,否则编译器将不会允许程序创建继承类的实例。例如,开发人员可以使用接口类来隔离操作系统依赖性,特别是当设计人员预计需要为多个操作系统实现程序时。我们可以通过下面的接口类 file 来定义读写文件的类。这个file 被称为抽象基类,因为它无法被实例化:

1
2
3
4
5
6
7
8
class File {
public:
virtual ~File() {}
virtual bool Open(Path& p) = 0;
virtual bool Close() = 0;
virtual int GetChar() = 0;
virtual unsigned GetErrorCode() = 0;
};

C++11 中的关键字 override 是可选关键字,它告诉编译器当前的声明会重写基类中虚函数的声明。当指定了 override 关键字后,如果在基类中没有虚函数声明,编译器会报出警告消息:

1
2
3
4
5
6
7
8
9
# include "File.h"
class WindowsFile : public File { // C++11风格的声明
public:
~File() {}
bool Open(Path& p) override;
bool Close() override;
int GetChar() override;
unsigned GetErrorCode() override;
};

有时,一个程序虽然定义了接口,但是只提供了一种实现。在这种情况下,通过移除接口,即移除 file.h 类定义中的 virtual 关键字并提供 file 的成员函数的实现,可以节省虚函数调用(特别是频繁地对GetChar()的调用)的开销。

避免使用PIMPL惯用法

PIMPL 是“Pointer to IMPLementation”的缩写,它是一种用作编译防火墙——一种防止修改一个头文件会触发许多源文件被重编译的机制——的编程惯用法。

假设BigClass是一个被其他类广泛使用的类,它有一些内联函数,而且使用了Foo类、Bar类和Baz类。一般情况下,bigclass.hfoo.hbar.h或是baz.h的任何改动,哪怕只是代码注释中的一个字符发生了变化,都会触发许多引用了bigclass.h的文件被重编译。

1
2
3
4
5
6
7
8
9
10
11
12
# include "foo.h"
# include "bar.h"
# include "baz.h"
class BigClass {
public:
BigClass();
void f1(int a) { ... }
void f2(float f) { ... }
Foo foo_;
Bar bar_;
Baz baz_;
};

要实现 PIMPL,开发人员要定义一个新的类,在本例中,我们将其命名为Implbigclass.h的修改如代码所示。

1
2
3
4
5
6
7
8
class Impl;
class BigClass {
public:
BigClass();
void f1(int a);
char f2(float f);
Impl* impl;
};

C++ 允许声明一个指向未完成类型,即一个还没有定义的对象的指针。在本例中,Impl就是一个未完成类型。这样的代码之所以能够工作,是因为所有指针的大小都是相同的,因此编译器知道如何预留指针的存储空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# include "foo.h"
# include "bar.h"
# include "baz.h"
# include "bigclass.h"
class Impl {
void g1(int a);
void g2(float f);
Foo foo_;
Bar bar_;
Baz baz_;
};
void Impl::g1(int a) { ... }
char Impl::g2(float f) { ... }
void BigClass::BigClass() {
impl_ = new Impl;
}
void BigClass::f1(int a) {
impl_ -> g1(a);
}
char BigClass::f2(float f) {
return impl_ -> g2(f)
}

实现了 PIMPL 后,在编译时,对foo.hbar.hbaz.h,或者是对Impl的实现的改动都会导致bigclass.cpp被重编译,但是bigclass.h不会改变,这样就限制了重编译的范围。

在运行时情况就不同了。PIMPL 给程序带来了延迟。之前BigClass中的成员函数可能会被内联,而现在则会发生一次成员函数调用。而且,现在每次成员函数调用都会调用Impl的成员函数。使用了 PIMPL 的工程往往会在很多地方使用它,导致形成了多层嵌套函数调用。更甚者,这些额外的函数调用层次使得调试变得更加困难。

使用静态成员函数取代成员函数

每次对成员函数的调用都有一个额外的隐式参数:指向成员函数被调用的类实例的 this 指针。通过对 this 指针加上偏移量可以获取类成员数据。虚成员函数必须解引 this 指针来获得虚函数表指针。

有时,一个成员函数中的处理仅仅使用了它的参数,而不用访问成员数据,也不用调用其他的虚成员函数。在这种情况下, this 指针没有任何作用。我们应当将这样的成员函数声明为静态函数。静态成员函数不会计算隐式 this 指针,可以通过普通函数指针,而不是开销更加昂贵的成员函数指针找到它们。

将虚析构函数移至基类中

任何有继承类的类的析构函数都应当被声明为虚函数。这是有必要的,这样 delete 表达式将会引用一个指向基类的指针,继承类和基类的析构函数都会被调用。

另外一个在继承层次关系顶端的基类中声明虚函数的理由是:确保在基类中有虚函数表指针。继承层次关系中的基类处于一个特殊的位置。如果在这个基类中有虚成员函数声明,那么虚函数表指针在其他继承类中的偏移量是 0;如果这个基类声明了成员变量且没有声明任何虚成员函数,但是有些继承类却声明了虚成员函数,那么每个虚成员函数调用都会在 this 指针上加上一个偏移量来得到虚函数表指针的地址。确保在这个基类中至少有一个成员函数,可以强制虚函数表指针出现在偏移量为 0 的位置上,这有助于产生更高效的代码。

优化表达式

简化表达式

C++ 会严格地以运算符的优先级和可结合性的顺序来计算表达式。只有像((a*b)+(a*c))这样书写表达式时才会进行a*b+a*c的计算,因为 C++ 的优先级规则规定乘法的优先级高于加法。

C++ 之所以让程序员手动优化表达式,是因为 C++ 的 int 类型的模运算并非是整数的数学运算, C++ 的 float 类型的近似计算也并非真正的数学运算。C++ 必须给予程序员足够的权力来清晰地表达他的意图,否则编译器会对表达式进行重排序,从而导致控制流程发生各种变化。这意味着开发人员必须尽可能使用最少的运算符来书写表达式。

用于计算多项式的霍纳法则(Horner Rule)证明了以一种更高效的形式重写表达式有多么厉害。尽管大多数 C++ 开发人员并不会每天都进行多项式计算,但是我们都很熟悉它。多项式y = ax3 + bx2 + cx + d在 C++ 中可以写为:y = a*x*x*x + b*x*x + c*x + d;。这条语句将会执行 6 次乘法运算和 3 次加法运算。我们可以根据霍纳法则重复地使用分配律来重写这条语句:y = (((a*x + b)*x) + c)*x + d;。这条优化后的语句只会执行 3 次乘法运算和 3 次加法运算。通常,霍纳法则可以将表达式的乘法运算次数从n(n-1)减少为 n,其中 n 是多项式的维数。

将常量组合在一起

我们应当总是用括号将常量表达式组合在一起,或是将它们放在表达式的左端,或者更好的一种的做法是,将它们独立出来初始化给一个常量,或者将它们放在一个常量表达式(constexpr)函数中。这样编译器能够在编译时高效地计算常量表达式。

使用更高效的运算符

整数表达式x*4可以被重编码为更高效的x<<2。任何差不多的编译器都可以优化这个表达式。但是如果表达式是x*yx*func()会怎样呢?许多情况下,编译器都无法确定yfunc()的返回值一定是 2 的幂。这时就需要依靠程序员了。如果其中一个参数可以用指数替换掉 2 的幂,那么开发人员就可以重写表达式,用位移运算替代乘法运算。

另一种优化是用位移运算和加法运算替代乘法。例如,整数表达式x*9可以被重写为x*8+x*1,进而可以重写为(x<<3)+x。当常量运算子中没有许多置为 1 的位时,这种优化最有效,因为每个置为 1 的位都会扩展为一个位移和加法表达式。

使用整数计算替代浮点型计算

浮点型计算的开销是昂贵的。浮点数值内部的表现比较复杂,它带有一个整数型尾数、一个独立的指数以及两个符号。

即使是在具有浮点型计算硬件单元的处理器上,即使对计算结果的整数部分进行了舍入处理,而不是截取处理,计算整数结果仍然能够比计算浮点型结果快至少 10 倍。如果是在没有浮点型计算硬件单元的小型处理器上用函数库进行浮点型计算,那么整数的计算速度会快得更多。但是我们仍然可以看到,有些开发人员在明明可以使用整数计算时,却使用浮点型计算。

双精度类型可能会比浮点型更快

双精度类型的计算速度比浮点类型的计算速度更快。Visual C++ 生成的浮点型指令会引用老式的“x87 FPU coprocessor”寄存器栈。在这种情况下,所有的浮点计算都会以 80 位格式进行。当单精度float 和双精度 double 值被移动到 FPU 寄存器中时,它们都会被加长。对 float 进行转换的时间可能比对 double 进行转换的时间更长。

用闭形式替代迭代计算

有许多特殊情况都需要对置为 1 的位计数,找到最高有效位,确定一个字的奇偶校验位,确定一个字的位是否是 2 的幂,等等。这个问题同样有一种闭形解决方法。如果 x 是 2 的 n 阶幂,那么它只在第 n 位有一个置为1 的位(以最低有效位作为第 0 位)。接着,我们用 x-1 作为当置为 1 的位在第 n-1,…,0 位时的位掩码,那么x& (x-1)等于 0。如果 x 不是 2 的幂,那么它就有不止一个置为 1 的位,那么使用 x-1 作为掩码计算后只会将最低有效位置为 0,x& (x-1)不再等于 0。

优化控制流程惯用法

由于当指令指针必须被更新为非连续地址时在处理器中会发生流水线停顿,因此计算比控制流程更快。

用switch替代if-else if-else

如果测试一个变量的值 n 次,那么需要 n 个 if-then-else if 语句块。如果所有的条件为真的概率都是一样的,那么 if-then-else if 将会进行 O(n) 次判断。switch 语句用 switch 的值与一系列常量进行比较,这样编译器可以进行一系列有效的优化。

一种常见的情况是被测试的常量是一组连续值或是近似一组连续值,这时 switch 语句会被编译为 jump 指令表,其索引是要测试的值或是派生于要测试的值的表达式。switch 语句会执行一次索引操作,然后跳转到表中的地址。无论有多少种要比较的情况,每次比较处理的开销都是 O(1)。我们在程序中不必对各种要比较的情况排序,因为编译器会排序 jump指令表。

如果这些被测试的常量不是连续值,而是互相之间相差很大的数值,那么 jump 指令表会变得异常庞大,难以管理。编译器可能仍然会排序这些要测试的常量并生成执行二分查找的代码。对于一个会与 n 个值进行比较的 switch 语句,这种查找的最差情况的开销是O(log2n)。在任何情况下,编译器编译 switch 语句后产生的代码都不会比编译 if-then 语句后产生的代码的速度慢。

用虚函数替代switch或if

在 C++ 出现之前,如果开发人员想要在程序中引入多态行为,那么他们必须编写一个带有标识变量的结构体或是联合体,然后通过这个标识变量来辨别出当前使用的是哪个结构体或是联合体。程序中应该会有很多类似下面的代码:

1
2
3
4
5
6
7
if (p->animalType == TIGER) {
tiger_pounce(p->tiger);
}
else if (p->animalType == RABBIT) {
rabit_hop(p->rabbit);
}
else if (...)

虚函数调用会通过索引虚函数表得到虚函数体的地址。这个操作的开销总是常量时间。因此,基类中的虚成员函数move()会被继承类中表示各种动物的 pounce、 hop 或 swim 等函数重写。

使用无开销的异常处理

异常处理是应当在设计阶段就进行优化的项目之一。错误传播方法的设计会影响程序中的每一行代码,因此改造程序的异常处理的代价可能会非常昂贵。可以说,使用异常处理可以使程序在通常运行时更加快速,在出错时表现得更加优秀。

如果程序不抛出异常,它可能会完全忽略错误码。那么在这种情况下,开发人员就会得到报应了。另外一种情况是,程序必须在各层函数调用之间耐心地、小心地传递错误码,然后在调用库函数的地方将错误码从一种形式转换为另一种形式并相应地释放资源。而且,无论每次运算是成功还是失败,都不能遗漏这些处理。

如果有异常,处理错误的部分开销就被从程序执行的正常路径转移至错误路径上。除此之外,编译器会通过调用在抛出异常和 try/catch 代码块之间的执行路径上的所有自动变量的析构函数,自动地回收资源。这简化了程序执行的正常路径的逻辑,从而提升性能。

在 C++11 中引入了一种新的异常规范,称为 noexcept。声明一个函数为 noexcept 会告诉编译器这个函数不可能抛出任何异常。如果这个函数抛出了异常,那么如同在throw()规范中一样,terminate()将会被调用。不过不同的是,编译器要求将移动构造函数和移动赋值语句声明为 noexcept 来实现移动语义。在这些函数上的 noexcept 规范的作用就像是发表了一份声明,表明对于某些对象而言,移动语义比强异常安全保证更重要。

小结

  • 循环中的语句的性能开销被放大的倍数是循环的次数。
  • 函数中的语句的性能开销被放大的倍数是其在函数中被调用的次数。
  • 被频繁地调用的编程惯用法的性能开销被放大的倍数是其被调用的次数。
  • 有些 C++ 语句(赋值、初始化、函数参数计算)中包含了隐藏的函数调用。
  • 调用操作系统的函数的开销是昂贵的。
  • 一种有效的移除函数调用开销的方法是内联函数。
  • double 计算可能会比 float 计算更快。

使用更好的库

优化标准库的使用

C++ 为以下常用功能提供了一个简洁的标准库。

  • 确定那些依赖于实现的行为,如每种数值类型的最大值和最小值。
  • 最好不要在 C++ 中编写的函数,如strcpy()memmove()
  • 易于使用但是编写和验证都很繁琐的可移植的超越函数(transcendental function),如正弦函数和余弦函数、对数函数和幂函数、随机数函数,等等。
  • 除了内存分配外,不依赖于操作系统的可移植的通用数据结构,如字符串、链表和表。
  • 可移植的通用数据查找算法、数据排序算法和数据转换算法。
  • 以一种独立于操作系统的方式与操作系统的基础服务相联系的执行内存分配、操作线程、管理和维护时间以及流 I/O 等任务的函数。考虑到兼容性,这其中包含了一个继承自 C编程语言的函数库。

优化查找和排序

只有少数开发人员知道,在 C++ 标准库中的<algorithm>头文件中包含了几种基于迭代器的查找序列容器的算法。即使在最优情况下,这些算法也并不都具有相同的大 O 性能。

使用std::map和std::string的键值对表

作为一个例子,本节将介绍对一种常用的键值对表进行各种查找和排序的性能。在这个例子中,表的键是一个由 ASCII 字符组成的字符串,我们可以用 C++ 字符串字面量来初始化它,或是将它保存在std::string中。我们通常会使用这样的表来解析初始化配置、命令行、 XML 文件、数据库表以及其他需要有限组键的应用程序。除非有一个非常大的值会影响高速缓存性能,否则值的类型对查找操作的性能不会有影响。

使用std::map构建一个std::string类型的名字与无符号整数值之间的映射关系的表是很容易的。可以如下这样简单地定义一个表:

1
2
3
# include <string>
# include <map>
std::map<std::string, unsigned> table;

如果使用的是支持 C++11 的编译器,开发人员可以如下这样使用初始化列表声明语法轻松地向表中插入数据项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::map<std::string, unsigned> const table {
{ "alpha", 1 }, { "bravo", 2 },
{ "charlie", 3 }, { "delta", 4 },
{ "echo", 5 }, { "foxtrot", 6 },
{ "golf", 7 }, { "hotel", 8 },
{ "india", 9 }, { "juliet", 10 },
{ "kilo", 11 }, { "lima", 12 },
{ "mike", 13 }, { "november",14 },
{ "oscar", 15 }, { "papa", 16 },
{ "quebec", 17 }, { "romeo", 18 },
{ "sierra", 19 }, { "tango", 20 },
{ "uniform",21 }, { "victor", 22 },
{ "whiskey",23 }, { "x-ray", 24 },
{ "yankee", 25 }, { "zulu", 26 }
};

否则,开发人员必须像下面这样编码来插入每条元素:

1
2
3
4
table["alpha"] = 1;
table["bravo"] = 2;
...
table["zulu"] = 26;

取得或是测试值也非常简单:

1
2
3
4
5
unsigned val = table["echo"];
...
std::string key = "diamond";
if (table.find(key) != table.end())
std::cout << "table contains " << key << std::endl;

优化std::map的查找

性能优化开发人员可以通过保持表数据结构不变,但改变键的数据结构,当然也包括改变比较键的算法,改善程序性能。

以固定长度的字符数组作为std::map的键

如果开发人员可以使用一种不会动态分配存储空间的数据结构作为键类型,就能够开销减半。而且,如果表使用std::string作为键,而开发人员希望如下这样用 C 风格的字符串字面常量来查找元素,那么每次查找都会将char*的字符串字面常量转换为std::string,其代价是分配更多的内存,而且这些内存紧接着会立即被销毁掉。

1
unsigned val = table["zulu"];

如果键的最大长度不是特别大,那么一种解决方法是使用足以包含最长键的字符数组作为键的类型。不过这里我们无法像下面这样直接使用数组,因为 C++ 数组没有内置的比较运算符。

1
std::map<char[10],unsigned> table

下面是一个名为charbuf的简单的固定长度字符数组模板类的定义:

1
2
3
4
5
6
7
8
9
10
11
template <unsigned N=10, typename T=char> struct charbuf {
charbuf();
charbuf(charbuf const& cb);
charbuf(T const* p);
charbuf& operator=(charbuf const& rhs);
charbuf& operator=(T const* rhs);
bool operator==(charbuf const& that) const;
bool operator<(charbuf const& that) const;
private:
T data_[N];
};

charbuf非常简单。我们可以用 C 风格的、以空字符结尾的字符串来对它进行初始化或是赋值,也可以用一个charbuf与另一个charbuf进行比较。由于这里没有明确地定义构造函数charbuf(T const*),因此我们还可以通过类型转换将charbuf与一个以空字符结尾的字符串进行比较。charbuf的长度是在编译时就确定了的,它不会动态分配内存。

以C风格的字符串组作为键使用std::map

有时,程序会访问那些存储期很长的、 C 风格的、以空字符结尾的字符串,那么我们就可以用这些字符串的char*指针作为std::map的键。例如,当程序使用 C++ 字符串字面常量来构造表时,我们可以直接使用char*来避免构造和销毁std::string的实例的开销。

不过,以char*作为键类型也有一个问题。std::map会在它的内部数据结构中,依据键类型的排序规则对键值对进行排序。默认情况下,它都会计算表达式 key1 < key2 的值。在std::string中定义了一个用于比较字符串的 < 运算符。虽然在char*中也定义了 < 运算符,但它比较的却是指针,而不是指针所指向的字符串。

std::map让开发人员能够通过提供一个非默认的比较算法来解决这个问题。这也是 C++ 允许开发人员对它的标准容器进行精准控制的一个例子。比较算法是通过std::map的第三个模板参数提供的。比较算法的默认值是函数对象std::less<Key>std::less定义了一个成员函数bool operator()(Key const& k1, Key const& k2),它会通过返回表达式key1 < key2的结果来比较两个键的大小。

程序还可以创建一个函数对象来封装比较操作。less_for_c_strings是一个类类型的名字,因此它可以用作类型参数,这样就无需使用指针。

1
2
3
4
5
6
7
8
9
struct less_for_c_strings {
bool operator()(char const* p1, char const* p2) {
return strcmp(p1,p2)<0;
}
};
...
std::map<char const*,
unsigned,
less_for_c_strings> table;

在 C++11 中,另外一种为std::map提供char*比较函数的方法是,定义一个lambda表达式并将它传递给 map 的构造函数。使用lambda表达式非常便利,因为我们可以在局部定义它们,而且它们的声明语法也非常简洁。

1
2
3
4
5
6
auto comp = [](char const* p1, char const* p2) {
return strcmp(p1,p2)<0;
};
std::map<char const*,
unsigned,
decltype(comp)> table(comp);

请注意,这段示例代码中使用了 C++11 中的decltype关键字。map 的第三个参数是一个类型。名字comp是一个变量,而decltype(comp)则是变量的类型。lambda表达式的类型没有名字,每个lambda表达式的类型都是唯一的,因此decltype是获得lambda表达式的类型的唯一方法。

当键就是值的时候, 使用map的表亲std::set

定义一种数据结构,其中包含一个键以及其他数据作为键所对应的值,有些程序员可能会觉得这是一件再自然不过的事了。事实上,std::map在内部声明了一种像下面这样的可以结合键与值的结构体:

1
2
3
4
5
template <typename KeyType, typename ValueType> struct value_type {
KeyType const first;
ValueType second;
// ……构造函数和赋值运算符
};

如果程序定义了这样一种数据结构,那么无法将它直接用于std::map中。出于一些实际的原因,std::map要求键和值必须分开定义。键必须是常量,因为修改键会导致整个 map 数据结构无效。同样,指定键可以让 map 知道如何访问它。

std::map有一个表亲——std::set。它是一种可以保存它们自己的键的数据结构。这种类型会使用一个比较函数,该比较函数默认使用std::less来比较两个完整元素。因此,要想使用std::set和一种包含自身的键的用户自定义的结构体,开发人员必须为那个用户自定义的结构体实现std::less、指定 < 运算符或是提供一个非默认的比较对象。

使用头文件优化算法

标准库查找算法接收两个迭代器参数:一个指向待查找序列的开始位置,另一个则指向待查找序列的末尾位置(最后一个元素的下一个位置)。所有的算法还都接收一个要查找的键作为参数以及一个可选的比较函数参数。这些算法的区别在于它们的返回值,以及比较函数必须定义键的排序关系还是只是比较是否相等。

以序列容器作为被查找的键值对表

相比于std::map或它的表亲std::set,有几个理由使得选择序列容器实现键值对表更好:序列容器消耗的内存比 map 少,它们的启动开销也更小。标准库算法的一个非常有用的特性是它们能够遍历任意类型的普通数组,因此,它们能够高效地查找静态初始化的结构体的数组。这样可以移除所有启动表的开销和销毁表的开销。

1
2
3
4
struct kv { //(键,值)对
char const* key;
unsigned value; // 可以是任何类型
};

由这些键值对构成的静态数组的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kv names[] = {// 以字母顺序排序
{ "alpha", 1 }, { "bravo", 2 },
{ "charlie", 3 }, { "delta", 4 },
{ "echo", 5 }, { "foxtrot", 6 },
{ "golf", 7 }, { "hotel", 8 },
{ "india", 9 }, { "juliet", 10 },
{ "kilo", 11 }, { "lima", 12 },
{ "mike", 13 }, { "november",14 },
{ "oscar", 15 }, { "papa", 16 },
{ "quebec", 17 }, { "romeo", 18 },
{ "sierra", 19 }, { "tango", 20 },
{ "uniform",21 }, { "victor", 22 },
{ "whiskey",23 }, { "x-ray", 24 },
{ "yankee", 25 }, { "zulu", 26 }
};

names数组的初始化是静态集合初始化。

标准库容器类提供了begin()end()成员函数,这样程序就能够得到一个指向待查找范围的迭代器。C 风格的数组更加简单,通常没有提供这些函数。不过,我们可以通过用一点模板“魔法”提供类型安全的模板函数来实现这个需求。由于它们接收一个数组类型作为参数,数组并不会像通常那样退化为一个指针:

1
2
3
4
5
6
7
8
9
10
// 得到C风格数组的大小和起始或终止位置
template <typename T, int N> size_t size(T (&a)[N]) {
return N;
}
template <typename T, int N> T* begin(T (&a)[N]) {
return &a[0];
}
template <typename T, int N> T* end(T (&a)[N]) {
return &a[N];
}

std::find()

在标准库<algorithm>头文件中如下定义了一个模板函数find()

1
template <class It, class T> It find(It first, It last, const T& key)

find()是一个简单的线性查找算法。线性查找是最通用的查找方式。它不需要待查找的数据已经排序完成,只需要能够比较两个键是否相等即可。

find()返回指向序列容器中第一条与待查找的键相等的元素的迭代器。迭代器参数firstlast限定了待查找的范围,其中last指向待查找数据的末尾的后一个元素。firstlast的类型是通过模板参数It指定的,这取决于find()要遍历的数据结构的类型。

find()的用法示例如代码所示。

1
kv* result=std::find(std::begin(names), std::end(names), key);

在这段示例代码中,names是待查找的数组的名字。key是要查找的关键字,它会与每条kv元素进行比较。要想进行比较操作,必须在find()被实例化的作用域内定义用于比较关键字的函数。该函数会告诉std::find()在进行比较时所需知道的一切信息。C++ 允许为各种类型的一对值重载等号运算符bool operator==(v1,v2)。如果键是一个指向 char 的指针,那么所需的比较关键字的函数就是:

1
2
3
bool operator==(kv const& n1, char const* key) {
return strcmp(n1.key, key) == 0;
}

find()函数的一种变化形式是find_if(),它接收比较函数作为第四个参数。这里开发人员不用在find()的作用域中定义operator==(),而是可以编写一个lambda表达式作为比较函数。

标准库算法binary_search()返回一个 bool 值,表示键是否存在于有序表中。非常奇怪的是,标准库却没有提供配套的返回匹配的表元素的函数。因此,find()binary_search()虽然从名字上看都像是我们要找的解决方法,但其实不然。如果程序只是想知道一个元素是否存在于表中,而不是找到它的值,那么我们可以使用binary_search()

使用std::equal_range()

如果序列容器是有序的,那么开发人员能够从 C++ 标准库提供的零零散散的函数中组合出一个高效的查找函数。不幸的是,这些零零散散的函数的名字都难以使人联想起二分查找。在 C++ 标准库的<algorithm>头文件中有一个模板函数std::equal_range(),它的定义如下:

1
2
3
template <class ForwardIt, class T>
std::pair<ForwardIt,ForwardIt>
equal_range(ForwardIt first, ForwardIt last, const T& value);

equal_range()会返回一对迭代器,它们确定的是范围是有序序列中包含要查找的元素的子序列[first, last)。如果没有找到元素,equal_range()会返回一对指向相等值的迭代器,这表示这个范围是空的。如果返回的两个迭代器不等,表示至少找到了一条元素。

如果找到了元素,就将result设置为指向找到的表元素的迭代器,否则result设置为指向表的末尾的迭代器。

1
2
3
4
auto res = std::equal_range(std::begin(names),
std::end(names),
key);
kv* result = (res.first == res.second) ? std::end(names) : res.first;

使用std::lower_bound()

尽管equal_range()所承诺的时间开销是 O(log2n),但除了表查找以外,它还有其他不必要的功能。equal_range()的一种可能实现方式看起来像下面这样:

1
2
3
4
5
6
template <class It, class T>
std::pair<It,It>
equal_range(It first, It last, const T& value) {
return std::make_pair(std::lower_bound(first, last, value),
std::upper_bound(first, last, value));
}

upper_bound()会在表中第二次分而治之来查找要返回的范围的末尾,这是因为equal_range()需要足够通用,能够适用于任何存在一个键对应多个值的有序序列。如代码所示,可以使用lower_bound()和一次额外的比较运算来进行查找就足够了。

1
2
3
4
5
kv* result = std::lower_bound(std::begin(names),
std::end(names),
key);
if (result != std::end(names) && key < *result.key)
result = std::end(names);

在这个例子中,std::lower_bound()返回一个指向表中键大于等于key的第一个元素的迭代器。如果表中所有元素的键都小于key,那么它会返回一个指向表末尾的迭代器。它也可能会返回一条大于key的元素。如果最后一条 if 语句中的所有条件都是 true,那么result会被设置为指向表末尾的迭代器;否则,它会返回键等于key的元素。

使用std::lower_bound()进行查找的性能与使用std::map的最佳实现方式的性能旗鼓相当,而且它还有一个额外的优势,那就是构造或是销毁静态表是没有任何开销的。

自己编写二分查找法

我们将初始表的取值的连续范围值定义为[start, end)。在每一步查找中,函数都会计算取值范围的中间位置,并将键与中间位置的元素进行比较。这种方法可以高效地将表的取值范围分为两部分——[start, mid+1)[mid+1, stop)

1
2
3
4
5
6
7
8
9
10
11
12
13
kv* find_binary_lessthan(kv* start, kv* end, char const* key) {
kv* stop = end;
while (start < stop) {
auto mid = start + (stop-start)/2;
if (*mid < key) {// 查找右半部分[mid+1,stop)
start = mid + 1;
}
else {// 查找左半部分[start,mid)
stop = mid;
}
}
return (start == end || key < *start) ? end : start;
}

使用strcmp()自己编写二分查找法

如果注意到<运算符可以用strcmp()替换,那么还可以进一步提高性能。与<运算符一样,strcmp()也会对两个键进行比较,但是strcmp()的输出结果包含的信息更多:如果第一个键小于、等于、大于第二个键,那么其返回结果就小于、等于、大于 0。

在 while 循环的每次迭代中,被查找的序列都是[start,stop)。在每一步中,mid都会被设置为被查找序列的中间位置。strcmp()的返回值不是将序列分为两部分,而是分为三部分:[start,mid)[mid,mid+1)[mid+1,stop)。如果mid->key大于要查找的键,我们就可以知道键肯定在序列中最左侧的mid之前的部分中。如果mid->key小于要查找的键,那么我们知道键肯定在序列中最右侧的以mid+1开头的部分中。如果mid->key等于要查找的键,循环终止。if/else 逻辑会先进行可能性更大的比较操作来改善性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kv* find_binary_3(kv* start, kv* end, char const* key) {
auto stop = end;
while (start < stop) {
auto mid = start + (stop-start)/2;
auto rc = strcmp(mid->key, key);
if (rc > 0) {
stop = mid;
}
else if (rc < 0) {
start = mid + 1;
}
else {
return mid;
}
}
return end;
}

优化键值对散列表中的查找

寻找高效的散列函数是实现散列表时的一个复杂环节。一个含有 10 个字符的字符串所包含的位数可能会比一个 32 位整数所包含的位数多。因此,可能存在多个字符串具有相同索引值的情况。我们必须提供一种机制来应对这种冲突。散列表中的每条元素都可能是散列到某个索引值的元素列表中的第一个元素。或者,可以寻找相邻索引值来查找匹配的元素,直到遇到一个空索引为止。

一个糟糕的散列函数或是一组不太走运的键可能会导致许多键散列到相同的索引值上。这样,散列表的性能会降到 O(n),使得相比于线性查找它没有任何优势。一个优秀的散列函数所计算出的数组索引不会与键的各个位的值紧密相关。

C++ 定义了一个称为std::hash的标准散列函数对象。std::hash是一个模板,为整数、浮点数据、指针和std::string都提供了特化实现。同样适用于指针的未特化的std::hash的定义会将散列类型转换为size_t,然后随机设置它的各个位的值。

使用std::unordered_map

在 C++11 中,标准头文件<unordered_map>提供了一个散列表。使用std::unordered_map创建散列表和插入元素的示例代码如代码。

1
2
3
std::unordered_map<std::string, unsigned> table;
for (auto it = names; it != names+namesize; ++it)
table[it->key] = it->value;

std::unordered_map使用的默认散列函数是模板函数对象std::hash。由于该模板为std::string提供了特化实现,因此我们无需显式地提供散列函数。当所有元素都被插入到表中后,就可以如下这样进行查找了:

1
auto it = table.find(key);

it是一个迭代器,它要么指向一条匹配元素,要么指向table.end()。以std::string为键的std::unordered_map会使用map模板的所有默认值来实现简单性和可观的性能。

对固定长度字符数组的键进行散列

下面这个模板继承了charbuf,提供了对字符串进行散列的方法以及在发生冲突的情况下可以比较键的==运算符:

1
2
3
4
5
6
7
8
9
10
11
12
template <unsigned N=10, typename T=char> struct charbuf {
charbuf();
charbuf(charbuf const& cb);
charbuf(T const* p);
charbuf& operator=(charbuf const& rhs);
charbuf& operator=(T const* rhs);
operator size_t() const;
bool operator==(charbuf const& that) const;
bool operator<(charbuf const& that) const;
private:
T data_[N];
};

散列函数是运算符size_t()。这有一点不直观,还有一点不纯净。std::hash()的默认特化实现会将参数转换为size_t。对于指针,通常情况下这只会转换指针的各个位,但是如果是charbuf&,那么charbufsize_t()运算符会被调用,它会返回散列值作为size_t

当然,由于size_t()运算符被劫持了,它无法再返回charbuf的长度。现在,表达式sizeof(charbuf)返回的是一个容易让人误解的值。使用charbuf的散列表的声明语句如下:

1
std::unordered_map<charbuf<>, unsigned> table;

以空字符结尾的字符串为键进行散列

如果能够用如 C++ 字符串字面常量这样的存储期很长的以空字符结尾的字符串来初始化散列表,那么就可以用指向这些字符串的指针来构造基于散列值的键值对表。以char*为键配合std::unordered_map一起使用是一座值得挖掘的性能金矿。

std::unordered_map 的完整定义是:

1
2
3
4
5
6
7
template<
typename Key,
typename Value,
typename Hash = std::hash<Key>,
typename KeyEqual = std::equal_to<Key>,
typename Allocator = std::allocator<std::pair<const Key, Value>>
> class unordered_map;

Hash是用于计算Key的散列值的函数的函数对象或是函数指针的类型声明。KeyEqual是通过比较两个键的实例是否相等来解决散列冲突的函数的函数对象或是函数指针的类型声明。如果Key是一个指针,那么Hash具有良好的定义。

但程序其实是错误的。std::hash会生成指针的值的散列值,而不是指针所指向的字符串的散列值。如果测试程序是从字符串数组初始化表,然后测试每个字符串是否能被找到,那么指向测试键的指针与指向初始化表的键的指针是同一个指针,因此程序看起来似乎可以正常工作。不过,如果在测试时使用用户另外输入的相同的字符串作为测试键,那么测试结果会是字符串并不在表中,因为指向测试字符串的指针与指向初始化表的键的指针不同。

我们可以通过提供一个非默认的散列函数替代模板的第三个参数的默认值来解决这个问题。就像对于map,这个参数可以是一个函数对象、lambda表达式声明或是非成员函数指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct hash_c_string {
void hash_combine(size_t& seed, T const& v) {
seed ^= v + 0x9e3779b9 + (seed << 6) + (seed >> 2);
}
std::size_t operator() (char const* p) const {
size_t hash = 0;
for (; *p; ++p)
hash_combine(hash, *p);
return hash;
}
};
// 这种解决方法是不完整的,理由请往下看
std::unordered_map<char const*, unsigned, hash_c_string> table;

这段代码仍然是错误的。问题出在std::unordered_map模板的第四个参数KeyEqual上。这个参数的默认值是std::equal_to,一个使用==比较两个运算对象的函数对象。虽然指针定义了==运算符,但它比较的是指针在计算机内存空间中的顺序,而不是指针所指向的字符串。

当然,解决方式是提供另外一个非默认的函数对象替代KeyEqual模板参数。完整的解决方案代码所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct hash_c_string {
void hash_combine(size_t& seed, T const& v) {
seed ^= v + 0x9e3779b9 + (seed << 6) + (seed >> 2);
}
std::size_t operator() (char const* p) const {
size_t hash = 0;
for (; *p; ++p)
hash_combine(hash, *p);
return hash;
}
};
struct comp_c_string {
bool operator()(char const* p1, char const* p2) const {
return strcmp(p1,p2) == 0;
}
};
std::unordered_map<
char const*,
unsigned,
hash_c_string,
comp_c_string
> table;

这个版本的键值对表是以char*为键的std::unordered_map

用自定义的散列表进行散列

在创建表时,对于给定的一组键不会产生冲突的散列称为完美散列。能够创建出无多余空间的表的散列称为最小散列。当键的数量相当有限时,容易创建完美散列,甚至是完美最小散列。这时,散列函数可以尝试通过首字母(或是前两个字母)、字母和以及键长来计算散列值。

代码展示了一个在实现上与std::unordered_map类似的简单的自定义散列表。

1
2
3
4
5
6
7
8
9
unsigned hash(char const* key) {
if (key[0] < 'a' || key[0] > 'z')
return 0;
return (key[0]-'a');
}
kv* find_hash(kv* first, kv* last, char const* key) {
unsigned i = hash(key);
return strcmp(first[i].key, key) ? last : first + i;
}

hash()会将key的首字母映射到 26 条表元素之一,因此对 26 求余。这里采用了一种保守编程方式,以防止当键是“@#$%”这样的字符串时程序会访问未定义的存储空间。

使用C++标准库优化排序

C++ 标准库提供了两种能够高效地对序列容器进行排序的标准算法——std::sort()std::stable_sort()。C++03 要求std::sort的平均性能达到 O(n log2n)。符合 C++03 标准的实现方式通常都会用快速排序实现std::sort,而且通常都会使用一些技巧来降低快速排序发生最差情况的 O(n^2) 时间开销的几率。C++11 要求最差情况性能为 O(n log2n)。符合 C++11 标准的实现通常都是

std::stable_sort()通常都是归并排序的变种。C++ 标准中的措辞比较奇怪,它指出如果能够分配足够的额外内存,那么std::stable_sort()的时间开销是 O(n log2n),否则它的时间开销是 O(n (log2n)^2)。如果递归深度不是太深,典型的实现方式是使用归并排序;而如果递归深度太深,那么典型的实现方式是堆排序。

C++ 标准库<algorithm>头文件包含各种排序算法,我们可以使用这些算法为那些具有额外特殊属性的输入数据定制更加复杂的排序。

  • std::heap_sort将一个具有堆属性的范围转换为一个有序范围。heap_sort 不是稳定排序。
  • std::partition会执行快速排序的基本操作。
  • std::merge会执行归并排序的基本操作。
  • 各种序列容器的insert成员函数会执行插入排序的基本操作。

优化数据结构

理解标准库容器

我们有充足的理由喜欢上 C++ 标准库容器,例如统一的命名,以及用于遍历容器的迭代器在概念上的一致性。但是对于性能优化而言,有些特性格外重要,包括:

  • 对于插入和删除操作的性能开销的大 O 标记的性能保证
  • 向序列容器中添加元素具有分摊常时性能开销
  • 具有精准地掌控容器的动态内存分配的能力

序列容器

序列容器std::stringstd::vectorstd::dequestd::liststd::forward_list中元素的顺序与它们被插入的顺序相同。因此,每个容器都有一头一尾。所有的序列容器都能够插入元素。除了std::forward_list外,所有的序列容器都有一个具有常量时间性能开销的成员函数能够将元素推入至序列容器的末尾。不过,只有std::dequestd::liststd::forward_list能够高效地将元素推入至序列容器的头部。

std::stringstd::vectorstd::deque中元素的索引是从 0 到 size–1,我们能够通过下标快速地访问这些元素。std::liststd::forward_list则不同,它们没有下标运算符。

std::stringstd::vectorstd::deque都是基于一个类似数组的内部骨架构建而成的。当一个新元素被插入时,之前被插入的所有元素都会被移动到数组中的下一个位置,因此在非末尾处插入元素的时间开销是 O(n),其中n是容器中元素的数量。当一个新元素被插入时,这个内部数组可能会被重新分配,导致所有的迭代器和指针失效。相比之下,在std::liststd::forward_list中,只有指向那些从链表中被移除的元素的迭代器和指针才会失效。我们甚至可以在保持迭代器不失效的情况下,拼接或是合并两个std::liststd::forward_list的实例。如果有一个迭代器已经指向插入位置了,那么在std::liststd::forward_list的中间插入元素的时间开销是常量时间。

关联容器

所有的关联容器都会按照元素的某种属性上的顺序关系,而不是按照插入的顺序来保存元素。所有关联容器都提供了高效、具有次线性时间开销的方法来访问存储在它们中的元素。

mapset代表了不同的接口。map能够保存一组独立定义的键与值,因而它提供了一种高效的从键到值的映射。set能够有序地存储唯一值,高效地测试值是否存在于set中的方法。multimapsmap的唯一不同是它允许插入多个相等的元素。

就实现上而言,一共有四种有序关联容器:std::mapstd::multimapstd::setstd::multiset。有序关联容器要求必须对键(std::map)或是元素自身(std::set)定义能够对它们进行排序的operator<()等。有序关联容器的实现是平衡二叉树,因此我们无需对有序关联容器进行排序。

在实践中,所有的四种关联容器都是基于相同的平衡二叉树数据结构实现的,不过它们具有独立的“外观”。

std::vector与std::string

这两种数据结构的“产品手册”如下。

  • 序列容器
  • 插入时间:在末尾插入元素的时间开销为 O(1),在其他位置插入元素的时间开销为 O(n)
  • 索引时间:根据位置进行索引,时间开销为 O(1)
  • 排序时间: O(n log2n)
  • 如果已排序,查找时间开销为 O(log2n),否则为 O(n)
  • 当内部数组被重新分配时,迭代器和引用失效
  • 迭代器从前向后或是从后向前生成元素
  • 合理控制分配容量,与大小无关

从大 O 标记上看,std::vector的许多操作都是高效的,具有常量时间开销。这些操作包括将一个新元素推入到vector的末尾和获得指向它的第i个元素的引用。得益于vector简单的内部结构,这些操作在绝对意义上也是非常快的。std::vector上的迭代器是随机访问迭代器,这意味着可以在常量时间内计算两个迭代器之间的距离。这个特性使得分而治之的查找算法和排序算法对std::vector非常高效。

重新分配的性能影响

std::vectorsize表示当前在vector中有多少个元素;std::vectorcapacity则表示存储元素的内部存储空间有多大。当size == capacity时,任何插入操作都会触发一次性能开销昂贵的存储空间扩展:重新分配内部存储空间,将vector中的元素复制到新的存储空间中,并使所有指向旧存储空间的迭代器和引用失效。当发生重新分配时,新的capacity会被设置为新size的若干倍。

高效地使用std::vector的一个秘诀是,通过调用void reserve(size_t n)预留出足够多的capacity,这样可以防止发生不必要的重新分配和复制的开销。高效地使用std::vector的另外一个秘诀是,即使其中的元素被移除了,它也不会自动将内存返回给内存管理器。如果程序将 100 万个元素推入到vector中,接着移除了所有元素,那么vector仍然占用着用于保存那 100 万个元素的存储空间。

std::vector中有几个成员函数会影响它的容量,但标准是笼统的,没有做出任何保证。void clear()会设置容器的大小为 0,但并不一定会重新分配内部存储空间来减小 vector的容量。

要想确保在所有版本的 C++ 中都能释放vector的内存,可以使用以下技巧:

1
2
3
std::vector<Foo> x;
...
vector<Foo>().swap(x);

这段语句会构造一个临时的空的矢量,将它的内容与矢量 x 交换,接着删除这个临时矢量,这样内存管理器会回收所有之前属于 x 的内存。

std::vector中的插入与删除

有多种方法可以向vector中插入数据。我测试了用这些方法构造一个含有 100 000 个kvstruct实例的vector的性能开销,发现其中既有很快的方法,也有很慢的方法。填充vector最快的方式是给它赋值:

1
2
3
std::vector<kvstruct> test_container, random_vector;
...
test_container = random_vector;

赋值操作非常高效,因为它知道要复制的vector的长度,而且只需要调用内存管理器一次来创建被赋值的vector的内部存储空间。

如果数据是在另外一个容器中,使用std::vector::insert()可以将它复制到vector中:

1
2
3
4
5
6
std::vector<kvstruct> test_container, random_vector;
...
test_container.insert(
test_container.end(),
random_vector.begin(),
random_vector.end());

成员函数std::vector::push_back()能够高效地(在常量时间内)将一个新元素插入到vector的尾部。由于这些元素是在另外一个vector中,我们有 3 种方法可以得到它们。

  • 使用vector的迭代器:
1
2
3
4
std::vector<kvstruct> test_container, random_vector;
...
for (auto it=random_vector.begin(); it!=random_vector.end(); ++it)
test_container.push_back(*it);
  • 使用std::vector::at()成员函数:
1
2
3
4
std::vector<kvstruct> test_container, random_vector;
...
for (unsigned i = 0; i < nelts; ++i)
test_container.push_back(random_vector.at(i));
  • 直接使用vector的下标:
1
2
3
4
std::vector<kvstruct> test_container, random_vector;
...
for (unsigned i = 0; i < nelts; ++i)
test_container.push_back(random_vector[i]);

这段代码更慢的原因是它每次只向vector中插入一个元素。vector 并不知道有多少个元素会被插入,因此它会不断地增大它内部的存储空间。在循环进行插入时,vector内部的空间会发生多次重新分配,并需要将旧空间中的元素复制到新空间中。std::vector确保了在集合中push_back()的性能开销是常量时间,但这不意味着它就没有开销。

开发人员可以通过预先分配一块能存储整个副本的足够大的存储空间,来提高这个循环的效率。下面这段是修改后的使用了迭代器的版本:

1
2
3
4
5
std::vector<kvstruct> test_container, random_vector;
...
test_container.reserve(nelts);
for (auto it=random_vector.begin(); it != random_vector.end(); ++it)
test_container.push_back(*it);

还有其他方法能够将元素插入到vector中,例如,我们还可以使用另外一个版本的insert()成员函数:

1
2
3
4
std::vector<kvstruct> test_container, random_vector;
...
for (auto it=random_vector.begin(); it != random_vector.end(); ++it)
test_container.insert(test_container.end(), *it);

看起来它的开销似乎应该与push_back()一样,但其实不然。

最后,我们来看看std::vector的一个超级弱点:在前端插入元素。std::vector并没有提供push_front()成员函数,因为这个操作的时间开销会是 O(n)。在前端插入元素是低效的,因为需要复制vector中的所有元素来为新元素腾出空间,而这确实很低效。下面这个循环所花费的时间几乎是在末尾插入元素的时间的 3000 倍:

1
2
3
4
std::vector<kvstruct> test_container, random_vector;
...
for (auto it=random_vector.begin(); it != random_vector.end(); ++it)
test_container.insert(test_container.begin(), *it);

因此,想要高效地填充一个vector,请按照赋值、使用迭代器和insert()从另外一个容器插入元素、push_back()和使用insert()在末尾插入元素的优先顺序选择最高效的方法。

遍历std::vector

遍历vector和访问其元素的开销并不大,但就像插入操作一样,不同方法的性能开销差异显著。

有三种方法可以遍历一个vector():使用迭代器、使用at()成员函数和使用下标。如果循环内部的处理的性能开销很昂贵,那么各种遍历方法之间的性能差异就没有那么明显了。不过,通常开发人员都只会对每个元素进行简单快速的处理。在本例中,循环将会累计值,这个操作所花费的时间微不足道(同时这也可以防止编译器将整个循环优化为无操作):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::vector<kvstruct> test_container;
...
unsigned sum = 0;
for (auto it=test_container.begin(); it!=test_container.end(); ++it)
sum += it->value;
std::vector<kvstruct> test_container;
...
unsigned sum = 0;
for (unsigned i = 0; i < nelts; ++i)
sum += test_container.at(i).value;
std::vector<kvstruct> test_container;
...

unsigned sum = 0;
for (unsigned i = 0; i < nelts; ++i)
sum += test_container[i].value;

开发人员可能会误以为这些循环的性能开销几乎相同,但事实并非如此。使用at()函数的版本性能稍好,下标版本更加高效。

对std::vector排序

在使用二分查找法查找元素前,可以先对vector进行一次高效的排序。C++ 标准库有两种排序算法——std::sort()std::stable_sort()。如果像std::vector一样,容器的迭代器是随机访问迭代器,那么两种算法的时间开销都是 O(n log2n),而且它们在有序数据上的排序速度更快。我们可以编写下面这段简短的程序来完成排序:

1
2
3
4
std::vector<kvstruct> sorted_container, random_vector;
...
sorted_container = random_vector;
std::sort(sorted_container.begin(), sorted_container.end());

查找std::vector

下面这段程序会在sorted_container中查找保存在random_vector中的所有键:

1
2
3
4
5
6
7
8
9
10
std::vector<kvstruct> sorted_container, random_vector;
...
for (auto it=random_vector.begin(); it!=random_vector.end(); ++it) {
kp = std::lower_bound(
sorted_container.begin(),
sorted_container.end(),
*it);
if (kp != sorted_container.end() && *it < *kp)
kp = sorted_container.end();
}

std::deque

deque 的“产品手册”如下。

  • 序列容器
  • 插入时间:在末尾插入元素的时间开销为 O(1),在其他位置插入元素的时间开销为 O(n)
  • 索引时间:根据位置进行索引,时间开销为 O(1)
  • 排序时间: O(n log2n)
  • 如果已排序,查找时间开销为 O(log2n),否则为 O(n)
  • 当内部数组被重新分配时,迭代器和引用会失效
  • 迭代器可以从后向前或是从前向后遍历元素

std::deque是一种专门用于创建“先进先出”(FIFO)队列的容器。在队列两端插入和删除元素的开销都是常量时间。下标操作也是常量时间。它的迭代器与std::vector一样,都是随机访问迭代器,因此对std::deque进行排序的时间开销是 O(n log2n)。由于std::dequestd::vector具有相同的性能保证,而且在两端插入元素的时间开销都是常量时间。deque的这些操作的常量比例比vector大。对这些共通操作的性能测量结果表明,deque的操作比vector相同的操作慢 3 到 10 倍。对deque而言,迭代、排序和查找相对来说是三个亮
点,只是比vector慢了大约 30%。

std::deque的典型实现方式是一个数组的数组。获取deque中元素所需的两个间接引用会降低缓存局部性,而且更加频繁地调用内存管理器所产生的性能开销也比vector 的要大。

将一个元素加入到deque的任何一端都会导致最多调用内存分配器两次:一次是为新元素分配另一块存储元素的区域;另一次则可能没那么频繁,那就是扩展deque的内部数组。deque的这种内存分配行为更加复杂,因而比vector的内存分配行为更加难以讨论明白。std::deque没有提供任何类似于std::vector的用于为其内部数据结构预先分配存储空间的reserve()成员函数。另外还有一个称为std::queue的容器适配器模板,而deque就是其默认实现。不过,无法保证这种用法具有优秀的内存分配性能。

std::deque中的插入和删除

std::deque不仅与std::vector具有相同的插入接口,它还有一个成员函数push_front()。下面这段程序会将一个deque赋值给另外一个deque

1
2
3
4
std::deque<kvstruct> test_container;
std::vector<kvstruct> random_vector;
...
test_container = random_vector;

下面这段代码展示了如何使用一对迭代器将元素插入到deque中。

1
2
3
4
5
6
7
std::deque<kvstruct> test_container;
std::vector<kvstruct> random_vector;
...
test_container.insert(
test_container.end(),
random_vector.begin(),
random_vector.end());

以下是三种使用push_back()将元素从vector复制到deque中的方法:

1
2
3
4
5
6
7
8
9
std::deque<kvstruct> test_container;
std::vector<kvstruct> random_vector;
...
for (auto it=random_vector.begin(); it!=random_vector.end(); ++it)
test_container.push_back(*it);
for (unsigned i = 0; i < nelts; ++i)
test_container.push_back(random_vector.at(i));
for (unsigned i = 0; i < nelts; ++i)
test_container.push_back(random_vector[i]);

开发人员很容易猜到这三个循环的性能开销大致是一样的。由于at()会进行额外的检查,因此它可能会稍微慢一点点。

使用insert()在末尾和前端插入元素的性能开销分别大约是push_back()push_front()的性能开销的两倍。

现在让我们来对比一下std::vectorstd::deque的性能。对于相同数量的元素,vector的赋值操作的性能是deque的 13 倍,删除操作的性能是deque的 22 倍,基于迭代器的插入操作的性能是deque的 9 倍,push_back()操作的性能是deque的两倍,使用insert()在末尾插入元素的性能则是deque的 3 倍。

std::list

std::list的“产品手册”如下。

  • 序列容器
  • 插入时间:任意位置的插入时间开销都是 O(1)
  • 排序时间: O(n log2n)
  • 查找时间: O(n)
  • 除非元素被移除了,否则迭代器和引用永远不会失效
  • 迭代器能够从后向前或是从前向后访问 list 中的元素

std::liststd::vectorstd::deque有许多相同的特性。与vectordeque一样,插入一个元素到list末尾的时间开销是常量时间;与deque一样(但与vector不同),插入一个元素到list前端的时间开销是常量时间。而且,与vectordeque不同的是,通过一个指向插入位置的迭代器插入一个元素到list中间的时间开销是常量时间。与vectordeque一样,我们也可以对list高效地进行排序。但是与vectordeque不同的是,无法高效地查找list。最快的查找list的方法是使用std::find(),它的时间开销是 O(n)。

尽管复制或是创建std::list的开销可能是std::vector的 10 倍,但是与std::deque相比,它还是具有竞争力的。将元素插入到list末尾的开销不足vector的两倍。遍历和排序list的开销只比vector多了 30%。对于我测试过的大部分操作,std::list都比std::deque的效率更高。

std::list的一个优点是在拼接(O(1) 时间开销)和合并时无需复制链表元素。即使是像splicesort这种操作也不会使std::list的迭代器失效。在list中间插入元素的时间开销是常数时间,因为程序已经知道要在哪里插入元素。因此,如果一个应用程序需要创建元素的集合并对它们进行这些操作,那么使用std::list会比std::vector更高效。std::list能够以一种简单且可预测的方式与内存管理器交互。当有需要时,list中的每个元素会被分别分配存储空间。在list中不存在未使用的额外的存储空间。

list中的每个元素所分配到的存储空间大小是相同的。这有助于提高复杂的内存管理器的工作效率,也降低了出现内存碎片的风险。我们还能够为std::list自定义简单的内存分配器,利用这个特性使其更高效地工作。

std::list中的插入和删除

除了开头的数据结构声明外,通过insert()push_back()push_front()将一个list复制到另一个list中的算法与vectordeque的代码清单中的算法是相同的。std::list的结构非常简单,编译器在编译过程中优化代码的余地很小。

list末尾插入元素是构造list的最快方式;出于某些原因,甚至比=运算符函数更快。

遍历std::list

list没有下标运算符。遍历它的唯一方式是使用迭代器。

对std::list排序

std::list上的迭代器是双向迭代器,不如std::vector的随机访问迭代器功能强大。这些迭代器的一个很特别的特性是,找到两个双向迭代器之间的距离或是元素个数的性能开销是 O(n)。因此,使用std::sort()std::list排序的时间开销是 O(n^2)。编译器的编译结果仍然是对list调用一次std::sort(),但性能可能远比开发人员所期待的差。
幸运的是,std::list有一种内置的更高效的排序方法,其时间开销是 O(n log2n)。

查找std::list

由于std::list只提供了双向迭代器,对于list,所有的二分查找算法的时间开销都是O(n)。另外, 使用std::find()查找list的时间开销也是 O(n),其中nlist中元素的数量。因此,std::list不适合替代关联容器。

std::forward_list

std::forward_list的“产品手册”如下。

  • 序列容器
  • 插入时间:任意位置的插入开销都是 O(1)
  • 排序时间: O(n log2n)
  • 查找时间: O(n)
  • 除非元素被移除了,否则迭代器和引用永远不会失效
  • 迭代器从前向后访问元素

std::forward_list是一种性能被优化到极限的序列容器。它有一个指向链表头部节点的指针。它的设计经过了深思熟虑,标准库的设计人员希望使它尽量贴近手动编码实现的单向链表。它没有back()rbegin()成员函数。

std::forward_list与内存管理器交互的方式非常简单,也是可预测的。当有需要时,前向链表会为每个元素单独分配内存。在前向链表中没有任何未使用的空间。前向链表中的每个元素所分配到的存储空间都是相同的。这有助于提高复杂的内存管理器的工作效率,也降低了出现内存碎片的风险。我们还能够为std::forward_list自定义简单的内存分配器,利用这个特性使其更高效地工作。

顾名思义,前向链表与链表的不同在于它只提供了前向迭代器。我们可以用普通的循环语句来遍历前向链表:

1
2
3
4
5
std::forward_list<kvstruct> flist;
// ...
unsigned sum = 0;
for (auto it = flist.begin(); it != flist.end(); ++it)
sum += it->value;

不过,插入方法则不同。std::forward_list并没有提供insert()方法,取而代之的是insert_after()方法。std::forward_list没有提供before_begin()这样能够得到指向第一个元素之前的位置的迭代器:

1
2
3
4
5
6
std::forward_list<kvstruct> flist;
std::vector<kvstruct> vect;
// ...
auto place = flist.before_begin();
for (auto it = vvect.begin(); it != vect.end(); ++it)
place = flist.insert_after(place, *it);

std::forward_list中的插入和删除

只要提供一个指向要插入位置之前的位置的迭代器,std::forward_list就能够以常量时间插入元素。std::forward_list还有一个push_front()

遍历std::forward_list

std::forward_list没有下标操作符。遍历它的唯一方式是使用迭代器。

对std::forward_list排序

std::list类似,std::forward_list也有一个时间开销为 O(n log2n) 的内置排序函数。

查找std::forward_list

由于std::forward_list只提供了前向迭代器,因此使用二分查找算法查找前向链表的时间开销是 O(n)。使用更加简单的std::find()进行查找的开销也是 O(n),其中 n 是前向链表中元素中的数量。这使得前向链表难以替代关联容器。

std::map与std::multimap

std::mapstd::multimap的“产品手册”如下。

  • 有序关联容器
  • 插入时间: O(log2n)
  • 索引时间:通过键进行索引的时间开销为 O(log2n)
  • 除非元素被移除,否则迭代器和引用永远不会失效
  • 利用迭代器对元素进行正向排序或是反向排序

std::map可以将键类型的实例映射为某个值类型的实例。std::mapstd::list一样,是一种基于节点的数据结构。不过,map会根据键的值对节点排序。map的内部实现是一棵带有便于使用迭代器遍历的额外链接的平衡二叉树

map为每个元素分配的存储空间的大小是相同的。这有助于提高复杂的内存管理器的工作效率,也降低了出现内存碎片的风险。我们还能够为std::map自定义简单的内存分配器,利用这个特性使其更高效地工作。

std::map中的插入和删除

由于需要遍历map的内部树来找到插入位置,因此向map中插入一个元素的时间开销通常是 O(log2n)。std::map提供了另外一个版本的insert()函数,该函数接收一个额外的map迭代器作为参数,利用这个参数提示map在迭代器所指向的位置插入元素会更高效。如果这种提示是最优的,那么插入操作的均摊时间开销会降低为 O(1)。

1
2
3
4
5
6
7
ContainerT test_container;
std::vector<kvstruct> sorted_vector;
...
std::stable_sort(sorted_vector.begin(), sorted_vector.end());
auto hint = test_container.end();
for (auto it = sorted_vector.rbegin(); it != sorted_vector.rend(); ++it)
hint = test_container.insert(hint, value_type(it->key, it->value));

一种常用的编程惯用法是在程序中先检查某个键是否存在于map中,然后根据结果进行相应的处理。当这些处理涉及插入或是更新键所对应的值时,那么就可能进行性能优化。理解性能优化的关键在于,由于需要先检查键是否存在于map中,然后再找到插入位置,因此map::find()map::insert()的时间开销都是 O(log2n)。这两种操作都会遍历map的二叉树数据结构中的相同的节点:

1
2
3
4
5
6
7
8
9
iterator it = table.find(key); // O(log n)
if (it != table.end()) {
// 找到key的分支
it->second = value;
}
else {
// 没有找到key的分支
it = table.insert(key, value); // O(log n)
}

如果程序程序能够得到第一次查找的结果,那么就能够将其作为对insert()的提示,将插入操作的时间开销提高到 O(1)。取决于程序的需求,有两种方法能够实现这个惯用法。如果只要知道是否找到了键即可,那么可以使用返回pair的版本的insert()。在被返回的pair中保存的是一个指向找到或是插入的元素的迭代器以及一个布尔型变量,当这个布尔型变量为 true 时表示该元素被找到了,而当这个布尔型变量为 false 时表示该元素是被插入的。当程序在检查元素是否存在于map之前知道如何初始化元素,或是更新值的性能开销并不大时,这种方法非常有效:

1
2
3
4
5
6
7
std::pair<value_t, bool> result = table.insert(key, value);
if (result.second) {
// k找到key的分支
}
else {
// 没有找到key的分支
}

对std::map排序

map本来就是有序的。使用迭代器遍历一个map会按照键和查找谓词的顺序访问元素。请注意,只有将所有的元素都从一个map中复制到另一个map中,才能对map重排序。

std::set与std::multiset

std::setstd::multiset的“产品手册”如下。

  • 有序关联容器
  • 插入时间: O(log2n)
  • 索引时间:通过键进行索引,时间开销为 (log2n)
  • 除非移除元素,否则迭代器和引用永远不会失效
  • 迭代器能够按照正序或反序遍历元素

std::mapstd::set之间的一个不同点是查找方法返回的元素是 const 的。这其实并不是什么大问题。如果你真的想使用set,可以将与排序关系无关的值类型的字段声明为mutable,指定它们不参与排序。

std::unordered_map与std::unordered_multimap

std::unordered_mapstd::unordered_multimap的“产品手册”如下。

  • 无序关联容器。
  • 插入时间:平均时间开销为 O(1),最差情况时间开销为 O(n)。
  • 索引时间:通过键索引的平均时间开销为 O(1),最差情况时间开销为 O(n)。
  • 再计算散列值时迭代器会失效;只有在移除元素后引用才会失效。
  • 可以独立于大小(size)扩大或是缩小它们的容量(capacity)。

std::unordered_map能够将键类型的实例映射到某个值类型的实例上。这种方式与std::map相似。不过,映射的完成过程不同。std::unordered_map被实现为了一个散列表。键会被转换为一个整数散列值,使用这个散列值能够以分摊平均常量时间的性能开销从unordered_map中查找到值。

std::string一样, C++ 标准也限制了std::unordered_map的实现。因此,尽管有多种方式能够实现散列表,但是只有采用了动态分配内存的骨干数组,然后在其中保存指向动态分配内存的节点组成的链表的桶的设计,才有可能符合标准定义。

unordered_map的构造是昂贵的。它包含了为表中所有元素动态分配的节点,另外还有一个会随着表的增长定期重新分配的动态可变大小的桶数组。因此,要想改善它的查找性能,需要消耗相当多的内存。每次桶数组重新分配时,迭代器都会失效。不过,只有在删除元素时,指向元素节点的引用才会失效。

unordered_map中元素的数量就是它的大小。计算出的size / buckets比例称为负载系数(load factor)。负载系数大于 1.0 表示有些桶有一条多个元素链接而成的元素链,降低了查询这些键的性能。在实际的散列表中,即使负载系数小于1.0,键之间的冲突也会导致形成元素链。负载系数小于 1.0 表示存在着未被使用,但却在unordered_map的骨干数组中占用了存储空间的桶(换言之,非最小散列)。当负载系数小于 1.0 时, (1 – 负载系数 ) 的值是空桶数量的下界,但是由于散列函数可能非完美,因此未使用的存储空间通常更多。

负载系数在unordered_map中是一个因变数。我们能够在程序中观察到它的值,但是无法在重新分配内存后直接设置或是预测它的值。当一条元素被插入到unordered_map中后,如果负载系数超过了程序指定的最大负载系数值,那么桶数组会被重新分配,所有的元素都被会重新计算散列值,这个值会被保存在新数组的桶中。由于桶数量的增长总是因负载系数大于 1 而引起的,因此插入操作的均摊时间开销 O(1)。当最大负载系数大于 1.0 这个默认值时,插入操作和查找操作的性能会显著降低。通过将最大负载系数降低到 1.0 以下能够适度地提高程序性能。

调用reserve(size_t n)可以确保在重新分配骨干数组之前预留出足够的空间来保存n条元素。这等价于调用rehash(ceil(n/max_load_factor()))

调用unordered_mapclear()成员函数会清除所有的元素,并将所有存储空间返回给内存管理器。这与vector或是stringclear()成员函数相比是一种更有力的承诺。

std::unordered_map中的插入与删除

std::map类似,std::unordered_map也提供了两种插入方法:带插入提示的和不带插入提示的。但与map不同的是,unordered_map并不使用插入提示。这只是一种接口兼容性。

遍历std::unordered_map

代码是一段遍历std::unordered_map的代码。

1
2
3
4
5
for (auto it = test_container.begin();
it != test_container.end();
++it) {
sum += it->second;
}

顾名思义,unordered_map无法被排序,使用迭代器遍历unordered_map中元素的顺序也是无规则的。

小结

  • 斯特潘诺夫的标准模板库是第一个可复用的高效容器和算法库。
  • 各容器类的大 O 标记性能并不能反映真实情况。有些容器比其他容器快许多倍。
  • 在进行插入、删除、遍历和排序操作时std::vector都是最快的容器。
  • 使用 std::lower_bound查找有序 std::vector的速度可以与查找 std::map的速度相匹敌。
  • std::deque 只比 std::list 稍快一点。
  • std::forward_list 并不比 std::list 更快。
  • 散列表 std::unordered_map 比std::map更快,但是相比所受到的开发人员的器重程度,
    它并没有比std::map快上一个数量级。
  • 互联网上有丰富的类似标注库容器的容器资源。

优化I/O

读取文件的秘诀

代码是一个将文件读取到字符串中的简单函数,在解析 XML 或是 JSON 前经常会出现这样的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::string file_reader(char const* fname) {
std::ifstream f;
f.open(fname);
if (!f) {
std::cout << "Can't open " << fname
<< " for reading" << std::endl;
return "";
}
std::stringstream s;
std::copy(std::istreambuf_iterator<char>(f.rdbuf()),
std::istreambuf_iterator<char>(),
std::ostreambuf_iterator<char>(s) );
return s.str();
}

fname是文件名。如果打不开文件,file_reader()会打印一条错误信息到标准输出中并返回空字符串。否则,std::copy()会将f的流缓冲区复制到std::stringstream s的流缓冲区中。

创建一个吝啬的函数签名

从库设计的角度看,file_reader()是可以改善的。它做了几件不同的事情:打开文件;进行错误处理(以报告打开文件出错的形式);读取已打开且有效的流到字符串中。作为一个库函数,这几种职责混合在一起使得调用方难以使用file_reader()函数。

file_reader()同样还会分配一块新的内存并返回它,这里存在一个潜在的问题,因为这会导致返回值在调用链中传递的时候发生多次复制。如果文件无法打开,file_reader()会返回空字符串。如果文件能够打开,但是其中一个字符都没有,那么它也会返回一个空字符串。

代码:带有吝啬函数签名的stream_read_streambuf_stringstream()

1
2
3
4
5
6
7
8
9
void stream_read_streambuf_stringstream(
std::istream& f,
std::string& result) {
std::stringstream s;
std::copy(std::istreambuf_iterator<char>(f.rdbuf()),
std::istreambuf_iterator<char>(),
std::ostreambuf_iterator<char>(s) );
std::swap(result, s.str());
}

stream_read_streambuf_stringstream()的最后一行将result的动态存储空间与s.str()的动态存储空间进行了交换。std::swap()对许多标准库类的特化实现都是调用它们的swap()成员函数。该成员函数会交换指针,这远比内存分配和复制操作的开销小。吝啬的回报是f变成了std::istream,而不是std::ifstream

客户端代码可以如下这样调用stream_read_streambuf_stringstream()

1
2
3
4
5
6
7
8
9
10
std::string s;
std::ifstream f;
f.open(fname);
if (!f) {
std::cerr << "Can't open " << fname
<< " for reading" << std::endl;
}
else {
stream_read_streambuf_stringstream(f, s);
}

更大的吞吐量——使用更大的输入缓冲区

C++ 流包含一个继承自std::streambuf的类,用于改善从操作系统底层以更大块的数据单位读取文件时的性能。数据会被读取到streambuf的缓冲区中。

1
2
3
4
std::ifstream in8k;
in8k.open(filename);
char buf[8192];
in8k.rdbuf()->pubsetbuf(buf, sizeof(buf));

更大的吞吐量——一次读取一行

我们有理由猜测使用逐行读取文件的函数能够减少函数调用次数,最好是在内部使用逐行读取或是填充缓冲区的接口。除此之外,如果不会频繁地更新结果字符串,那么复制和重新分配存储空间的次数也会较少。确实,在标准库中就有一个叫作getline()的函数。

1
2
3
4
5
6
void stream_read_getline(std::istream& f, std::string& result) {
std::string line;
result.clear();
while (getline(f, line))
(result += line) += "\n";
}

stream_read_getline()会将读取的字符串添加到result中。result的内容必须在最开始被清空,因为当它被传递给这个函数时,函数并不要求它里面没有保存任何内容。clear()不会将字符串的动态缓冲区返回给内存管理器,它只是设置字符串的长度为 0。根据在函数调用之前使用这个字符串参数的情况,可能它已经有一个大块的动态缓冲区了,这样能够减小分配存储空间的性能开销。

优化并发

复习并发

并发是多线程控制的同步(或近似同步)执行。并发的目标并不是减少指令执行的次数或是每秒访问数据的次数。相反,它是通过提高计算资源的使用率来减少程序运行的时间的。

并发通过在其他程序活动等待事件发生或是资源变为可用状态时,允许某些程序活动向前执行来提高程序性能。这样能够增加计算资源的使用时间。C++ 为共享内存的基于线程的并发提供了一个库。

并发概述

最著名的几种并发形式如下。

时间分割(time slicing):这是操作系统中的一个调度函数。操作系统是依赖于处理器和硬件的。它会使用计时器和周期性的中断来调整处理器的调度。C++ 程序并不知道它被时间分割了。

虚拟化(Virtualization):一种常见的虚拟化技术是让一个称为“hypervisor”的轻量级操作系统将处理器的时间块分配给客户虚拟机。当 hypervisor 运行客户虚拟机后,某些处理器指令和对内存区域的某些访问会产生 Trap(陷入),并将它下传给hypervisor,这将允许 hypervisor 竞争 I/O 设备和其他硬件资源。

虚拟化技术的优点如下。

  • 客户虚拟机是在运行时被打包为磁盘文件的,因此我们能够对客户虚拟机设置检查点(checkpoint),保存客户虚拟机,加载和继续执行客户虚拟机,以及在多台主机上运行客户虚拟机。
  • 只要资源允许,我们能够并发地运行多台客户虚拟机。hypervisor 会与计算机虚拟内存保护硬件共同协作,隔离这些客户虚拟机。
  • 我们能够配置客户虚拟机使用主机的一部分资源(物理内存、处理器核心)。计算资源能够根据每台客户虚拟机上正在运行的程序的需求“量体裁衣”,确保并发地在同一硬件上运行的多台虚拟机保持性能稳定,并防止它们之间意外地发生交互。

容器化(containerization):容器化与虚拟化的相似之处在于,容器中也有一个包含了程序在检查点的状态的文件系统镜像和内存镜像;不同之处在于容器主机是一个操作系统,这样能够直接地提供 I/O和系统资源,而不必通过 hypervisor 去较低效地竞争资源。

对称式多处理(symmetric multiprocessing):对称式多处理器(symmetric multiprocessor)是一种包含若干执行相同机器代码并访问相同物理内存的执行单元的计算机。现代多核处理器都是对称式多处理器。当前正在执行的程序和系统任务能够运行于任何可用的执行单元上,尽管选择执行单元可能会给性能带来影响。

对称式多处理器使用真正的硬件并发执行多线程控制。如果对称式多处理器有 n 个执行单元,那么一个计算密集型程序的执行时间最多可以被缩短为 1/n。

同步多线程(simultaneous multithreading):有些处理器的硬件核心有两个(或多个)寄存器集,可以相应地执行两条或多条指令流。当一条指令流停顿时(如需要访问主内存),处理器核心能够执行另外一条指令流上的指令。具有这种特性的处理器核心的行为就像是有两个(或多个)核心一样,这样一个“四核处理器”就能够真正地处理八个硬件线程。

多进程:进程是并发的执行流,这些执行流有它们自己的受保护的虚拟内存空间。进程之间通过管道、队列、网络 I/O 或是其他不共享的机制进行通信。线程使用同步原语或是通过等待输入(即发生阻塞直至输入变为可用状态)来进行同步。

复习C++并发方式

线程

<thread>头文件提供了std::thread模板类,它允许程序创建线程对象作为操作系统自身的线程工具的包装器。std::thread的构造函数接收一个可调用对象(函数指针、函数对象、lambda或是绑定表达式)作为参数,并会在新的软件线程上下文中执行这个对象。C++ 使用可变模板参数转发“魔法”调用带有可变参数列表的函数,而底层操作系统的线程调用通常接收一个指向带有void*参数的void函数的指针作为参数。

std::thread是一个用于管理操作系统线程的 RAII(资源获取即初始化)类。它有一个返回操作系统原生线程处理句柄的get()成员函数,程序可以使用该处理句柄访问操作系统中更丰富的作用于线程上的函数集合。

1
2
3
4
5
6
7
8
9
10
11
12
13
void f1(int n) {
std::cout << "thread " << n << std::endl;
}
void thread_example() {
std::thread t1; // 线程变量,不是一个线程
t1 = std::thread(f1, 1); // 将一个线程赋值给线程变量
t1.join(); // 等待线程结束
std::thread t2(f1, 2);
std::thread t3(std::move(t2));
std::thread t4([]() { return; });// 也可以与lambda表达式配合使用
t4.detach();
t3.join();
}

线程t1会被初始化为一个空线程。由于每个线程都有一个唯一的指向底层资源的句柄,因此线程无法被复制,但是我们能够使用移动赋值运算符将一个右值赋值给空线程。t1可以拥有任何一个执行接收一个整数参数的函数的线程。std::thread的构造函数接收一个指向f1的函数指针和一个整数作为参数。第二个参数会被转发给在std::thread的构造函数中启动的可调用对象(f1)。

线程t2是用同一个函数但是不同参数启动的。线程t3是一个移动构造函数的示例。在被移动构造后,t3运行的是作为t2启动的线程,t2变为了空线程。线程t4展示了如何使用lambda表达式作为线程的可调用对象启动线程。

std::thread代表的操作系统线程必须在std::thread被销毁之前被销毁掉。我们可以像t3.join()这样加入线程,这表示当前线程会等待被加入的线程执行完毕。我们可以像t4.detach()这样将操作系统线程从std::thread对象中分离出来。在这种情况下,线程会继续执行,但对启动它的线程来说变成了不可见的。当被分离线程的可调用对象返回时它就会结束。如果可调用对象不会返回,那么就会发生资源泄露,被分离的线程会继续消耗资源,直到整个程序结束。

如果在std::thread被销毁前既没有调用过join()也没有调用过detach(),它的析构函数会调用terminate(),整个程序会突然停止。尽管我们能够直接使用std::thread,但是使用基于它编写出更加优秀的工具的话,可能有助于提高生产率。函数对象返回的任何值都会被忽略。函数对象抛出的异常会导致terminate()被调用,使程序无条件地突然停止。

promise和future

C++ 中的std::promise模板类和std::future分别是一个线程向另外一个线程发送和接收消息的模板类。promisefuture允许线程异步地计算值和抛出异常。promisefuture共享一个称为共享状态(shared state)的动态分配内存的变量,这个变量能够保存一个已定义类型的值,或是在标准包装器中封装的任意类型的异常。一个执行线程能够在future上被挂起,因此future也扮演着同步设备的角色。

C++<future>头文件中包含promisefuture的功能。std::promise模板的实例允许线程将共享状态设置为一个指定类型的值或是一个异常。发送线程并不会等待共享状态变为可读状态,它能够立即继续执行。

promise的共享状态直到被设置为一个值或是一个异常后才就绪。共享状态必须且只能被设置一次,否则会发生以下情况。

  • 如果某个线程多次试图将共享状态设置为一个值或是一个异常,那么共享状态将会被设置为std::future_error异常,错误代码是promise_already_satisfied,而且共享状态变为就绪状态,为释放所有在promise上等待的future做好准备。
  • 如果某个线程从来没有将共享状态设置为一个值或是一个异常,那么在promise被销毁时,它的析构函数会将共享状态设置为std::future_error异常,错误代码是broken_promise,而且共享状态变为就绪状态,为释放所有在promise上等待的future做好准备。

要想获得这个有用的错误提示,我们必须在线程的可调用对象中销毁promise

std::future允许线程接收保存在promise的共享状态中的值或是异常。future是一个同步原语,接收线程会在对futureget()成员函数的调用中挂起,直到相应的promise设置了共享状态的值或是异常,变为就绪状态为止。

在被构造出来或是通过promise赋值后,future才是有效的。在future无效时,接收线程是无法在future上挂起的。future必须在发送线程被执行之前通过promise构造出来。否则,接收线程会试图在future变为有效之前在它上面挂起。

promisefuture无法被复制。它们是代表特定通信集结点的实体。我们能构造和移动构造它们,可以将一个promise赋值给一个future。理想情况下,promise是在发送线程中被创建的,而future则是在接收线程中被创建的。有一种编程惯用法是在发送线程中创建promise,然后使用std::move(promise)将其作为右值引用传递给接收线程,这样它的内容就会被移动到属于接收线程的promise中。开发人员可以使用std::async()来做到这一点。

1
2
3
4
5
6
7
8
9
void promise_future_example() {
auto meaning = [](std::promise<int>& prom) {
prom.set_value(42); // 计算"meaning of life"
};
std::promise<int> prom;
std::thread(meaning, std::ref(prom)).detach();
std::future<int> result = prom.get_future();
std::cout << "the meaning of life: " << result.get() << "\n";
}

promisepromstd::thread被调用之前被创建出来了。这种写法并不完美,因此它没有考虑broken_promise的情况。尽管如此,但这是有必要的,因为如果没有在线程开始之前构造出prom,就无法确保在调用result.get()之前futureresult是有效的。程序接着构造出一个匿名std::thread。它有两个参数,一个是lambda表达式meaning,它是待执行的可调用对象;另一个是promise类型的变量prom,它是传给meaning使用的参数。请注意,由于prom是一个引用参数,因此必须将其包装在std::ref()中才能使参数转发正常工作。调用detach()函数会从被销毁的匿名std::thread中分离出正在运行的线程。

现在正在发生两件事情:一件是操作系统正在为执行meaning做准备,另一件是程序正在创建future类型的result。程序可能会在线程开始运行之前执行prom.get_future()。这就是在构造出线程之前先创建prom的原因——这样future是有效的,程序会挂起等待线程。

程序会在result.get()中挂起,等待线程设置prom的共享状态。线程调用prom.set_value(42),让共享状态就绪并释放程序。程序在输出” the meaning of life:42”后结束。

异步任务

C++ 标准库任务模板类在 try 语句块中封装了一个可调用对象,并将返回值或是抛出的异常保存在promise中。任务允许线程异步地调用可调用对象。C++ 标准库中的基于任务的并发只是一个半成品。C++11 提供了将可调用对象包装为任务,并在可复用的线程上调用它的async()模板函数。async()有点像“上帝函数”,它隐藏了线程池和任务队列的许多细节。

在 C++ 标准库<future>头文件中定义了任务。std::packaged_task模板类能够包装任意的可调用对象,使其能够被异步调用。packaged_task自身也是一个可调用对象,它可以作为可调用对象参数传递给std::thread。与其他可调用对象相比,任务的最大优点是一个任务能够在不突然终止程序的情况下抛出异常或返回值。任务的返回值或抛出的异常会被存储在一个可以通过std::future对象访问的共享状态中。

1
2
3
4
5
6
7
8
void promise_future_example_2() {
auto meaning = std::packaged_task<int(int)>(
[](int n) { return n; });
auto result = meaning.get_future();
auto t = std::thread(std::move(meaning), 42);
std::cout << "the meaning of life: " << result.get() << "\n";
t.join();
}

packaged_task类型的变量meaning包含一个可调用对象和一个std::promise。这解决了在线程的上下文中调用promise的析构函数的问题。请注意meaning中的lambda表达式只是简单的返回参数,设定promise的部分被优雅地隐藏起来了。

在本例中,程序加入(join)了线程,而不是分离(detach)它。尽管在这个例子中并没有体现得特别明显,但是在主程序得到future的值后,主程序和线程都能继续并发地执行。<async>库提供了一个基于任务的工具——std::async()。模板函数std::async()执行一个可调用对象参数,这个可调用参数可能是在新线程的上下文中被执行的。不过,std::async()返回的是一个std::future,它既能够保存一个返回值,也能够保存可调用对象抛出的异常。而且,有些实现方式可能会为了改善性能而选择在线程池外部分配std::async()线程。

1
2
3
4
5
void promise_future_example_3() {
auto meaning = [](int n) { return n; };
auto result = std::async(std::move(meaning), 42);
std::cout << "the meaning of life: " << result.get() << "\n";
}

这里定义了lambda表达式meaning,并且将lambda表达式的参数传递给了std::async()。这里使用了类型推导来决定std::async()的模板参数。std::async()返回一个能够得到一个整数值或是一个异常的future,它会被移动到result中。result.get()的调用会挂起,直到std::async()调用的线程通过返回它的整数型参数设置它的promise。线程终止是由std::async()负责的,它可能会将线程保留在线程池中。

互斥量

<mutex>头文件包含了四种互斥量模板。

  • std::mutex一种简单且相对高效的互斥量。
  • std::recursive_mutex:一种线程能够递归获取的互斥量,就像函数的嵌套调用一样。由于该类需要对它被获取的次数计数,因此可能稍微低效。
  • std::timed_mutex:允许在一定时间内尝试获取互斥量。要想在一定时间内尝试获取互斥量,通常需要操作系统的介入,导致与std::mutex相比,这类互斥量的延迟显著地增大了。
  • std::recursive_timed_mutex:一种能够在一定时间内递归地获取的互斥量。这类互斥量很“美味”,但是开销也非常昂贵。

获取互斥量也被称为锁住互斥量,释放互斥量也被称为解锁互斥量。在 C++ 中,互斥量的获取互斥量的成员函数的名字叫作lock()。C++ 标准库提供了一种简单的用于获得单个互斥量的锁,还提供了一种更加通用的用于获得多个互斥量的锁。后者实现了一种避免死锁的算法。在<mutex>头文件中有两个锁模板。

  • std::lock_guard:一种简单的 RAII 锁。在这个类的构造过程中,程序会等待直到获得锁;而在析构过程中则会释放锁。这个类的预标准实现通常被称为scope_guard
  • std::unique_lock:一个通用的互斥量所有权类,提供了 RAII 锁、延迟锁、定时锁、互斥量所有权的转移和条件变量的使用。

在 C++14 的<shared_mutex>头文件中加入了共享互斥量的锁。

  • std::shared_lock:共享(reader/writer)互斥量的一个互斥量所有权类。它提供了std::unique_lock的所有复杂特性,另外还有共享互斥量的控制权。一个单独的线程能够以排他模式锁住一个共享互斥量来原子性地更新数据结构。多个线程能够以共享模式锁住一个共享互斥量来原子性地读取数据结构,但是在所有的读取者都释放互斥量之前无法以排他模式锁住它。

条件变量

一个监视器在多线程之间共享一个数据结构。当一个线程成功地进入监视器后,它就拥有一个允许它更新共享数据结构的互斥量。线程可能会在更新共享数据结构后退出监视器,放弃它的排他访问权限。它也可能会阻塞在一个条件变量上,暂时地放弃排他访问权限直到这个条件变量变为特定的值。

一个监视器可以有一个或多个条件变量。每个条件变量都表示数据结构中一个概念上的状态改变事件。当运行于监视器中的一个线程更新数据结构时,它必须通知所有会受到这次更新影响的条件变量,它们所表示的事件发生了。

C++ 在<condition_variable>头文件中提供了条件变量的两种实现方式。它们之间的区别在于所接收的参数锁的一般性。

  • std::condition_variable:最高效的条件变量,它需要使用std::unique_lock来锁住互斥量。
  • std::condition_variable_any:一种能够使用任何BasicLockable锁(即任何具有lock()unlock()成员函数的锁)的条件变量。该条件变量可能会比std::condition_variable低效。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
void cv_example() {
std::mutex m;
std::condition_variable cv;
bool terminate = false;
int shared_data = 0;
int counter = 0;
auto consumer = [&]() {
std::unique_lock<std::mutex> lk(m);
do {
while (!(terminate || shared_data != 0))
cv.wait(lk);
if (terminate)
break;
std::cout << "consuming " << shared_data << std::endl;
shared_data = 0;
cv.notify_one();
} while (true);
};
auto producer = [&]() {
std::unique_lock<std::mutex> lk(m);
for (counter = 1; true; ++counter) {
cv.wait(lk,[&]() {return terminate || shared_data == 0;});
if (terminate)
break;
shared_data = counter;
std::cout << "producing " << shared_data << std::endl;
cv.notify_one();
}
};
auto p = std::thread(producer);
auto c = std::thread(consumer);
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
{
std::lock_guard<std::mutex> l(m);
terminate = true;
}
std::cout << "total items consumed " << counter << std::endl;
cv.notify_all();
p.join();
c.join();
exit(0);
}

生产者通过将一个名为shared_data的整数变量设为非零值来进行“生产”。消费者通过将其重新设置为零来“消费”shared_data。主程序线程会启动生产者和消费者,接着打一个 1000 毫秒的盹。当主线程醒来后,它会锁住互斥量m短暂地进入监视器,接着设置terminate标识位,这会使生产者线程和消费者线程都退出执行。主程序通知条件变量terminate的状态发生了改变,加入两个线程并退出。

消费者通过锁住互斥量m进入监视器。消费者是一个挂起在名为cv的条件变量上的单层循环。当它挂起在cv上时,消费者不在监视器内,互斥量m是可用的。当没有东西可以消费时,cv会收到通知。消费者会醒来,重新锁住互斥量m并从它对cv.wait()的调用中返回,在概念上重新进入监视器。

消费者通常等待的更新是shared_data != 0,但它也需要在terminate == true时醒来。这是对同步原语的合理使用。另外一种类似的情况是使用一个条件变量唤醒消费者,使用另一个条件变量唤醒生产者。消费者在一个循环中调用cv.wait(),每次醒来时都会检查是否满足了合适的条件。这是因为有些实现能够在不合适的时候假装意外地唤醒等待条件变量变为合适值的线程。如果条件满足了,那么退出while循环。如果唤醒消费者的条件是terminate == true,那么消费者会退出外层循环并返回。否则,条件就是shared_data != 0。消费者会打印出一条消息,接着通过设置shared_data为 0 表示它已经消费了数据并通知cv共享数据发生了变化。在这时,消费者仍然在监视器内,持有加在互斥量m上的锁,但它会继续循环,再次进入cv.wait(),释放互斥量并在概念上退出监视器。

生产者也是类似的。它会挂起,直到它看到了加在互斥量m上的锁,然后它会进入到外层循环中,直到它看到了terminate == true。生产者会等待cv状态发生改变。在本例中,生产者使用了一个接收谓词(predicate)作为参数的版本的wait(),这会导致一直循环直至判断式为 false。因此谓词就是在通知条件变量中隐藏的条件。

共享变量上的原子操作

C++ 标准库<atomic>头文件提供了用于构建多线程同步原语的底层工具:内存栅栏和原子性的加载与存储。std::atomic提供了一种更新任意数据结构的标准机制,前提条件是这种数据结构有可用的复制构造函数或是移动构造函数。std::atomic的任何特化实现都必须为任意类型T
供以下函数。

  • std::atomic<T>提供了成员函数T load(memory_order),它可以原子性地复制T对象到std::atomic<T>外部。
  • std::atomic<T>提供了成员函数store(T, memory_order),它可以原子性地复制T对象到std::atomic<T>内部。
  • is_lock_free():如果在这个类型上定义的所有的操作的实现都没有使用互斥,is_lock_free()返回true,就如同是使用一条单独的读 - 改 - 写机器指令进行操作一样。

std::atomic为整数和指针类型提供了特化实现。只要处理器支持,这些特化不调用操作系统的同步原语就能够同步内存。这些特化实现提供了一组能够在现代硬件上实现的原子性操作。

std::atomic的性能取决于编译代码的处理器。

  • Intel 架构的 PC 具有丰富的读 - 改 - 写指令,原子性访问的性能开销取决于内存栅栏,其中部分栅栏完全没有性能开销。
  • 在有读 - 改 - 写指令的单核心处理器上,std::atomic可能根本不会生成任何额外代码。
  • 在没有原子性的读 - 改 - 写指令的处理器上,std::atomic可能是用昂贵的互斥实现的。

std::atomic的许多成员函数都会接收一个可选参数memory_order,它会选择一个围绕在操作上下的栅栏。如果没有提供memory_order参数,它的默认值是memory_order_acq_rel

内存栅栏通过多个硬件线程的高速缓存来同步主内存。通常,在一个线程与另一个线程同步时,这两个线程上都会加上内存栅栏。在 C++ 中能够使用以下内存栅栏。

  • memory_order_acquire:你可以将memory_order_acquire理解为“通过其他线程完成所有工作”的意思。它确保随后的加载不会被移动到当前的加载或是前面的加载之前。自相矛盾的是,它是通过等待在处理器和主内存之间的当前的存储操作完成来实现这一点的。如果没有栅栏,当一次存储还处于处理器和主内存之间,它的线程就在相同的地址进行了一次加载时,该线程会得到旧的数据,仿佛这次在程序中加载被移动到了存储之前。memory_order_acquire可能会比默认的完全栅栏高效。例如,在原子性地读取在繁忙等待while循环中的标识位时,可以使用memory_order_acquire
  • memory_order_release:你可以将memory_order_release理解为“通过这个线程将所有工作释放到这个位置”的意思。它确保这个线程完成的之前的加载和存储不会被移动到当前的存储之后。它是通过等待这个线程内部的当前存储操作完成来实现这一点的。memory_order_release可能会比默认的完全栅栏高效。例如,在自定义的互斥量的尾部设置标识位时,可以使用memory_order_release
  • memory_order_acq_rel:这会结合之前的两种“确保”,创建一个完全栅栏。
  • memory_order_consumememory_order_consumememory_order_acquire的一种弱化(但更快)的形式,它只要求当前的加载发生在其他依赖这次加载数据的操作之前。例如,当一个指针的加载被标记为memory_order_consume时,紧接着的解引这个指针的操作就不会被移动它之前。
  • memory_order_relaxed:使用这个值意味着允许所有的重新排序。

优化多线程C++程序

用std::async替代std::thread

从性能优化的角度看,std::thread有一个非常严重的问题,那就是每次调用都会启动一个新的软件线程。启动线程时,直接开销和间接开销都会使得这个操作非常昂贵。

  • 直接开销包括调用操作系统为线程在操作系统的表中分配空间的开销、为线程的栈分配内存的开销、初始化线程寄存器组的开销和调度线程运行的开销。如果线程得到了一份新的调度量子(scheduling quantum),在它开始执行之前会有一段延迟。如果它得到了其他正在被调用线程的调度量子,那么在存储正在被调用线程的寄存器时会发生延迟。
  • 创建线程的间接开销是增加了所使用的内存总量。每个线程都必须为它自己的函数调用栈预留存储空间。如果频繁地启动和停止大量线程,那么在计算机上执行的线程会竞争访问有限的高速缓存资源,导致高速缓存发生抖动。
  • 当软件线程的数量比硬件线程的数量多时会带来另外一种间接开销。由于需要操作系统进行调度,因此所有线程的速度都会变慢。

thread内部的空函数会立即返回。这是可能的最短的函数。调用join()会让主线程等待thread结束,导致线程调用变为了“端到端”,没有任何并发。如果不是为了测量线程的启动开销而有意为之,那么它就是一个糟糕的并发设计。

1
2
3
std::thread t;
t = std::thread([]() { return; });
t.join();

尽管切换线程的有些开销(保存和恢复寄存器并刷新和重新填充高速缓存)是相同的,但可以移除或减少为线程分配内存以及操作系统调度线程等其他开销。模板函数std::async()会运行线程上下文中的可调用对象,但是它的实现方式允许复用线程。从 C++ 标准来看,std::async()可能是使用线程池的方式实现的。

1
std::async(std::launch::async, []() { return; });

async()会返回一个std::future,在这种情况下它是一个匿名临时变量。只要std::async()一返回,程序就会立即调用这个匿名std::future的析构函数。析构函数会等待该future变为就绪状态,因此它可以抛出所有会发生的异常。这里不需要显式地调用join()或是detach()

实现任务队列和线程池

在面向任务的编程中,程序是一组可运行任务(runnable task)对象的集合,这些任务由线程池中的线程负责执行。当一个线程变为可用状态后,它会从任务队列中取得一个任务并执行。执行完任务后,线程并不会终止,而是要么继续做下一个任务,要么挂起,等待新任务的到来。面向任务的编程有以下几个优点。

  • 面向任务的编程能够通过非阻塞 I/O 调用高效地处理 I/O 完成事件,提高处理器的利用率。
  • 使用线程池和任务队列能够移除为短周期任务启动线程的间接开销。
  • 面向任务的编程将异步处理集中在一组数据结构中,因此容易限制使用中的线程的数量。

面向任务的编程的一个缺点是控制返转(inversion of control)。控制流不再由程序指定,而是变为事件消息接收的顺序。

在单独的线程中执行I/O

磁盘转速和网络连接距离等物理现实问题造成在程序请求数据和数据变为可用状态之间存在着延迟。因此, I/O 是适用并发的绝佳位置。另外一个典型的 I/O 问题是,程序在写数据之前或是读数据之后必须对它进行转换。

优化内存管理

复习C++内存管理器API

动态变量的生命周期

动态变量有五个唯一的生命阶段。最常见的 new 表达式的各种重载形式执行分配和放置生命阶段。在使用阶段后, delete 表达式会执行销毁和释放阶段。C++ 提供了单独管理每个阶段的方法。

  • 分配:程序要求内存管理器返回一个指向至少包含指定数量未类型化的内存字节的连续内存地址的指针。如果没有足够的可用内存,那么分配将会失败。
  • 放置:程序创建动态变量的初始值,将值放置到被分配的内存中。如果变量是一个类的实例,那么它的构造函数之一将会被调用。如果变量是一个简单类型,那么它可能会被初始化。如果构造函数抛出异常,那么放置会失败,需要将被分配的存储空间返回给内存管理器。new 表达式参与这个阶段。
  • 使用:程序从动态变量中读取值,调用动态变量的成员函数并将值写入到动态变量中。
  • 销毁:如果变量是一个类实例,那么程序会调用它的析构函数对动态变量执行最后的操作。析构对动态变量而言是一次机会,它可以趁机返回持有的所有系统资源,完成所有清理工作。如果析构函数抛出一个在析构函数体内不会处理的异常,析构会失败。若发生了这种情况,程序会无条件终止。
  • 释放:程序将属于被销毁的动态变量的存储空间返回给内存管理器。

内存管理函数分配和释放内存

C++ 提供了一组内存管理函数,而不是 C 中简单的malloc()free()。重载new()运算符能够为任意类型的单实例分配存储空间。重载new[]()运算符能够为任意类型的数组分配空间。当数组版本和非数组版本的函数以相同的方式进行处理时,我将它们统一称为new()运算符,表示还包括一个相同的new[]()运算符。

new表达式会调用new()运算符的若干版本之一来获得动态变量的内存,或是调用new[]()运算符获得动态数组的内存。C++ 提供了这些运算符的默认实现。它还隐式地声明了这些运算符,这样程序无需包含<new>头文件即可调用它们。

new()运算符对于性能优化非常重要,因为默认内存管理器的开销是昂贵的。在有些情况下,通过实现专门的运算符能够让程序非常高效地分配内存。

C++ 定义了new()运算符的几种重载形式。

1
void* ::operator new(size_t)

默认情况下,所有动态分配的变量的内存都是通过调用new()运算符的带有指定要分配内存的最小字节数参数的重载形式分配的。当没有足够多的内存能够满足请求时,这种重载形式的标准库实现会抛出std::bad_alloc异常。

new()运算符的所有其他重载形式的标准库实现都会调用这个重载形式。通过在任意编译单元中提供一个::operator new(size_t)的定义,程序能够全局地改变内存的分配方式。

尽管 C++ 标准并没有规定这是必需的,但是标准库中的这个重载版本的实现通常都会调用malloc()

1
void* ::operator new[](size_t)

程序用new()运算符的这个重载版本为数组分配内存。在标准库中,该版本的实现会调用::operator new(size_t)

1
2
void* ::operator new(size_t, const std::nothrow_tag&)
Foo* p = new(std::nothrow) Foo(123);

这样的new表达式会调用new()运算符的不抛出异常的重载形式。如果没有可用内存,该版本会返回nullptr,而不会抛出std::bad_alloc异常。在标准库中,该版本的实现会调用new(size_t)运算符并捕捉所有可能会抛出的异常。

1
void* ::operator new[](size_t, const std::nothrow_tag&)

这是new()运算符的无异常抛出版本的数组版本。

new表达式能够调用第一个参数是size_t类型的、具有任意函数签名的new()运算符。所有这些new()运算符的重载形式都被称为定位放置new()运算符。new表达式通过将定位放置new()运算符的参数类型与可用的new()运算符函数签名进行匹配,来确定使用哪个函数。

标准库提供并隐式声明了定位放置new()运算符的两种重载版本。它们不会分配内存(动态变量生命周期的第一阶段),取而代之的是接收一个额外的参数,这个参数是一个指向程序所分配的内存的指针。两种重载版本如下。

1
void* ::operator new(size_t, void*)

这是用于单个变量的定位放置new()运算符。它接收一个指向内存的指针作为它的第二个参数,并简单地返回该指针。

1
void* ::operator new[](size_t, void*)

这是数组版本的定位放置new()运算符。它接收一个指向内存的指针作为它的第二个参数并返回该指针。

这两个定位放置new()运算符的重载会被定位放置new表达式new(p)类型调用,其中p是指向有效存储空间的指针。根据 C++ 标准,这些重载是不能被替换为开发人员自己的代码的。

delete表达式会调用delete()运算符,将分配给动态变量的内存返回给运行时系统,调用delete[]()运算符将分配给动态数组的内存返回给运行时系统。

new运算符和delete运算符共同工作,分配和释放内存。如果一个程序定义了new()运算符来从一个特殊的内存池中或是以一种特别的方式分配内存,它也必须在相同的作用域内相应地定义一个delete()运算符,将所分配的内存返回给内存池,否则delete()运算符的行为就是未定义的。

为了确保与 C 程序的兼容性, C++ 提供了 C 语言库函数malloc()calloc()realloc()来分配内存,以及free()函数来返回不再需要的内存。

  • void* malloc(size_t size)实现了一个动态变量生命周期的分配阶段,它会返回一个指向可以存储size字节大小的存储空间的指针,如果没有可用存储空间则会返回nullptr
  • void free(void* p)实现了一个动态变量生命周期的释放阶段,它会将p所指向的存储空间返回给内存管理器。
  • void* calloc(size_t count, size_t size)实现了一个动态数组生命周期的分配阶段。它会执行一个简单的计算来算出含有count个大小为size的元素的数组的字节长度,并使用这个值调用malloc()
  • void* realloc(void* p, size_t size)可以改变一块内存的大小,如果有需要会将内存块移动到一个新的存储空间中去。旧的内存块中的内容将会被复制到新的存储块中,被复制的内容的大小是新旧两块内存块大小中的较小值。必须谨慎使用realloc()。有时它会移动参数所指向的内存块并删除旧的内存块。如果它这么做了,指向旧内存块的指针将变为无效。有时它会重用现有的内存块,而这个内存块可能会比所请求的大小大。

根据 C++ 标准,malloc()free()作用于一块称为“堆”(heap)的内存区域上,而new()运算符和delete()运算符的重载版本则作用于称为“自由存储区”(free store)的内存区域上。C++ 标准中这种严谨的定义能够让库开发人员实现两套不同的函数。也就是说,在 C 和 C++ 中内存管理的需求是相似的。只是对于一个编译器来说,有两套并行但不同的实现是不合理的。在我所知道的所有标准库实现中,new()运算符都会调用malloc()来进行实际的内存分配。通过替换malloc()free()函数,一个程序能够全局地改变管理内存的方式。

new表达式构造动态变量

C++ 程序使用new表达式请求创建一个动态变量或是动态数组。new表达式包含关键字new,紧接着是一个类型,一个指向new表达式返回的地址的指针。new表达式还有一个用于初始化变量值或是每个数组元素的初始化列表。new表达式会返回一个指向被完全初始化的 C++ 变量或数组的有类型指针,而不是指向 C++new()运算符或是 C 语言中内存管理函数返回的未初始化的存储空间的简单空指针。new 表达式返回一个指向动态变量或是动态数组的第一个元素的右值指针。

如果 placement-params 中包含有关键字std::nothrow,那么new表达式不会抛出std::bad_alloc。它不会尝试构造对象,而是直接返回nullptr

通常认为异常处理会降低效率,因此不抛出异常的 new 表达式应该会更快。不过,现代C++ 编译器实现的异常处理仅在异常被抛出后才会发生非常小的运行时开销,因此这条常理的真相可能取决于编译器。

如果 placement-params 是一个指向已经存在的有效存储空间的指针,那么 new 表达式不会调用内存管理器,而只是简单地将 type 放置在指针所指向的内存地址,而且这块内存必须能够容下 type。定位放置 new 表达式的用法如下:

1
2
3
char mem[1000];
class Foo {...};
Foo* foo_p = new (mem) Foo(123);

在这段示例代码中,Foo类的一个实例被放置在了数组mem的顶部。定位放置new表达式调用类的构造函数对类的实例进行初始化。对于基本类型,定位放置new表达式会执行初始化,而不是调用构造函数。

由于定位放置new表达式并不分配存储空间,因此它没有相应的定位放置delete表达式。当mem超出作用域后,被定位放置new表达式放置在mem顶部的Foo的实例不会被自动地销毁。开发人员需要显式地调用类的析构函数来销毁定位放置new表达式创建的实例。事实上,如果Foo的实例被放在了为Bar的实例所分配的存储空间中,那么Bar的析构函数将会被调用,这会带来未定义的灾难性的结果。因此,必须在new()运算符返回的内存或是char或其他基本数据类型的数组占用的内存上使用定位放置new表达式。

new表达式在要创建的类型范围中查找new()运算符。因此,一个类能够通过提供这些运算的实现来精准地掌握对它自己的内存分配。如果在类中没有定义类专用new()运算符,那么全局new()运算符将会被使用。要想使用全局new()运算符替代类专用new()运算符,程序员需要如下这样在new表达式中指定全局作用域运算符::

1
Foo* foo_p = ::new Foo(123);

只有为定义了这种运算符的类实例分配存储空间时,类专用new()运算符才会被调用。当在类的成员函数中用new表达式生成其他类的实例时,如果有为其他类定义的new()运算符,那么就会使用该运算符,否则就会调用默认的全局new()运算符。

类专用new()运算符是高效的,因为它为大小固定的对象分配内存。因此,第一个未使用的内存块总是可用的。如果类没有被用在多线程中,那么类专用new()运算符就可以免去确保类的内部数据结构是线程安全的这项开销。

类专用new()运算符需要定义为类的静态成员函数。这是有原因的,因为new()运算符会为每个实例分配存储空间。如果一个类实现了自定义定位放置new()运算符,那么它必须实现相应的delete()运算符,否则全局delete()运算符就会被调用。

高性能内存管理器

默认情况下,所有申请存储空间的请求都会经过::operator new(),释放存储空间的请求都会经过::operator delete()。这些函数形成了 C++ 的默认内存管理器。默认的 C++ 内存管理器必须满足许多需求。

  • 它必须足够高效,因此它非常有可能成为热点代码。
  • 它必须能够在多线程程序中正常工作。访问默认内存管理器中的数据结构必须被序列化。
  • 它必须能够高效地分配许多相同大小的对象(例如链表节点)。
  • 它必须能够高效地分配许多不同大小的对象(例如字符串)。
  • 它必须既能够分配非常大的数据结构(I/O 缓冲区,含有数百万个整数值的数组), 也能够分配非常小的数据结构(例如一个指针)。
  • 为了使性能最大化,它必须至少知道较大内存块的指针的对齐边界、缓存行和虚拟内存页。
  • 它的运行时性能不能随着时间而降低。
  • 它必须能够高效地复用返回给它的内存。

提供类专用内存管理器

即使是最先进的malloc()也是对创建优化机会的妥协。我们还能够在类级别重写new()运算符。当动态创建类实例的代码被确定为热点代码时,通过提供类专用内存管理器能够改善程序性能。

如果一个类实现了new()运算符,那么当为该类申请内存时就不会调用全局new()运算符,而是调用这个new()运算符。相比于默认版本的new()运算符,我们可以利用对对象的了解在类专用内存管理器中编写更多有利于提升性能的处理。所有为某个类的实例申请分配内存的请求都会申请相同的字节大小。编写高效地处理分配相同大小内存的请求的内存管理器是很容易的,原因如下。

  • 分配固定大小内存的内存管理器能够高效地复用被返回的内存。它们不必担心碎片,因为所有的请求都申请相同大小的内存。
  • 能够以很少甚至零内存间接开销的方式实现分配固定大小内存的内存管理器。
  • 分配固定大小内存的内存管理器能够确保所消耗内存总量的上限。
  • 在分配固定大小内存的内存管理器中,分配和释放内存的函数都非常简单,因此它们会被高效地内联,而默认 C++ 内存分配器中的函数则无法被内联。它们必须是函数调用,这样才能够被开发人员定义的重写版本所替代。出于同样的原因, C 语言中的内存管理函数malloc()free()也必须都是普通函数。
  • 分配固定大小内存的内存管理器具有优秀的高速缓存行为。最后一个被释放的节点可以是下一个被分配的节点。

分配固定大小内存的内存管理器

代码定义了一个简单的分配固定大小内存块的内存管理器,它会从一个名为“分配区”(arena)的单独的、静态声明的存储空间块中分配内存块。作为从自由存储区分配内存的一种方式。

fixed_block_memory_manager非常简单:一个单独的未使用内存块的链表。这种简单的设计将会在本章中多处被用到,因此我们来详细地看看它。代码分配固定大小内存块的内存管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <class Arena> struct fixed_block_memory_manager {
template <int N>
fixed_block_memory_manager(char(&a)[N]);
fixed_block_memory_manager(fixed_block_memory_manager&) = delete;
~fixed_block_memory_manager() = default;
void operator=(fixed_block_memory_manager&) = delete;
void* allocate(size_t);
size_t block_size() const;
size_t capacity() const;
void clear();
void deallocate(void*);
bool empty() const;
private:
struct free_block {
free_block* next;
};
free_block* free_ptr_;
size_t block_size_;
Arena arena_;
};
# include "block_mgr.inl"

在代码中定义的构造函数接收一个 C 风格的字符数组作为它的参数。这个数组形成了分配内存块的分配区。它的构造函数是一个以数组大小作为模板参数的模板函数。

1
2
3
4
5
6
7
template <class Arena>
template <int N>
inline fixed_block_memory_manager<Arena>
::fixed_block_memory_manager(char(&a)[N]) :
arena_(a), free_ptr_(nullptr), block_size_(0) {
/* empty */
}

当函数定义出现在模板类外部时,我们需要使用一种更冗长的语法来帮助编译器连接函数定义与模板类体中的声明。在上一个例子中,第一行template <class Arena>声明了类的模板参数。第二行template <int N>适用于构造函数自身,它是一个模板函数。当成员函数定义出现在模板类体的外部时,必须明确写明关键字 inline,否则只有当函数定义出现在类的内部时才会进行内联。

代码中的成员函数allocate()会在有可用内存块时,将一个内存块弹出未使用内存块的链表并返回它。如果未使用内存块的链表是空的,allocate()会试图从分配区管理器中获得一个新的未使用内存块的链表,我会在后面讲解这一点。如果分配区管理器没有可分配的内存,它会返回nullptr,而allocate()则会抛出std::bad_alloc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <class Arena>
inline void* fixed_block_memory_manager<Arena>
::allocate(size_t size) {
if (empty()) {
free_ptr_ = reinterpret_cast<free_block*>
(arena_.allocate(size));
block_size_ = size;
if (empty())
throw std::bad_alloc();
}
if (size != block_size_)
throw std::bad_alloc();
auto p = free_ptr_;
free_ptr_ = free_ptr_->next;
return p;
}

deallocate()成员函数非常简单。它会将一个内存块推入到未使用内存块的链表中:

1
2
3
4
5
6
7
8
9
template <class Arena>
inline void fixed_block_memory_manager<Arena>
::deallocate(void* p) {
if (p == nullptr)
return;
auto fp = reinterpret_cast<free_block*>(p);
fp->next = free_ptr_;
free_ptr_ = fp;
}

下面是其他成员函数的定义。我们使用 C++11 语法在类定义中禁用了内存管理器的复制和赋值。

1
2
3
4
5
6
7
8
9
10
template <class Arena>
inline size_t fixed_block_memory_manager<Arena>
::capacity() const {
return arena_.capacity();
}
template <class Arena>
inline void fixed_block_memory_manager<Arena>::clear() {
free_ptr_ = nullptr;
arena_.clear();
}

内存块分配区

fixed_block_memory_manager中唯一的复杂点在于未使用内存块的链表是如何被初始化的。这种复杂性被考虑在单独的模板类内部。这里要展示的实现方式称为fixed_arena_controller。正如这里所用到的一样,arena表示一个发生某些活动的封闭空间。block_arena是一个能够被block_manager分配的固定大小的内存池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct fixed_arena_controller {
template <int N>
fixed_arena_controller(char(&a)[N]);

fixed_arena_controller(fixed_arena_controller&) = delete;
~fixed_arena_controller() = default;
void operator=(fixed_arena_controller&) = delete;
void* allocate(size_t);
size_t block_size() const;
size_t capacity() const;
void clear();
bool empty() const;
private:
void* arena_;
size_t arena_size_;
size_t block_size_;
};

fixed_arena_controller类的目的是创建一个内存块链表,其中所有的内存块的大小都是相同的。这个大小是在第一次调用allocate()时设置的。链表中的每个内存块都必须足够大,能够满足请求的字节数,同时还必须能够存储一个指针,当该内存块在未使用内存块的链表中时这个指针会被使用。

构造函数模板函数接收来自fixed_block_memory_manager的分配区数组,保存数组大小和一个指向数组起始位置的指针:

1
2
3
4
5
template <int N>
inline fixed_arena_controller
::fixed_arena_controller(char (&a)[N]) :
arena_(a), arena_size_(N), block_size_(0) { /*空*/
}

allocate()成员函数是发生分配操作的地方。当未使用内存块的链表为空时,它会被fixed_block_memory_manager的成员函数allocate()调用,这会在第一次分配请求到来时发生。

fixed_arena_controller有一个内存块可分配。如果这个内存块已经被使用了,allocate()会再次被调用并且必须返回一个错误提示。在这种情况下错误提示是nullptr。其他种类的分配区控制器可能会通过调用::operator new()等将它们所得到的大内存块分解为小块。对于其他分配区控制器,多次调用allocate()是没有问题的。

allocate()初次被调用时,它会设置内存块大小和容量。实际创建未使用内存块的链表是将未类型化的内存字节重新解释为类型化指针的过程。字符数组被解释为一组端到端的内存块。每个内存块的第一个字节都是一个指向下一个内存块的指针。最后一个内存块的指针是nullptrfixed_arena_controller无法控制分配区数组的大小。可能在尾部会有数个未使用的字节永远不会被分配。设置未使用内存块指针的代码并不优雅。它需要继续将一种指针重新解释为另外一种指针,退出 C++ 类型系统,进入到实现定义(implementation-defined)行为的“国度”。不过,这是内存管理器都存在的不可避免的问题。

fixed_arena_controller中的分配和释放代码很简单:在提供给构造函数的存储空间上分配未使用节点的链表,返回一个指向链表第一个元素的指针。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
inline void* fixed_arena_controller ::allocate(size_t size) {
if (!empty())
return nullptr; // arena已经被分配了
block_size_ = std::max(size, sizeof(void*));
size_t count = capacity();
if (count == 0)
return nullptr; // arena太小了,甚至容不下一个元素
char* p;
for (p = (char*)arena_; count > 1; --count, p += size) {
*reinterpret_cast<char**>(p) = p + size;
}
*reinterpret_cast<char**>(p) = nullptr;
return arena_;
}

下面是fixed_arena_controller的其他部分:

1
2
3
4
5
6
7
8
9
10
11
12
inline size_t fixed_arena_controller::block_size() const {
return block_size_;
}
inline size_t fixed_arena_controller::capacity() const {
return block_size_ ? (arena_size_ / block_size_) : 0;
}
inline void fixed_arena_controller::clear() {
block_size_ = 0;
}
inline bool fixed_arena_controller::empty() const {
return block_size_ == 0;
}

添加一个类专用new()运算符

代码是一个具有类专用new()运算符和delete()运算符的非常简单的类。其中还有一个静态成员变量mgr_new()运算符和delete()运算符都是内联函数,它们会将请求转发给mgr_的成员函数allocate()deallocate()

1
2
3
4
5
6
7
8
9
10
11
12
class MemMgrTester {
int contents_;
public:
MemMgrTester(int c) : contents_(c) {}
static void* operator new(size_t s) {
return mgr_.allocate(s);
}
static void operator delete(void* p) {
mgr_.deallocate(p);
}
static fixed_block_memory_manager<fixed_arena_controller> mgr_;
};

mgr_被声明为public,这样我能够通过调用mrg_.clear()重新初始化未使用内存块的链表,以方便编写性能测试。如果mgr_只在程序启动时被初始化一次,之后永远无需重新初始化,那么最好将其声明为private成员变量。

能够像这样被重置的内存管理器被称作内存池管理器(pool memory manager), 它所控制的分配区则被称为内存池(memory pool)。内存池管理器非常适用于数据结构被构造、使用然后被销毁的情况。如果能够快速地重新初始化整个内存池,那么程序就能够避免逐节点地释放数据结构。

mgr_BlockTester类的一个静态成员变量。在程序中的某个地方,也必须如代码这样定义静态成员。这段代码定义了一个内存分配区以及mgr_mgr_的构造函数接收这个分配区作为参数。

1
2
3
char arena[4004];
fixed_block_memory_manager<fixed_arena_controller>
MemMgrTester::mgr_(arena);

这段代码没有定义类专用new[]()运算符来分配数组的存储空间。分配固定大小内存块的内存管理器无法工作于根据定义可能会有不同数量元素的数组之上。如果程序试图分配一个MemMgrTester数组,那么new表达式会使用全局new[]()运算符,因为没有定义类专用的运算符。也就是说,程序会使用分配固定大小内存块的内存管理器来为单独的类实例分配存储空间,使用malloc()为数组分配存储空间。

自定义标准库分配器

std::list<T>中动态分配的变量不是用户提供的类型T。它们是像listitem<T>这样的无形类型,不但包含有效载荷类型T,还包含指向前向节点和后向节点的指针。在std::map<K,V>中动态分配的变量是另外一种像treenode<std::pair<const K, V>>这样的隐藏类型。这些模板类藏在编译器提供的头文件中。我们无法修改这些类,在其中加入类专用new()运算符和delete()运算符。

标准库容器可以接收一个Allocator参数,它具有与类专用new()运算符相同的自定义内存管理器的能力。Allocator是一个管理内存的模板类。作为被扩展的基础,一个分配器会做三件事情:从内存管理器中获取存储空间,返回存储空间给内存管理器,以及从相关联的分配器中复制构造出它自己。

分配器的实现可以非常简单,也可以复杂到让人头脑发麻。默认分配器std::allocator<T>::operator new()的一个简单的包装器。分配器有两种基本类型。最简单的分配器是无状态的,也就是说一种没有非静态状态的分配器类型。默认分配器std::allocator<T>对于标准库容器是无状态的。

  • 无状态分配器能够被默认构造,无需显式地创建一个无状态分配器的实例,然后将它传递给容器类的构造函数。语句std::list<myClass, myAlloc> my_list;会构造一个由无状态分配器myAlloc分配的myClass的实例所组成的链表。
  • 一个无状态分配器不会在容器实例中占用任何空间。大多数标准库容器类都继承自它们的分配器,会利用空基类的优化生成一个零字节的基类。

无状态分配器my_allocator<T>的两个实例是难以区分的。这意味着一个无状态分配器分配的对象能够被另外一个分配器释放。这使得像std::listsplice()成员函数的操作变为可能。像AllocX<T>AllocX<U>这样的不同类型的两个无状态分配器有时会相等,但并非总是相等。确实是这样的,std::allocator就是一个例子。

相等还意味着可以高效地进行移动赋值和std::swap()操作。如果两个分配器不等,那么必须使用目标容器类的分配器来深复制原来容器中的内容。请注意,尽管像AllocX<T>AllocX<U>这样两个完全无关的分配器类型的实例可能会碰巧相等,但是这种特性是没有价值的。容器的类型包括分配器的类型。你无法将一个std::list<T,AllocX>拼接到std::list<T,AllocY>上,就像你不能将std::list<int>拼接到std::list<string>上一样。

创建和使用带有内部状态的分配器更加复杂,原因如下。

  • 在大多数情况下,一个带有局部状态的分配器是无法被默认构造出来的。这个分配器必须被手动地构造出来,然后传递给容器的构造函数。
1
2
3
char arena[10000];
MyAlloc<Foo> alloc(arena);
std::list<Foo, MyAlloc<Foo>> foolist(alloc);
  • 分配器的状态必须被存储在所有变量中,这会增大它们的大小。这对于像std::liststd::map这样的创建许多节点的容器是非常痛苦的,但也是容器编程人员最希望自定义的。
  • 两个相同类型的分配器在进行比较时可能会不相等,因为它们具有不同的内部状态,使得使用该分配器类型在容器上进行的某些操作变为不可用或是非常低效。

不过带状态的分配器具有一个很重要的优点,那就是当所有的分配请求无需通过一个单独的全局内存管理器时,为多种不同用途创建多种类型的内存分配区也更容易了。

最小C++11分配器

如果开发人员足够幸运,有一个完全符合 C++11 标准的编译器和标准库,那么他就可以提供一个只需要极少定义的最小分配器。代码展示了一个类似于std::allocator的分配器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <typename T> struct my_allocator {
using value_type = T;
my_allocator() = default;

template <class U> my_allocator(const my_allocator<U>&) {}
T* allocate(std::size_t n, void const* = 0) {
return reinterpret_cast<T*>(::operator new(n*sizeof(T)));
}
void deallocate(T* ptr, size_t) {
::operator delete(ptr);
}
};
template <typename T, typename U>
inline bool operator==(const my_allocator<T>&, const my_allocator<U>&) {
return true;
}
template <typename T, typename U>
inline bool operator!=(const my_allocator<T>& a, const my_allocator<U>& b) {
return !(a == b);
}

在该最小分配器中只有以下这些函数。

  • allocator():这是默认构造函数。如果分配器有一个默认构造函数,开发人员就无需显式地创建一个实例,然后将其传递给容器的构造函数。在无状态分配器的构造函数中,默认构造函数通常都是空函数,在具有非静态状态的分配器中则通常不存在默认构造函数。
  • template <typename U> allocator(U&):这个复制构造函数使得将一个allocator<T>转换为如allocator<treenode<T>>这样的一个私有类的关联分配器成为可能。这非常重要,因为在大多数容器中,类型T的节点都不会被分配。

在无状态分配器中,复制构造函数通常都是空函数,但在具有非静态状态的分配器中,复制构造函数中则必须复制或克隆状态。

1
T* allocate(size_type n, const void* hint = 0)

该函数允许分配器分配足够存储 n 字节的存储空间,并返回一个指向这块存储空间的指针,或是在没有足够的内存空间时抛出std::bad_alloc

1
void deallocate(T* p, size_t n)

该函数用于将之前allocate()分配的指针p所指向的占用n字节的存储空间返回给内存管理器。n必须与调用allocate()时的参数相等,p则指向allocate()所分配的存储空间。

1
2
bool operator==(allocator const& a) const
bool operator!=(allocator const& a) const

这一对函数用于比较两个相同类型的分配器的实例是否相等。如果两个实例的比较结果是相等,那么由一个实例分配的对象就可以安全地被另外一个实例释放。这意味着两个实例从相同的存储区域中分配对象。

C++98分配器的其他定义

在 C++11 之前,每个分配器包含了最小分配器中的所有函数,另外再加上以下这些。

  • value_type:待分配的对象的类型。
  • size_type:一个足以保存这个分配器能够分配的最大字节数的整数类型。对于用作标准库容器模板的参数的分配器,这个定义需要定义别名typedef size_t size_type;
  • difference_type:一个足以保存两个指针之间的最大差值的整数类型。对于用作标准库容器模板的参数的分配器,这个定义需要定义别名typedef ptrdiff_t difference_type;

  • pointer/const_pointer:一个指向(const) T的指针类型。对于用作标准库容器模板的参数的分配器,这个定义需要定义别名:

1
2
typedef T* pointer;
typedef T const* const_pointer;

对于其他分配器,指针可能会是一个实现了用于解引指针的operator*()的类指针类。

  • reference/const_reference:一个指向(const) T的引用类型。对于用作标准库容器模板的参数的分配器,这个定义需要定义别名:
1
2
typedef T& reference;
typedef T const& const_reference;
  • pointer address(reference)/const_pointer address(const_reference):分别是用于返回一个指向(const) T的指针的函数和返回一个指向(const) T的引用的函数。

对于用作标准库容器模板的参数的分配器,这两个函数需要定义为:

1
2
pointer address(reference r) { return &r; }
const_pointer address(const_reference r) { return &r; }

这些函数原本是用于抽象内存模型的。不幸的是,它们需要与标准库容器兼容,这要求pointer必须是T*,因此线性随机访问迭代器和二分查找能够高效地进行工作。尽管这些定义对于标准库容器的分配器有固定值,但是定义还是需要的,因为 C++98 中的容器代码使用了它们。例如:

1
typedef size_type allocator::size_type;

C++要求我们要用特定的类型来声明变量,函数以及其他一些内容。这样很多代码可能就只是处理的变量类型有所不同。比如对不同的数据类型,quicksort的算法实现在结构上可能完全一样,不管是对整形的array,还是字符串类型的vector,只要他们所包含的内容之间可以相互比较。如果你所使用的语言不支持这一泛型特性,你将可能只有如下糟糕的选择:

  1. 你可以对不同的类型一遍又一遍的实现相同的算法。
  2. 你可以在某一个公共基类(common base type,比如Object和void*)里面实现通用的算法代码。
  3. 你也可以使用特殊的预处理方法。

如果你是从其它语言转向C++,你可能已经使用过以上几种或全部的方法了。然而他们都各有各的缺点:

  1. 如果你一遍又一遍地实现相同算法,你就是在重复地制造轮子!
  2. 如果在公共基类里实现统一的代码,就等于放弃了类型检查的好处。而且,有时候某些类必须要从某些特殊的基类派生出来,这会进一步增加维护代码的复杂度。
  3. 如果采用预处理的方式,你需要实现一些“愚蠢的文本替换机制”,这将很难兼顾作用域和类型检查,因此也就更容易引发奇怪的语义错误。

而模板这一方案就不会有这些问题。模板是为了一种或者多种未明确定义的类型而定义的函数或者类。在使用模板时,需要显式地或者隐式地指定模板参数。由于模板是C++的语言特性,类型和作用域检查将依然得到支持。

函数模板(Function Templates)

本章将介绍函数模板。函数模板是被参数化的函数,因此他们代表的是一组具有相似行为的函数。

函数模板初探

函数模板提供了适用于不同数据类型的函数行为。也就是说,函数模板代表的是一组函数。除了某些信息未被明确指定之外,他们看起来很像普通函数。这些未被指定的信息就是被参数化的信息。我们将通过下面一个简单的例子来说明这一问题。

定义模板

以下就是一个函数模板,它返回两个数之中的最大值:

1
2
3
4
5
6
template<typename T>
T max (T a, T b)
{
//如果b < a,返回a,否则返回b
return b < a ? a : b;
}

这个模板定义了一组函数,它们都返回函数的两个参数中值较大的那一个。这两个参数的类型并没有被明确指定,而是被表示为模板参数T。如你所见,模板参数必须按照如下语法声明:

1
template<由逗号分割的模板参数>

在我们的例子中,模板参数是typename T。请留意<>的使用,它们在这里被称为尖括号。关键字typename标识了一个类型参数。这是到目前为止C++中模板参数最典型的用法,当然也有其他参数(非类型模板参数)。

在这里T是类型参数。你可以用任意标识作为类型参数名,但是习惯上是用T。类型参数可以代表任意类型,它在模板被调用的时候决定。但是该类型(可以是基础类型,类或者其它类型)应该支持模板中用到的运算符。

由于历史原因,除了typename之外你还可以使用class来定义类型参数。关键字typename在C++98标准发展过程中引入的较晚。在那之前,关键字class是唯一可以用来定义类型参数的方法,而且目前这一方法依然有效。因此模板max()也可以被定义成如下等效的方式:

1
2
3
4
5
template<class T>
T max (T a, T b)
{
return b < a ? a : b;
}

从语义上来讲,这样写不会有任何不同。因此,在这里你依然可以使用任意类型作为类型参数。只是用class的话可能会引起一些歧义(T并不是只能是class类型),你应该优先使用typename。但是与定义class的情况不同,在声明模板类型参数的时候,不可以用关键字struct
取代typename

使用模板

下面的程序展示了使用模板的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "max1.hpp"
#include <iostream>
#include <string>
int main()
{
int i = 42;
std::cout << "max(7,i): " << ::max(7,i) << "\n";
double f1 = 3.4;
double f2 = -6.7;
std::cout << "max(f1,f2): " << ::max(f1,f2) << "\n";
std::string s1 = "mathematics";
std::string s2 = "math";
std::cout << "max(s1,s2): " << ::max(s1,s2) << "\n";
}

在这段代码中,max()被调用了三次:一次是比较两个int,一次是比较两个double,还有一次是比较两个std::string。每一次都会算出最大值。下面是输出结果:

1
2
3
max(7,i): 42
max(f1,f2): 3.4
max(s1,s2): mathematics

注意在调用max()模板的时候使用了作用域限制符::。这样程序将会在全局作用域中查找max()模板。否则的话,在某些情况下标准库中的std::max()模板将会被调用,或者有时候不太容易确定具体哪一个模板会被调用。

在编译阶段,模板并不是被编译成一个可以支持多种类型的实体。而是对每一个用于该模板的类型都会产生一个独立的实体。因此在本例中,max()会被编译出三个实体,因为它被用于三种类型。比如第一次调用时:

1
2
3
4
int i = 42;
... ma
x(7,i)
...

函数模板的类型参数是int。因此语义上等效于调用了如下函数:

1
2
3
4
int max (int a, int b)
{
return b < a ? a : b;
}

以上用具体类型取代模板类型参数的过程叫做“实例化”。它会产生模板的一个实例。值得注意的是,模板的实例化不需要程序员做额外的请求,只是简单的使用函数模板就会触发这一实例化过程。

同样的,另外两次调用也会分别为doublestd::string各实例化出一个实例,就像是分别定义了下面两个函数一样:

1
2
double max (double, double);
std::string max (std::string, std::string);

另外,只要结果是有意义的,void作为模板参数也是有效的。比如:

1
2
3
4
5
6
template<typename T>
T foo(T*)
{ }
void* vp = nullptr;
foo(vp); // OK:模板参数被推断为void
foo(void*)

两阶段编译检查(Two-Phase Translation )

在实例化模板的时候,如果模板参数类型不支持所有模板中用到的操作符,将会遇到编译期错误。比如:

1
2
3
std::complex<float> c1, c2; // std::complex<>没有提供小于运算符
... ::
max(c1,c2); //编译期ERROR

但是在定义的地方并没有遇到错误提示。这是因为模板是被分两步编译的:

  • 在模板定义阶段,模板的检查并不包含类型参数的检查。只包含下面几个方面:
    • 语法检查。比如少了分号。
    • 使用了未定义的不依赖于模板参数的名称(类型名,函数名,……)。
    • 未使用模板参数的static assertions。
  • 在模板实例化阶段,为确保所有代码都是有效的,模板会再次被检查,尤其是那些依赖于类型参数的部分。

比如:

1
2
3
4
5
6
7
8
template<typename T>
void foo(T t)
{
undeclared(); //如果undeclared()未定义,第一阶段就会报错,因为与模板参数无关
undeclared(t); //如果undeclared(t)未定义,第二阶段会报错,因为与模板参数有关
static_assert(sizeof(int) > 10,"int too small"); //与模板参数无关,总是报错
static_assert(sizeof(T) > 10, "T too small"); //与模板参数有关,只会在第二阶段报错
}

名称被检查两次这一现象被称为“两阶段查找”。需要注意的是,有些编译器并不会执行第一阶段中的所有检查。因此如果模板没有被至少实例化一次的话,你可能一直都不会发现代码中的常规错误。

编译和链接

两阶段的编译检查给模板的处理带来了一个问题:当实例化一个模板的时候,编译器需要(一定程度上)看到模板的完整定义。这不同于函数编译和链接分离的思想,函数在编译阶段只需要声明就够了。

模板参数推断

当我们调用形如max()的函数模板来处理某些变量时,模板参数将由被传递的调用参数决定。如果我们传递两个int类型的参数给模板函数,C++编译器会将模板参数T推断为int。不过T可能只是实际传递的函数参数类型的一部分。比如我们定义了如下接受常量引用作为函数参数的模板:

1
2
3
4
5
template<typename T>
T max (T const& a, T const& b)
{
return b < a ? a : b;
}

此时如果我们传递int类型的调用参数,由于调用参数和int const &匹配,类型参数T将被推断为int。

类型推断中的类型转换

在类型推断的时候自动的类型转换是受限制的:

  • 如果调用参数是按引用传递的,任何类型转换都不被允许。通过模板类型参数T定义的两个参数,它们实参的类型必须完全一样。
  • 如果调用参数是按值传递的,那么只有退化(decay)这一类简单转换是被允许的: const和volatile限制符会被忽略,引用被转换成被引用的类型,raw array和函数被转换为相应的指针类型。
  • 通过模板类型参数T定义的两个参数,它们实参的类型在退化(decay)后必须一样。

例如:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
T max (T a, T b);
... in
T const c = 42;
int i = 1; //原书缺少i的定义
max(i, c); // OK: T被推断为int,c中的const被decay掉
max(c, c); // OK: T被推断为int
int& ir = i;
max(i, ir); // OK: T被推断为int,ir中的引用被decay掉
int arr[4];
foo(&i, arr); // OK: T被推断为int*

但是像下面这样是错误的:

1
2
3
max(4, 7.2); // ERROR:不确定T该被推断为int还是double
std::string s;
foo("hello", s); //ERROR:不确定T该被推断为const[6]还是std::string

有两种办法解决以上错误:

  • 对参数做类型转换
1
max(static_cast<double>(4), 7.2); // OK
  • 显式地指出类型参数T的类型,这样编译器就不再会去做类型推导。
1
max<double>(4, 7.2); // OK
  • 指明调用参数可能有不同的类型(多个模板参数)。

对默认调用参数的类型推断

需要注意的是,类型推断并不适用于默认调用参数。例如:

1
2
3
4
5
template<typename T>
void f(T = "");
...
f(1); // OK: T被推断为int,调用f<int> (1)
f(); // ERROR:无法推断T的类型

为应对这一情况,你需要给模板类型参数也声明一个默认参数:

1
2
3
4
template<typename T = std::string>
void f(T = "");
... f(
); // OK

多个模板参数

目前我们看到了与函数模板相关的两组参数:

  1. 模板参数,定义在函数模板前面的尖括号里:
  2. 调用参数,定义在函数模板名称后面的圆括号里:
1
2
template<typename T> // T是模板参数
T max (T a, T b) // a和b是调用参数

模板参数可以是一个或者多个。比如,你可以定义这样一个max()模板,它可能接受两个不同类型的调用参数:

1
2
3
4
5
6
7
template<typename T1, typename T2>
T1 max (T1 a, T2 b)
{
return b < a ? a : b;
}

m = ::max(4, 7.2); // OK,但是返回类型是第一个模板参数T1的类型

如果你使用其中一个类型参数的类型作为返回类型,当应该返回另一个类型的值的时候,返回值会被做类型转换。这将导致返回值的具体类型和参数的传递顺序有关。C++提供了多种应对这一问题的方法:

  1. 引入第三个模板参数作为返回类型。
  2. 让编译器找出返回类型。
  3. 将返回类型定义为两个参数类型的“公共类型”

下面将逐一进行讨论。

作为返回类型的模板参数

我们可以不去显式的指出模板参数的类型。但是也提到,我们也可以显式的指出模板参数的类型:

1
2
3
4
template<typename T>
T max (T a, T b);
... ::
max<double>(4, 7.2); // max()被针对double实例化

当模板参数和调用参数之间没有必然的联系,且模板参数不能确定的时候,就要显式的指明模板参数。比如你可以引入第三个模板来指定函数模板的返回类型:

1
2
template<typename T1, typename T2, typename RT>
RT max (T1 a, T2 b);

但是模板类型推断不会考虑返回类型,而RT又没有被用作调用参数的类型。因此RT不会被推断。这样就必须显式的指明模板参数的类型。比如:

1
2
3
4
template<typename T1, typename T2, typename RT>
RT max (T1 a, T2 b);
... ::
max<int,double,double>(4, 7.2); // OK,但是太繁琐

另一种办法是只指定第一个模板参数的类型,其余参数的类型通过推断获得。通常而言,我们必须显式指定所有模板参数的类型,直到某一个模板参数的类型可以被推断出来为止。因此,如果你改变了上面例子中的模板参数顺序,调用时只需要指定返回值的类型就可以了:

1
2
3
4
template<typename RT, typename T1, typename T2>
RT max (T1 a, T2 b);
... ::
max<double>(4, 7.2) //OK:返回类型是double,T1和T2根据调用参数推断

在本例中,调用max<double>时,显式的指明了RT的类型是doubleT1T2则基于传入调用参数的类型被推断为intdouble。然而改进版的max()并没有带来显著的变化。使用单模板参数的版本,即使传入的两个调用参数的类型不同,你依然可以显式的指定模板参数类型(也作为返回类型)

返回类型推断

如果返回类型是由模板参数决定的,那么推断返回类型最简单也是最好的办法就是让编译器来做这件事。从C++14开始,这成为可能,而且不需要把返回类型声明为任何模板参数类型(不过你需要声明返回类型为auto):

1
2
3
4
5
template<typename T1, typename T2>
auto max (T1 a, T2 b)
{
return b < a ? a : b;
}

事实上,在不使用尾置返回类型(trailing return type)的情况下将auto用于返回类型,要求返回类型必须能够通过函数体中的返回语句推断出来。当然,这首先要求返回类型能够从函数体中推断出来。因此,必须要有这样可以用来推断返回类型的返回语句,而且多个返回语句之间的推断结果必须一致。

在C++14之前,要想让编译器推断出返回类型,就必须让或多或少的函数实现成为函数声明的一部分。在C++11中,尾置返回类型(trailing return type)允许我们使用函数的调用参数。也就是说,我们可以基于运算符?:的结果声明返回类型:

1
2
3
4
5
template<typename T1, typename T2>
auto max (T1 a, T2 b) -> decltype(b<a?a:b)
{
return b < a ? a : b;
}

在这里,返回类型是由运算符?:的结果决定的,这虽然复杂但是可以得到想要的结果。需要注意的是

1
2
template<typename T1, typename T2>
auto max (T1 a, T2 b) -> decltype(b<a?a:b);

是一个声明,编译器在编译阶段会根据运算符?:的返回结果来决定实际的返回类型。不过具体的实现可以有所不同,事实上用true作为运算符?:的条件就足够了:

1
2
template<typename T1, typename T2>
auto max (T1 a, T2 b) -> decltype(true?a:b);

但是在某些情况下会有一个严重的问题:由于T可能是引用类型,返回类型就也可能被推断为引用类型。因此你应该返回的是decay后的T,像下面这样:

1
2
3
4
5
6
#include <type_traits>
template<typename T1, typename T2>
auto max (T1 a, T2 b) -> typename std::decay<decltype(true? a:b)>::type
{
return b < a ? a : b;
}

在这里我们用到了类型萃取(type trait)std::decay<>,它返回其type成员作为目标类型,定义在标准库<type_trait>中。由于其type成员是一个类型,为了获取其结果,需要用关键字typename修饰这个表达式。

在这里请注意,在初始化auto变量的时候其类型总是退化之后了的类型。当返回类型是auto的时候也是这样。用auto作为返回结果的效果就像下面这样,a的类型将被推断为i退化后的类型,也就是int:

1
2
3
int i = 42;
int const& ir = i; // ir是i的引用
auto a = ir; // a的类型是it decay之后的类型,也就是int

将返回类型声明为公共类型(Common Type)

从C++11开始,标准库提供了一种指定“更一般类型”的方式。std::common_type<>::type产生的类型是他的两个模板参数的公共类型。比如:

1
2
3
4
5
6
#include <type_traits>
template<typename T1, typename T2>
std::common_type_t<T1,T2> max (T1 a, T2 b)
{
return b < a ? a : b;
}

同样的,std::common_type也是一个类型萃取(type trait),定义在<type_traits>中,它返回一个结构体,结构体的type成员被用作目标类型。因此其主要应用场景如下:

1
typename std::common_type<T1,T2>::type //since C++11

不过从C++14开始,你可以简化“萃取”的用法,只要在后面加个_t,就可以省掉typename::type,简化后的版本变成:

1
std::common_type_t<T1,T2> // equivalent since C++14

std::common_type<>的实现用到了一些比较取巧的模板编程手法。它根据运算符?:的语法规则或者对某些类型的特化来决定目标类型。因此::max(4, 7.2)::max(7.2, 4)都返回double类型的7.2。需要注意的是,std::common_type<>的结果也是退化的。

默认模板参数

这些默认值被称为默认模板参数并且可以用于任意类型的模板。它们甚至可以根据其前面的模板参数来决定自己的类型。比如如果你想将前述定义返回类型的方法和多模板参数一起使用,你可以为返回类型引入一个模板参数RT,并将其默认类型声明为其它两个模板参数的公共类型。同样地,我们也有多种实现方法:

我们可以直接使用运算符?:。不过由于我们必须在调用参数a和b被声明之前使用运算符?:,我们只能像下面这样:

1
2
3
4
5
6
7
#include <type_traits>
template<typename T1, typename T2, typename RT =
std::decay_t<decltype(true ? T1() : T2())>>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}

请注意在这里我们用到了std::decay_t<>来确保返回的值不是引用类型。同样值得注意的是,这一实现方式要求我们能够调用两个模板参数的默认构造参数。还有另一种方法,使用std::declval,不过这将使得声明部分变得更加复杂。

我们也可以利用类型萃取std::common_type<>作为返回类型的默认值:

1
2
3
4
5
6
7
#include <type_traits>
template<typename T1, typename T2, typename RT =
std::common_type_t<T1,T2>>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}

在这里std::common_type<>也是会做类型退化的,因此返回类型不会是引用。

在以上两种情况下,作为调用者,你即可以使用RT的默认值作为返回类型:

1
auto a = ::max(4, 7.2);

也可以显式的指出所有的模板参数的类型:

1
auto b = ::max<double,int,long double>(7.2, 4);

但是,我们再次遇到这样一个问题:为了显式指出返回类型,我们必须显式的指出全部三个模板参数的类型。因此我们希望能够将返回类型作为第一个模板参数,并且依然能够从其它两个模板参数推断出它的类型。

原则上这是可行的,即使后面的模板参数没有默认值,我们依然可以让第一个模板参数有默认值:

1
2
3
4
5
template<typename RT = long, typename T1, typename T2>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}

基于这个定义,你可以这样调用:

1
2
3
4
int i; long l;
... ma
x(i, l); //返回值类型是long (RT的默认值)
max<int>(4, 42); //返回int,因为其被显式指定

函数模板的重载

定义多个有相同函数名的函数,当实际调用的时候,由C++编译器负责决定具体该调用哪一个函数。即使在不考虑模板的时候,这一决策过程也可能异常复杂。下面几行程序展示了函数模板的重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// maximum of two int values:
int max (int a, int b)
{
return b < a ? a : b;
}
// maximum of two values of any type:
template<typename T>
T max (T a, T b)
{
return b < a ? a : b;
}

int main()
{
::max(7, 42); // calls the nontemplate for two ints
::max(7.0, 42.0); // calls max<double> (by argument deduction)
::max("a", "b"); //calls max<char> (by argument deduction)
::max<>(7, 42); // calls max<int> (by argumentdeduction)
::max<double>(7, 42); // calls max<double> (no argumentdeduction)
::max("a", 42.7); //calls the nontemplate for two ints
}

如你所见,一个非模板函数可以和一个与其同名的函数模板共存,并且这个同名的函数模板可以被实例化为与非模板函数具有相同类型的调用参数。在所有其它因素都相同的情况下,模板解析过程将优先选择非模板函数,而不是从模板实例化出来的函数。第一个调用就属于这种情况:

1
::max(7, 42); // both int values match the nontemplate function perfectly

如果模板可以实例化出一个更匹配的函数,那么就会选择这个模板。正如第二和第三次调用max()时那样:

1
2
::max(7.0, 42.0); // calls the max<double> (by argument deduction)
::max("a", "b"); //calls the max<char> (by argument deduction)

在这里模板更匹配一些,因为它不需要从double和char到int的转换。

也可以显式指定一个空的模板列表。这表明它会被解析成一个模板调用,其所有的模板参数会被通过调用参数推断出来:

1
::max<>(7, 42); // calls max<int> (by argument deduction)

由于在模板参数推断时不允许自动类型转换,而常规函数是允许的,因此最后一个调用会选择非模板参函数(‘a’和42.7都被转换成int):

1
::max("a", 42.7); //only the nontemplate function allows nontrivial conversions

一个有趣的例子是我们可以专门为max()实现一个可以显式指定返回值类型的模板:

1
2
3
4
5
6
7
8
9
10
11
template<typename T1, typename T2>
auto max (T1 a, T2 b)
{
return b < a ? a : b;
}

template<typename RT, typename T1, typename T2>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}

现在我们可以像下面这样调用max():

1
2
auto a = ::max(4, 7.2); // uses first template
auto b = ::max<long double>(7.2, 4); // uses second template

但是像下面这样调用的话:

1
auto c = ::max<int>(4, 7.2); // ERROR: both function templates match

两个模板都是匹配的,这会导致模板解析过程不知道该调用哪一个模板,从而导致未知错误。因此当重载函数模板的时候,你要保证对任意一个调用,都只会有一个模板匹配。一个比较有用的例子是为指针和C字符串重载max()模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <cstring>
#include <string>
// maximum of two values of any type:
template<typename T>
T max (T a, T b)
{
return b < a ? a : b;
}
// maximum of two pointers:
template<typename T>
T* max (T* a, T* b)
{
return *b < *a ? a : b;
}
// maximum of two C-strings:
char const* max (char const* a, char const* b)
{
return std::strcmp(b,a) < 0 ? a : b;
}
int main ()
{
int a = 7;
int b = 42;
auto m1 = ::max(a,b); // max() for two values of type int
std::string s1 = "hey"; "
std::string s2 = "you"; "
auto m2 = ::max(s1,s2); // max() for two values of type std::string
int* p1 = &b;
int* p2 = &a;
auto m3 = ::max(p1,p2); // max() for two pointers
char const* x = "hello";
char const* y = "world";
auto m4 = ::max(x,y); // max() for two C-strings
}

注意上面所有max()的重载模板中,调用参数都是按值传递的。通常而言,在重载模板的时候,要尽可能少地做改动。你应该只是改变模板参数的个数或者显式的指定某些模板参数。

否则,可能会遇到意想不到的问题。比如,如果你实现了一个按引用传递的max()模板,然后又重载了一个按值传递两个C字符串作为参数的模板,你不能用接受三个参数的模板来计算三个C字符串的最大值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <cstring>
// maximum of two values of any type (call-by-reference)
template<typenameT> T const& max (T const& a, T const& b)
{
return b < a ? a : b;
}
// maximum of two C-strings (call-by-value)
char const* max (char const* a, char const* b)
{
return std::strcmp(b,a) < 0 ? a : b;
}
// maximum of three values of any type (call-by-reference)
template<typename T>
T const& max (T const& a, T const& b, T const& c)
{
return max (max(a,b), c); // error if max(a,b) uses call-by-value
}
int main ()
{
auto m1 = ::max(7, 42, 68); // OK
char const* s1 = "frederic";
char const* s2 = "anica";
char const* s3 = "lucas";
auto m2 = ::max(s1, s2, s3); //run-time ERROR
}

问题在于当用三个C字符串作为参数调用max()的时候,

1
return max (max(a,b), c);

会遇到run-time error,这是因为对C字符串,max(max(a, b), c)会创建一个用于返回的临时局部变量,而在返回语句接受后,这个临时变量会被销毁,导致max()使用了一个悬空的引用。不幸的是,这个错误几乎在所有情况下都不太容易被发现。

作为对比,在求三个int最大值的max()调用中,则不会遇到这个问题。这里虽然也会创建三个临时变量,但是这三个临时变量是在main()里面创建的,而且会一直持续到语句结束。这只是模板解析规则和期望结果不一致的一个例子。再者,需要确保函数模板在被调用时,其已经在前方某处定义。这是由于在我们调用某个模板时,其相关定义不一定是可见的。比如我们定义了一个三参数的max(),由于它看不到适用于两个int的max(),因此它最终会调用两个参数的模板函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
// maximum of two values of any type:
template<typename T>
T max (T a, T b)
{
std::cout << "max<T>() \n";
return b < a ? a : b;
}
// maximum of three values of any type:
template<typename T>
T max (T a, T b, T c)
{
return max (max(a,b), c); // uses the template version even for ints
}
//because the following declaration comes
// too late:
// maximum of two int values:
int max (int a, int b)
{
std::cout << "max(int,int) \n";
return b < a ? a : b;
}
int main()
{
::max(47,11,33); // OOPS: uses max<T>() instead of max(int,int)
}

难道,我们不应该…?

按值传递还是按引用传递?

通常而言,建议将按引用传递用于除简单类型(比如基础类型和std::string_view)以外的类型,这样可以免除不必要的拷贝成本。不过出于以下原因,按值传递通常更好一些:

  • 语法简单。
  • 编译器能够更好地进行优化。
  • 移动语义通常使拷贝成本比较低。
  • 某些情况下可能没有拷贝或者移动。

再有就是,对于模板,还有一些特有情况:

  • 模板既可以用于简单类型,也可以用于复杂类型,因此如果默认选择适合于复杂类型可能方式,可能会对简单类型产生不利影响。
  • 作为调用者,你通常可以使用std::ref()std::cref()来按引用传递参数。
  • 虽然按值传递string literal和raw array经常会遇到问题,但是按照引用传递它们通常只会遇到更大的问题。

为什么不适用inline?

通常而言,函数模板不需要被声明成inline。不同于非inline函数,我们可以把非inline的函数模板定义在头文件里,然后在多个编译单元里include这个文件。唯一一个例外是模板对某些类型的全特化,这时候最终的code不在是“泛型”的。

严格地从语言角度来看,inline只意味着在程序中函数的定义可以出现很多次。不过它也给了编译器一个暗示,在调用该函数的地方函数应该被展开成inline的:这样做在某些情况下可以提高效率,但是在另一些情况下也可能降低效率。现代编译器在没有关键字inline暗示的情况下,通常也可以很好的决定是否将函数展开成inline的。

为什么不用constexpr?

从C++11开始,你可以通过使用关键字constexpr来在编译阶段进行某些计算。对于很多模板,这是有意义的。比如为了可以在编译阶段使用求最大值的函数,你必须将其定义成下面这样:

1
2
3
4
5
template<typename T1, typename T2>
constexpr auto max (T1 a, T2 b)
{
return b < a ? a : b;
}

如此你就可以在编译阶段的上下文中,实时地使用这个求最大值的函数模板:

1
int a[::max(sizeof(char),1000u)];

或者指定std::array<>的大小:

1
std::array<std::string, ::max(sizeof(char),1000u)> arr;

在这里我们传递的1000是unsigned int类型,这样可以避免直接比较一个有符号数值和一个无符号数值时产生的警报。

总结

  • 函数模板定义了一组适用于不同类型的函数。
  • 当向模板函数传递变量时,函数模板会自行推断模板参数的类型,来决定去实例化出那种类型的函数。
  • 你也可以显式的指出模板参数的类型。
  • 你可以定义模板参数的默认值。这个默认值可以使用该模板参数前面的模板参数的类型,而且其后面的模板参数可以没有默认值。
  • 函数模板可以被重载。
  • 当定义新的函数模板来重载已有的函数模板时,必须要确保在任何调用情况下都只有一个模板是最匹配的。
  • 当你重载函数模板的时候,最好只是显式地指出了模板参数得了类型。
  • 确保在调用某个函数模板之前,编译器已经看到了相对应的模板定义。

类模板(Class Templates)

和函数类似,类也可以被一个或多个类型参数化。容器类(Container classes)就是典型的一个例子,它可以被用来处理某一指定类型的元素。

Stack类模板的实现

和函数模板一样,我们把类模板Stack<>的声明和定义都放在头文件里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <vector>
#include <cassert>
template<typename T>
class Stack {
private:
std::vector<T> elems; // elements
public:
void push(T const& elem); // push element
void pop(); // pop element
T const& top() const; // return top element
bool empty() const { // return whether the stack is empty
return elems.empty();
}
};
template<typename T>
void Stack<T>::push (T const& elem)
{
elems.push_back(elem); // append copy of passed elem
}
template<typename T>
void Stack<T>::pop ()
{
assert(!elems.empty());
elems.pop_back(); // remove last element
}
template<typename T>
T const& Stack<T>::top () const
{
assert(!elems.empty());
return elems.back(); // return copy of last element
}

如上所示,这个类模板是通过使用一个C++标准库的类模板vector<>实现的。这样我们就不需要自己来实现内存管理,拷贝构造函数和赋值构造函数了,从而可以把更多的精力放在这个类模板的接口实现上。

声明一个类模板

声明类模板和声明函数模板类似:在开始定义具体内容之前,需要先声明一个或者多个作为模板的类型参数的标识符。同样地,这一标识符通常用T表示:

1
2
3
4
template<typename T>
class Stack {
...
};

在这里,同样可以用关键字class取代typename

1
2
3
4
template<class T>
class Stack {
...
};

在类模板内部,T可以像普通类型一样被用来声明成员变量和成员函数。在这个例子中,T被用于声明vector中元素的类型,用于声明成员函数push()的参数类型,也被用于成员函数top的返回类型:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
class Stack {
private:
std::vector<T> elems; // elements
public:
void push(T const& elem); // push element
void pop(); // pop element
T const& top() const; // return top element
bool empty() const { // return whether the stack is empty
return elems.empty();
}
};

这个类的类型是Stack<T>,其中T是模板参数。在将这个Stack<T>类型用于声明的时候,除非可以推断出模板参数的类型,否则就必须使用Stack<T>(Stack后面必须跟着<T>)。不过,如果在类模板内部使用Stack而不是Stack<T>,表明这个内部类的模板参数类型和模板
类的参数类型相同。

比如,如果需要定义自己的复制构造函数和赋值构造函数,通常应该定义成这样:

1
2
3
4
5
6
template<typename T>
class Stack {
... Stack (Stack const&); // copy constructor
Stack& operator= (Stack const&); // assignment operator
...
};

它和下面的定义是等效的:

1
2
3
4
5
6
template<typename T>
class Stack {
... Stack (Stack<T> const&); // copy constructor
Stack<T>& operator= (Stack<T> const&); // assignment operator
...
};

一般<T>暗示要对某些模板参数做特殊处理,所以最好还是使用第一种方式。但是如果在类模板的外面,就需要这样定义:

1
2
template<typename T>
bool operator== (Stack<T> const& lhs, Stack<T> const& rhs);

注意在只需要类的名字而不是类型的地方,可以只用Stack。这和声明构造函数和析构函数的情况相同。

另外,不同于非模板类,不可以在函数内部或者块作用域内({…})声明和定义模板。通常模板只能定义在global/namespace作用域,或者是其它类的声明里面。

成员函数的实现

定义类模板的成员函数时,必须指出它是一个模板,也必须使用该类模板的所有类型限制。因此,要像下面这样定义Stack<T>的成员函数push():

1
2
3
4
5
template<typename T>
void Stack<T>::push (T const& elem)
{
elems.push_back(elem); // append copy of passed elem
}

这里调用了其vector成员的push_back()方法,它向vector的尾部追加一个元素。注意vectorpop_back()方法只是删除掉尾部的元素,并不会返回这一元素。这主要是为了异常安全(exception safety)。不过如果忽略掉这一风险,我们依然可以实现一个返回被删除元素的pop()。为了达到这一目的,我们只需要用T定义一个和vector元素有相同类型的局部变量就可以了:

1
2
3
4
5
6
7
8
template<typename T>
T Stack<T>::pop ()
{
assert(!elems.empty());
T elem = elems.back(); // save copy of last element
elems.pop_back(); // remove last element
return elem; // return copy of saved element
}

由于vectorback()(返回其最后一个元素)和pop_back()(删除最后一个元素)方法在vector为空的时候行为未定义,因此需要对vector是否为空进行测试。在程序中我们断言(assert)vector不能为空,这样可以确保不会对空的Stack调用pop()方法。在top()中也是这样,它返回但是不删除首元素:

1
2
3
4
5
6
template<typename T>
T const& Stack<T>::top () const
{
assert(!elems.empty());
return elems.back(); // return copy of last element
}

当然,就如同其它成员函数一样,你也可以把类模板的成员函数以内联函数的形式实现在类模板的内部。比如:

1
2
3
4
5
6
7
template<typename T>
class Stack {
...
void push (T const& elem) {
elems.push_back(elem); // append copy of passed elem
}
};

Stack类模板的使用

直到C++17,在使用类模板的时候都需要显式的指明模板参数。下面的例子展示了该如何使用Stack<>类模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "stack1.hpp"
#include <iostream>
#include <string>
int main()
{
Stack<int> intStack; // stack of ints
Stack<std::string> stringStack; // stack of strings
// manipulate int stack
intStack.push(7);
std::cout << intStack.top() << "\n";
// manipulate string stack
stringStack.push("hello");
std::cout << stringStack.top() << "\n";
stringStack.pop();
}

通过声明Stack<int>类型,在类模板内部int会被用作类型T。被创建的instStack会使用一个存储int的vector作为其elems成员,而且所有被用到的成员函数都会被用int实例化。同样的,对于用Stack<std::string>定义的对象,它会使用一个存储std::string的vector作为其elems成员,所有被用到的成员函数也都会用std::string实例化。注意,模板函数和模板成员函数只有在被调用的时候才会实例化。这样一方面会节省时间和空间,同样也允许只是部分的使用类模板。在这个例子中,对intstd::string,默认构造函数,push()以及top()函数都会被实例化。而pop()只会针对std::string实例化。如果一个类模板有static成员,对每一个用到这个类模板的类型,相应的静态成员也只会被实例化一次。

被实例化之后的类模板类型(Stack<int>之类)可以像其它常规类型一样使用。可以用const以及volatile修饰它,或者用它来创建数组和引用。可以通过typedef和using将它用于类型定义的一部分,也可以用它来实例化其它的模板类型。比如:

1
2
3
4
5
6
7
void foo(Stack <int> const& s) // parameter s is int stack
{
using IntStack = Stack <int>; // IntStack is another name for
Stack<int>
Stack< int> istack[10]; // istack is array of 10 int stacks
IntStack istack2[10]; // istack2 is also an array of 10 int stacks (same type)
}

模板参数可以是任意类型,比如指向float的指针,甚至是存储int的stack:

1
2
Stack<float*> floatPtrStack; // stack of float pointers
Stack<Stack<int>> intStackStack; // stack of stack of ints

模板参数唯一的要求是:它要支持模板中被用到的各种操作(运算符)。

部分地使用类模板

一个类模板通常会对用来实例化它的类型进行多种操作(包含构造函数和析构函数)。这可能会让你以为,要为模板参数提供所有被模板成员函数用到的操作。但是事实不是这样:模板参数只需要提供那些会被用到的操作(而不是可能会被用到的操作)。比如Stack<>类可能会提供一个成员函数printOn()来打印整个stack的内容,它会调用operator <<来依次打印每一个元素:

1
2
3
4
5
6
7
8
template<typename T>
class Stack {
void printOn() (std::ostream& strm) const {
for (T const& elem : elems) {
strm << elem << ""; // call << for each element
}
}
};

这个类依然可以用于那些没有提供operator <<运算符的元素:

1
2
3
4
5
Stack<std::pair< int, int>> ps; // note: std::pair<> has no operator<< defined
ps.push({4, 5}); // OK
ps.push({6, 7}); // OK
std::cout << ps.top().first << "\n"; // OK
std::cout << ps.top().second << "\n"; // OK

只有在调用printOn()的时候,才会导致错误,因为它无法为这一类型实例化出对operator<<的调用:

1
ps.printOn(std::cout); // ERROR: operator<< not supported for element type

Concept

这样就有一个问题:我们如何才能知道为了实例化一个模板需要哪些操作?名词concept通常被用来表示一组反复被模板库要求的限制条件。例如C++标准库是基于这样一些concepts的:可随机进入的迭代器(random access iterator)和可默认构造的(default constructible)。

从C++11开始,你至少可以通过关键字static_assert和其它一些预定义的类型萃取(type traits)来做一些简单的检查。比如:

1
2
3
4
5
6
template<typename T>
class C
{
static_assert(std::is_default_constructible<T>::value,
"Class C requires default-constructible elements");
};

即使没有这个static_assert,如果需要T的默认构造函数的话,依然会遇到编译错误。然而还有更复杂的情况需要检查,比如模板类型T的实例需要提供一个特殊的成员函数,或者需要能够通过operator <进行比较。

友元

相比于通过printOn()来打印stack的内容,更好的办法是去重载stack的operator <<运算符。而且和非模板类的情况一样,operator<<应该被实现为非成员函数,在其实现中可以调用printOn()

1
2
3
4
5
6
7
8
9
template<typename T>
class Stack {
void printOn() (std::ostream& strm) const {
}
friend std::ostream& operator<< (std::ostream& strm, Stack<T> const& s) {
s.printOn(strm);
return strm;
}
};

注意在这里Stack<>operator<<并不是一个函数模板,而是在需要的时候,随类模板实例化出来的一个常规函数。然而如果你试着先声明一个友元函数,然后再去定义它,情况会变的很复杂。

事实上我们有两种选择:

  • 可以隐式的声明一个新的函数模板,但是必须使用一个不同于类模板的模板参数,比如用U:
1
2
3
4
5
template<typename T>
class Stack {
template<typename U>
friend std::ostream& operator<< (std::ostream&, Stack<U> const&);
};

无论是继续使用T还是省略掉模板参数声明,都不可以(要么是里面的T隐藏了外面的T,要么是在命名空间作用域内声明了一个非模板函数)。

  • 也可以先将Stack<T>operator<<声明为一个模板,这要求先对Stack<T>进行声明:
1
2
3
4
template<typename T>
class Stack;
template<typename T>
std::ostream& operator<< (std::ostream&, Stack<T> const&);

接着就可以将这一模板声明为Stack<T>的友元:

1
2
3
4
template<typename T>
class Stack {
friend std::ostream& operator<< <T> (std::ostream&, Stack<T> const&);
}

注意这里在operator<<后面用了<T>,这相当于声明了一个特例化之后的非成员函数模板作为友元。如果没有<T>的话,则相当于定义了一个新的非模板函数。

无论如何,你依然可以将Stack<T>用于没有定义operator <<的元素,只是当你调用operator<<的时候会遇到一个错误:

1
2
3
4
5
6
Stack<std::pair< int, int>> ps; // std::pair<> has no operator<< defined
ps.push({4, 5}); // OK
ps.push({6, 7}); // OK
std::cout << ps.top().first << "\n"; // OK
std::cout << ps.top().second << "\n"; // OK
std::cout << ps << "\n"; // ERROR: operator<< not supported for element type

模板类的特例化

可以对类模板的某一个模板参数进行特化。和函数模板的重载类似,类模板的特化允许我们对某一特定类型做优化,或者去修正类模板针对某一特定类型实例化之后的行为。不过如果对类模板进行了特化,那么也需要去特化所有的成员函数。为了特化一个类模板,在类模板声明的前面需要有一个template<>,并且需要指明所希望特化的类型。这些用于特化类模板的类型被用作模板参数,并且需要紧跟在类名的后面:

1
2
3
4
template<>
class Stack<std::string> {
...
};

对于被特化的模板,所有成员函数的定义都应该被定义成“常规”成员函数,也就是说所有出现T的地方,都应该被替换成用于特化类模板的类型:

1
2
3
4
void Stack<std::string>::push (std::string const& elem)
{
elems.push_back(elem); // append copy of passed elem
}

下面是一个用std::string实例化Stack<>类模板的完整例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include "stack1.hpp"
#include <deque>
#include <string>
#include <cassert>
template<>
class Stack<std::string> {
private:
std::deque<std::string> elems; // elements
public:
void push(std::string const&); // push element
void pop(); // pop element
std::string const& top() const; // return top element
bool empty() const { // return whether the stack is empty
return elems.empty();
}
};
void Stack<std::string>::push (std::string const& elem)
{
elems.push_back(elem); // append copy of passed elem
}
void Stack<std::string>::pop ()
{
assert(!elems.empty());
elems.pop_back(); // remove last element
}
std::string const& Stack<std::string>::top () const
{
assert(!elems.empty());
return elems.back(); // return copy of last element
}

在这个例子中,特例化之后的类在向push()传递参数的时候使用了引用语义,对当前std::string类型这是有意义的,这可以提高性能。

另一个不同是使用了一个deque而不再是vector来存储stack里面的元素。虽然这样做可能不会有什么好处,不过这能够说明,模板类特例化之后的实现可能和模板类的原始实现有很大不同。

部分特例化

类模板可以只被部分的特例化。这样就可以为某些特殊情况提供特殊的实现,不过使用者还是要定义一部分模板参数。比如,可以特殊化一个Stack<>来专门处理指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "stack1.hpp"
// partial specialization of class Stack<> for pointers:
template<typename T>
class Stack<T*> {
private:
std::vector<T*> elems; // elements
public:
void push(T*); // push element
T* pop(); // pop element
T* top() const; // return top element
bool empty() const { // return whether the stack is empty
return elems.empty();
}
};
template<typename T>
void Stack<T*>::push (T* elem)
{
elems.push_back(elem); // append copy of passed elem
}
template<typename T>
T* Stack<T*>::pop ()
{
assert(!elems.empty());
T* p = elems.back();
elems.pop_back(); // remove last element
return p; // and return it (unlike in the general case)
}
template<typename T>
T* Stack<T*>::top () const
{
assert(!elems.empty());
return elems.back(); // return copy of last element
}

通过

1
2
template<typename T>
class Stack<T*> { };

定义了一个依然是被类型T参数化,但是被特化用来处理指针的类模板Stack<T*>。同样的,特例化之后的函数接口可能不同。比如对pop(),他在这里返回的是一个指针,因此如果这个指针是通过new创建的话,可以对这个被删除的值调用delete:

1
2
3
4
Stack<int*> ptrStack; // stack of pointers (specialimplementation)
ptrStack.push(new int{42});
std::cout << *ptrStack.top() << "\n";
delete ptrStack.pop();

多模板参数的部分特例化

类模板也可以特例化多个模板参数之间的关系。比如对下面这个类模板:

1
2
3
template<typename T1, typename T2>
class MyClass {
};

进行如下这些特例化都是可以的:

1
2
3
4
5
6
7
8
9
10
11
12
// partial specialization: both template parameters have same type
template<typename T>
class MyClass<T,T> {
};
// partial specialization: second type is int
template<typename T>
class MyClass<T,int> {
};
// partial specialization: both template parameters are pointer types
template<typename T1, typename T2>
class MyClass<T1*,T2*> {
};

下面的例子展示了以上各种类模板被使用的情况:

1
2
3
4
MyClass<int, float> mif; // uses MyClass<T1,T2>
MyClass<float, float> mff; // uses MyClass<T,T>
MyClass<float, int> mfi; // uses MyClass<T,int>
MyClass<int*, float*> mp; // uses MyClass<T1*,T2*>

如果有不止一个特例化的版本可以以相同的情形匹配某一个调用,说明定义是有歧义的:

1
2
MyClass<int, int> m; // ERROR: matches MyClass<T,T> // and MyClass<T,int>
MyClass<int*, int*> m; // ERROR: matches MyClass<T,T> // and MyClass<T1*,T2*>

为了消除第二种歧义,你可以提供一个单独的特例化版本来处理相同类型的指针:

1
2
3
template<typename T>
class MyClass<T*,T*> {
};

默认类模板参数

和函数模板一样,也可以给类模板的模板参数指定默认值。比如对Stack<>,你可以将其用来容纳元素的容器声明为第二个模板参数,并指定其默认值是std::vector<>:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <vector>
#include <cassert>
template<typename T, typename Cont = std::vector<T>>
class Stack {
private:
Cont elems; // elements
public:
void push(T const& elem); // push element
void pop(); // pop element
T const& top() const; // return top element
bool empty() const { // return whether the stack is empty
return elems.empty();
}
};
template<typename T, typename Cont>
void Stack<T,Cont>::push (T const& elem)
{
elems.push_back(elem); // append copy of passed elem
}
template<typename T, typename Cont>
void Stack<T,Cont>::pop ()
{
assert(!elems.empty());
elems.pop_back(); // remove last element
}
template<typename T, typename Cont>
T const& Stack<T,Cont>::top () const
{
assert(!elems.empty());
return elems.back(); // return copy of last element
}

由于现在有两个模板参数,因此每个成员函数的定义也应该包含两个模板参数:

1
2
3
4
5
template<typename T, typename Cont>
void Stack<T,Cont>::push (T const& elem)
{
elems.push_back(elem); // append copy of passed elem
}

这个Stack<>模板可以像之前一样使用。如果只提供第一个模板参数作为元素类型,那么vector将被用来处理Stack中的元素:

1
2
3
4
5
template<typename T, typename Cont = std::vector<T>>
class Stack {
private:
Cont elems; // elements
};

而且在程序中,也可以为Stack指定一个容器类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "stack3.hpp"
#include <iostream>
#include <deque>
int main()
{
// stack of ints:
Stack<int> intStack;
// stack of doubles using a std::deque<> to manage the elements
Stack<double,std::deque<double>> dblStack;
// manipulate int stack
intStack.push(7);
std::cout << intStack.top() << "\n";
intStack.pop();
// manipulate double stack
dblStack.push(42.42);
std::cout << dblStack.top() << "\n";
dblStack.pop();
}

通过

1
Stack<double,std::deque<double>>

定义了一个处理double型元素的Stack,其使用的容器是std::deque<>

类型别名(Type Aliases)

通过给类模板定义一个新的名字,可以使类模板的使用变得更方便。

Typedefs和Alias声明

为了简化给类模板定义新名字的过程,有两种方法可用:

使用关键字typedef:

1
2
3
typedef Stack<int> IntStack; // typedef
void foo (IntStack const& s); // s is stack of ints
IntStack istack[10]; // istack is array of 10 stacks of ints

我们称这种声明方式为typedef,被定义的名字叫做typedef-name.

使用关键字using(从C++11开始)

1
2
3
using IntStack = Stack <int>; // alias declaration
void foo (IntStack const& s); // s is stack of ints
IntStack istack[10]; // istack is array of 10 stacks of ints

在这两种情况下我们都只是为一个已经存在的类型定义了一个别名,并没有定义新的类型。因此在:

1
typedef Stack <int> IntStack;

或者:

1
using IntStack = Stack <int>;

之后,IntStackStack<int>将是两个等效的符号。以上两种给一个已经存在的类型定义新名字的方式,被称为type alias declaration。新的名字被称为type alias。

Alias Templates(别名模板)

不同于typedef,alias declaration也可以被模板化,这样就可以给一组类型取一个方便的名字。这一特性从C++11开始生效,被称作alias templates。下面的DequeStack别名模板是被元素类型T参数化的,代表将其元素存储在std::deque中的一组Stack

1
2
template<typename T>
using DequeStack = Stack<T, std::deque<T>>;

因此,类模板和alias templates都是可以被参数化的类型。同样地,这里alias template只是一个已经存在的类型的新名字,原来的名字依然可用。DequeStack<int>Stack<int, std::deque<int>>代表的是同一种类型。

同样的,通常模板(包含Alias Templates)只可以被声明和定义在global/namespace作用域,或者在一个类的声明中。

Alias Templates for Member Types(class成员的别名模板)

使用alias templates可以很方便的给类模板的成员类型定义一个快捷方式,在:

1
2
3
struct C {
typedef ... iterator;
};

或者

1
2
3
struct MyType {
using iterator = ...;
};

之后,下面这样的定义:

1
2
template<typename T>
using MyTypeIterator = typename MyType<T>::iterator;

允许我们使用:

1
MyTypeIterator<int> pos;

取代:

1
typename MyType<T>::iterator pos;

Type Traits Suffix_t (Suffix_t类型萃取)

从C++14开始,标准库使用上面的技术,给标准库中所有返回一个类型的type trait定义了快捷方式。比如为了能够使用:

1
std::add_const_t<T> // since C++14

而不是:

1
typename std::add_const<T>::type // since C++11

标准库做了如下定义:

1
2
3
4
namespace std {
template<typename T>
using add_const_t = typename add_const<T>::type;
}

类模板的类型推导

直到C++17,使用类模板时都必须显式指出所有的模板参数的类型(除非它们有默认值)。从C++17开始,这一要求不在那么严格了。如果构造函数能够推断出所有模板参数的类型(对那些没有默认值的模板参数),就不再需要显式的指明模板参数的类型。

比如在之前所有的例子中,不指定模板类型就可以调用copy constructor:

1
2
3
Stack<int> intStack1; // stack of strings
Stack<int> intStack2 = intStack1; // OK in all versions
Stack intStack3 = intStack1; // OK since C++17

通过提供一个接受初始化参数的构造函数,就可以推断出Stack的元素类型。比如可以定义下面这样一个Stack,它可以被一个元素初始化:

1
2
3
4
5
6
7
8
9
10
template<typename T>
class Stack {
private:
std::vector<T> elems; // elements
public:
Stack () = default;
Stack (T const& elem) // initialize stack with one element
: elems({elem}) {
}...
};

然后就可以像这样声明一个Stack:

1
Stack intStack = 0; // Stack<int> deduced since C++17

通过用0初始化这个stack时,模板参数T被推断为int,这样就会实例化出一个Stack<int>。但是请注意下面这些细节:

  • 由于定义了接受int作为参数的构造函数,要记得向编译器要求生成默认构造函数及其全部默认行为,这是因为默认构造函数只有在没有定义其它构造函数的情况下才会默认生成,方法如下:
1
Stack() = default;
  • 在初始化Stack的vector成员elems时,参数elem被用{}括了起来,这相当于用只有一个元素elem的初始化列表初始化了elems:
1
: elems({elem})

这是因为vector没有可以直接接受一个参数的构造函数。

和函数模板不同,类模板可能无法部分的推断模板类型参数(比如在显式的指定了一部分类模板参数的情况下)

类模板对字符串常量参数的类型推断(Class Template Arguments Deduction with String Literals)

原则上,可以通过字符串常量来初始化Stack:

1
Stack stringStack = "bottom"; // Stack<char const[7]> deduced since C++17

不过这样会带来一堆问题:当参数是按照T的引用传递的时候(上面例子中接受一个参数的构造函数,是按照引用传递的),参数类型不会被decay,也就是说一个裸的数组类型不会被转换成裸指针。这样我们就等于初始化了一个这样的Stack:

1
Stack<char const[7]>

类模板中的T都会被实例化成char const[7]。这样就不能继续向Stack追加一个不同维度的字符串常量了,因为它的类型不是char const[7]。不过如果参数是按值传递的,参数类型就会被decay,也就是说会将裸数组退化成裸指针。这样构造函数的参数类型T会被推断为char const *,实例化后的类模板类型会被推断为

1
Stack<char const *>。

基于以上原因,可能有必要将构造函数声明成按值传递参数的形式:

1
2
3
4
5
6
7
8
9
10
template<typename T>
class Stack {
private:
std::vector<T> elems; // elements
public:
Stack (T elem) // initialize stack with one element by value
: elems({elem}) { // to decay on class tmpl arg deduction
}
...
};

这样下面的初始化方式就可以正常工作:

1
Stack stringStack = "bottom"; // Stack<char const*> deduced since C++17

在这个例子中,最好将临时变量elem move到stack中,这样可以免除不必要的拷贝:

1
2
3
4
5
6
7
8
9
10
template<typename T>
class Stack {
private:
std::vector<T> elems; // elements
public:
Stack (T elem) // initialize stack with one element by value
: elems({std::move(elem)}) {
}
...
};

推断指引(Deduction Guides)

针对以上问题,除了将构造函数声明成按值传递的,还有一个解决方案:由于在容器中处理裸指针容易导致很多问题,对于容器一类的类,不应该将类型推断为字符的裸指针(char const *)。

可以通过提供“推断指引”来提供额外的模板参数推断规则,或者修正已有的模板参数推断规则。比如你可以定义,当传递一个字符串常量或者C类型的字符串时,应该用std::string实例化Stack模板类:

1
Stack( char const*) -> Stack<std::string>;

这个指引语句必须出现在和模板类的定义相同的作用域或者命名空间内。通常它紧跟着模板类的定义。->后面的类型被称为推断指引的“guided type”。现在,根据这个定义:

1
Stack stringStack{"bottom"}; // OK: Stack<std::string> deduced since C++17

Stack将被推断为Stack<std::string>。但是下面这个定义依然不可以:

1
Stack stringStack = "bottom"; // Stack<std::string> deduced, but still not valid

此时模板参数类型被推断为std::string,也会实例化出Stack<std::string>:

1
2
3
4
5
6
7
8
class Stack {
private:
std::vector<std::string> elems; // elements
public:
Stack (std::string const& elem) // initialize stack with one element
: elems({elem}) {
}
};

但是根据语言规则,不能通过将字符串字面量传递给一个期望接受std::string的构造函数来拷贝初始化(使用=初始化)一个对象,因此必须要像下面这样来初始化这个Stack:

1
Stack stringStack{"bottom"}; // Stack<std::string> deduced and valid

如果还不是很确信的话,这里可以明确告诉你,模板参数推断的结果是可以拷贝的。在将stringStack声明为Stack<std::string>之后,下面的初始化语句声明的也将是Stack<std::string>类型的变量(通过拷贝构造函数),而不是用Stack<std::string>类型的元素去初始化一个stack(也就是说,Stack存储的元素类型是std::string,而不是Stack<std::string>):

1
2
3
Stack stack2{stringStack}; // Stack<std::string> deduced
Stack stack3(stringStack); // Stack<std::string> deduced
Stack stack4 = {stringStack}; // Stack<std::string> deduced

聚合类的模板化(Templatized Aggregates)

聚合类(这样一类class或者struct:没有用户定义的显式的,或者继承而来的构造函数,没有private或者protected的非静态成员,没有虚函数,没有virtual,private或者protected的基类)也可以是模板。比如:

1
2
3
4
5
template<typename T>
struct ValueWithComment {
T value;
std::string comment;
};

定义了一个成员val的类型被参数化了的聚合类。可以像定义其它类模板的对象一样定义一个聚合类的对象:

1
2
3
ValueWithComment<int> vc;
vc.value = 42;
vc.comment = "initial value";

从C++17开始,对于聚合类的类模板甚至可以使用“类型推断指引” :

1
2
3
ValueWithComment(
char const*, char const*) -> ValueWithComment<std::string>;
ValueWithComment vc2 = {"hello", "initial value"};

没有“推断指引”的话,就不能使用上述初始化方法,因为ValueWithComment没有相应的构造函数来完成相关类型推断。

标准库的std::array<>类也是一个聚合类,其元素类型和尺寸都是被参数化的。

总结

  • 类模板是一个被实现为有一个或多个类型参数待定的类。
  • 使用类模板时,需要显式或者隐式地传递相应的待定类型参数作为模板参数。之后类模板会被按照传入的模板参数实例化(并且被编译)。
  • 对于类模板,只有其被用到的成员函数才会被实例化。
  • 可以针对某些特定类型对类模板进行特化。
  • 也可以针对某些特定类型对类模板进行部分特化。
  • 从C++17开始,可以(不是一定可以)通过类模板的构造函数来推断模板参数的类型。
  • 可以定义聚合类的类模板。
  • 调用参数如果是按值传递的,那么相应的模板类型会decay。
  • 模板只能被声明以及定义在global或者namespace作用域,或者是定义在其它类的定义里面。

非类型模板参数

和类模板使用类型作为参数类似,可以使代码的另一些细节留到被使用时再确定,只是对非类型模板参数,待定的不再是类型,而是某个数值。在使用这种模板时需要显式的指出待定数值的具体值,之后代码会被实例化。

类模板的非类型参数

作为和之前章节中Stack实现方式的对比,可以定义一个使用固定尺寸的array作为容器的Stack。这种方式的优点是可以避免由开发者或者标准库容器负责的内存管理开销。不过对不同应用,这一固定尺寸的具体大小也很难确定。如果指定的值过小,那么Stack就会很容易满。如果指定的值过大,则可能造成内存浪费。因此最好是让Stack的用户根据自身情况指定Stack的大小。

为此,可以将Stack的大小定义成模板的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <array>
#include <cassert>
template<typename T, std::size_t Maxsize>
class Stack {
private:
std::array<T, Maxsize> elems; // elements
std::size_t numElems; // current number of elements
public:
Stack(); // constructor
void push(T const& elem); // push element
void pop(); // pop element
T const& top() const; // return top element
bool empty() const { //return whether the stack is empty
return numElems == 0;
}
std::size_t size() const { //return current number of elements
return numElems;
}
};
template<typename T, std::size_t Maxsize>
Stack<T,Maxsize>::Stack ()
: numElems(0) //start with no elements
{
// nothing else to do
}
template<typename T, std::size_t Maxsize>
void Stack<T,Maxsize>::push (T const& elem)
{
assert(numElems < Maxsize);
elems[numElems] = elem; // append element
++numElems; // increment number of elements
}
template<typename T, std::size_t Maxsize>
void Stack<T,Maxsize>::pop ()
{
assert(!elems.empty());
--numElems; // decrement number of elements
}
template<typename T, std::size_t Maxsize>
T const& Stack<T,Maxsize>::top () const
{
assert(!elems.empty());
return elems[numElems-1]; // return last element
}

第二个新的模板参数Maxsize是int类型的。通过它指定了Stack中array的大小:

1
2
3
4
5
template<typename T, std::size_t Maxsize>
class Stack {
private:
std::array<T,Maxsize> elems; // elements
};

成员函数push()也用它来检测Stack是否已满:

1
2
3
4
5
6
7
template<typename T, std::size_t Maxsize>
void Stack<T,Maxsize>::push (T const& elem)
{
assert(numElems < Maxsize);
elems[numElems] = elem; // append element
++numElems; // increment number of elements
}

为了使用这个类模板,需要同时指出Stack中元素的类型和Stack的最大容量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "stacknontype.hpp"
#include <iostream>
#include <string>
int main()
{
Stack<int,20> int20Stack; // stack of up to 20 ints
Stack<int,40> int40Stack; // stack of up to 40 ints
Stack<std::string,40> stringStack; // stack of up to 40 strings
// manipulate stack of up to 20 ints
int20Stack.push(7);
std::cout << int20Stack.top() << "\n";
int20Stack.pop();
// manipulate stack of up to 40 strings
stringStack.push("hello");
std::cout << stringStack.top() << "\n";
stringStack.pop();
}

上面每一次模板的使用都会实例化出一个新的类型。因此int20Stackint40Stack是两种不同的类型,而且由于它们之间没有定义隐式或者显式的类型转换规则。也就不能使用其中一个取代另一个,或者将其中一个赋值给另一个。

对非类型模板参数,也可以指定默认值:

1
2
3
4
template<typename T = int, std::size_t Maxsize = 100>
class Stack {
...
};

但是从程序设计的角度来看,这可能不是一个好的设计方案。默认值应该是直观上正确的。不过对于一个普通的Stack,无论是默认的int类型还是Stack的最大尺寸100,看上去都不够直观。

函数模板的非类型参数

同样也可以给函数模板定义非类型模板参数。比如下面的这个函数模板,定义了一组可以返回传入参数和某个值之和的函数:

1
2
3
4
5
template<int Val, typename T>
T addValue (T x)
{
return x + Val;
}

当该类函数或操作是被用作其它函数的参数时,可能会很有用。比如当使用C++标准库给一个集合中的所有元素增加某个值的时候,可以将这个函数模板的一个实例化版本用作第4个参数:

1
2
3
std::transform (source.begin(), source.end(), //start and end of source
dest.begin(), //start of destination
addValue<5,int>); // operation

第4个参数是从addValue<>()实例化出一个可以给传入的int型参数加5的函数实例。这一实例会被用来处理集合source中的所有元素,并将结果保存到目标集合dest中。注意在这里必须将addValue<>()的模板参数T指定为int类型。因为类型推断只会对立即发生的调用起作用,而std::transform()又需要一个完整的类型来推断其第四个参数的类型。目前还不支持先部分地替换或者推断模板参数的类型,然后再基于具体情况去推断其余的模板参数。

同样也可以基于前面的模板参数推断出当前模板参数的类型。比如可以通过传入的非类型模板参数推断出返回类型:

1
2
template<auto Val, typename T = decltype(Val)>
T foo();

或者可以通过如下方式确保传入的非类型模板参数的类型和类型参数的类型一致:

1
2
template<typename T, T Val = T{}>
T bar();

非类型模板参数的限制

使用非类型模板参数是有限制的。通常它们只能是整形常量(包含枚举),指向objects/functions/members的指针,objects或者functions的左值引用,或者是std::nullptr_t(类型是nullptr)。浮点型数值或者class类型的对象都不能作为非类型模板参数使用:

1
2
3
4
5
6
7
8
9
template<double VAT> // ERROR: floating-point values are not
double process (double v) // allowed as template parameters
{
return v * VAT;
}
template<std::string name> // ERROR: class-type objects are not
class MyClass { // allowed as template parameters
...
};

当传递对象的指针或者引用作为模板参数时,对象不能是字符串常量,临时变量或者数据成员以及其它子对象。由于在C++17之前,C++版本的每次更新都会放宽以上限制,因此还有一些针对不同版本的限制:

  • 在C++11中,对象必须要有外部链接。
  • 在C++14中,对象必须是外部链接或者内部链接。

因此下面的写法是不对的:

1
2
3
4
5
template<char const* name>
class MyClass {
...
};
MyClass<"hello"> x; //ERROR: string literal "hello" not allowed

不过有如下变通方法(视C++版本而定):

1
2
3
4
5
6
7
8
9
extern char const s03[] = "hi"; // external linkage
char const s11[] = "hi"; // internal linkage
int main()
{
MyClass<s03> m03; // OK (all versions)
MyClass<s11> m11; // OK since C++11
static char const s17[] = "hi"; // no linkage
MyClass<s17> m17; // OK since C++17
}

上面三种情况下,都是用”hello”初始化了一个字符串常量数组,然后将这个字符串常量数组对象用于类模板中被声明为char const *的模板参数。如果这个对象有外部链接(s03),那么对所有版本的C++都是有效的,如果对象有内部链接(s11),那么对C++11和C++14也是有效的,而对C++17,即使对象没有链接属性也是有效的。

避免无效表达式

非类型模板参数可以是任何编译期表达式。比如:

1
2
3
4
template<int I, bool B>
class C;

C<sizeof(int) + 4, sizeof(int)==4> c;

不过如果在表达式中使用了operator >,就必须将相应表达式放在括号里面,否则>会被作为模板参数列表末尾的>,从而截断了参数列表:

1
2
C<42, sizeof(int) > 4> c; // ERROR: first > ends the template argument list
C<42, (sizeof(int) > 4)> c; // OK

用auto作为非模板类型参数的类型

从C++17开始,可以不指定非类型模板参数的具体类型(代之以auto),从而使其可以用于任意有效的非类型模板参数的类型。通过这一特性,可以定义如下更为泛化的大小固定的Stack类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <array>
#include <cassert>
template<typename T, auto Maxsize>
class Stack {
public:
using size_type = decltype(Maxsize);
private:
std::array<T,Maxsize> elems; // elements
size_type numElems; // current number of elements
public:
Stack(); // constructor
void push(T const& elem); // push element
void pop(); // pop element
T const& top() const; // return top element
bool empty() const { //return whether the stack isempty
return numElems == 0;
}
size_type size() const { //return current number of elements
return numElems;
}
};
// constructor
template<typename T, auto Maxsize>
Stack<T,Maxsize>::Stack ()
: numElems(0) //start with no elements
{
// nothing else to do
}
template<typename T, auto Maxsize>
void Stack<T,Maxsize>::push (T const& elem)
{
assert(numElems < Maxsize);
elems[numElems] = elem; // append element
++numElems; // increment number of elements
}
template<typename T, auto Maxsize>
void Stack<T,Maxsize>::pop ()
{
assert(!elems.empty());
--numElems; // decrement number of elements
}
template<typename T, auto Maxsize>
T const& Stack<T,Maxsize>::top () const
{
assert(!elems.empty());
return elems[numElems-1]; // return last element
}

通过使用auto的如下定义:

1
2
3
4
template<typename T, auto Maxsize>
class Stack {
...
};

定义了类型待定的Maxsize。它的类型可以是任意非类型参数所允许的类型。在模板内部,既可以使用它的值:

1
std::array<T,Maxsize> elems; // elements

也可以使用它的类型:

1
using size_type = decltype(Maxsize);

然后可以将它用于成员函数size()的返回类型:

1
2
3
size_type size() const { //return current number of elements
return numElems;
}

从C++14开始,也可以通过使用auto,让编译器推断出具体的返回类型:

1
2
3
auto size() const { //return current number of elements
return numElems;
}

根据这个类的声明,Stack中numElems成员的类型是由非类型模板参数的类型决定的,当像下面这样使用它的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <string>
#include "stackauto.hpp"
int main()
{
Stack<int,20u> int20Stack; // stack of up to 20 ints
Stack<std::string,40> stringStack; // stack of up to 40 strings
// manipulate stack of up to 20 ints
int20Stack.push(7);
std::cout << int20Stack.top() << "\n";auto size1 =
int20Stack.size();
// manipulate stack of up to 40 strings
stringStack.push("hello");
std::cout << stringStack.top() << "\n";
auto size2 = stringStack.size();
if (!std::is_same_v<decltype(size1), decltype(size2)>) {
std::cout << "size types differ" << "\n";
}
}

对于

1
Stack<int,20u> int20Stack; // stack of up to 20 ints

由于传递的非类型参数是20u,因此内部的size_typeunsigned int类型的。

对于

1
Stack<std::string,40> stringStack; // stack of up to 40 strings

由于传递的非类型参数是int,因此内部的size_type是int类型的。因为这两个Stack中成员函数size()的返回类型是不一样的,所以

1
2
3
auto size1 = int20Stack.size();
...
auto size2 = stringStack.size();

size1size2的类型也不一样。这可以通过标准类型萃取std::is_samedecltype来验证:

1
2
3
if (!std::is_same<decltype(size1), decltype(size2)>::value) {
std::cout << "size types differ" << "\n";
}

输出结果将是:

1
size types differ

从C++17开始,对于返回类型的类型萃取,可以通过使用下标_v省略掉::value

1
2
3
if (!std::is_same_v<decltype(size1), decltype(size2)>) {
std::cout << "size types differ" << "\n";
}

注意关于非类型模板参数的限制依然存在。比如:

1
Stack<int,3.14> sd; // ERROR: Floating-point nontype argument

由于可以将字符串作为常量数组用于非类型模板参数,下面的用法也是可以的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
template<auto T> // take value of any possible nontype parameter (since C++17)
class Message {
public:
void print() {
std::cout << T << "\n";
}
};
int main()
{
Message<42> msg1;
msg1.print(); // initialize with int 42 and print that value
static char const s[] = "hello";
Message<s> msg2; // initialize with char const[6] "hello"
msg2.print(); // and print that value
}

也可以使用template<decltype(auto)>,这样可以将N实例化成引用类型:

1
2
3
4
5
6
template<decltype(auto) N>
class C {
...
};
int i;
C<(i)> x; // N is int&

总结

  • 模板的参数不只可以是类型,也可以是数值。
  • 不可以将浮点型或者class类型的对象用于非类型模板参数。使用指向字符串常量,临时变量和子对象的指针或引用也有一些限制。
  • 通过使用关键字auto,可以使非类型模板参数的类型更为泛化。

变参模板

从C++11开始,模板可以接受一组数量可变的参数。这样就可以在参数数量和参数类型都不确定的情况下使用模板。一个典型应用是通过class或者framework向模板传递一组数量和类型都不确定的参数。另一个应用是提供泛型代码处理一组数量任意且类型也任意的参数。

变参模板

可以将模板参数定义成能够接受任意多个模板参数的情况。这一类模板被称为变参模板(variadic template)。

变参模板实列

比如,可以通过调用下面代码中的print()函数来打印一组数量和类型都不确定的参数:

1
2
3
4
5
6
7
8
9
#include <iostream>
void print ()
{}
template<typename T, typename... Types>
void print (T firstArg, Types... args)
{
std::cout << firstArg << "\n"; //print first argument
print(args...); // call print() for remaining arguments
}

如果传入的参数是一个或者多个,就会调用这个函数模板,这里通过将第一个参数单独声明,就可以先打印第一个参数,然后再递归的调用print()来打印剩余的参数。这些被称为args的剩余参数,是一个函数参数包(function parameter pack):

1
void print (T firstArg, Types... args)

这里使用了通过模板参数包(template parameter pack)定义的类型“Types” :

1
template<typename T, typename... Types>

为了结束递归,重载了不接受参数的非模板函数print(),它会在参数包为空的时候被调用。比如,这样一个调用:

1
2
std::string s("world");
print (7.5, "hello", s);

输出如下结果:

1
2
3
7.5
hello
World

因为这个调用首先会被扩展成:

1
print<double, char const*, std::string> (7.5, "hello", s);

其中:

  • firstArg的值是7.5,其类型T是double。
  • args是一个可变模板参数,它包含类型是char const*的“hello”和类型是std::string的“world”

在打印了firstArg对应的7.5之后,继续调用print()打印剩余的参数,这时print()被扩展为:

1
print<char const*, std::string> ("hello", s);

其中:

  • firstArg的值是“hello”,其类型T是char const *
  • args是一个可变模板参数,它包含的参数类型是std::string

在打印了firstArg对应的“hello”之后,继续调用print()打印剩余的参数,这时print()被扩展为:

1
print<std::string> (s);

其中:

  • firstArg的值是“world”,其类型T是std::string。
  • args是一个空的可变模板参数,它没有任何值。

这样在打印了firstArg对应的“ world”之后,就会调用被重载的不接受参数的非模板函数print(),从而结束了递归。

变参和非变参模板的重载

上面的例子也可以这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
template<typename T>
void print (T arg)
{
std::cout << arg << "\n"; //print passed argument
}

template<typename T, typename... Types>
void print (T firstArg, Types... args)
{
print(firstArg); // call print() for the first argument
print(args...); // call print() for remainingarguments
}

也就是说,当两个函数模板的区别只在于尾部的参数包的时候,会优先选择没有尾部参数包的那一个函数模板。

sizeof…运算符

C++11为变参模板引入了一种新的sizeof运算符:sizeof...。它会被扩展成参数包中所包含的参数数目。因此:

1
2
3
4
5
6
7
template<typename T, typename... Types>
void print (T firstArg, Types... args)
{
std::cout << firstArg << "\n"; //print first argument
std::cout << sizeof...(Types) << "\n"; //print number of remaining types
std::cout << sizeof...(args) << "\n"; //print number of remaining args
}

在将第一个参数打印之后,会将参数包中剩余的参数数目打印两次。如你所见,运算符sizeof...既可以用于模板参数包,也可以用于函数参数包。这样可能会让你觉得,可以不使用为了结束递归而重载的不接受参数的非模板函数print(),只要在没有参数的时候不去调用任何函数就可以了:

1
2
3
4
5
6
7
8
template<typename T, typename... Types>
void print (T firstArg, Types... args)
{
std::cout << firstArg << "\n";
if (sizeof...(args) > 0) { //error if sizeof...(args)==0
print(args...); // and no print() for no arguments declared
}
}

但是这一方式是错误的,因为通常函数模板中if语句的两个分支都会被实例化。是否使用被实例化出来的代码是在运行期间(run-time)决定的,而是否实例化代码是在编译期间(compile-time)决定的。因此如果在只有一个参数的时候调用print()函数模板,虽然args...为空,if语句中的print(args...)也依然会被实例化,但此时没有定义不接受参数的print()函数,因此会报错。

不过从C++17开始,可以使用编译阶段的if语句,这样通过一些稍微不同的语法,就可以实现前面想要的功能。8.5节会对这一部分内容进行讨论。

折叠表达式

从C++17开始,提供了一种可以用来计算参数包(可以有初始值)中所有参数运算结果的二元运算符。比如,下面的函数会返回s中所有参数的和:

1
2
3
4
template<typename... T>
auto foldSum (T... s) {
return (... + s); // ((s1 + s2) + s3) ...
}

如果参数包是空的,这个表达式将是不合规范的(不过此时对于运算符&&,结果会是true,对运算符||,结果会是false,对于逗号运算符,结果会是void())。

表4.1列举了可能的折叠表达式:
|Fold Expression |Evaluation|
|—-|—-|
|( … op pack )|((( pack1 op pack2 ) op pack3 ) … op packN )|
|( pack op … )|( pack1 op ( … ( packN-1 op packN )))|
|( init op … op pack )|((( init op pack1 ) op pack2 ) … op packN )|
|( pack op … op init )|( pack1 op ( … ( packN op init )))|

几乎所有的二元运算符都可以用于折叠表达式。比如可以使用折叠表达式和运算符->*遍历一条二叉树的路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// define binary tree structure and traverse helpers:
struct Node {
int value;
Node* left;
Node* right;
Node(int i=0) : value(i), left(nullptr), right(nullptr) {
}...
};
auto left = &Node::left;
auto right = &Node::right;
// traverse tree, using fold expression:
template<typename T, typename... TP>
Node* traverse (T np, TP... paths) {
return (np ->* ... ->* paths); // np ->* paths1 ->* paths2 ...
}

int main()
{
// init binary tree structure:
Node* root = new Node{0};
root->left = new Node{1};
root->left->right = new Node{2};
... //
traverse binary tree:
Node* node = traverse(root, left, right);
...
}

这里

1
(np ->* ... ->* paths)

使用了折叠表达式从np开始遍历了paths中所有可变成员。通过这样一个使用了初始化器的折叠表达式,似乎可以简化打印变参模板参数的过程,像上面那样:

1
2
3
4
5
template<typename... Types>
void print (Types const&... args)
{
(std::cout << ... << args) << "\n";
}

不过这样在参数包各元素之间并不会打印空格。为了打印空格,还需要下面这样一个类模板,它可以在所有要打印的参数后面追加一个空格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
class AddSpace
{
private:
T const& ref; // refer to argument passed in constructor
public:
AddSpace(T const& r): ref(r) {
}
friend std::ostream& operator<< (std::ostream& os, AddSpace<T> s) {
return os << s.ref <<" "; // output passed argument and a space
}
};
template<typename... Args>
void print (Args... args) {
( std::cout << ... << AddSpace<Args>(args) ) << "\n";
}

注意在表达式AddSpace(args)中使用了类模板的参数推导(见2.9节),相当于使用了AddSpace<Args>(args),它会给传进来的每一个参数创建一个引用了该参数的AddSpace对象,当将这个对象用于输出的时候,会在其后面加一个空格。

变参模板的使用

一个重要的作用是转发任意类型和数量的参数。比如在如下情况下会使用这一特性:

  • 向一个由智能指针管理的,在堆中创建的对象的构造函数传递参数:
1
2
// create shared pointer to complex<float> initialized by 4.2 and 7.7:
auto sp = std::make_shared<std::complex<float>>(4.2, 7.7);
  • 向一个由库启动的thread传递参数:
1
std::thread t (foo, 42, "hello"); //call foo(42,"hello") in a separate thread
  • 向一个被push进vector中的对象的构造函数传递参数:
1
2
3
std::vector<Customer> v;
...
v.emplace("Tim", "Jovi", 1962); //insert a Customer initialized by three arguments

通常是使用移动语义对参数进行完美转发(perfectly forwarded),它们像下面这样进行声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace std {
template<typename T, typename... Args>
shared_ptr<T> make_shared(Args&&... args);

class thread {
public:
template<typename F, typename... Args>
explicit thread(F&& f, Args&&... args);
...
};
template<typename T, typename Allocator = allocator<T>>
class vector {
public:
template<typename... Args>
reference emplace_back(Args&&... args);
...
};
}

注意,之前关于常规模板参数的规则同样适用于变参模板参数。比如,如果参数是按值传递的,那么其参数会被拷贝,类型也会退化(decay)。如果是按引用传递的,那么参数会是实参的引用,并且类型不会退化:

1
2
3
4
// args are copies with decayed types:
template<typename... Args> void foo (Args... args);
// args are nondecayed references to passed objects:
template<typename... Args> void bar (Args const&... args);

变参类模板和变参表达式

除了上面提到的例子,参数包还可以出现在其它一些地方,比如表达式,类模板,using声明,甚至是推断指引中。

变参表达式

除了转发所有参数之外,还可以做些别的事情。比如计算它们的值。下面的例子先是将参数包中的所有的参数都翻倍,然后将结果传给print()

1
2
3
4
5
template<typename... T>
void printDoubled (T const&... args)
{
print (args + args...);
}

如果这样调用它:

1
printDoubled(7.5, std::string("hello"), std::complex<float>(4,2));

效果上和下面的调用相同(除了构造函数方面的不同):

1
2
print(7.5 + 7.5, std::string("hello") + std::string("hello"),
std::complex<float>(4,2) + std::complex<float>(4,2));

如果只是想向每个参数加1,省略号…中的点不能紧跟在数值后面:

1
2
3
4
5
6
7
template<typename... T>
void addOne (T const&... args)
{
print (args + 1...); // ERROR: 1... is a literal with too many decimal points
print (args + 1 ...); // OK
print ((args + 1)...); // OK
}

编译阶段的表达式同样可以像上面那样包含模板参数包。比如下面这个例子可以用来判断所有参数包中参数的类型是否相同:

1
2
3
4
5
template<typename T1, typename... TN>
constexpr bool isHomogeneous (T1, TN...)
{
return (std::is_same<T1,TN>::value && ...); // since C++17
}

这是折叠表达式的一种应用。对于:

1
isHomogeneous(43, -1, "hello")

会被扩展成:

1
std::is_same<int,int>::value && std::is_same<int,char const*>::value

结果自然是false。而对:

1
isHomogeneous("hello", "", "world", "!")

结果则是true,因为所有的参数类型都被推断为char const *(这里因为是按值传递,所以发生了类型退还,否则类型将依次被推断为:char const[6], char const[1], char const[6]char const[2])。

变参下标(Variadic Indices)

作为另外一个例子,下面的函数通过一组变参下标来访问第一个参数中相应的元素:

1
2
3
4
5
template<typename C, typename... Idx>
void printElems (C const& coll, Idx... idx)
{
print (coll[idx]...);
}

当调用:

1
2
std::vector<std::string> coll = {"good", "times", "say", "bye"};
printElems(coll,2,0,3);

时,相当于调用了:

1
print (coll[2], coll[0], coll[3]);

也可以将非类型模板参数声明成参数包。比如对:

1
2
3
4
5
template<std::size_t... Idx, typename C>
void printIdx (C const& coll)
{
print(coll[Idx]...);
}

可以这样调用:

1
2
std::vector<std::string> coll = {"good", "times", "say", "bye"};
printIdx<2,0,3>(coll);

效果上和前面的例子相同。

变参类模板

类模板也可以是变参的。一个重要的例子是,通过任意多个模板参数指定了class相应数据成员的类型:

1
2
template<typename... Elements>class Tuple;
Tuple<int, std::string, char> t; // t can hold integer, string, and character

另一个例子是指定对象可能包含的类型:

1
2
3
template<typename... Types>
class Variant;
Variant<int, std::string, char> v; // v can hold integer, string, or character

也可以将class定义成代表了一组下表的类型:

1
2
3
4
// type for arbitrary number of indices:
template<std::size_t...>
struct Indices {
};

可以用它定义一个通过print()打印std::array或者std::tuple中元素的函数,具体打印哪些元素由编译阶段的get<>从给定的下标中获取:
1
2
3
4
5
template<typename T, std::size_t... Idx>
void printByIdx(T t, Indices<Idx...>)
{
print(std::get<Idx>(t)...);
}

可以像下面这样使用这个模板:

1
2
std::array<std::string, 5> arr = {"Hello", "my", "new", "!", "World"};
printByIdx(arr, Indices<0, 4, 3>());

或者像下面这样:

1
2
auto t = std::make_tuple(12, "monkeys", 2.0);
printByIdx(t, Indices<0, 1, 2>());

变参推断指引

推断指引也可以是变参的。比如在C++标准库中,为std::array定义了如下推断指引:

1
2
3
4
namespace std {
template<typename T, typename... U> array(T, U...)
-> array<enable_if_t<(is_same_v<T, U> && ...), T>, (1 + sizeof...(U))>;
}

针对这样的初始化:

1
std::array a{42,45,77};

会将指引中的T推断为array(首)元素的类型,而U...会被推断为剩余元素的类型。因此array中元素总数目是1 + sizeof...(U),等效于如下声明:
1
std::array<int, 3> a{42,45,77};

其中对array第一个参的操作std::enable_if<>是一个折叠表达式,可以展开成这样:

1
is_same_v<T, U1> && is_same_v<T, U2> && is_same_v<T, U3> ...

如果结果是false(也就是说array中元素不是同一种类型),推断指引会被弃用,总的类型推断失败。这样标准库就可以确保在推断指引成功的情况下,所有元素都是同一种类型。

变参基类及其使用

最后,考虑如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <string>
#include <unordered_set>
class Customer
{
private:
std::string name;
public:
Customer(std::string const& n) : name(n) { }
std::string getName() const { return name; }
};
struct CustomerEq {
bool operator() (Customer const& c1, Customer const& c2) const {
return c1.getName() == c2.getName();
}
};
struct CustomerHash {
std::size_t operator() (Customer const& c) const {
return std::hash<std::string>()(c.getName());
}
};
// define class that combines operator() for variadic base classes:
template<typename... Bases>
struct Overloader : Bases...
{
using Bases::operator()...; // OK since C++17
};
int main()
{
// combine hasher and equality for customers in one type:
using CustomerOP = Overloader<CustomerHash,CustomerEq>;
std::unordered_set<Customer,CustomerHash,CustomerEq> coll1;
std::unordered_set<Customer,CustomerOP,CustomerOP> coll2;
...
}

这里首先定义了一个Customer类和一些用来比较Customer对象以及计算这些对象hash值的函数对象。通过

1
2
3
4
5
template<typename... Bases>
struct Overloader : Bases...
{
using Bases::operator()...; // OK since C++17
};

从个数不定的基类派生出了一个新的类,并且从其每个基类中引入了operator()的声明。比如通过:

1
using CustomerOP = Overloader<CustomerHash,CustomerEq>;

CustomerHashCustomerEq派生出了CustomerOP,而且派生类中会包含两个基类中的operator()的实现。

总结

  • 通过使用参数包,模板可以有任意多个任意类型的参数。
  • 为了处理这些参数,需要使用递归,而且需要一个非变参函数终结递归(如果使用编译期判断,则不需要非变参函数来终结递归)。
  • 运算符sizeof…用来计算参数包中模板参数的数目。
  • 变参模板的一个典型应用是用来发送(forward)任意多个任意类型的模板参数。
  • 通过使用折叠表达式,可以将某种运算应用于参数包中的所有参数。

基础技巧

本章将涉及一些和模板实际使用有关的晋级知识,包含:关键字typename的使用,定义为模板的成员函数以及嵌套类,模板参数模板(template template parameters),零初始化以及其它一些关于使用字符串常量作为模板参数的细节讨论。

typename关键字

关键字typename在C++标准化过程中被引入进来,用来澄清模板内部的一个标识符代表的是某种类型,而不是数据成员。考虑下面这个例子:

1
2
3
4
5
6
7
template<typename T>
class MyClass {
public:
void foo() {
typename T::SubType* ptr;
}
};

其中第二个typename被用来澄清SubType是定义在class T中的一个类型。因此在这里ptr是一个指向T::SubType类型的指针。

如果没有typename的话,SubType会被假设成一个非类型成员(比如static成员或者一个枚举常量,亦或者是内部嵌套类或者using声明的public别名)。这样的话,表达式T::SubType* ptr会被理解成class Tstatic成员SubTypeptr的乘法运算,这不是一个错误,因为对MyClass<>的某些实例化版本而言,这可能是有效的代码。

通常而言,当一个依赖于模板参数的名称代表的是某种类型的时候,就必须使用typename。使用typename的一种场景是用来声明泛型代码中标准容器的迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
// print elements of an STL container
template<typename T>
void printcoll (T const& coll)
{
typename T::const_iterator pos; // iterator to iterate over coll
typename T::const_iterator end(coll.end()); // end position
for (pos=coll.begin(); pos!=end; ++pos) {
std::cout << *pos << "";
}
std::cout << "\n";
}

在这个函数模板中,调用参数是一个类型为T的标准容器。为了遍历容器中的所有元素,使用了声明于每个标准容器中的迭代器类型:

1
2
3
4
5
6
class stlcontainer {
public:
using iterator = ...; // iterator for read/write access
using const_iterator = ...; // iterator for read access
...
};

因此为了使用模板类型T的cons_iterator,必须在其前面使用typename:

1
typename T::const_iterator pos;

零初始化

对于基础类型,比如int,double以及指针类型,由于它们没有默认构造函数,因此它们不会被默认初始化成一个有意义的值。比如任何未被初始化的局部变量的值都是未定义的:

1
2
3
4
5
void foo()
{
int x; // x has undefined value
int* ptr; // ptr points to anywhere (instead of nowhere)
}

因此在定义模板时,如果想让一个模板类型的变量被初始化成一个默认值,那么只是简单的定义是不够的,因为对内置类型,它们不会被初始化:

1
2
3
4
5
template<typename T>
void foo()
{
T x; // x has undefined value if T is built-in type
}

出于这个原因,对于内置类型,最好显式的调用其默认构造函数来将它们初始化成0(对于bool类型,初始化为false,对于指针类型,初始化成nullptr)。通过下面你的写法就可以保证即使是内置类型也可以得到适当的初始化:

1
2
3
4
5
template<typename T>
void foo()
{
T x{}; // x is zero (or false) if T is a built-in type
}

这种初始化的方法被称为“值初始化(value initialization)”,它要么调用一个对象已有的构造函数,要么就用零来初始化这个对象。即使它有显式的构造函数也是这样。

在C++11之前,确保一个对象得到显示初始化的方式是:

1
T x = T(); // x is zero (or false) if T is a built-in type

在C++17之前,只有在与拷贝初始化对应的构造函数没有被声明为explicit的时候,这一方式才有效(目前也依然被支持)。从C++17开始,由于强制拷贝省略(mandatory copy elision)的使用,这一限制被解除,因此在C++17之后以上两种方式都有效。不过对于用花括号初始化的情况,如果没有可用的默认构造函数,它还可以使用列表初始化构造函数(initializer-list constructor)。

为确保类模板中类型被参数化了的成员得到适当的初始化,可以定义一个默认的构造函数并在其中对相应成员做初始化:

1
2
3
4
5
6
7
8
9
10
template<typename T>
class MyClass {
private:
T x;
public:
MyClass() : x{} { // ensures that x is initialized even for
built-in types
}
...
};

C++11之前的语法:

1
2
MyClass() : x() { //ensures that x is initialized even forbuilt-in types
}

也依然有效。从C++11开始也可以通过如下方式对非静态成员进行默认初始化:

1
2
3
4
5
6
template<typename T>
class MyClass {
private:
T x{}; // zero-initialize x unless otherwise specified
...
};

但是不可以对默认参数使用这一方式,比如:

1
2
3
4
template<typename T>
void foo(T p{}) { //ERROR
...
}

对这种情况必须像下面这样初始化:

1
2
3
4
template<typename T>
void foo(T p = T{}) { //OK (must use T() before C++11)
...
}

使用this->

对于类模板,如果它的基类也是依赖于模板参数的,那么对它而言即使x是继承而来的,使用this->xx也不一定是等效的。比如:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
class Base {
public:
void bar();
};
template<typename T>
class Derived : Base<T> {
public:
void foo() {
bar(); // calls external bar() or error
}
};

Derived中的bar()永远不会被解析成Base中的bar()。因此这样做要么会遇到错误,要么就是调用了其它地方的bar()(比如可能是定义在其它地方的global的bar())。

作为经验法则,建议当使用定义于基类中的、依赖于模板参数的成员时,用this->或者Base<T>::来修饰它。

使用裸数组或者字符串常量的模板

当向模板传递裸数组或者字符串常量时,需要格外注意以下内容:

第一,如果参数是按引用传递的,那么参数类型不会退化(decay)。也就是说当传递”hello”作为参数时,模板类型会被推断为char const[6]。这样当向模板传递长度不同的裸数组或者字符串常量时就可能遇到问题,因为它们对应的模板类型不一样。只有当按值传递参数时,模板类型才会退化(decay),这样字符串常量会被推断为char const *

不过也可以像下面这样定义专门用来处理裸数组或者字符串常量的模板:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T, int N, int M>
bool less (T(&a)[N], T(&b)[M])
{
for (int i = 0; i<N && i<M; ++i)
{
if (a[i]<b[i])
return true;
if (b[i]<a[i])
return false;
}
return N < M;
}

当像下面这样使用该模板的时候:

1
2
3
int x[] = {1, 2, 3};
int y[] = {1, 2, 3, 4, 5};
std::cout << less(x,y) << "\n";

less<>中的T会被实例化成int,N被实例化成3,M被实例化成5。也可以将该模板用于字符串常量:

1
std::cout << less("ab","abc") << "\n";

这里less<>中的T会被实例化成char const,N被实例化成3,M被实例化成4。如果想定义一个只是用来处理字符串常量的函数模板,可以像下面这样:

1
2
3
4
5
6
7
8
9
template<int N, int M>
bool less (char const(&a)[N], char const(&b)[M])
{
for (int i = 0; i<N && i<M; ++i) {
if (a[i]<b[i]) return true;
if (b[i]<a[i]) return false;
}
return N < M;
}

请注意你可以某些情况下可能也必须去为边界未知的数组做重载或者部分特化。下面的代码展示了对数组所做的所有可能的重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>
template<typename T>
struct MyClass; //主模板

template<typename T, std::size_t SZ>
struct MyClass<T[SZ]> // partial specialization for arrays of known bounds
{
static void print()
{
std::cout << "print() for T[" << SZ << "]\n";
}
};
template<typename T, std::size_t SZ>
struct MyClass<T(&)[SZ]> // partial spec. for references to arrays of known bounds
{
static void print() {
std::cout << "print() for T(&)[" << SZ <<"]\n";
}
};
template<typename T>
struct MyClass<T[]> // partial specialization for arrays of unknown bounds
{
static void print() {
std::cout << "print() for T[]\n";
}
};
template<typename T>
struct MyClass<T(&)[]> // partial spec. for references to arrays of unknown bounds
{
static void print() {
std::cout << "print() for T(&)[]\n";
}
};
template<typename T>
struct MyClass<T*> // partial specialization for pointers
{
static void print() {
std::cout << "print() for T*\n";
}
};

上面的代码针对以下类型对MyClass<>做了特化:边界已知和未知的数组,边界已知和未知的数组的引用,以及指针。它们之间互不相同,在各种情况下的调用关系如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "arrays.hpp"
template<typename T1, typename T2, typename T3>
void foo(int a1[7], int a2[], // pointers by language rules
int (&a3)[42], // reference to array of known bound
int (&x0)[], // reference to array of unknown bound
T1 x1, // passing by value decays
T2& x2, T3&& x3) // passing by reference
{
MyClass<decltype(a1)>::print(); // uses MyClass<T*>
MyClass<decltype(a2)>::print(); // uses MyClass<T*> a1, a2退化成指针
MyClass<decltype(a3)>::print(); // uses MyClass<T(&)[SZ]>
MyClass<decltype(x0)>::print(); // uses MyClass<T(&)[]>
MyClass<decltype(x1)>::print(); // uses MyClass<T*>
MyClass<decltype(x2)>::print(); // uses MyClass<T(&)[]>
MyClass<decltype(x3)>::print(); // uses MyClass<T(&)[]> //万能引用,引用折叠
}
int main()
{
int a[42];
MyClass<decltype(a)>::print(); // uses MyClass<T[SZ]>
extern int x[]; // forward declare array
MyClass<decltype(x)>::print(); // uses MyClass<T[]>
foo(a, a, a, x, x, x, x);
}
int x[] = {0, 8, 15}; // define forward-declared array

注意,根据语言规则,如果调用参数被声明为数组的话,那么它的真实类型是指针类型。而
且针对未知边界数组定义的模板,可以用于不完整类型,比如:

1
extern int i[];

当这一数组被按照引用传递时,它的类型是int(&)[],同样可以用于模板参数。

成员模板

类的成员也可以是模板,对嵌套类和成员函数都是这样。这一功能的作用和优点同样可以通过Stack<>类模板得到展现。通常只有当两个stack类型相同的时候才可以相互赋值(stack的类型相同说明它们的元素类型也相同)。即使两个stack的元素类型之间可以隐式转换,也不能相互赋值:

1
2
3
4
5
Stack<int> intStack1, intStack2; // stacks for ints
Stack<float> floatStack; // stack for floats
...
intStack1 = intStack2; // OK: stacks have same type
floatStack = intStack1; // ERROR: stacks have different types

默认的赋值运算符要求等号两边的对象类型必须相同,因此如果两个stack之间的元素类型不同的话,这一条件将得不到满足。但是,只要将赋值运算符定义成模板,就可以将两个元素类型可以做转换的stack相互赋值。新的Stack<>定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T>
class Stack {
private:
std::deque<T> elems; // elements
public:
void push(T const&); // push element
void pop(); // pop element
T const& top() const; // return top element
bool empty() const { // return whether the stack is empty
return elems.empty();
}
// assign stack of elements of type T2
template<typename T2>
Stack& operator= (Stack<T2> const&);
};

以上代码中有如下两点改动:

  1. 赋值运算符的参数是一个元素类型为T2的stack。
  2. 新的模板使用std::deque<>作为内部容器。这是为了方便新的赋值运算符的定义。新的赋值运算符被定义成下面这样:
1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
template<typename T2>
Stack<T>& Stack<T>::operator= (Stack<T2> const& op2)
{
Stack<T2> tmp(op2); // create a copy of the assigned stack
elems.clear(); // remove existing elements
while (!tmp.empty()) { // copy all elements
elems.push_front(tmp.top());
tmp.pop();
}
return *this;
}

下面先来看一下成员模板的定义语法。在模板类型为T的模板内部,定义了一个模板类型为T2的内部模板:

1
2
3
template<typename T>
template<typename T2>
...

在模板函数内部,你可能希望简化op2中相关元素的访问。但是由于op2属于另一种类型,因此最好使用它们的公共接口。这样访问元素的唯一方法就是通过调用top()。这就要求op2中所有元素相继出现在栈顶,为了不去改动op2,就需要做一次op2的拷贝。由于top()返回的是最后一个被添加进stack的元素,因此需要选用一个支持在另一端插入元素的容器,这就是为什么选用std::deque<>的原因,因为它的push_front()方法可以将元素添加到另一端。

为了访问op2的私有成员,可以将其它所有类型的stack模板的实例都定义成友元:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T>
class Stack {
private:
std::deque<T> elems; // elements
public:
void push(T const&); // push element
void pop(); // pop element
T const& top() const; // return top element
bool empty() const { // return whether the stack is empty
return elems.empty();
}
// assign stack of elements of type T2
template<typename T2>
Stack& operator= (Stack<T2> const&);
// to get access to private members of Stack<T2> for any type T2:
template<typename> friend class Stack;
};

如你所见,由于模板参数的名字不会被用到,因此可以被省略掉:

1
template<typename> friend class Stack;

这样就就可以将赋值运算符定义成如下形式:

1
2
3
4
5
6
7
8
9
10
template<typename T>
template<typename T2>
Stack<T>& Stack<T>::operator= (Stack<T2> const& op2)
{
elems.clear(); // remove existing elements
elems.insert(elems.begin(), // insert at the beginning
op2.elems.begin(), // all elements from op2
op2.elems.end());
return *this;
}

无论采用哪种实现方式,都可以通过这个成员模板将存储int的stack赋值给存储float的stack:

1
2
3
4
5
Stack<int> intStack; // stack for ints
Stack<float> floatStack; // stack for floats
...
floatStack = intStack; // OK: stacks have different types,
// but int converts to float

当然,这样的赋值就不会改变floatStack的类型,也不会改变它的元素的类型。在赋值之后,floatStack存储的元素依然是float类型,top()返回的值也依然是float类型。看上去这个赋值运算符模板不会进行类型检查,这样就可以在存储任意类型的两个stack之间相互赋值,但是事实不是这样。必要的类型检查会在将源stack中的元素插入到目标stack中的时候进行:

1
elems.push_front(tmp.top());

比如如果将存储string的stack赋值给存储int的stack,那么在编译这一行代码的时候会遇到如下错误信息:不能将通过tmp.top()返回的string用作elems.push_front()的参数

1
2
3
4
Stack<std::string> stringStack; // stack of strings
Stack<float> floatStack; // stack of floats
...
floatStack = stringStack; // ERROR: std::string doesn"t convert to float

同样也可以将内部的容器类型参数化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T, typename Cont = std::deque<T>>
class Stack {
private:
Cont elems; // elements
public:
void push(T const&); // push element
void pop(); // pop element
T const& top() const; // return top element
bool empty() const { // return whether the stack is empty
return elems.empty();
}
// assign stack of elements of type T2
template<typename T2, typename Cont2>
Stack& operator= (Stack<T2,Cont2> const&);
// to get access to private members of Stack<T2> for any type T2:
template<typename, typename> friend class Stack;
};

此时赋值运算符的实现会像下面这样:

1
2
3
4
5
6
7
8
9
10
template<typename T, typename Cont>
template<typename T2, typename Cont2>
Stack<T,Cont>& Stack<T,Cont>::operator= (Stack<T2,Cont2> const& op2)
{
elems.clear(); // remove existing elements
elems.insert(elems.begin(), // insert at the beginning
op2.elems.begin(), // all elements from op2
op2.elems.end());
return *this;
}

记住,对类模板而言,其成员函数只有在被用到的时候才会被实例化。因此对上面的例子,如果能够避免在不同元素类型的stack之间赋值的话,甚至可以使用vector(没有push_front方法)作为内部容器:

1
2
3
4
5
// stack for ints using a vector as an internal container
Stack<int,std::vector<int>> vStack;
...
vStack.push(42); vStack.push(7);
std::cout << vStack.top() << "\n";

由于没有用到赋值运算符模板,程序运行良好,不会报错说vector没有push_front()方法。

成员模板的特例化

成员函数模板也可以被全部或者部分地特例化。比如对下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
class BoolString {
private:
std::string value;
public:
BoolString (std::string const& s)
: value(s) {}
template<typename T = std::string>
T get() const { // get value (converted to T)
return value;
}
};

可以像下面这样对其成员函数模板get()进行全特例化:

1
2
3
4
5
// full specialization for BoolString::getValue<>() for bool
template<>
inline bool BoolString::get<bool>() const {
return value == "true" || value == "1" || value == "on";
}

注意我们不需要也不能够对特例化的版本进行声明;只能定义它们。由于这是一个定义于头文件中的全实例化版本,如果有多个编译单include了这个头文件,为避免重复定义的错误,必须将它定义成inline的。

可以像下面这样使用这个class以及它的全特例化版本:

1
2
3
4
5
6
std::cout << std::boolalpha;
BoolString s1("hello");
std::cout << s1.get() << "\n"; //prints hello
std::cout << s1.get<bool>() << "\n"; //prints false
BoolString s2("on");
std::cout << s2.get<bool>() << "\n"; //prints true

特殊成员函数的模板

如果能够通过特殊成员函数copy或者move对象,那么相应的特殊成员函数(copy构造函数以及move构造函数)也将可以被模板化。和前面定义的赋值运算符类似,构造函数也可以是模板。但是需要注意的是,构造函数模板或者赋值运算符模板不会取代预定义的构造函数和赋值运算符。成员函数模板不会被算作用来copy或者move对象的特殊成员函数。在上面的例子中,如果在相同类型的stack之间相互赋值,调用的依然是默认赋值运算符。这种行为既有好处也有坏处:

  • 某些情况下,对于某些调用,构造函数模板或者赋值运算符模板可能比预定义的copy/move构造函数或者赋值运算符更匹配,虽然这些特殊成员函数模板可能原本只打算用于在不同类型的stack之间做初始化。
  • 想要对copy/move构造函数进行模板化并不是一件容易的事情,比如该如何限制其存在的场景。

.template的使用

某些情况下,在调用成员模板的时候需要显式地指定其模板参数的类型。这时候就需要使用关键字template来确保符号<会被理解为模板参数列表的开始,而不是一个比较运算符。考虑下面这个使用了标准库中的bitset的例子:

1
2
3
4
5
6
template<unsigned long N>
void printBitset (std::bitset<N> const& bs) {
std::cout << bs.template to_string<char,
std::char_traits<char>,
std::allocator<char>>();
}

对于bitset类型的bs,调用了其成员函数模板to_string(),并且指定了to_string()模板的所有模板参数。如果没有.template的话,编译器会将to_string()后面的<符号理解成小于运算符,而不是模板的参数列表的开始。这一这种情况只有在点号前面的对象依赖于模板参数的时候才会发生。在我们的例子中,bs依赖于模板参数N。

.template标识符(标识符->template::template也类似)只能被用于模板内部,并且它前面的对象应该依赖于模板参数。

泛型lambdas和成员模板

在C++14中引入的泛型lambdas,是一种成员模板的简化。对于一个简单的计算两个任意类型参数之和的lambda:

1
2
3
[] (auto x, auto y) {
return x + y;
}

编译器会默认为它构造下面这样一个类:

1
2
3
4
5
6
7
8
class SomeCompilerSpecificName {
public:
SomeCompilerSpecificName(); // constructor only callable by compiler
template<typename T1, typename T2>
auto operator() (T1 x, T2 y) const {
return x + y;
}
};

变量模板

从C++14开始,变量也可以被某种类型参数化。称为变量模板。例如可以通过下面的代码定义pi,但是参数化了其类型:

1
2
template<typename T>
constexpr T pi{3.1415926535897932385};

注意,和其它几种模板类似,这个定义最好不要出现在函数内部或者块作用域内部。

在使用变量模板的时候,必须指明它的类型。比如下面的代码在定义pi<>的作用域内使用了两个不同的变量:

1
2
std::cout << pi<double> << "\n";
std::cout << pi<float> << "\n";

变量模板也可以用于不同编译单元:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T> T val{}; // zero initialized value
//== translation unit 1:
#include "header.hpp"
int main()
{
val<long> = 42;
print();
}
//== translation unit 2:
#include "header.hpp"
void print()
{
std::cout << val<long> << "\n"; // OK: prints 42
}

也可有默认模板类型:

1
2
template<typename T = long double>
constexpr T pi = T{3.1415926535897932385};

可以像下面这样使用默认类型或者其它类型:

1
2
std::cout << pi<> << "\n"; //outputs a long double
std::cout << pi<float> << "\n"; //outputs a float

只是无论怎样都要使用尖括号<>,不可以只用pi:

1
std::cout << pi << "\n"; //ERROR

同样可以用非类型参数对变量模板进行参数化,也可以将非类型参数用于参数器的初始化。比如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <array>
template<int N>
std::array<int,N> arr{}; // array with N elements, zero-initialized

template<auto N>
constexpr decltype(N) dval = N; // type of dval depends on passed value
int main()
{
std::cout << dval<"c"> << "\n"; // N has value "c"of type char
arr<10>[0] = 42; // sets first element of global arr
for (std::size_t i=0; i<arr<10>.size(); ++i) { // uses values set in arr
std::cout << arr<10>[i] << "\n";
}
}

注意在不同编译单元间初始化或者遍历arr的时候,使用的都是同一个全局作用域里的

1
std::array<int, 10> arr。

用于数据成员的变量模板

变量模板的一种应用场景是,用于定义代表类模板成员的变量模板。比如如果像下面这样定义一个类模板:

1
2
3
4
5
template<typename T>
class MyClass {
public:
static constexpr int max = 1000;
};

那么就可以为MyClass<>的不同特例化版本定义不同的值:

1
2
template<typename T>
int myMax = MyClass<T>::max;

应用工程师就可以使用下面这样的代码:

1
auto i = myMax<std::string>;

而不是:

1
auto i = MyClass<std::string>::max;

这意味着对于一个标准库的类:

1
2
3
4
5
6
7
namespace std {
template<typename T>
class numeric_limits {
public:
static constexpr bool is_signed = false;
};
}

可以定义:

1
2
template<typename T>
constexpr bool isSigned = std::numeric_limits<T>::is_signed;

这样就可以用:

1
isSigned<char>

代替:

1
std::numeric_limits<char>::is_signed

类型萃取Suffix_v

从C++17开始,标准库用变量模板为其用来产生一个值(布尔型)的类型萃取定义了简化方式。比如为了能够使用:

1
std::is_const_v<T> // since C++17

而不是:

1
std::is_const<T>::value //since C++11

标准库做了如下定义:

1
2
3
4
namespace std {
template<typename T>
constexpr bool is_const_v = is_const<T>::value;
}

模板参数模板

如果允许模板参数也是一个类模板的话,会有不少好处。在这里依然使用Stack类模板作为例子。对5.5节中的stack模板,如果不想使用默认的内部容器类型std::deque,那么就需要两次指定stack元素的类型。也就是说为了指定内部容器的类型,必须同时指出容器的类型和元素
的类型:

1
Stack<int, std::vector<int>> vStack; // integer stack that uses a vector

使用模板参数模板,在声明Stack类模板的时候就可以只指定容器的类型而不去指定容器中元素的类型:

1
Stack<int, std::vector> vStack; // integer stack that uses a vector

为此就需要在Stack的定义中将第二个模板参数声明为模板参数模板。可能像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T, template<typename Elem> class Cont = std::deque>
class Stack {
private:
Cont<T> elems; // elements
public:
void push(T const&); // push element
void pop(); // pop element
T const& top() const; // return top element
bool empty() const { // return whether the stack is empty
return elems.empty();
}
...
};

区别在于第二个模板参数被定义为一个类模板:

1
template<typename Elem> class Cont

默认类型也从std::deque<T>变成std::deque。这个参数必须是一个类模板,它将被第一个模板参数实例化:Cont<T> elems;

用第一个模板参数实例化第二个模板参数的情况是由Stack自身的情况决定的。实际上,可以在类模板内部用任意类型实例化一个模板参数模板。和往常一样,声明模板参数时可以使用class代替typename。在C++11之前,Cont只能被某个类模板的名字取代。

1
2
3
4
template<typename T, template<class Elem> class Cont = std::deque>
class Stack { //OK
...
};

从C++11开始,也可以用别名模板(alias template)取代Cont,但是直到C++17,在声明模板参数模板时才可以用typename代替class:

1
2
3
4
template<typename T, template<typename Elem> typename Cont = std::deque>
class Stack { //ERROR before C++17
...
};

这两个变化的目的都一样:用class代替typename不会妨碍我们使用别名模板(alias template)作为和Cont对应的模板参数。由于模板参数模板中的模板参数没有被用到,作为惯例可以省略它:

1
2
3
4
template<typename T, template<typename> class Cont = std::deque>
class Stack {
...
};

成员函数也要做相应的更改。必须将第二个模板参数指定为模板参数模板。比如对于push()成员,其实现如下:

1
2
3
4
5
template<typename T, template<typename> class Cont>
void Stack<T,Cont>::push (T const& elem)
{
elems.push_back(elem); // append copy of passed elem
}

注意,虽然模板参数模板是类或者别名类(alias templates)的占位符,但是并没有与其对应的函数模板或者变量模板的占位符。

模板参数模板的参数匹配

如果你尝试使用新版本的Stack,可能会遇到错误说默认的std::deque和模板参数模板Cont不匹配。这是因为在C++17之前,template<typename Elem> typename Cont = std::deque中的模板参数必须和实际参数(std::deque)的模板参数匹配。而且实际参数(std::deque有两个参数,第二个是默认参数allocator)的默认参数也要被匹配,这样template<typename Elem> typename Cont = std::dequ就不满足以上要求。

作为变通,可以将类模板定义成下面这样:

1
2
3
4
5
6
7
template<typename T, template<typename Elem,
typename Alloc = std::allocator<Elem>> class Cont = std::deque>
class Stack {
private:
Cont<T> elems; // elements
...
};

其中的Alloc同样可以被省略掉。因此最终的Stack模板会像下面这样(包含了赋值运算符模板):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <deque>
#include <cassert>
#include <memory>
template<typename T, template<typename Elem, typename =
std::allocator<Elem>> class Cont = std::deque>
class Stack {
private:
Cont<T> elems; // elements
public:
void push(T const&); // push element
void pop(); // pop element
T const& top() const; // return top element
bool empty() const { // return whether the stack is empty
return elems.empty();
}
// assign stack of elements of type T2
template<typename T2, template<typename Elem2,
typename = std::allocator<Elem2> >class Cont2>
Stack<T,Cont>& operator= (Stack<T2,Cont2> const&);
// to get access to private members of any Stack with elements of type T2:
template<typename, template<typename, typename>class>
friend class Stack;
};
template<typename T, template<typename,typename> class Cont>
void Stack<T,Cont>::push (T const& elem)
{
elems.push_back(elem); // append copy of passed elem
}
template<typename T, template<typename,typename> class Cont>
void Stack<T,Cont>::pop ()
{
assert(!elems.empty());
elems.pop_back(); // remove last element
}
template<typename T, template<typename,typename> class Cont>
T const& Stack<T,Cont>::top () const
{
assert(!elems.empty());
return elems.back(); // return copy of last element
}
template<typename T, template<typename,typename> class Cont>
template<typename T2, template<typename,typename> class Cont2>
Stack<T,Cont>& Stack<T,Cont>::operator= (Stack<T2,Cont2> const& op2)
{
elems.clear(); // remove existing elements
elems.insert(elems.begin(), // insert at the beginning
op2.elems.begin(), // all elements from op2
op2.elems.end());
return *this;
}

这里为了访问赋值运算符op2中的元素,将其它所有类型的Stack声明为friend(省略模板参数的名称):

1
2
template<typename, template<typename, typename>class>
friend class Stack;

同样,不是所有的标准库容器都可以用做Cont参数。比如std::array就不行,因为它有一个非类型的代表数组长度的模板参数,在上面的模板中没有与之对应的模板参数。下面的例子用到了最终版Stack模板的各种特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "stack9.hpp"
#include <iostream>
#include <vector>
int main()
{
Stack<int> iStack; // stack of ints
Stack<float> fStack; // stack of floats
// manipulate int stack
iStack.push(1);
iStack.push(2);
std::cout << "iStack.top(): " << iStack.top() << "\n";
// manipulate float stack:
fStack.push(3.3);
std::cout << "fStack.top(): " << fStack.top() << "\n";
// assign stack of different type and manipulate again
fStack = iStack;
fStack.push(4.4);
std::cout << "fStack.top(): " << fStack.top() << "\n";
// stack for doubless using a vector as an internal container
Stack<double, std::vector> vStack;
vStack.push(5.5);
vStack.push(6.6);
std::cout << "vStack.top(): " << vStack.top() << "\n";
vStack = fStack;
std::cout << "vStack: ";
while (! vStack.empty()) {
std::cout << vStack.top() << "";
vStack.pop();
}
std::cout << "\n";
}

程序输出如下:

1
2
3
4
5
iStack.top(): 2
fStack.top(): 3.3
fStack.top(): 4.4
vStack.top(): 6.6
vStack: 4.4 2 1

总结

  • 为了使用依赖于模板参数的类型名称,需要用typename修饰该名称。
  • 为了访问依赖于模板参数的父类中的成员,需要用this->或者类名修饰该成员。
  • 嵌套类或者成员函数也可以是模板。一种应用场景是实现可以进行内部类型转换的泛型代码。
  • 模板化的构造函数或者赋值运算符不会取代预定义的构造函数和赋值运算符。
  • 使用花括号初始化或者显式地调用默认构造函数,可以保证变量或者成员模板即使被内置类型实例化,也可以被初始化成默认值。
  • 可以为裸数组提供专门的特化模板,它也可以被用于字符串常量。
  • 只有在裸数组和字符串常量不是被按引用传递的时候,参数类型推断才会退化。(裸数组退化成指针)
  • 可以定义变量模板(从C++14开始)。
  • 模板参数也可以是类模板,称为模板参数模板(template template parameters)。
  • 模板参数模板的参数类型必须得到严格匹配。

移动语义和enable_if<>

移动语义(move semantics)是C++11引入的一个重要特性。在copy或者赋值的时候,可以通过它将源对象中的内部资源move(“steal” )到目标对象,而不是copy这些内容。当然这样做的前提是源对象不在需要这些内部资源或者状态(因为源对象将会被丢弃)。

完美转发(Perfect Forwarding)

假设希望实现的泛型代码可以将被传递参数的基本特性转发出去:

  • 可变对象被转发之后依然可变。
  • Const对象被转发之后依然是const的。
  • 可移动对象(可以从中窃取资源的对象)被转发之后依然是可移动的。

不使用模板的话,为达到这一目的就需要对以上三种情况分别编程。比如为了将调用f()时传递的参数转发给函数g():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <utility>
#include <iostream>
class X {
...
};
void g (X&) {
std::cout << "g() for variable\n";
}
void g (X const&) {
std::cout << "g() for constant\n";
}
void g (X&&) {
std::cout << "g() for movable object\n";
}
// let f() forward argument val to g():
void f (X& val) {
g(val); // val is non-const lvalue => calls g(X&)
}
void f (X const& val) {
g(val); // val is const lvalue => calls g(X const&)
}
void f (X&& val) {
g(std::move(val)); // val is non-const lvalue => needs ::move() to call g(X&&)
}

int main()
{
X v; // create variable
X const c; // create constant
f(v); // f() for nonconstant object calls f(X&) => calls g(X&)
f(c); // f() for constant object calls f(X const&) => calls g(X const&)
f(X()); // f() for temporary calls f(X&&) => calls g(X&&)
f(std::move(v)); // f() for movable variable calls f(X&&) => calls
g(X&&)
}

这里定义了三种不同的f(),它们分别将其参数转发给g():

1
2
3
4
5
6
7
8
9
void f (X& val) {
g(val); // val is non-const lvalue => calls g(X&)
}
void f (X const& val) {
g(val); // val is const lvalue => calls g(X const&)
}
void f (X&& val) {
g(std::move(val)); // val is non-const lvalue => needs std::move() to call g(X&&)
}

注意其中针对可移动对象(一个右值引用)的代码不同于其它两组代码;它需要用std::move()来处理其参数,因为参数的移动语义不会被一起传递。虽然第三个f()中的val被声明成右值引用,但是当其在f()内部被使用时,它依然是一个非常量左值,其行为也将和第一个f()中的情况一样。因此如果不使用std::move()的话,在第三个f()中调用的将是g(X&)而不是g(X&&)。如果试图在泛型代码中统一以上三种情况,会遇到这样一个问题:

1
2
3
4
template<typename T>
void f (T val) {
g(val);
}

这个模板只对前两种情况有效,对第三种用于可移动对象的情况无效。

基于这一原因,C++11引入了特殊的规则对参数进行完美转发(perfect forwarding)。实现这一目的的惯用方法如下:

1
2
3
4
template<typename T>
void f (T&& val) {
g(std::forward<T>(val)); // perfect forward val to g()
}

注意std::move没有模板参数,并且会无条件地移动其参数;而std::forward<>会根据被传递参数的具体情况决定是否“转发”其潜在的移动语义。

不要以为模板参数TT&&和具体类型XX&&是一样的。虽然语法上看上去类似,��是它们适用于不同的规则:

  • 具体类型XX&&声明了一个右值引用参数。只能被绑定到一个可移动对象上(一个prvalue,比如临时对象,一个xvalue,比如通过std::move()传递的参数)。它的值总是可变的,而且总是可以被“窃取”。
  • 模板参数TT&&声明了一个转发引用(亦称万能引用)。可以被绑定到可变、不可变(比如const)或者可移动对象上。在函数内部这个参数也可以是可变、不可变或者指向一个可以被窃取内部数据的值。

注意T必须是模板参数的名字。只是依赖于模板参数是不可以的。对于模板参数T,形如typename T::iterator&&的声明只是声明了一个右值引用,不是一个转发引用。因此,一个可以完美转发其参数的程序会像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <utility>
#include <iostream>
class X {
...
};
void g (X&) {
std::cout << "g() for variable\n";
}
void g (X const&) {
std::cout << "g() for constant\n";
}
void g (X&&) {
std::cout << "g() for movable object\n";
}
// let f() perfect forward argument val to g():
template<typename T>
void f (T&& val) {
g(std::forward<T>(val)); // call the right g() for any passed argument val
}
int main()
{
X v; // create variable
X const c; // create constant
f(v); // f() for variable calls f(X&) => calls g(X&)
f(c); // f() for constant calls f(X const&) => calls g(X const&)
f(X()); // f() for temporary calls f(X&&) => calls g(X&&)
f(std::move(v)); // f() for move-enabled variable calls f(X&&)=>
calls g(X&&)
}

完美转发同样可以被用于变参模板。

特殊成员函数模板

特殊成员函数也可以是模板,比如构造函数,但是有时候这可能会带来令人意外的结果。考虑下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <utility>
#include <string>
#include <iostream>
class Person
{
private:
std::string name;
public:
// constructor for passed initial name:
explicit Person(std::string const& n) : name(n) {
std::cout << "copying string-CONSTR for " << name << "\n";
}
explicit Person(std::string&& n) : name(std::move(n)) {
std::cout << "moving string-CONSTR for " << name << "\n";
}
// copy and move constructor:
Person (Person const& p) : name(p.name) {
std::cout << "COPY-CONSTR Person " << name << "/n";
}
Person (Person&& p) : name(std::move(p.name)) {
std::cout << "MOVE-CONSTR Person " << name << "\n";
}
};

int main(){
std::string s = "sname";
Person p1(s); // init with string object => calls copying string-CONSTR
Person p2("tmp"); // init with string literal => calls moving string-CONSTR
Person p3(p1); // copy Person => calls COPY-CONSTR
Person p4(std::move(p1)); // move Person => calls MOVE-CONST
}
//copying string-CONSTR for sname
//moving string-CONSTR for tmp
//COPY-CONSTR Persosname
//MOVE-CONSTR Person sname

例子中Person类有一个string类型的name成员和几个初始化构造函数。为了支持移动语义,重载了接受std::string作为参数的构造函数:

  • 一个以std::string对象为参数,并用其副本来初始化name成员:
1
2
3
Person(std::string const& n) : name(n) {
std::cout << "copying string-CONSTR for "" << name << ""\n";
}
  • 一个以可移动的std::string对象作为参数,并通过std:move()从中窃取值来初始化name
1
2
3
Person(std::string&& n) : name(std::move(n)) {
std::cout << "moving string-CONSTR for "" << name << ""\n";
}

和预期的一样,当传递一个正在使用的值(左值)作为参数时,会调用第一个构造函数,而以可移动对象(右值)为参数时,则会调用第二个构造函数:

1
2
3
std::string s = "sname";
Person p1(s); // init with string object => calls copying string-CONSTR
Person p2("tmp"); // init with string literal => calls moving string-CONSTR

除了这两个构造函数,例子中还提供了一个拷贝构造函数和一个移动构造函数,从中可以看出Person对象是如何被拷贝和移动的:

1
2
Person p3(p1); // copy Person => calls COPY-CONSTR
Person p4(std::move(p1)); // move Person => calls MOVE-CONSTR

现在将上面两个以std::string作为参数的构造函数替换为一个泛型的构造函数,它将传入的参数完美转发(perfect forward)给成员name:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <utility>
#include <string>
#include <iostream>
class Person
{
private:
std::string name;
public:
// generic constructor for passed initial name:
template<typename STR>
explicit Person(STR&& n) : name(std::forward<STR>(n)) {
std::cout << "TMPL-CONSTR for "" << name << ""\n";
}
// copy and move constructor:
Person (Person const& p) : name(p.name) {
std::cout << "COPY-CONSTR Person "" << name << ""\n";
}
Person (Person&& p) : name(std::move(p.name)) {
std::cout << "MOVE-CONSTR Person "" << name << ""\n";
}
};

这时如果传入参数是std::string的话,依然能够正常工作:

1
2
3
std::string s = "sname";
Person p1(s); // init with string object => calls TMPL-CONSTR
Person p2("tmp"); //init with string literal => calls TMPL-CONS

注意这里在构建p2的时候并不会创建一个临时的std::string对象:STR的类型被推断为char const[4]。但是将std::forward<STR>用于指针参数没有太大意义。成员name将会被一个以null结尾的字符串构造。但是,当试图调用拷贝构造函数的时候,会遇到错误:

1
Person p3(p1); // ERROR

而用一个可移动对象初始化Person的话却可以正常工作:

1
Person p4(std::move(p1)); // OK: move Person => calls MOVECONST

如果试图拷贝一个Personconst对象的话,也没有问题:

1
2
Person const p2c("ctmp"); //init constant object with string literal
Person p3c(p2c); // OK: copy constant Person => calls COPY-CONSTR

问题出在这里:根据C++重载解析规则,对于一个非const左值的Person p,成员模板

1
2
template<typename STR>
Person(STR&& n)

通常比预定义的拷贝构造函数更匹配:

1
Person (Person const& p)

这里STR可以直接被替换成Person&,但是对拷贝构造函数还要做一步const转换。

额外提供一个非const的拷贝构造函数看上去是个不错的方法:

1
Person (Person& p)

不过这只是一个部分解决问题的方法,更好的办法依然是使用模板。我们真正想做的是当参数是一个Person对象或者一个可以转换成Person对象的表达式时,不要启用模板。这可以通过std::enable_if<>实现。

通过std::enable_if<>禁用模板

从C++11开始,通过C++标准库提供的辅助模板std::enable_if<>,可以在某些编译期条件下忽略掉函数模板。比如,如果函数模板foo<>的定义如下:

1
2
3
template<typename T>
typename std::enable_if<(sizeof(T) > 4)>::type foo() {
}

这一模板定义会在sizeof(T) > 4不成立的时候被忽略掉。如果sizeof<T> > 4成立,函数模板会展开成:

1
2
3
template<typename T>
void foo() {
}

也就是说std::enable_if<>是一种类型萃取(type trait),它会根据一个作为其(第一个)模板参数的编译期表达式决定其行为:

  • 如果这个表达式结果为true,它的type成员会返回一个类型:
    • 如果没有第二个模板参数,返回类型是void。
    • 否则,返回类型是其第二个参数的类型。
  • 如果表达式结果false,则其成员类型是未定义的。根据模板的一个叫做SFINAE(substitute failure is not an error,替换失败不是错误)的规则,这会导致包含std::enable_if<>表达式的函数模板被忽略掉。

由于从C++14开始所有的模板萃取(type traits)都返回一个类型,因此可以使用一个与之对应的别名模板std::enable_if_t<>,这样就可以省略掉template::type了。如下:

1
2
3
template<typename T>
std::enable_if_t<(sizeof(T) > 4)> foo() {
}

如果给std::enable_if<>或者std::enable_if_t<>传递第二个模板参数:

1
2
3
4
5
template<typename T>
std::enable_if_t<(sizeof(T) > 4), T>
foo() {
return T();
}

那么在sizeof(T) > 4时,enable_if会被扩展成其第二个模板参数。因此如果与T对应的模板参数被推断为MyType,而且其size大于4,那么其等效于:

1
MyType foo();

但是由于将enable_if表达式放在声明的中间不是一个明智的做法,因此使用std::enable_if<>的更常见的方法是使用一个额外的、有默认值的模板参数:

1
2
3
template<typename T, typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo() {
}

如果sizeof(T) > 4,它会被展开成:

1
2
3
template<typename T, typename = void>
void foo() {
}

如果你认为这依然不够明智,并且希望模板的约束更加明显,那么你可以用别名模板(alias template)给它定义一个别名:

1
2
3
4
5
template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;
template<typename T, typename = EnableIfSizeGreater4<T>>
void foo() {
}

使用enable_if<>

我们要解决的问题是:当传递的模板参数的类型不正确的时候,禁用如下构造函数模板:

1
2
template<typename STR>
Person(STR&& n);

为了这一目的,需要使用另一个标准库的类型萃取,std::is_convertiable<FROM, TO>。在C++17中,相应的构造函数模板的定义如下:

1
2
3
template<typename STR, typename =
std::enable_if_t<std::is_convertible_v<STR, std::string>>>
Person(STR&& n);

如果STR可以转换成std::string,这个定义会扩展成:

1
2
template<typename STR, typename = void>
Person(STR&& n);

否则这个函数模板会被忽略。

这里同样可以使用别名模板给限制条件定义一个别名:

1
2
3
4
5
6
template<typename T>
using EnableIfString = std::enable_if_t<std::is_convertible_v<T,
std::string>>;
...
template<typename STR, typename = EnableIfString<STR>>
Person(STR&& n);

现在完整Person类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>
template<typename T>
using EnableIfString =
std::enable_if_t<std::is_convertible_v<T,std::string>>;
class Person
{
private:
std::string name;
public:
// generic constructor for passed initial name:
template<typename STR, typename = EnableIfString<STR>>
explicit Person(STR&& n) : name(std::forward<STR>(n)) {
std::cout << "TMPL-CONSTR for "" << name << ""\n";
}
// copy and move constructor:
Person (Person const& p) : name(p.name) {
std::cout << "COPY-CONSTR Person "" << name << ""\n";
}
Person (Person&& p) : name(std::move(p.name)) {
std::cout << "MOVE-CONSTR Person "" << name << ""\n";
}
};

所有的调用也都会表现正常:

1
2
3
4
5
6
7
8
9
#include "specialmemtmpl3.hpp"
int main()
{
std::string s = "sname";
Person p1(s); // init with string object => calls TMPL-CONSTR
Person p2("tmp"); // init with string literal => calls TMPL-CONSTR
Person p3(p1); // OK => calls COPY-CONSTR
Person p4(std::move(p1)); // OK => calls MOVE-CONST
}

注意在C++14中,由于没有给产生一个值的类型萃取定义带_v的别名,必须使用如下定义:

1
2
3
template<typename T>
using EnableIfString =
std::enable_if_t<std::is_convertible<T,std::string>::value>;

而在C++11中,由于没有给产生一个类型的类型萃取定义带_t的别名,必须使用如下定义:

1
2
3
4
template<typename T>
using EnableIfString
= typename std::enable_if<std::is_convertible<T,
std::string>::value >::type;

但是通过定义EnableIfString,这些复杂的语法都被隐藏了。

除了使用要求类型之间可以隐式转换的std::is_convertible<>之外,还可以使用std::is_constructible<>,它要求可以用显式转换来做初始化。但是需要注意的是,它的参数顺序和std::is_convertible<>相反:

1
2
3
template<typename T>
using EnableIfString =
std::enable_if_t<std::is_constructible_v<std::string, T>>;

禁用某些成员函数

注意我们不能通过使用enable_if<>来禁用copy/move构造函数以及赋值构造函数。这是因为成员函数模板不会被算作特殊成员函数(依然会生成默认构造函数),而且在需要使用copy构造函数的地方,相应的成员函数模板会被忽略掉。因此即使像下面这样定义类模板:

1
2
3
4
5
6
7
class C {
public:
template<typename T>
C (T const&) {
std::cout << "tmpl copy constructor\n";
}
};

在需要copy构造函数的地方依然会使用预定义的copy构造函数:

1
2
C x;
C y{x}; // still uses the predefined copy constructor (not the member template)

删掉copy构造函数也不行,因为这样在需要copy构造函数的地方会报错说该函数被删除了。但是也有一个办法:可以定义一个接受const volatile的copy构造函数并将其标示为delete。这样做就不会再隐式声明一个接受const参数的copy构造函数。在此基础上,可以定义一个构造函数模板,对于non volatile的类型,它会优选被选择(相较于已删除的copy构造函数):

1
2
3
4
5
6
7
8
9
10
11
12
13
class C
{
public:
... // user-define the predefined copy constructor as deleted
// (with conversion to volatile to enable better matches)
C(C const volatile&) = delete;
// implement copy constructor template with better match:
template<typename T>
C (T const&) {
std::cout << "tmpl copy constructor\n";
}
...
};

这样即使对常规copy,也会调用模板构造函数:

1
2
C x;
C y{x}; // uses the member template

于是就可以给这个模板构造函数添加enable_if<>限制。比如可以禁止对通过int类型参数实例化出来的C<>模板实例进行copy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T>
class C
{
public:
... // user-define the predefined copy constructor as deleted
// (with conversion to volatile to enable better matches)
C(C const volatile&) = delete;
// if T is no integral type, provide copy constructor template with better match:
template<typename U, typename = std::enable_if_t<!std::is_integral<U>::value>>
C (C<U> const&) {
...
}
...
};

使用concept简化enable_if<>表达式

即使使用了模板别名,enable_if的语法依然显得很蠢,因为它使用了一个变通方法:为了达到目的,使用了一个额外的模板参数,并且通过“滥用”这个参数对模板的使用做了限制。原则上我们所需要的只是一个能够对函数施加限制的语言特性,当这一限制不被满足的时候,函数会被忽略掉。

这个语言特性就是人们期盼已久的concept,可以通过其简单的语法对函数模板施加限制条件。不幸的是,虽然已经讨论了很久,但是concept依然没有被纳入C++17标准。一些编译器目前对concept提供了试验性的支持,不过其很有可能在C++17之后的标准中得到支持。通过使用concept可以写出下面这样的代码:

1
2
3
4
5
template<typename STR>
requires std::is_convertible_v<STR,std::string>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}

甚至可以将其中模板的使用条件定义成通用的concept:

1
2
template<typename T>
concept ConvertibleToString = std::is_convertible_v<T,std::string>;

然后将这个concept用作模板条件:

1
2
3
4
5
template<typename STR>
requires ConvertibleToString<STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}

也可以写成下面这样:

1
2
3
4
template<ConvertibleToString STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}

总结

  • 在模板中,可以通过使用“转发引用” (亦称“万能引用”,声明方式为模板参数T&&)和std::forward<>将模板调用参完美地数转发出去。
  • 将完美转发用于成员函数模板时,在copy或者move对象的时候它们可能比预定义的特殊成员函数更匹配。
  • 可以通过使用std::enable_if<>并在其条件为false的时候禁用模板。
  • 通过使用std::enable_if<>,可以避免一些由于构造函数模板或者赋值构造函数模板比隐式产生的特殊构造函数更加匹配而带来的问题。
  • 可以通过删除对const volatile类型参数预定义的特殊成员函数,并结合使用std::enable_if<>,将特殊成员函数模板化。
  • 通过concept可以使用更直观的语法对函数模板施加限制。

按值传递还是按引用传递

  1. X const &(const左值引用):参数引用了被传递的对象,并且参数不能被更改。
  2. X &(非const左值引用):参数引用了被传递的对象,但是参数可以被更改。
  3. X &&(右值引用):参数通过移动语义引用了被传递的对象,并且参数值可以被更改或者被“窃取”。

仅仅对已知的具体类型,决定参数的方式就已经很复杂了。在参数类型未知的模板中,就更难选择合适的传递方式了。

我们曾经建议在函数模板中应该优先使用按值传递,除非遇到以下情况:

  • 对象不允许被copy。
  • 参数被用于返回数据。
  • 参数以及其所有属性需要被模板转发到别的地方。
  • 可以获得明显的性能提升。

按值传递

当按值传递参数时,原则上所有的参数都会被拷贝。因此每一个参数都会是被传递实参的一份拷贝。对于class的对象,参数会通过class的拷贝构造函数来做初始化。调用拷贝构造函数的成本可能很高。但是有多种方法可以避免按值传递的高昂成本:事实上编译器可以通过移动语义(move semantics)来优化掉对象的拷贝,这样即使是对复杂类型的拷贝,其成本也不会很高。比如下面这个简单的按值传递参数的函数模板:

1
2
3
4
template<typename T>
void printV (T arg) {
...
}

当将该函数模板用于int类型参数时,实例化后的代码是:

1
2
3
void printV (int arg) {
...
}

参数arg变成任意实参的一份拷贝,不管实参是一个对象,一个常量还是一个函数的返回值。

如果定义一个std::string对象并将其用于上面的函数模板:

1
2
std::string s = "hi";
printV(s);

模板参数T被实例化为std::string,实例化后的代码是:

1
2
3
4
void printV (std::string arg)
{
...
}

在传递字符串时,arg变成s的一份拷贝。此时这一拷贝是通过std::string的拷贝构造函数创建的,这可能会是一个成本很高的操作,因为这个拷贝操作会对源对象做一次深拷贝,它需要开辟足够的内存来存储字符串的值。

但是并不是所有的情况都会调用拷贝构造函数。考虑如下情况:

1
2
3
4
5
6
std::string returnString();
std::string s = "hi";
printV(s); //copy constructor
printV(std::string("hi")); //copying usually optimized away (if not, move constructor)
printV(returnString()); // copying usually optimized away (if not, move constructor)
printV(std::move(s)); // move constructor

在第一次调用中,被传递的参数是左值(lvalue),因此拷贝构造函数会被调用。但是在第二和第三次调用中,被传递的参数是纯右值,此时编译器会优化参数传递,使得拷贝构造函数不会被调用。从C++17开始,C++标准要求这一优化方案必须被实现。在C++17之前,如果编译器没有优化掉这一类拷贝,它至少应该先尝试使用移动语义,这通常也会使拷贝成本变得比较低廉。

在最后一次调用中,被传递参数是xvalue(一个使用了std::move()的已经存在的非const对象),这会通过告知编译器我们不在需要s的值来强制调用移动构造函数(move constructor)。

综上所述,在调用printV()(参数是按值传递的)的时候,只有在被传递的参数是lvalue(对象在函数调用之前创建,并且通常在之后还会被用到,而且没有对其使用std::move())时,调用成本才会比较高。不幸的是,这唯一的情况也是最常见的情况,因为我们几乎总是先创建一个对象,然后在将其传递给其它函数。

按值传递会导致类型退化(decay)

关于按值传递,还有一个必须被讲到的特性:当按值传递参数时,参数类型会退化(decay)。也就是说,裸数组会退化成指针,const和volatile等限制符会被删除(就像用一个值去初始化一个用auto声明的对象那样):

1
2
3
4
5
6
7
8
9
template<typename T>
void printV (T arg) {
...
}
std::string const c = "hi";
printV(c); // c decays so that arg has type std::string
printV("hi"); //decays to pointer so that arg has type char const*
int arr[4];
printV(arr); // decays to pointer so that arg has type int *

当传递字符串常量“hi”的时候,其类型char const[3]退化成char const *,这也就是模板参数T被推断出来的类型。此时模板会被实例化成:

1
2
3
4
void printV (char const* arg)
{
...
}

这一行为继承自C语言,既有优点也有缺点。通常它会简化对被传递字符串常量的处理,但是缺点是在printV()内部无法区分被传递的是一个对象的指针还是一个存储一组对象的数组。

按引用传递

现在来讨论按引用传递。按引用传递不会拷贝对象(因为形参将引用被传递的实参)。而且,按引用传递时参数类型也不会退化(decay)。

按const引用传递

为了避免(不必要的)拷贝,在传递非临时对象作为参数时,可以使用const引用传递。比如:

1
2
3
4
template<typename T>
void printR (T const& arg) {
...
}

这个模板永远不会拷贝被传递对象(不管拷贝成本是高还是低):

1
2
3
4
5
6
std::string returnString();
std::string s = "hi";
printR(s); // no copy
printR(std::string("hi")); // no copy
printR(returnString()); // no copy
printR(std::move(s)); // no copy

即使是按引用传递一个int类型的变量,虽然这样可能会事与愿违,也依然不会拷贝。因此如下调用:

1
2
int i = 42;
printR(i); // passes reference instead of just copying i

会将printR()实例化为:
1
2
3
void printR(int const& arg) {
...
}

这样做之所以不能提高性能,是因为在底层实现上,按引用传递还是通过传递参数的地址实现的。

按引用传递不会做类型退化(decay)

按引用传递参数时,其类型不会退化(decay)。也就是说不会把裸数组转换为指针,也不会移除const和volatile等限制符。而且由于调用参数被声明为T const &,被推断出来的模板参数T的类型将不包含const。比如:

1
2
3
4
5
6
7
8
9
template<typename T>
void printR (T const& arg) {
...
}
std::string const c = "hi";
printR(c); // T deduced as std::string, arg is std::string const&
printR("hi"); // T deduced as char[3], arg is char const(&)[3]
int arr[4];
printR(arr); // T deduced as int[4], arg is int const(&)[4]

因此对于在printR()中用T声明的变量,它们的类型中也不会包含const。

按非const引用传递

如果想通过调用参数来返回变量值(比如修改被传递变量的值),就需要使用非const引用(要么就使用指针)。同样这时候也不会拷贝被传递的参数。被调用的函数模板可以直接访问被传递的参数。考虑如下情况:

1
2
3
4
template<typename T>
void outR (T& arg) {
...
}

注意对于outR(),通常不允许将临时变量(prvalue)或者通过std::move()处理过的已存在的变量(xvalue)用作其参数:

1
2
3
4
5
6
std::string returnString();
std::string s = "hi";
outR(s); //OK: T deduced as std::string, arg is std::string&
outR(std::string("hi")); //ERROR: not allowed to pass a temporary (prvalue)
outR(returnString()); // ERROR: not allowed to pass a temporary (prvalue)
outR(std::move(s)); // ERROR: not allowed to pass an xvalue

同样可以传递非const类型的裸数组,其类型也不会decay:

1
2
int arr[4];
outR(arr); // OK: T deduced as int[4], arg is int(&)[4]

这样就可以修改数组中元素的值,也可以处理数组的长度。比如:

1
2
3
4
5
6
template<typename T>
void outR (T& arg) {
if (std::is_array<T>::value) {
std::cout << "got array of " << std::extent<T>::value << "elems\n";
}...
}

但是在这里情况有一些复杂。此时如果传递的参数是const的,arg的类型就有可能被推断为const引用,也就是说这时可以传递一个右值(rvalue)作为参数,但是模板所期望的参数类型却是左值(lvalue):

1
2
3
4
5
std::string const c = "hi";
outR(c); // OK: T deduced as std::string const
outR(returnConstString()); // OK: same if returnConstString() returns const string
outR(std::move(c)); // OK: T deduced as std::string const6
outR("hi"); // OK: T deduced as char const[3]

在这种情况下,在函数模板内部,任何试图更改被传递参数的值的行为都是错误的。在调用表达式中也可以传递一个const对象,但是当函数被充分实例化之后(可能发生在接接下来的编译过程中),任何试图更改参数值的行为都会触发错误。

如果想禁止向非const应用传递const对象,有如下选择:

  • 使用static_assert触发一个编译期错误:
1
2
3
4
5
template<typename T>
void outR (T& arg) {
static_assert(!std::is_const<T>::value, "out parameter of foo<T>(T&) is const");
...
}
  • 通过使用std::enable_if<>禁用该情况下的模板:
1
2
3
4
5
template<typename T,
typename = std::enable_if_t<!std::is_const<T>::value>
void outR (T& arg) {
...
}
  • 或者是在concepts被支持之后,通过concepts来禁用该模板:
1
2
3
4
template<typename T>
requires !std::is_const_v<T>
void outR (T& arg) {
}

按转发引用传递参数(Forwarding Reference)

使用引用调用(call-by-reference)的一个原因是可以对参数进行完美转发(perfect forward)。但是请记住在使用转发引用时,有它自己特殊的规则。考虑如下代码:

1
2
3
4
template<typename T>
void passR (T&& arg) { // arg declared as forwarding reference
...
}

可以将任意类型的参数传递给转发引用,而且和往常的按引用传递一样,都不会创建被传递参数的备份:

1
2
3
4
5
6
std::string s = "hi";
passR(s); // OK: T deduced as std::string& (also the type of arg)
passR(std::string("hi")); // OK: T deduced as std::string, arg is std::string&&
passR(returnString()); // OK: T deduced as std::string, arg is std::string&&
passR(std::move(s)); // OK: T deduced as std::string, arg is std::string&&
passR(arr); // OK: T deduced as int(&)[4] (also the type of arg)

但是,这种情况下类型推断的特殊规则可能会导致意想不到的结果:

1
2
3
4
5
std::string const c = "hi";
passR(c); //OK: T deduced as std::string const&
passR("hi"); //OK: T deduced as char const(&)[3] (also the type of arg)
int arr[4];
passR(arr); //OK: T deduced as int (&)[4] (also the type of arg)

在以上三种情况中,都可以在passR()内部从arg的类型得知被传递的参数是一个右值(rvalue)还是一个const或者非const的左值(lvalue)。这是唯一一种可以传递一个参数,并用它来区分以上三种情况的方法。

看上去将一个参数声明为转发引用总是完美的。但是,没有免费的午餐。比如,由于转发引用是唯一一种可以将模板参数T隐式推断为引用的情况,此时如果在模板内部直接用T声明一个未初始化的局部变量,就会触发一个错误(引用对象在创建的时候必须被初始化):

1
2
3
4
5
6
7
template<typename T>
void passR(T&& arg) { // arg is a forwarding reference
T x; // for passed lvalues, x is a reference, which requires an initializer
}
foo(42); // OK: T deduced as int
int i;
foo(i); // ERROR: T deduced as int&, which makes the declaration of x in passR() invalid

使用std::ref()和std::cref()

从C++11开始,可以让调用者自行决定向函数模板传递参数的方式。如果模板参数被声明成按值传递的,调用者可以使用定义在头文件<functional>中的std::ref()std::cref()将参数按引用传递给函数模板。比如:

1
2
3
4
5
6
7
template<typename T>
void printT (T arg) {
...
}
std::string s = "hello";
printT(s); //pass s By value
printT(std::cref(s)); // pass s “as if by reference”

但是请注意,std::cref()并没有改变函数模板内部处理参数的方式。相反,在这里它使用了一个技巧:它用一个行为和引用类似的对象对参数进行了封装。事实上,它创建了一个std::reference_wrapper<>的对象,该对象引用了原始参数,并被按值传递给了函数模板。

std::reference_wrapper<>可能只支持一个操作:向原始类型的隐式类型转换,该转换返回原始参数对象。因此当需要操作被传递对象时,都可以直接使用这个std::reference_wrapper<>对象。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <functional> // for std::cref()
#include <string>
#include <iostream>
void printString(std::string const& s)
{
std::cout << s << "\n";
}
template<typename T>
void printT (T arg)
{
printString(arg); // might convert arg back to std::string
}
int main()
{
std::string s = "hello";
printT(s); // print s passed by value
printT(std::cref(s)); // print s passed “as if by reference”
}

最后一个调用将一个std::reference_wrapper<string const>对象按值传递给参数arg,这样std::reference_wrapper<string const>对象被传进函数模板并被转换为原始参数类型std::string

注意,编译器必须知道需要将std::reference_wrapper<string const>对象转换为原始参数类型,才会进行隐式转换。因此std::ref()std::cref()通常只有在通过泛型代码传递对象时才能正常工作。比如如果尝试直接输出传递进来的类型为T的对象,就会遇到错误,因为std::reference_wrapper<string const>中并没有定义输出运算符:

1
2
3
4
5
6
7
template<typename T>
void printV (T arg) {
std::cout << arg << "\n";
}...
std::string s = "hello";
printV(s); //OK
printV(std::cref(s)); // ERROR: no operator << for reference wrapper defined

同样下面的代码也会报错,因为不能将一个std::reference_wrapper<string const>对象和一个char const*或者std::string进行比较:

1
2
3
4
5
6
7
8
template<typename T1, typename T2>
bool isless(T1 arg1, T2 arg2)
{
return arg1 < arg2;
}...
std::string s = "hello";
if (isless(std::cref(s), "world")) ... //ERROR
if (isless(std::cref(s), std::string("world"))) ... //ERROR

此时即使让arg1arg2使用相同的模板参数T,也不会有帮助:

1
2
3
4
5
template<typename T>
bool isless(T arg1, T arg2)
{
return arg1 < arg2;
}

因为编译器在推断arg1arg2的类型时会遇到类型冲突。

综上,std::reference_wrapper<>是为了让开发者能够像使用“第一类对象(first class object)”一样使用引用,可以对它进行拷贝并将其按值传递给函数模板。也可以将它用在class内部,比如让它持有一个指向容器中对象的引用。但是通常总是要将其转换会原始类型。

处理字符串常量和裸数组

到目前为止,我们看到了将字符串常量和裸数组用作模板参数时的不同效果:

  • 按值传递时参数类型会decay,参数类型会退化成指向其元素类型的指针。
  • 按引用传递是参数类型不会decay,参数类型是指向数组的引用。

两种情况各有其优缺点。将数组退化成指针,就不能区分它是指向对象的指针还是一个被传递进来的数组。另一方面,如果传递进来的是字符串常量,那么类型不退化的话就会带来问题,因为不同长度的字符串的类型是不同的。比如:

1
2
3
4
5
6
template<typename T>
void foo (T const& arg1, T const& arg2)
{
...
}
foo("hi", "guy"); //ERROR

这里foo("hi", "guy")不能通过编译,因为hi的类型是char const [3],而guy的类型是char const [4],但是函数模板要求两个参数的类型必须相同。这种code只有在两个字符串常量的长度相同时才能通过编译。因此,强烈建议在测试代码中使用长度不同的字符串。如果将foo()声明成按值传递的,这种调用可能可以正常运行:

1
2
3
4
5
6
template<typename T>
void foo (T arg1, T arg2)
{
...
}
foo("hi", "guy"); //compiles, but ...

但是这样并不能解决所有的问题。反而可能会更糟,编译期间的问题可能会变为运行期间的问题。考虑如下代码,它用==运算符比较两个传进来的参数:

1
2
3
4
5
6
7
8
template<typename T>
void foo (T arg1, T arg2)
{
if (arg1 == arg2) { //OOPS: compares addresses of passed arrays
...
}
}
foo("hi", "guy"); //compiles, but ...

如上,此时很容易就能知道需要将被传递进来的的字符指针理解成字符串。但是情况并不总是这么简单,因为模板还要处理类型可能已经退化过了的字符串常量参数。然而,退化在很多情况下是有帮助的,尤其是在需要验证两个对象(两个对象都是参数,或者一个对象是参数,并用它给另一个赋值)是否有相同的类型或者可以转换成相同的类型的时候。这种情况的一个典型应用就是用于完美转发(perfect forwarding)。但是使用完美转发需要将参数声明为转发引用。这时候就需要使用类型萃取std::decay<>()显式的退化参数类型。

注意,有些类型萃取本身可能就会对类型进行隐式退化,比如用来返回两个参数的公共类型的std::common_type<>

关于字符串常量和裸数组的特殊实现

有时候可能必须要对数组参数和指针参数做不同的实现。此时当然不能退化数组的类型。为了区分这两种情况,必须要检测到被传递进来的参数是不是数组。通常有两种方法:

  • 可以将模板定义成只能接受数组作为参数:
1
2
3
4
5
6
7
8
9
template<typename T, std::size_t L1, std::size_t L2>
void foo(T (&arg1)[L1], T (&arg2)[L2])
{
T* pa = arg1; // decay arg1
T* pb = arg2; // decay arg2
if (compareArrays(pa, L1, pb, L2)) {
...
}
}

参数arg1arg2必须是元素类型相同、长度可以不同的两个数组。但是为了支持多种不同类型的裸数组,可能需要更多实现方式。

  • 可以使用类型萃取来检测参数是不是一个数组:
1
2
3
4
5
6
template<typename T, typename =
std::enable_if_t<std::is_array_v<T>>>
void foo (T&& arg1, T&& arg2)
{
...
}

由于这些特殊的处理方式过于复杂,最好还是使用一个不同的函数名来专门处理数组参数。或者更近一步,让模板调用者使用std::vector或者std::array作为参数。但是只要字符串还是裸数组,就必须对它们进行单独考虑。

处理返回值

返回值也可以被按引用或者按值返回。但是按引用返回可能会带来一些麻烦,因为它所引用的对象不能被很好的控制。不过在日常编程中,也有一些情况更倾向于按引用返回:

  • 返回容器或者字符串中的元素(比如通过[]运算符或者front()方法访问元素)
  • 允许修改类对象的成员
  • 为链式调用返回一个对象(比如>>和<<运算符以及赋值运算符)

另外对成员的只读访问,通常也通过返回const引用实现。但是如果使用不当,以上几种情况就可能导致一些问题。比如:

1
2
3
4
std::string* s = new std::string("whatever");
auto& c = (*s)[0];
delete s;
std::cout << c; //run-time ERROR

这里声明了一个指向字符串中元素的引用,但是在使用这个引用的地方,对应的字符串却不存在了(成了一个悬空引用),这将导致未定义的行为。比如:

1
2
3
4
auto s = std::make_shared<std::string>("whatever");
auto& c = (*s)[0];
s.reset();
std::cout << c; //run-time ERROR

因此需要确保函数模板采用按值返回的方式。但是正如接下来要讨论的,使用函数模板T作为返回类型并不能保证返回值不会是引用,因为T在某些情况下会被隐式推断为引用类型:

1
2
3
4
5
template<typename T>
T retR(T&& p) // p is a forwarding reference
{
return T{...}; // OOPS: returns by reference when called for lvalues
}

即使函数模板被声明为按值传递,也可以显式地将T指定为引用类型:

1
2
3
4
5
6
7
template<typename T>
T retV(T p) //Note: T might become a reference
{
return T{...}; // OOPS: returns a reference if T is a reference
}
int x;
retV<int&>(x); // retT() instantiated for T as int&

安全起见,有两种选择:

  • 用类型萃取std::remove_reference<>将T转为非引用类型:
1
2
3
4
5
template<typename T>
typename std::remove_reference<T>::type retV(T p)
{
return T{...}; // always returns by value
}

std::decay<>之类的类型萃取可能也会有帮助,因为它们也会隐式的去掉类型的引用。

  • 将返回类型声明为auto,从而让编译器去推断返回类型,这是因为auto也会导致类型退化:
1
2
3
4
5
template<typename T>
auto retV(T p) // by-value return type deduced by compiler
{
return T{...}; // always returns by value
}

关于模板参数声明的推荐方法

正如前几节介绍的那样,函数模板有多种传递参数的方式:

  • 将参数声明成按值传递:这一方法很简单,它会对字符串常量和裸数组的类型进行退化,但是对比较大的对象可能会受影响性能。在这种情况下,调用者仍然可以通过std::cref()std::ref()按引用传递参数,但是要确保这一用法是有效的。
  • 将参数声明成按引用传递:对于比较大的对象这一方法能够提供比较好的性能。尤其是在下面几种情况下:
    • 将已经存在的对象(lvalue)按照左值引用传递,
    • 将临时对象(prvalue)或者被std::move()转换为可移动的对象(xvalue)按右值引用传递,
    • 或者是将以上几种类型的对象按照转发引用传递。

由于这几种情况下参数类型都不会退化,因此在传递字符串常量和裸数组时要格外小心。对于转发引用,需要意识到模板参数可能会被隐式推断为引用类型(引用折叠)。

对于函数模板有如下建议:

  1. 默认情况下,将参数声明为按值传递。这样做比较简单,即使对字符串常量也可以正常工作。对于比较小的对象、临时对象以及可移动对象,其性能也还不错。对于比较大的对象,为了避免成本高昂的拷贝,可以使用std::ref()std::cref()
  2. 如果有充分的理由,也可以不这么做:
    1. 如果需要一个参数用于输出,或者即用于输入也用于输出,那么就将这个参数按非const引用传递。
    2. 如果使用模板是为了转发它的参数,那么就使用完美转发(perfect forwarding)。也就是将参数声明为转发引用并在合适的地方使用std::forward<>()。考虑使用std::decay<>或者std::common_type<>来处理不同的字符串常量类型以及裸数组类型的情况。
    3. 如果重点考虑程序性能,而参数拷贝的成本又很高,那么就使用const引用。不过如果最终还是要对对象进行局部拷贝的话,这一条建议不适用。
  3. 如果你更了解程序的情况,可以不遵循这些建议。但是请不要仅凭直觉对性能做评估。

不要过分泛型化

值得注意的是,在实际应用中,函数模板通常并不是为了所有可能的类型定义的,而是有一定的限制。这时候最好不要将该函数模板定义的过于泛型化,否则,可能会有一些令人意外的副作用。针对这种情况应该使用如下的方式定义模板:

1
2
3
4
5
template<typename T>
void printVector (std::vector<T> const& v)
{
...
}

这里通过的参数v,可以确保T不会是引用类型,因为vector不能用引用作为其元素类型。而且将vector类型的参数声明为按值传递不会有什么好处,因为按值传递一个vector的成本明显会比较高昂(vector的拷贝构造函数会拷贝vector中的所有元素)。此处如果直接将参数v的类型声明为T,就不容易从函数模板的声明上看出该使用那种传递方式了。

以std::make_pair<>为例

std::make_pair<>()是一个很好的介绍参数传递机制相关陷阱的例子。使用它可以很方便的通过类型推断创建std::pair<>对象。它的定义在各个版本的C++中都不一样:

  • 在第一版C++标准C++98中,std::make_pair<>被定义在std命名空间中,并且使用按引用传递来避免不必要的拷贝:
1
2
3
4
5
template<typename T1, typename T2>
pair<T1,T2> make_pair (T1 const& a, T2 const& b)
{
return pair<T1,T2>(a,b);
}

但是当使用std::pair<>存储不同长度的字符串常量或者裸数组时,这样做会导致严重的问题。

  • 因此在C++03中,该函数模板被定义成按值传递参数:
1
2
3
4
5
template<typename T1, typename T2>
pair<T1,T2> make_pair (T1 a, T2 b)
{
return pair<T1,T2>(a,b);
}
  • 不过在C++11中,由于make_pair<>()需要支持移动语义,就必须使用转发引用。因此,其定义大体上是这样:
1
2
3
4
5
6
template<typename T1, typename T2>
constexpr pair<typename decay<T1>::type, typename decay<T2>::type>
make_pair (T1&& a, T2&& b)
{
return pair<typename decay<T1>::type, typename decay<T2>::type>(forward<T1>(a), forward<T2>(b));
}

完整的实现还要复杂的多:为了支持std::ref()std::cref(),该函数会将std::reference_wrapper展开成真正的引用。

总结

  • 最好使用不同长度的字符串常量对模板进行测试。
  • 模板参数的类型在按值传递时会退化,按引用传递则不会。
  • 可以使用std::decay<>对按引用传递的模板参数的类型进行退化。
  • 在某些情况下,对被声明成按值传递的函数模板,可以使用std::cref()std::ref()将参数按引用进行传递。
  • 按值传递模板参数的优点是简单,但是可能不会带来最好的性能。
  • 除非有更好的理由,否则就将模板参数按值传递。
  • 对于返回值,请确保按值返回(这也意味着某些情况下不能直接将模板参数直接用于返回类型)。
  • 在比较关注性能时,做决定之前最好进行实际测试。不要相信直觉,它通常都不准确。

编译期编程

模板元编程

模板的实例化发生在编译期间(而动态语言的泛型是在程序运行期间决定的)。事实证明C++模板的某些特性可以和实例化过程相结合,这样就产生了一种C++自己内部的原始递归的“编程语言”。因此模板可以用来“计算一个程序的结果”。下面的代码在编译期间就能判断一个数是不是质数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<unsigned p, unsigned d> // p: number to check, d: current divisor
struct DoIsPrime {
static constexpr bool value = (p%d != 0) && DoIsPrime<p,d-1>::value;
};
template<unsigned p> // end recursion if divisor is 2
struct DoIsPrime<p,2> {
static constexpr bool value = (p%2 != 0);
};
template<unsigned p> // primary template
struct IsPrime {
// start recursion with divisor from p/2:
static constexpr bool value = DoIsPrime<p,p/2>::value;
};
// special cases (to avoid endless recursion with template instantiation):
template<>
struct IsPrime<0> { static constexpr bool value = false; };
template<>
struct IsPrime<1> { static constexpr bool value = false; };
template<>
struct IsPrime<2> { static constexpr bool value = true; };
template<>
struct IsPrime<3> { static constexpr bool value = true; };

IsPrime<>模板将结果存储在其成员value中。为了计算出模板参数是不是质数,它实例化了DoIsPrime<>模板,这个模板会被递归展开,以计算p除以p/22之间的数之后是否会有余数。

  • 我们通过递归地展开DoIsPrime<>来遍历所有介于p/2和2之间的数,以检查是否有某个数可以被p整除。
  • d等于2偏特例化出来的DoIsPrime<>被用于终止递归调用。

但是以上过程都是在编译期间进行的。也就是说:

1
IsPrime<9>::value

在编译期间就被扩展成false了。

通过constexpr进行计算

C++11引入了一个叫做constexpr的新特性,它大大简化了各种类型的编译期计算。如果给定了合适的输入,constexpr函数就可以在编译期间完成相应的计算。虽然C++11对constexpr函数的使用有诸多限制,但是在C++14中这些限制中的大部分都被移除了。当然,为了能够成功地进行constexpr函数中的计算,依然要求各个计算步骤都能在编译期进行:目前堆内存分配和异常抛出都不被支持。

在C++11中,判断一个数是不是质数的实现方式如下:

1
2
3
4
5
6
7
8
9
10
11
constexpr bool
doIsPrime (unsigned p, unsigned d) // p: number to check, d: current divisor
{
return d!=2 ? (p%d!=0) && doIsPrime(p,d-1) // check this and smaller divisors
: (p%2!=0); // end recursion if divisor is 2
}
constexpr bool isPrime (unsigned p)
{
return p < 4 ? !(p<2) // handle special cases
: doIsPrime(p,p/2); // start recursion with divisor from p/2
}

为了满足C++11中只能有一条语句的要求,此处只能使用条件运算符来进行条件选择。不过由于这个函数只用到了C++的常规语法,因此它比第一版中,依赖于模板实例化的代码要容易理解的多。

在C++14中,constexpr函数可以使用常规C++代码中大部分的控制结构。因此为了判断一个数是不是质数,可以不再使用笨拙的模板方式以及略显神秘的单行代码方式,而直接使用一个简单的for循环:

1
2
3
4
5
6
7
8
constexpr bool isPrime (unsigned int p)
{
for (unsigned int d=2; d<=p/2; ++d) {
if (p % d == 0) {
return false; // found divisor without remainder}
}
return p > 1; // no divisor without remainder found
}

在C++11和C++14中实现的constexpr isPrime(),都可以通过直接调用:

1
isPrime(9)

来判断9是不是一个质数。但是上面所说的“可以”在编译期执行,并不是一定会在编译期执行。在其他上下文中,编译期可能会也可能不会尝试进行编译期计算,如果在编译期尝试了,但是现有条件不满足编译期计算的要求,那么也不会报错,相应的函数调用被推迟到运行期间执行。比如:

1
constexpr bool b1 = isPrime(9); // evaluated at compile time

会在编译期进行计算(因为b1constexpr修饰)。而对

1
const bool b2 = isPrime(9); // evaluated at compile time if in namespace scope

如果b2被定义于全局作用域或者namespace作用域,也会在编译期进行计算。如果b2被定义于块作用域({}内),那么将由编译器决定是否在编译期间进行计算。下面这个例子就属于这种情况:

1
2
3
bool fiftySevenIsPrime() {
return isPrime(57); // evaluated at compile or running time
}

此时是否进行编译期计算将由编译期决定。

另一方面,在如下调用中:

1
2
3
int x;
... st
d::cout << isPrime(x); // evaluated at run time

不管x是不是质数,调用都只会在运行期间执行。

通过部分特例化进行路径选择

诸如isPrime()这种在编译期进行相关测试的功能,有一个有意思的应用场景:可以在编译期间通过部分特例化在不同的实现方案之间做选择。

比如,可以以一个非类型模板参数是不是质数为条件,在不同的模板之间做选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// primary helper template:
template<int SZ, bool = isPrime(SZ)>
struct Helper;
// implementation if SZ is not a prime number:
template<int SZ>
struct Helper<SZ, false>
{
...
};
// implementation if SZ is a prime number:
template<int SZ>
struct Helper<SZ, true>
{
...
};
template<typename T, std::size_t SZ>
long foo (std::array<T,SZ> const& coll)
{
Helper<SZ> h; // implementation depends on whether array has prime number as size
...
}

这里根据参数std::array<>size是不是一个质数,实现了两种Helper<>模板。这一偏特例化的使用方法,被广泛用于基于模板参数属性,在不同模板实现方案之间做选择。在上面的例子中,对两种可能的情况实现了两种偏特例化版本。但是也可以将主模板用于其中一种情况,然后再特例化一个版本代表另一种情况:

1
2
3
4
5
6
7
8
9
10
11
12
// primary helper template (used if no specialization fits):
template<int SZ, bool = isPrime(SZ)>
struct Helper
{
...
};
// special implementation if SZ is a prime number:
template<int SZ>
struct Helper<SZ, true>
{
...
};

由于函数模板不支持部分特例化,当基于一些限制在不同的函数实现之间做选择时,必须要使用其它一些方法:

  • 使用有static函数的类,
  • 使用std::enable_if
  • 使用SFINAE特性,
  • 或者使用从C++17开始生效的编译期的if特性。

SFINAE (Substitution Failure Is Not An Error,替换失败不是错误)

在一个函数调用的备选方案中包含函数模板时,编译器首先要决定应该将什么样的模板参数用于各种模板方案,然后用这些参数替换函数模板的参数列表以及返回类型,最后评估替换后的函数模板和这个调用的匹配情况。但是这一替换过程可能会遇到问题:替换产生的结果可能没有意义。不过这一类型的替换不会导致错误,C++语言规则要求忽略掉这一类型的替换结果。这一原理被称为SFINAE(发音类似sfee-nay),代表的是“substitution failure is not an error”。

但是上面讲到的替换过程和实际的实例化过程不一样:即使对那些最终被证明不需要被实例化的模板也要进行替换(不然就无法知道到底需不需要实例化)。不过它只会替换直接出现在函数模板声明中的相关内容(不包含函数体)。考虑如下的例子:

1
2
3
4
5
6
7
8
9
10
11
12
// number of elements in a raw array:
template<typename T, unsigned N>
std::size_t len (T(&)[N])
{
return N;
}
// number of elements for a type having size_type:
template<typename T>
typename T::size_type len (T const& t)
{
return t.size();
}

这里定义了两个接受一个泛型参数的函数模板len()

  1. 第一个函数模板的参数类型是T (&)[N],也就是说它是一个包含了N个T型元素的数组。
  2. 第二个函数模板的参数类型就是简单的T,除了返回类型要是T::size_type之外没有别的限制,这要求被传递的参数类型必须有一个size_type成员。

当传递的参数是裸数组或者字符串常量时,只有那个为裸数组定义的函数模板能够匹配:

1
2
3
int a[10];
std::cout << len(a); // OK: only len() for array matches
std::cout << len("tmp"); //OK: only len() for array matches

如果只是从函数签名来看的话,对第二个函数模板也可以分别用int[10]char const [4]替换类型参数T,但是这种替换在处理返回类型T::size_type时会导致错误。因此对于这两个调用,第二个函数模板会被忽略掉。

如果传递std::vector<>作为参数的话,则只有第二个模板参数能够匹配:

1
2
std::vector<int> v;
std::cout << len(v); // OK: only len() for a type with size_type matches

如果传递的是裸指针话,以上两个模板都不会被匹配上(但是不会因此而报错)。此时编译期会抱怨说没有发现合适的len()函数:

1
2
int* p;
std::cout << len(p); // ERROR: no matching len() function found

但是这和传递一个有size_type成员但是没有size()成员函数的情况不一样。比如如果传递的参数是std::allocator<>:

1
2
std::allocator<int> x;
std::cout << len(x); // ERROR: len() function found, but can"t size()

此时编译器会匹配到第二个函数模板。因此不会报错说没有发现合适的len()函数,而是会报一个编译期错误说对std::allocator<int>而言size()是一个无效调用。此时第二个模板函数不会被忽略掉。

如果忽略掉那些在替换之后返回值类型为无效的备选项,那么编译器会选择另外一个参数类型匹配相差的备选项。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// number of elements in a raw array:
template<typename T, unsigned N>
std::size_t len (T(&)[N])
{
return N;
}
// number of elements for a type having size_type:
template<typename T>
typename T::size_type len (T const& t)
{
return t.size();
}
//对所有类型的应急选项:
std::size_t len (...)
{
return 0;
}

此处额外提供了一个通用函数len(),它总会匹配所有的调用,但是其匹配情况也总是所有重载选项中最差的。

此时对于裸数组和vector,都有两个函数可以匹配上,但是其中不是通过省略号(…)匹配的那一个是最佳匹配。对于指针,只有应急选项能够匹配上,此时编译器不会再报缺少适用于本次调用的len()。不过对于std::allocator<int>的调用,虽然第二个和第三个函数都能匹配上,但是第二个函数依然是最佳匹配项。因此编译器依然会报错说缺少size()成员函数:

1
2
3
4
5
6
7
8
9
int a[10];
std::cout << len(a); // OK: len() for array is best match
std::cout << len("tmp"); //OK: len() for array is best match
std::vector<int> v;
std::cout << len(v); // OK: len() for a type with size_type is best match
int* p;
std::cout << len(p); // OK: only fallback len() matches
std::allocator<int> x;
std::cout << len(x); // ERROR: 2nd len() function matches best, but can’t call size() for x

当我们说“我们SFINAE掉了一个函数”时,意思是我们通过让模板在一些限制条件下产生无效代码,从而确保在这些条件下会忽略掉该模板。当你在C++标准里读到“除非在某些情况下,该模板不应该参与重载解析过程”时,它的意思就是“在该情况下,使用SFINAE方法SFINAE掉了这个函数模板”。比如std::thread类模板声明了如下构造函数:

1
2
3
4
5
6
7
8
9
namespace std {
class thread {
public:
...
template<typename F, typename... Args>
explicit thread(F&& f, Args&&... args);
...
};
}

并做了如下备注:如果decay_t<F>的类型和std:thread相同的话,该构造函数不应该参与重载解析过程。

它的意思是如果在调用该构造函数模板时,使用std::thread作为第一个也是唯一一个参数的话,那么这个构造函数模板就会被忽略掉。这是因为一个类似的成员函数模板在某些情况下可能比预定义的copy或者move构造函数更能匹配相关调用。通过SFINAE掉将该构造函数模板用于thread的情况,就可以确保在用一个thread构造另一个thread的时候总是会调用预定义的copy或者move构造函数。

但是使用该技术逐项禁用相关模板是不明智的。幸运的是标准库提供了更简单的禁用模板的方法。其中最广为人知的一个就是std::enable_if<>。因此典型的std::thread的实现如下:

1
2
3
4
5
6
7
8
9
namespace std {
class thread {
public:
...
template<typename F, typename... Args, typename = std::enable_if_t<!std::is_same_v<std::decay_t<F>, thread>>>
explicit thread(F&& f, Args&&... args);
...
};
}

通过decltype进行SFINAE(此处是动词)的表达式

对于有些限制条件,并不总是很容易地就能找到并设计出合适的表达式来SFINAE掉函数模板。

比如,对于有size_type成员但是没有size()成员函数的参数类型,我们想要保证会忽略掉函数模板len()。如果没有在函数声明中以某种方式要求size()成员函数必须存在,这个函数模板就会被选择并在实例化过程中导致错误:

1
2
3
4
5
6
7
template<typename T>
typename T::size_type len (T const& t)
{
return t.size();
}
std::allocator<int> x;
std::cout << len(x) << "\n"; //ERROR: len() selected, but x has no size()

处理这一情况有一种常用模式或者说习惯用法:

  • 通过尾置返回类型语法(trailing return type syntax)来指定返回类型(在函数名前使用auto,并在函数名后面的->后指定返回类型)。
  • 通过decltype和逗号运算符定义返回类型。
  • 将所有需要成立的表达式放在逗号运算符的前面(为了预防可能会发生的运算符被重载的情况,需要将这些表达式的类型转换为void)。
  • 在逗号运算符的末尾定义一个类型为返回类型的对象。

比如:

1
2
3
4
5
template<typename T>
auto len (T const& t) -> decltype( (void)(t.size()), T::size_type() )
{
return t.size();
}

这里返回类型被定义成:

1
decltype( (void)(t.size)(), T::size_type() )

类型指示符decltype的操作数是一组用逗号隔开的表达式,因此最后一个表达式T::size_type()会产生一个类型为返回类型的对象(decltype会将其转换为返回类型)。而在最后一个逗号前面的所有表达式都必须成立,在这个例子中逗号前面只有t.size()。之所以将其类型转换为void,是为了避免因为用户重载了该表达式对应类型的逗号运算符而导致的不确定性。注意decltype的操作数是不会被计算的,也就是说可以不调用构造函数而直接创建其“dummy”对象。

编译期if

部分特例化,SFINAE以及std::enable_if可以一起被用来禁用或者启用某个模板。而C++17又在此基础上引入了同样可以在编译期基于某些条件禁用或者启用相应模板的编译期if语句。通过使用if constexpr(...)语法,编译器会使用编译期表达式来决定是使用if语句的then对应的部分还是else对应的部分。

作为第一个例子,考虑变参函数模板print()。它用递归的方法打印其参数(可能是任意类型)。如果使用constexp if,就可以在函数内部决定是否要继续递归下去,而不用再单独定义一个函数来终结递归:

1
2
3
4
5
6
7
8
template<typename T, typename... Types>
void print (T const& firstArg, Types const&... args)
{
std::cout << firstArg << "\n";
if constexpr(sizeof...(args) > 0) {
print(args...); //code only available if sizeof...(args)>0 (since C++17)
}
}

这里如果只给print()传递一个参数,那么args...就是一个空的参数包,此时sizeof...(args)等于0。这样if语句里面的语句就会被丢弃掉,也就是说这部分代码不会被实例化。因此也就不再需要一个单独的函数来终结递归。

事实上上面所说的不会被实例化,意思是对这部分代码只会进行第一阶段编译,此时只会做语法检查以及和模板参数无关的名称检查。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T>
void foo(T t)
{
if constexpr(std::is_integral_v<T>) {
if (t > 0) {
foo(t-1); // OK
}
}
else {
undeclared(t); // error if not declared and not discarded (i.e. T is not integral)
undeclared(); // error if not declared (even if discarded)
static_assert(false, "no integral"); // always asserts (even if discarded)
static_assert(!std::is_integral_v<T>, "no integral"); //OK
}
}

此处if constexpr的使用并不仅限于模板函数,而是可以用于任意类型的函数。它所需要的只是一个可以返回布尔值的编译期表达式。比如:

1
2
3
4
5
6
7
8
9
10
int main()
{
if constexpr(std::numeric_limits<char>::is_signed) {
foo(42); // OK
}else {
undeclared(42); // error if undeclared() not declared
static_assert(false, "unsigned"); // always asserts (even if discarded)
static_assert(!std::numeric_limits<char>::is_signed, "char is unsigned"); //OK
}
}

利用这一特性,也可以让编译期函数isPrime()在非类型参数不是质数的时候执行一些额外的代码:

1
2
3
4
5
6
7
template<typename T, std::size_t SZ>
void foo (std::array<T,SZ> const& coll)
{
if constexpr(!isPrime(SZ)) {
... //special additional handling if the passed array has no prime number as size
}
}

总结

  • 模板提供了在编译器进行计算的能力(比如使用递归进行迭代以及使用部分特例化或者?:进行选择)。
  • 通过使用constexpr函数,可以用在编译期上下文中能够被调用的“常规函数(要有constexpr)”替代大部分的编译期计算工作。
  • 通过使用部分特例化,可以基于某些编译期条件在不同的类模板实现之间做选择。
  • 模板只有在被需要的时候才会被使用,对函数模板声明进行替换不会产生有效的代码。这一原理被称为SFINAE。
  • SFINAE可以被用来专门为某些类型或者限制条件提供函数模板。
  • 从C++17开始,可以通过使用编译期if基于某些编译期条件启用或者禁用某些语句。

在实践中使用模板

包含模式

有很多中组织模板源码的方式。本章讨论这其中最流行的一种方法:包含模式。

链接错误

大多数C和C++程序员都会按照如下方式组织代码:

  • 类和其它类型被放在头文件里。其文件扩展名为.hpp
  • 对于全局变量(非inline)和函数(非inline),只将其声明放在头文件里,定义则被放在一个被当作其自身编译单元的文件里。这一类文件的扩展名为.cpp。

这样做效果很好:既能够在整个程序中很容易的获得所需类型的定义,同时又避免了链接过程中的重复定义错误。

受这一惯例的影响,刚开始接触模板的程序员通常都会遇到下面这个程序中的错误。和处理“常规代码”的情况一样,在头文件中声明模板:

1
2
3
4
5
6
#ifndef MYFIRST_HPP
#define MYFIRST_HPP
// declaration of template
template<typename T>
void printTypeof (T const&);
#endif //MYFIRST_HPP

其中printTypeof()是一个简单的辅助函数的声明,它会打印一些类型相关信息。而它的具体实现则被放在了一个CPP文件中:

1
2
3
4
5
6
7
8
9
#include <iostream>
#include <typeinfo>
#include "myfirst.hpp"
// implementation/definition of template
template<typename T>
void printTypeof (T const& x)
{
std::cout << typeid(x).name() << "\n";
}

这个函数用typeid运算符打印了一个用来描述被传递表达式的类型的字符串。该运算符返回一个左值静态类型std::type_info,它的成员函数name()可以返回某些表达式的类型。C++标准并没有要求name()必须返回有意义的结果,但是在比较好的C++实现中,它的返回结果应该能够很好的表述传递给typeid的参数的类型。

接着在另一个CPP文件中使用该模板,它会include该模板的头文件:

1
2
3
4
5
6
7
#include "myfirst.hpp"
// use of the template
int main()
{
double ice = 3.0;
printTypeof(ice); // call function template for type double
}

编译器很可能会正常编译这个程序,但是链接器则可能会报错说:找不到函数printTypeof()的定义。出现这一错误的原因是函数模板printTypeof()的定义没有被实例化。为了实例化一个模板,编译器既需要知道需要实例化哪个函数,也需要知道应该用哪些模板参数来进行实例化。不���的是,在上面这个例子中,这两组信息都是被放在别的文件里单独进行编译的。因此当编译器遇到对printTypeof()的调用时,却找不到相对应的函数模板定义来针对double类型进行实例化

头文件中的模板

解决以上问题的方法和处理宏以及inline函数的方法一样:将模板定义和模板声明都放在头文件里。

也就是说需要重写myfirst.hpp,让它包含所有模板声明和模板定义,而不再提供myfirst.cpp文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef MYFIRST_HPP#define MYFIRST_HPP
#include <iostream>
#include <typeinfo>
// declaration of template
template<typename T>
void printTypeof (T const&);
// implementation/definition of template
template<typename T>
void printTypeof (T const& x)
{
std::cout << typeid(x).name() << "\n";
}
#endif //MYFIRST_HPP

这种组织模板相关代码的方法被称为“包含模式”。使用这个方法,程序的编译,链接和执行都可以正常进行。

目前有几个问题需要指出。最值得注意的一个是,这一方法将大大增加include头文件myfirst.hpp的成本。在这个例子中,成本主要不是由模板自身定义导致的,而是由那些为了使用这个模板而必须包含的头文件导致的,比如<iostream><typeinfo>。由于诸如<iostream>的头文件还会包含一些它们自己的模板,因此这可能会带来额外的数万行的代码。

模板和inline

提高程序运行性能的一个常规手段是将函数声明为inline的。Inline关键字的意思是给编译器做一个暗示,要优先在函数调用处将函数体做inline替换展开,而不是按常规的调用机制执行。

和inline函数类似,函数模板也可以被定义在多个编译单元中。比如我们通常将模板定义放在头文件中,而这个头文件又被多个CPP文件包含。但是这并不意味着函数模板在默认情况下就会使用inline替换。在模板调用处是否进行inline替换完全是由编译器决定的事情。编译器通常能够更好的评估inline替换一个被调用函数是否能够提升程序性能。因此不同编译器之间对inline函数处理的精准原则也是不同的,这甚至会受编译选项的影响。

程序员希望自己能够决定是否需要进行inline替换。有时候这只能通过编译器的具体属性实现,比如noinlinealways_inline

预编译头文件

即使不适用模板,C++的头文件也会大到需要很长时间进行编译。而模板的引入则进一步加剧了这一问题,程序员对这一问题的抱怨促使编译器供应商提供了一种叫做预编译头文件(PCH: precomplied header)的方案来降低编译时间。

预编译头文件方案的实现基于这样一个事实:在组织代码的时候,很多文件都以相同的几行代码作为开始。为了便于讨论,假设那些将要被编译文件的前N行内容都相同。这样就可以单独编译这N行代码,并将编译完成后的状态保存在一个预编译头文件中(precompiledheader)。接着所有以这N行代码开始的文件,在编译时都会重新载入这个被保存的状态,然后从第N+1行开始编译。在这里需要指出,重新载入被保存的前N行代码的预编译状态可能会比再次编译这N行代码要快很多很多倍。但是保存这个状态可能要比单次编译这N行代码慢的多,编译时间可能延长20%到200%。

因此利用预编译头文件提高编译速度的关键点是;让尽可能多的文件,以尽可能多的相同的代码作为开始。也就是说在实践中,文件要以相同的#include指令(它们可能占用大量的编译时间)开始。因此如果#include头文件的顺序相同的话,就会对提高编译性能很有帮助。但是对下面的文件:

1
2
#include <vector>
#include <list>


1
2
#include <list>
#include <vector>

预编译头文件不会起作用,因为它们的起始状态并不一致(顺序不一致)。一些程序员认为,即使可能会错过一个利用预编译头文件加速文件编译的机会,也应该多#include一些可能用不到的头文件。这样做可以大大简化预编译头文件的使用方式。比如通常可以创建一个包含所有标准头文件的头文件,称之为std.hpp

1
2
3
4
5
#include <iostream>
#include <string>
#include <vector>
#include <deque>
#include <list>

这个文件可以被预编译,其它所有用到标准库的文件都可以直接在文件开始处include这个头文件:

破译大篇幅的错误信息

常规函数的编译错误信息通常非常简单且直中要点。比如当编译器报错说”class X has no member ‘fun’”时,找到代码中相应的错误并不会很难。但是模板并不是这样。看下面这些例子。

简单的类型不匹配情况

考虑下面这个使用了C++标准库的简单例子:

1
2
3
4
5
6
7
8
9
#include <string>
#include <map>
#include <algorithm>
int main()
{
std::map<std::string,double> coll;
... // find the first nonempty string in coll:
auto pos = std::find_if (coll.begin(), coll.end(), [] (std::string const& s){return s != ""; });
}

其中有一个相当小的错误:一个lambda函数被用来找到第一个匹配的字符串,它依次将map中的元素和一个字符串比较。但是,由于map中的元素是key/value对,因此传入lambda的元素也将是一个std::pair<std::string const, double>,而它是不能直接和字符串进行比较的。针对这个错误,主流的GUN C++编译器会报如下错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1 In file included from /cygdrive/p/gcc/gcc61-include/bits/stl_algobase.h:71:0,
2 from /cygdrive/p/gcc/gcc61-include/bits/char_traits.h:39,
3 from /cygdrive/p/gcc/gcc61-include/string:40,
4 from errornovel1.cpp:1:
5 /cygdrive/p/gcc/gcc61-
include/bits/predefined_ops.h: In instantiation of 'bool __gnu_cxx ::__ops::_Iter_pred<_Predicate>::operator() (_Iterator) [with _Iterator = std::_Rb_tree_i terator<std::pair<const std::__cxx11::basic_string<char>, double::<lambda(const string&)>]':
6 /cygdrive/p/gcc/gcc61-include/bits/stl_algo.h:104:42: required from '_InputIterator std::__find_if(_InputIterator, _InputIterator, _Predicate, std:[with _InputIterator = std::_Rb_tree_iterator<std::pair<const <char>, double> >; _Predicate = __gnu_cxx::__ops::_Iter_pred<<lambda(const string&)> >]'
7 /cygdrive/p/gcc/gcc61-include/bits/stl_algo.h:161:23: required from '_Iterator std::__find_if(_Iterator, _Iterator, _Predicate) [with _Iterator = std pair<const std::__cxx11::basic_string<char>, double> >; _Predic Iter_pred<main()::<lambda(const string&)> >]'
8 /cygdrive/p/gcc/gcc61-include/bits/stl_algo.h:3824:28: required from '_IIter std::find_if(_IIter, _IIter, _Predicate) [with _IIter = std::_Rb_tree_it std::__cxx11::basic_string<char>, double> >; _Predicate = main <lambda(const string&)>]'
9 errornovel1.cpp:13:29: required from here /cygdrive/p/gcc/gcc61-include/bits/predefined_ops.h:234:11: error: no match for call to '(main()::<lambda(const string&)>) (std::pair<const std::__cxx11::basic_string<double>&)'11 { return bool(_M_pred(*__it)); }
12 ^~~~~~~~~~~~~~~~~~~~
13 /cygdrive/p/gcc/gcc61-include/bits/predefined_ops.h:234:11: note: candidate: bool (*)( const string&) {aka bool (*)(const std::__cxx11::basic_string<char>&)} <conversion>
14 /cygdrive/p/gcc/gcc61-include/bits/predefined_ops.h:234:11: note: candidate expects 2arguments, 2 provided 15 errornovel1.cpp:11:52: note: candidate: main()::<lambda(const string&)>
16 [] (std::string const& s) {
17 ^
18 errornovel1.cpp:11:52: note: no known conversion for argument std::__cxx11::basic_string<char>, double>' to 'const string& {a basic_string<char>&}'

以上错误信息中第一部分的意思是,在一个函数模板的实例中遇到了错误,这个模板位于一个内部头文件predefined_ops.h中。在这一行以及后面的几行中,编译器报告了哪些模板被用哪些参数实例化了。本例子中从以下开始:

1
2
3
4
auto pos = std::find_if (coll.begin(), coll.end(),
[] (std::string const& s) {
return s != "";
});

这导致了一个find_if的实例化,这个在stl_algo.h头文件

1
_IIter = std::_Rb_tree_iterator<std::pair<const std::__cxx11::basic_string<char>, double> >_Predicate = main()::<lambda(const string&)>

编译器会报告所有这些,以防我们根本不期望所有这些模板都被实例化。它允许我们确定导致实例化的事件链。然而,在我们的示例中,我们愿意相信各种模板都需要实例化,我们只是想知道为什么它不起作用。此信息出现在消息的最后部分:“调用不匹配”部分表示由于参数类型和参数类型不匹配,因此无法解析函数调用。它列出了调用的内容:

1
(main()::<lambda(const string&)>) (std::pair<const std::__cxx11::basic_string<char>, double>&)

此外,就在这之后,包含“note: candidate:”的行解释说有一个候选类型需要一个const string&,并且这个候选类型在errornovel1.cpp的第 11 行中定义lambda [] (std::string const& s)

毫无疑问,错误信息可能会更好。实际问题可能会在实例化之前发出,而不是使用完全扩展的模板实例化名称,如std::__cxx11::basic_string<char>,仅使用std::string可能就足够了。但是,此诊断中的所有信息在某些情况下可能很有用。

总结

  • 模板的包含模式被广泛用来组织模板代码。第14章会介绍另一种替代方法。
  • 当被定义在头文件中,且不在类或者结构体中时,函数模板的全特例化版本需要使用inline。
  • 为了充分发挥预编译的特性,要确保#include指令的顺序相同。
  • Debug模板相关代码很有挑战性。

模板基本术语

“类模板”还是“模板类”

在C++中,structs,classes以及unions都被称为class types。如果没有特殊声明的话,“class”的字面意思是用关键字class或者struct声明的class types。注意class types包含unions,但是class不包含。关于该如何称呼一个是模板的类,有一些困扰:

  • 术语class template是指这个class是模板。也就是说它是一组class的参数化表达。
  • 术语template class则被:
    • 用作class template的同义词。
    • 用来指代从template实例化出来的classes。
    • 用来指代名称是一个template-id(模板名+ <模板参数>)的类。

替换,实例化,和特例化

在处理模板相关的代码时,C++编译器必须经常去用模板实参替换模板参数。有时后这种替换只是试探性的:编译器需要验证这个替换是否有效。用实际参数替换模板参数,以从一个模板创建一个常规类、类型别名、函数、成员函数或者变量的过程,被称为“模板实例化”。

不过令人意外的是,目前就该如何表示通过模板参数替换创建一个声明(不是定义)的过程,还没有相关标准以及基本共识。有人使用“部分实例化(partial instantiation)”或者“声明的实例化(instantiation of a declaration)”,但是这些用法都不够普遍。或许使用“不完全实例化(incomplete instantiation)”会更直观一些。

通过实例化或者不完全实例化产生的实体通常被称为特例化(specialization)。但是在C++中,实例化过程并不是产生特例化的唯一方式。另外一些方式允许程序员显式的指定一个被关联到模板参数的、被进行了特殊替换的声明。这一类特例化以一个template<>开始:

1
2
3
4
5
6
7
8
template<typename T1, typename T2> // primary class template
class MyClass {
...
};
template<> // explicit specialization
class MyClass<std::string,float> {
...
};

严格来说,这被称为显式特例化(explicit specialization)。

如果特例化之后依然还有模板参数,就称之为部分特例化。

1
2
3
4
5
6
7
8
template<typename T> // partial specialization
class MyClass<T,T> {
...
};
template<typename T> // partial specialization
class MyClass<bool,T> {
...
};

声明和定义

到目前为止,“声明”和“定义”只在本书中使用了几次。但是在标准C++中,这些单词有着明确的定义,我们也将采用这些定义。

“声明”是一个C++概念,它将一个名称引入或者再次引入到一个C++作用域内。引入的过程中可能会包含这个名称的一部分类别,但是一个有效的声明并不需要相关名称的太多细节。比如:

1
2
3
class C; // a declaration of C as a class
void f(int p); // a declaration of f() as a function and p as a named parameter
extern int v; // a declaration of v as a variable

注意,在C++中虽然宏和goto标签也都有名字,但是它们并不是声明。对于声明,如果其细节已知,或者是需要申请相关变量的存储空间,那么声明就变成了定义。对于class类型的定义和函数定义,意味着需要提供一个包含在{}中的主体,或者是对函数使用了=defaul/=delete。对于变量,如果进行了初始化或者没有使用extern,那么声明也会变成定义。下面是一些“定义”的例子:

1
2
3
4
5
6
class C {}; // definition (and declaration) of class C
void f(int p) { //definition (and declaration) of function f()
std::cout << p << "\n";
}
extern int v = 1; // an initializer makes this a definition for v
int w; // global variable declarations not preceded by extern are also definitions

作为扩展,如果一个类模板或者函数模板有包含在{}中的主体的话,那么声明也会变成定义。

1
2
template<typename T>
void func (T);

是一个声明。而:

1
2
template<typename T>
class S {};

则是一个定义。

完整类型和非完整类型(complete versus incomplete types)

类型可以是完整的(complete)或者是不完整的(incomplete),这一名词和声明以及定义之间的区别密切相关。有些语言的设计要求完整类型,有一些也适用于非完整类型。非完整类型是以下情况之一:

  • 一个被声明但是还没有被定义的class类型。
  • 一个没有指定边界的数组。
  • 一个存储非完整类型的数组。
  • Void类型。
  • 一个底层类型未定义或者枚举值未定义的枚举类型。
  • 任何一个被const或者volatile修饰的以上某种类型。

其它所有类型都是完整类型。比如:

1
2
3
4
5
6
7
class C; // C is an incomplete type
C const* cp; // cp is a pointer to an incomplete type
extern C elems[10]; // elems has an incomplete type
extern int arr[]; // arr has an incomplete type...
class C { }; // C now is a complete type (and therefore cpand elems
// no longer refer to an incomplete type)
int arr[10]; // arr now has a complete type

唯一定义法则

C++语言中对实体的重复定义做了限制。这一限制就是“唯一定义法则(one-definition rule, ODR)”。目前只要记住以下基础的ODR就够了:

  • 常规(比如非模板)非inline函数和成员函数,以及非inline的全局变量和静态数据成员,在整个程序中只能被定义一次。
  • Class类型(包含struct和union),模板(包含部分特例化,但不能是全特例化),以及inline函数和变量,在一个编译单元中只能被定义一次,而且不同编译单元间的定义应该相同。

编译单元是通过预处理源文件产生的一个文件;它包含通过#include指令包含的内容以及宏展开之后的内容。

在后面的章节中,可链接实体(linkable entity)指的是下面的任意一种:一个函数或者成员函数,一个全局变量或者静态数据成员,以及通过模板产生的类似实体,只要对linker可见就行。

Template Arguments versus Template Parameters

考虑如下类模板:

1
2
3
4
5
template<typename T, int N>
class ArrayInClass {
public:
T array[N];
};

和一个类似的类:

1
2
3
4
class DoubleArrayInClass {
public:
double array[10];
};

如果将前者中的模板参数T和N替换为double和10,那么它将和后者相同。在C++中这种类型的模板参数替换被表示为:

1
ArrayInClass<double,10>

注意模板名称后面的尖括号以及其中的模板实参。

不管这些实参是否和模板参数有关,模板名称以及其后面的尖括号和其中的模板实参,被称为template-id。其用法和非模板类的用法非常相似。比如:

1
2
3
4
5
int main()
{
ArrayInClass<double,10> ad;
ad.array[0] = 1.0;
}

有必要对模板参数(template parameters)和模板实参(template arguments)进行区分。简单来讲可以说“模板参数是被模板实参初始化的”。或者更准确的说:

  • 模板参数是那些在模板定义或者声明中,出现在template关键字后面的尖括号中的名称。
  • 模板实参是那些用来替换模板参数的内容。不同于模板参数,模板实参可以不只是“名称”。

当指出模板的template-id的时候,用模板实参替换模板参数的过程就是显式的,但是在很多情况这一替换则是隐式的(比如模板参数被其默认值替换的情况)。

一个基本原则是:任何模板实参都必须是在编译期可知的。就如接下来会澄清的,这一要求对降低模板运行期间的成本很有帮助。由于模板参数最终都会被编译期的值进行替换,它们也可以被用于编译期表达式。在ArrayInClass模板中指定成员array的尺寸时就用到了这一特性。数组的尺寸必须是一个常量表达式,而模板参数N恰好满足这一要求。

对这一特性的使用可以更进一步:由于模板参数是编译期实体,它们也可以被用作模板实参。就像下面这个例子这样:

1
2
3
4
5
template<typename T>
class Dozen {
public:
ArrayInClass<T,12> contents;
};

其中T既是模板参数也是模板实参。这样这一原理就可以被用来从简单模板构造更复杂的模板。当然,在原理上,这和我们构造类型和函数并没有什么不同。

总结

  • 对那些是模板的类,函数和变量,我们称之为类模板,函数模板和变量模板。
  • 模板实例化过程是一个用实参取代模板参数,从而创建常规类或者函数的过程。最终产生的实体是一个特化。
  • 类型可以是完整的或者非完整的。
  • 根据唯一定义法则(ODR),非inline函数,成员函数,全局变量和静态数据成员在整个程序中只能被定义一次。

泛型库

可调用对象(Callables)

一些库包含这样一种接口,客户端代码可以向该类接口传递一个实体,并要求该实体必须被调用。相关的例子有:必须在另一个线程中被执行的操作,一个指定该如何处理hash值并将其存在hash表中的函数(hash函数),一个指定集合中元素排序方式的对象。标准库也不例外:它定义了很多可以接受可调用对象作为参数的组件。

这里会用到一个叫做回调(callback)的名词。传统上这一名词被作为函数调用实参使用,我们将保持这一传统。比如一个排序函数可能会接受一个回调参数并将其用作排序标准,该回调参数将决定排序顺序。

在C++中,由于一些类型既可以被作为函数调用参数使用,也可以按照f(...)的形式调用,因此可以被用作回调参数:

  • 函数指针类型
  • 重载了operator()的class类型(有时被称为仿函数(functors)),这其中包含lambda函数
  • 包含一个可以产生一个函数指针或者函数引用的转换函数的class类型

这些类型被统称为函数对象类型(function object types),其对应的值被称为函数对象(function object)。

如果可以接受某种类型的可调用对象的话,泛型代码通常可以从中受益,而模板使其称为可能。

函数对象的支持

来看一下标准库中的for_each()算法是如何实现的:

1
2
3
4
5
6
7
8
template<typename Iter, typename Callable>
void foreach (Iter current, Iter end, Callable op)
{
while (current != end) { //as long as not reached the end
op(*current); // call passed operator for current element
++current; // and move iterator to next element
}
}

下面的代码展示了将以上模板用于多种函数对象的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <vector>
#include "foreach.hpp"
// a function to call:
void func(int i)
{
std::cout << "func() called for: " << i << "\n";
}
// a function object type (for objects that can be used as functions):
class FuncObj {
public:
void operator() (int i) const { //Note: const member function
std::cout << "FuncObj::op() called for: " << i << "\n";
}
};
int main()
{
std::vector<int> primes = { 2, 3, 5, 7, 11, 13, 17, 19 };
foreach(primes.begin(), primes.end(), // range
func); // function as callable (decays to pointer)
foreach(primes.begin(), primes.end(), // range
&func); // function pointer as callable
foreach(primes.begin(), primes.end(), // range
FuncObj()); // function object as callable
foreach(primes.begin(), primes.end(), // range
[] (int i) { //lambda as callable
std::cout << "lambda called for: " << i << "\n";
});
}

详细看一下以上各种情况:

  • 当把函数名当作函数参数传递时,并不是传递函数本体,而是传递其指针或者引用。和数组情况类似,在按值传递时,函数参数退化为指针,如果参数类型是模板参数,那么类型会被推断为指向函数的指针。和数组一样,按引用传递的函数的类型不会decay。但是函数类型不能真正用const限制。如果将foreach()的最后一个参数的类型声明为Callable const &,const会被省略。
  • 在第二个调用中,函数指针被显式传递(传递了一个函数名的地址)。这和第一中调用方式相同(函数名会隐式的decay成指针),但是相对而言会更清楚一些。
  • 如果传递的是仿函数,就是将一个类的对象当作可调用对象进行传递。通过一个class类型进行调用通常等效于调用了它的operator()。因此下面这样的调用:
1
op(*current);

会被转换成:

1
op.operator()(*current); // call operator() with parameter *current for op

注意在定义operator()的时候最好将其定义成const成员函数。否则当一些框架或者库不希望该调用会改变被传递对象的状态时,会遇到很不容易debug的error。

  • Lambda表达式会产生仿函数(也称闭包),因此它与仿函数(重载了operator()的类)的情况没有不同。不过Lambda引入仿函数的方法更为简便,因此它们从C++11开始变得很常见。
    • 有意思的是,以[]开始的lambdas(没有捕获)会产生一个向函数指针进行转换的运算符。

处理成员函数以及额外的参数

在以上例子中漏掉了另一种可以被调用的实体:成员函数。这是因为在调用一个非静态成员函数的时候需要像下面这样指出对象:object.memfunc(...)或者ptr->memfunc(...),这和常规情况下的直接调用方式不同:func(...)

幸运的是,从C++17开始,标准库提供了一个工具:std::invlke(),它非常方便的统一了上面的成员函数情况和常规函数情况,这样就可以用同一种方式调用所有的可调用对象。下面代码中foreach()的实现使用了std::invoke()

1
2
3
4
5
6
7
8
9
10
11
12
#include <utility>
#include <functional>
template<typename Iter, typename Callable, typename... Args>
void foreach (Iter current, Iter end, Callable op, Args const&...args)
{
while (current != end) { //as long as not reached the end of the elements
std::invoke(op, //call passed callable with
args..., //any additional args
*current); // and the current element
++current;
}
}

这里除了作为参数的可调用对象,foreach()还可以接受任意数量的参数。然后foreach()将参数传递给std::invoke()std::invoke()会这样处理相关参数:

  • 如果可调用对象是一个指向成员函数的指针,它会将args...中的第一个参数当作this对象(不是指针)。args...中其余的参数则被当做常规参数传递给可调用对象。
  • 否则,所有的参数都被直接传递给可调用对象。

注意这里对于可调用对象和agrs...都不能使用完美转发(perfect forward):因为第一次调用可能会steal(偷窃)相关参数的值,导致在随后的调用中出现错误。

现在既可以像之前那样调用foreach(),也可以向它传递额外的参数,而且可调用对象可以是一个成员函数。正如下面的代码展现的那样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <vector>
#include <string>
#include "foreachinvoke.hpp"
// a class with a member function that shall be called
class MyClass {
public:
void memfunc(int i) const {
std::cout << "MyClass::memfunc() called for: " << i << "\n";
}
};
int main()
{
std::vector<int> primes = { 2, 3, 5, 7, 11, 13, 17, 19 };
// pass lambda as callable and an additional argument:
foreach(primes.begin(), primes.end(), //elements for 2nd arg of lambda
[](std::string const& prefix, int i) { //lambda to call
std::cout << prefix << i << "\n";
}, "- value:"); //1st arg of lambda
// call obj.memfunc() for/with each elements in primes passed as argument
MyClass obj;
foreach(primes.begin(), primes.end(), //elements used as args
&MyClass::memfunc, //member function to call
obj); // object to call memfunc() for
}

第一次调用foreach()时,第四个参数被作为lambda函数的第一个参数传递给lambda,而vector中的元素被作为第二个参数传递给lambda。第二次调用中,第三个参数memfunc()被第四个参数obj调用。

函数调用的包装

std::invoke()的一个常规用法是封装一个单独的函数调用。此时可以通过完美转发可调用对象以及被传递的参数来支持移动语义:

1
2
3
4
5
6
7
8
#include <utility> // for std::invoke()
#include <functional> // for std::forward()
template<typename Callable, typename... Args>
decltype(auto) call(Callable&& op, Args&&... args)
{
return std::invoke(std::forward<Callable>(op), //passed callable with
std::forward<Args>(args)...); // any additional args
}

一个比较有意思的地方是该如何处理被调用函数的返回值,才能将其“完美转发”给调用者。为了能够返回引用(比如std::ostream&),需要使用decltype(auto)而不是auto

1
2
template<typename Callable, typename... Args>
decltype(auto) call(Callable&& op, Args&&... args)

decltype(auto)(在C++14中引入)是一个占位符类型,它根据相关表达式决定了变量、返回值、或者模板实参的类型。

如果你想暂时的将std::invoke()的返回值存储在一个变量中,并在做了某些别的事情后将其返回(比如处理该返回值或者记录当前调用的结束),也必须将该临时变量声明为decltype(auto)类型:

1
2
3
4
decltype(auto) ret{std::invoke(std::forward<Callable>(op),
std::forward<Args>(args)...)};
...
return ret;

注意这里将ret声明为auto &&是不对的。auto&&作为引用会将变量的生命周期扩展到作用域的末尾,但是不会扩展到超出return的地方。不过即使是使用decltype(auto)也还是有一个问题:如果可调用对象的返回值是void,那么将ret初始化为decltype(auto)是不可以的,这是因为void是不完整类型。此时有如下选择:

  • 在当前行前面声明一个对象,并在其析构函数中实现期望的行为。比如:
1
2
3
4
5
6
7
struct cleanup {
~cleanup() {
... //code to perform on return
}
} dummy;
return std::invoke(std::forward<Callable>(op),
std::forward<Args>(args)...);
  • 分别实现void和非void的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <utility> // for std::invoke()
#include <functional> // for std::forward()
#include <type_traits> // for std::is_same<> and invoke_result<>
template<typename Callable, typename... Args>
decltype(auto) call(Callable&& op, Args&&... args)
{
if constexpr(std::is_same_v<std::invoke_result_t<Callable,
Args...>, void>) {// return type is void:
std::invoke(std::forward<Callable>(op),
std::forward<Args>(args)...);
...
return;
} else {
// return type is not void:
decltype(auto) ret{std::invoke(std::forward<Callable>(op),
std::forward<Args>(args)...)};
return ret;
}
}

其中:

1
if constexpr(std::is_same_v<std::invoke_result_t<Callable, Args...>, void>)

在编译期间检查使用Args...的callable的返回值是不是void类型。后续的C++版本可能会免除掉这种对void的特殊操作。

其他一些实现泛型库的工具

std::invoke()只是C++标准库提供的诸多有用工具中的一个。在接下来的内容中,我们会介绍其他一些重要的工具。

类型萃取

标准库提供了各种各样的被称为类型萃取(type traits)的工具,它们可以被用来计算以及修改类型。这样就可以在实例化的时候让泛型代码适应各种类型或者对不同的类型做出不同的响应。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <type_traits>
template<typename T>
class C
{
// ensure that T is not void (ignoring const or volatile):
static_assert(!std::is_same_v<std::remove_cv_t<T>,void>,
"invalid instantiation of class C for void type");
public:
template<typename V>
void f(V&& v) {
if constexpr(std::is_reference_v<T>) {
... // special code if T is a reference type
} if constexpr(std::is_convertible_v<std::decay_t<V>,T>) {
... // special code if V is convertible to T
} if constexpr(std::has_virtual_destructor_v<V>) {
... // special code if V has virtual destructor
}
}
};

如上所示,通过检查某些条件,可以在模板的不同实现之间做选择。在这里用到了编译期的if特性,该特性从C++17开始可用,作为替代选项,这里也可以使用std::enable_if、部分特例化或者SFINAE。但是使用类型萃取的时候需要额外小心:其行为可能和程序员的预期不同。比如:

1
std::remove_const_t<int const&> // yields int const&

这里由于引用不是const类型的(虽然你不可以改变它),这个操作不会有任何效果。这样,删除引用和删除const的顺序就很重要了:

1
2
std::remove_const_t<std::remove_reference_t<int const&>> // int
std::remove_reference_t<std::remove_const_t<int const&>> // int const

另一种方法是,直接调用:

1
std::decay_t<int const&> // yields int

但是这同样会让裸数组和函数类型退化为相应的指针类型。

当然还有一些类型萃取的使用是有要求的。这些要求不被满足的话,其行为将是未定义的。比如:

1
2
make_unsigned_t<int> // unsigned int
make_unsigned_t<int const&> // undefined behavior (hopefully error)

某些情况下,结果可能会让你很意外。比如:

1
2
add_rvalue_reference_t<int const> // int const&&
add_rvalue_reference_t<int const&> // int const& (lvalueref remains lvalue-ref)

这里我们期望add_rvalue_reference总是能够返回一个右值引用,但是C++中的引用塌缩会令左值引用和右值引用的组合返回一个左值引用。另一个例子是:

1
2
is_copy_assignable_v<int> // yields true (generally, you can assign an int to an int)
is_assignable_v<int,int> // yields false (can"t call 42 = 42)

其中is_copy_assignable通常只会检查是否能够将一个int赋值给另外一个(检查左值的相关操作),而is_assignable则会考虑值的种类(value category,会检查是否能将一个右值赋值给另外一个)。也就是说第一个语句等效于:

1
is_assignable_v<int&,int&> // yields true

对下面的例子也是这样:

1
2
3
is_swappable_v<int> // yields true (assuming lvalues)
is_swappable_v<int&,int&> // yields true (equivalent to the previous check)
is_swappable_with_v<int,int> // yields false (taking value category into account)

std::addressoff()

函数模板std::addressof<>()会返回一个对象或者函数的准确地址。即使一个对象重载了运算符&也是这样。虽然后者中的情况很少遇到,但是也会发生(比如在智能指针中)。因此,如果需要获得任意类型的对象的地址,那么推荐使用addressof()

1
2
3
4
5
6
7
template<typename T>
void f (T&& x)
{
auto p = &x; // might fail with overloaded operator &
auto q = std::addressof(x); // works even with overloaded operator &
...
}

std::declval()

函数模板std::declval()可以被用作某一类型的对象的引用的占位符。该函数模板没有定义,因此不能被调用(也不会创建对象)。因此它只能被用作不会被计算的操作数(比如decltypesizeof)。也因此,在不创建对象的情况下,依然可以假设有相应类型的可用对象。比如在如下例子中,会基于模板参数T1T2推断出返回类型RT:

1
2
3
4
5
6
7
8
#include <utility>
template<typename T1, typename T2,
typename RT = std::decay_t<decltype(true ? std::declval<T1>() :
std::declval<T2>())>>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}

为了避免在调用运算符?:的时候不得不去调用T1和T2的(默认)构造函数,这里使用了std::declval,这样可以在不创建对象的情况下“使用”它们。不过该方式只能在不会做真正的计算时(比如decltype)使用。不要忘了使用std::decay<>来确保返回类型不会是一个引用,因为std::declval<>本身返回的是右值引用。否则,类似max(1,2)这样的调用将会返回一个int&&类型。

完美转发临时变量

可以使用转发引用(forwarding reference)以及std::forward<>来完美转发泛型参数:

1
2
3
4
5
template<typename T>
void f (T&& t) // t is forwarding reference
{
g(std::forward<T>(t)); // perfectly forward passed argument t to g()
}

但是某些情况下,在泛型代码中我们需要转发一些不是通过参数传递进来的数据。此时我们可以使用auto &&创建一个可以被转发的变量。比如,假设我们需要相继的调用get()set()两个函数,并且需要将get()的返回值完美的转发给set():

1
2
3
4
template<typename T>void foo(T x)
{
set(get(x));
}

假设以后我们需要更新代码对get()的返回值进行某些操作,可以通过将get()的返回值存储在一个被声明为auto &&的变量中实现:

1
2
3
4
5
6
7
template<typename T>
void foo(T x)
{
auto&& val = get(x);
... // perfectly forward the return value of get() to set():
set(std::forward<decltype(val)>(val));
}

这样可以避免对中间变量的多余拷贝。

作为模板参数的引用

虽然不是很常见,但是模板参数的类型依然可以是引用类型。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
template<typename T>
void tmplParamIsReference(T) {
std::cout << "T is reference: " << std::is_reference_v<T> << "\n";
}
int main()
{
std::cout << std::boolalpha;
int i;
int& r = i;
tmplParamIsReference(i); // false
tmplParamIsReference(r); // false
tmplParamIsReference<int&>(i); // true
tmplParamIsReference<int&>(r); // true
}

即使传递给tmplParamIsReference()的参数是一个引用变量,T依然会被推断为被引用的类型(因为对于引用变量v,表达式v的类型是被引用的类型,表达式(expression)的类型永远不可能是引用类型)。不过我们可以显示指定T的类型化为引用类型:

1
2
tmplParamIsReference<int&>(r);
tmplParamIsReference<int&>(i);

这样做可以从根本上改变模板的行为,不过由于这并不是模板最初设计的目的,这样做可能会触发错误或者不可预知的行为。考虑如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename T, T Z = T{}>
class RefMem {
private:
T zero;
public:
RefMem() : zero{Z} { }
};
int null = 0;
int main()
{
RefMem<int> rm1, rm2;
rm1 = rm2; // OK
RefMem<int&> rm3; // ERROR: invalid default value for N
RefMem<int&, 0> rm4; // ERROR: invalid default value for N extern
int null;
RefMem<int&,null> rm5, rm6;
rm5 = rm6; // ERROR: operator= is deleted due to reference member
}

此处模板的模板参数为T,其非类型模板参数z被进行了零初始化。用int实例化该模板会获得预期的行为。但是如果尝试用引用对其进行实例化的话,情况就有点复杂了:

  • 非模板参数的默认初始化不在可行。
  • 不再能够直接用0来初始化非参数模板参数。
  • 最让人意外的是,赋值运算符也不再可用,因为对于具有非static引用成员的类,其默赋值运算符会被删除掉。

而且将引用类型用于非类型模板参数同样会变的复杂和危险。考虑如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <vector>
#include <iostream>
template<typename T, int& SZ> // Note: size is reference
class Arr {
private:
std::vector<T> elems;
public:
Arr() : elems(SZ) { //use current SZ as initial vector size
}
void print() const {
for (int i=0; i<SZ; ++i) { //loop over SZ elements
std::cout << elems[i] << "";
}
}
};
int size = 10;
int main()
{
Arr<int&,size> y; // compile-time ERROR deep in the code of class
std::vector<>
Arr<int,size> x; // initializes internal vector with 10 elements
x.print(); // OK
size += 100; // OOPS: modifies SZ in Arr<>
x.print(); // run-time ERROR: invalid memory access: loops over 120 elements
}

其中尝试将Arr的元素实例化为引用类型会导致std::vector<>中很深层次的错误,因为其元素类型不能被实例化为引用类型:

1
2
Arr<int&,size> y; // compile-time ERROR deep in the code of class
std::vector<>

可能更糟糕的是将引用用于size这一类参数导致的运行时错误:可能在容器不知情的情况下,自身的size却发生了变化(比如size值变得无效)。如下这样使用size的操作就很可能会导致未定义的行为:

1
2
3
4
5
int size = 10;
...
Arr<int,size> x; // initializes internal vector with 10 elements
size += 100; // OOPS: modifies SZ in Arr<>
x.print(); // run-time ERROR: invalid memory access: loops over 120 elements

注意这里并不能通过将SZ声明为int const &来修正这一错误,因为size本身依然是可变的。看上去这一类问题根本就不会发生。但是在更复杂的情况下,确实会遇到此类问题。比如在C++17中,非类型模板参数可以通过推断得到:

1
2
template<typename T, decltype(auto) SZ>
class Arr;

使用decltype(auto)很容易得到引用类型,因此在这一类上下文中应该尽量避免使用auto。基于这一原因,C++标准库在某些情况下制定了很特殊的规则和限制。比如:

  • 在模板参数被用引用类型实例化的情况下,为了依然能够正常使用赋值运算符,std::pair<>std::tuple<>都没有使用默认的赋值运算符,而是做了单独的定义。比如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace std {
template<typename T1, typename T2>
struct pair {
T1 first;
T2 second;
... // default copy/move constructors are OK even with references:
pair(pair const&) = default;
pair(pair&&) = default;
... // but assignment operator have to be defined to be available with references:
pair& operator=(pair const& p);
pair& operator=(pair&& p) noexcept(...);
...
};
}
  • 由于这些副作用可能导致的复杂性,在C++17中用引用类型实例化标准库模板std::optional<>std::variant<>的过程看上去有些古怪。为了禁止用引用类型进行实例化,一个简单的static_assert就够了:
1
2
3
4
5
6
7
template<typename T>
class optional
{
static_assert(!std::is_reference<T>::value, "Invalid
instantiation of optional<T> for references");
...
};

通常引用类型和其他类型有很大不同,并且受一些语言规则的限制。这会影响对调用参数的声明以及对类型萃取的定义。

推迟计算(Defer Evaluation)

在实现模板的过程中,有时候需要面对是否需要考虑不完整类型的问题。考虑如下的类模板:

1
2
3
4
5
6
7
template<typename T>
class Cont {
private:
T* elems;
public:
...
};

到目前为止,该class可以被用于不完整类型。这很有用,比如可以让其成员指向其自身的类型。

1
2
3
4
5
struct Node
{
std::string value;
Cont<Node> next; // only possible if Cont accepts incomplete types
};

但是,如果使用了某些类型萃取的话,可能就不能将其用于不完整类型了。比如:

1
2
3
4
5
6
7
8
template<typename T>
class Cont {
private:
T* elems;
public:
...
typename std::conditional<std::is_move_constructible<T>::value, T&&, T& >::type foo();
};

这里通过使用std::conditional来决定foo()的返回类型是T&&还是T&。决策标准是看模板参数T是否支持move语义。问题在于std::is_move_constructible要求其参数必须是完整类型。使用这种类型的foo()struct node的声明就会报错。为了解决这一问题,需要使用一个成员模板代替现有foo()的定义,这样就可以将std::is_move_constructible的计算推迟到foo()的实例化阶段:

1
2
3
4
5
6
7
8
template<typename T>
class Cont {
private:
T* elems;
public:
template<typename D = T>
typename std::conditional<std::is_move_constructible<D>::value, T&&, T&>::type foo();
};

现在,类型萃取依赖于模板参数D(默认值是T),并且编译器会一直等到foo()被以完整类型(比如Node)为参数调用时,才会对类型萃取部分进行计算(此时Node是一个完整类型,其只有在定义时才是非完整类型)。

在写泛型库时需要考虑的事情

  • 在模板中使用转发引用来转发数值。如果数值不依赖于模板参数,就使用auto &&
  • 如果一个参数被声明为转发引用,并且传递给它一个左值的话,那么模板参数会被推断为引用类型。
  • 在需要一个依赖于模板参数的对象的地址的时候,最好使用std::addressof()来获取地址,这样能避免因为对象被绑定到一个重载了operator &的类型而导致的意外情况。
  • 对于成员函数,需要确保它们不会比预定义的copy/move构造函数或者赋值运算符更能匹配某个调用。
  • 如果模板参数可能是字符串常量,并且不是被按值传递的,那么请考虑使用std::decay
  • 如果你有被用于输出或者即用于输入也用于输出的、依赖于模板参数的调用参数,请为可能的、 const类型的模板参数做好准备。
  • 请为将引用用于模板参数的副作用做好准备。尤其是在你需要确保返回类型不会是引用的时候。
  • 请为将不完整类型用于嵌套式数据结构这一类情况做好准备。
  • 为所有数组类型进行重载,而不仅仅是T[SZ]

总结

  • 可以将函数,函数指针,函数对象,仿函数和lambdas作为可调用对象(callables)传递给模板。
  • 如果需要为一个class重载operator(),那么就将其声明为const的(除非该调用会修改它的状态)。
  • 通过使用std::invoke(),可以实现能够处理所有类型的、可调用对象(包含成员函数)的代码。
  • 使用decltype(auto)来完美转发返回值。
  • 类型萃取是可以检查类型的属性和功能的类型函数。
  • 当在模板中需要一个对象的地址时,使用std::addressof()
  • 在不经过表达式计算的情况下,可以通过使用std::declval()创建特定类型的值。
  • 在泛型代码中,如果一个对象不依赖于模板参数,那么就使用auto&&来完美转发它。
  • 可以通过模板来延迟表达式的计算(这样可以在class模板中支持不完整类型)。

深入模板

C++ 目前支持四种基本类型的模板:类模板、函数模板、变量模板和别名模板。这些模板类型中的每一种都可以出现在命名空间范围内,也可以出现在类范围内。在类范围内,它们成为嵌套类模板、成员函数模板、静态数据成员模板和成员别名模板。注意 C++17 引入了另一个构造:演绎指南。这些在本书中不被称为模板,但选择的语法是为了让人想起函数模板。首先,一些例子说明了四种模板。它们可以出现在命名空间范围内(全局或在命名空间中),如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename T> // a namespace scope class template 
class Data {
public:
static constexpr bool copyable = true;
...
};
template<typename T> // a namespace scope function template
void log (T x) {
...
}
template<typename T> // a namespace scope variable template (since C++14)
T zero = 0;

template<typename T> // a namespace scope variable template (since C++14)
bool dataCopyable = Data<T>::copyable;

template<typename T> // a namespace scope alias template
using DataList = Data<T*>;

请注意,在此示例中,静态数据成员Data<T>::copyable不是变量模板,即使它是通过类模板 Data 的参数化间接参数化的。但是,变量模板可以出现在类范围内(如下例所示),在这种情况下,它是一个静态数据成员模板。以下示例将四种模板显示为在其父类中定义的类成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Collection {
public:
template<typename T> // an in-class member class template definition
class Node {
...
};
template<typename T> // an in-class (and therefore implicitly inline)
T* alloc() { //member function template definition
...
}

template<typename T> // a member variable template (since C++14)
static T zero = 0;

template<typename T> // a member alias template
using NodePtr = Node<T>*;
};

请注意,在 C++17 中,变量(包括静态数据成员)和变量模板可以“内联”,这意味着它们的定义可以跨翻译单元重复。这对于变量模板来说是多余的,它总是可以在多个翻译单元中定义。然而,与成员函数不同的是,在其封闭类中定义的静态数据成员不会使其内联:关键字 inline 在所有情况下都必须指定。

最后,以下代码演示了如何在类外定义非别名模板的成员模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
template<typename T> // a namespace scope class template
class List {
public:
List() = default; // because a template constructor is defined
template<typename U> // another member class template,
class Handle; // without its definition

template<typename U> // a member function template
List (List<U> const&); // (constructor)

template<typename U> // a member variable template (since C++14)
static U zero;
};

template<typename T> // out-of-class member class template definition
template<typename U>
class List<T>::Handle {
...
};

template<typename T> // out-of-class member function template definition
template<typename T2>
List<T>::List (List<T2> const& b)
{
...
}

template<typename T> // out-of-class static data member template definition
template<typename U>
U List<T>::zero = 0;

在其封闭类之外定义的成员模板可能需要多个模板参数化子句:一个用于每个封闭类模板,一个用于成员模板本身。从最外层的类模板开始列出子句。

另请注意,构造函数模板(一种特殊的成员函数模板)禁用默认构造函数的隐式声明(因为只有在没有声明其他构造函数时才隐式声明它)。添加默认声明List() = default;确保List<T>的实例是默认可构造的,具有隐式声明的构造函数的语义。

联合模板联合模板也是可能的(它们被认为是一种类模板):

1
2
3
4
5
template<typename T>
union AllocChunk {
T object;
unsigned char bytes[sizeof(T)];
};

函数模板可以像普通函数声明一样具有默认调用参数:

1
2
3
4
5
template<typename T>
void report_top (Stack<T> const&, int number = 10);

template<typename T>
void fill (Array<T>&, T const& = T{}); // T{} is zero for built-in types

后一个声明表明默认调用参数可能依赖于模板参数。也可以定义为

1
2
template<typename T>
void fill (Array<T>&, T const& = T()); // T() is zero for built-in types

调用fill()函数时,如果提供了第二个函数调用参数,则不会实例化默认参数。这样可以确保如果无法为特定 T 实例化默认调用参数,则不会发出错误。例如:

1
2
3
4
5
6
7
8
9
10
class Value {
public:
explicit Value(int); // no default constructor
};
void init (Array<Value>& array)
{
Value zero(0);
fill(array, zero); // OK: default constructor not used
fill(array); // ERROR: undefined default constructor for Value is used
}

类模板的非模板成员

除了在类中声明的四种基本模板之外,您还可以通过作为类模板的一部分来参数化普通类成员。它们有时(错误地)也称为成员模板。它们的参数完全由它们所属的模板决定。例如:

1
2
3
4
5
6
7
8
template<int I>
class CupBoard
{
class Shelf; // ordinary class in class template
void open(); // ordinary function in class template
enum Wood : unsigned char; // ordinary enumeration type in class template
static double totalWeight; // ordinary static data member in class template
};

相应的定义只为父类模板指定了参数化子句,但没有为成员本身指定一个参数化子句,因为它不是模板(即,没有参数化子句与出现在最后一个::之后的名称相关联):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<int I> // definition of ordinary class in class template
class CupBoard<I>::Shelf {
...
};
template<int I> // definition of ordinary function in class template
void CupBoard<I>::open()
{
...
}
template<int I> // definition of ordinary enumeration type class in class template
enum CupBoard<I>::Wood {
Maple, Cherry, Oak
};
template<int I> // definition of ordinary static member in class template
double CupBoard<I>::totalWeight = 0.0;

从C++17开始,静态的totalWeight成员可以在模板类内初始化

1
2
3
4
template<int I>
class CupBoard {
inline static double totalWeight = 0.0;
};

尽管此类参数化定义通常称为模板,但该术语并不完全适用于它们。偶尔为这些实体提出的术语是temploid。自 C++17 以来,C++ 标准确实定义了模板化实体的概念,它包括模板和模板以及递归地在模板化实体中定义或创建的任何实体。到目前为止,模板实体和模板实体都没有获得太大的吸引力,但它们可能是将来更准确地传达 C++ 模板的有用术语。

虚拟成员函数

成员函数模板不能声明为虚拟的。施加此约束是因为虚函数调用机制的通常实现使用一个固定大小的表,每个虚函数有一个条目。然而,成员函数模板的实例化数量在整个程序被翻译之前是不固定的。因此,支持虚拟成员函数模板需要在 C++ 编译器和链接器中支持一种全新的机制。

相反,类模板的普通成员可以是虚拟的,因为它们的数量在类被实例化时是固定的:

模板外联

每个模板都必须有一个名称,并且该名称在其范围内必须是唯一的,除了函数模板可以重载。特别注意,与类类型不同,类模板不能与不同类型的实体共享名称:

1
2
3
4
5
6
7
8
9
10
int C;
class C; // OK: class names and nonclass names are in a different “space”
int X;
...
template<typename T>
class X; // ERROR: conflict with variable X
struct S;
...
template<typename T>
class S; // ERROR: conflict with struct S

模板名称有链接,但不能有 C 链接。非标准链接可能具有依赖于实现的含义(但是,我们不知道支持模板的非标准名称链接的实现):

1
2
3
4
5
6
7
8
9
extern "C++" template<typename T>
void normal(); //this is the default: the linkage specification could be left out

extern "C" template<typename T>
void invalid(); //ERROR: templates cannot have C linkage

extern "Java" template<typename T>
void javaLink(); //non standard, but maybe some compiler will someday
// support linkage compatible with Java generics

模板通常具有外部链接。唯一的例外是具有静态说明符的命名空间范围函数模板、作为未命名命名空间的直接或间接成员(具有内部链接)的模板以及未命名类的成员模板(没有链接)。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename T> // refers to the same entity as a declaration of the same name (and scope) in another file
void external();

template<typename T> // unrelated to a template with the same name in another file
static void internal();

template<typename T> // redeclaration of the previous declaration
static void internal();

namespace {
template<typename> // also unrelated to a template with the same name
void otherInternal(); // in another file, even one that similarly appears
} //in an unnamed namespace

namespace {
template<typename> // redeclaration of the previous template declaration
void otherInternal();
}
struct {
template<typename T> void f(T) {} //no linkage: cannot be redeclared
} x;

请注意,由于后一个成员模板没有链接,因此必须在未命名的类中定义它,因为无法在类之外提供定义。

目前模板不能在函数作用域或局部类作用域中声明,但是具有包含成员函数模板的相关闭包类型的通用 lambdas可以出现在局部作用域中,这实际上意味着一种本地成员函数模板。

模板实例的链接就是模板的链接。例如,从上面声明的模板 internal 实例化的函数internal<void>()将具有内部链接。这在变量模板的情况下会产生一个有趣的结果。实际上,请考虑以下示例:

1
template<typename T> T zero = T{};

zero的所有实例都有外部链接,甚至像zero<int const>这样的东西。考虑到int const zero_int = int{};,这可能是违反直觉的,但也具有内部链接,因为它是用 const 类型声明的。同样,模板的所有实例化都有外部链接,尽管所有这些实例也具有 int const 类型。

1
template<typename T> int const max_volume = 11;

主要模板

模板的正常声明声明了主模板。此类模板声明的声明无需在模板名称后的尖括号中添加模板参数:

1
2
3
4
5
6
template<typename T> class Box; // OK: primary template
template<typename T> class Box<T>; // ERROR: does not specialize
template<typename T> void translate(T); // OK: primary template
template<typename T> void translate<T>(T); // ERROR: not allowed for functions
template<typename T> constexpr T zero = T{}; // OK: primary template
template<typename T> constexpr T zero<T> = T{}; // ERROR: does not specialize

声明类或变量模板的部分特化时会出现非主模板。函数模板必须始终是主模板。

模板参数

有三个基本的模板参数

  1. 类型参数
  2. 非类型参数
  3. 模板模板参数

模板参数在临时声明的介绍性参数化子句中声明,这些声明不需要命名:

1
2
template<typename, int>
class X

当然,如果稍后在模板中引用参数,则需要参数名。还请注意,模板参数名称可以在后续参数声明中引用(但不能在此之前):

1
2
3
4
5
template<typename T, T Root, template<T> class Buf> 
//the first parameter is used
//in the declaration of the second one and
// in the declaration of the third one
class Structure;

类型参数

类型参数与关键字typename或关键字class一起引入:两者完全等效。关键字后面必须跟一个简单标识符,该标识符后面必须跟一个逗号以表示下一个参数声明的开始,一个收尾角括号(>)以表示参数化子句的结束,或者一个等号(=)以表示默认模板参数的开始。在模板声明中,类型参数的作用很像类型别名。例如,当T是模板参数时,即使T被类类型替换,也不可能使用格式类T的详细名称:

1
2
3
4
5
6
template<typename Allocator>
class List {
class Allocator* allocptr; // ERROR: use “Allocator* allocptr”
friend class Allocator; // ERROR: use “friend Allocator”
...
};

非类型参数

非类型模板参数代表可在编译或链接时确定的常量值。此类参数的类型(换句话说,它所代表的值的类型)必须是以下类型之一:

  • 整数类型或枚举类型
  • 指针类型
  • 指向成员类型的指针
  • 左值引用类型(对象引用和函数引用均可接受)
  • std::nullptr_t
  • 包含auto或decltype(自动)的类型

目前排除了所有其他类型(尽管将来可能会添加浮点类型)。也许令人惊讶的是,在某些情况下,非类型模板参数的声明也可以以关键字typename或者class开始:

1
2
3
4
5
6
template<typename T, //a type parameter
typename T::Allocator* Allocator> // a nontype parameter
class List;

template<class X*> // a nontype parameter of pointer type
class Y;

这两种情况很容易区分,因为第一种情况后面跟着一个简单的标识符,然后是一组小标记中的一个(“=”表示默认参数,“,”表示后面跟着另一个模板参数,或是结束>表示模板参数列表)。

可以指定函数和数组类型,但它们会隐式地调整为它们所退化到的指针类型:

1
2
3
4
template<int buf[5]> class Lexer; // buf is really an int*
template<int* buf> class Lexer; // OK: this is a redeclaration
template<int fun()> struct FuncWrap; // fun really has pointer to function type
template<int (*)()> struct FuncWrap; // OK: this is a redeclaration

非类型模板参数的声明非常类似于变量,但它们不能具有静态、可变等非类型说明符。它们可以有常量和volatile限定符,但如果这样的限定符出现在参数类型的最外层,它将被忽略:

1
2
template<int const length> class Buffer; // const is useless here
template<int length> class Buffer; // same as previous declaration

最后,在表达式中使用非引用非类型参数时,它们始终是prvalues。他们的地址无法获取,也无法分配给。另一方面,左值引用类型的非类型参数可用于表示左值:
1
2
3
4
5
template<int& Counter>
struct LocalIncrement {
LocalIncrement() { Counter = Counter + 1; } //OK: reference to an integer
~LocalIncrement() { Counter = Counter - 1; }
};

模板模板参数

模板模板参数是类模板或别名模板的占位符。它们的声明非常类似于类模板,但不能使用关键字struct和union:

1
2
3
4
5
6
7
8
template<template<typename X> class C> // OK
void f(C<int>* p);

template<template<typename X> struct C> // ERROR: struct not valid here
void f(C<int>* p);

template<template<typename X> union C> // ERROR: union not valid here
void f(C<int>* p);

C++17允许使用typename而不是class:这一变化的动机是,模板参数不仅可以由类模板替换,还可以由别名模板(实例化为任意类型)替换。因此,在C++17中,我们上面的示例可以写成

1
2
template<template<typename X> typename C> // OK since C++17
void f(C<int>* p);

在其声明范围内,模板参数的使用与其他类或别名模板一样。

模板参数的参数可以具有默认模板参数。如果在使用模板参数时未指定相应的参数,则这些默认参数适用:

1
2
3
4
5
template<template<typename T, typename A = MyAllocator> class Container>
class Adaptation {
Container<int> storage; // implicitly equivalent to Container<int,MyAllocator>
...
};

T和A是模板参数容器的模板参数的名称。这些名称只能在声明该模板参数的其他参数时使用。以下模板说明了此概念:

1
2
3
4
5
template<template<typename T, T*> class Buf> // OK
class Lexer {
static T* storage; // ERROR: a template template parameter cannot be used here
...
};

但是,通常在声明其他模板参数时不需要模板参数的模板参数名称,因此通常不命名。例如,我们早期的适应模板可以声明如下:

1
2
3
4
5
template<template<typename, typename = MyAllocator> class Container>
class Adaptation {
Container<int> storage; // implicitly equivalent to Container<int,MyAllocator>
...
};

模板参数包

自C++11以来,任何类型的模板参数都可以通过在模板参数名称之前引入省略号(…)转换为模板参数包,或者,如果模板参数未命名,则模板参数名称将出现在以下位置:

1
2
template<typename... Types> // declares a template parameter pack named Types
class Tuple;

模板参数包的行为与其基础模板参数类似,但有一个关键区别:虽然普通模板参数只匹配一个模板参数,但模板参数包可以匹配任意数量的模板参数。这意味着上面声明的元组类模板接受任意数量(可能不同)的类型作为模板参数:

1
2
3
4
using IntTuple = Tuple<int>; // OK: one template argument
using IntCharTuple = Tuple<int, char>; // OK: two template arguments
using IntTriple = Tuple<int, int, int>; // OK: three template arguments
using EmptyTuple = Tuple<>; // OK: zero template arguments

类似地,非类型和模板参数的模板参数包可以分别接受任意数量的非类型或模板参数:

1
2
3
4
5
template<typename T, unsigned... Dimensions>
class MultiArray; // OK: declares a nontype template parameter pack
using TransformMatrix = MultiArray<double, 3, 3>; // OK: 3x3 matrix
template<typename T, template<typename, typename>... Containers>
void testContainers(); // OK: declares a template template parameter pack

MultiArray示例要求所有非类型模板参数都是同一类型的无符号参数。C++17引入了推导非类型模板参数的可能性,这允许我们在某种程度上绕过该限制

主类模板、变量模板和别名模板最多可以有一个模板参数包,如果存在,模板参数包必须是最后一个模板参数。函数模板有一个较弱的限制:允许使用多个模板参数包,只要模板参数包后面的每个模板参数都有一个默认值(请参见下一节)或可以推断:

1
2
3
4
5
6
7
8
9
10
template<typename... Types, typename Last>
class LastType; // ERROR: template parameter pack is not the last template parameter

template<typename... TestTypes, typename T>
void runTests(T value); // OK: template parameter pack is followed by a deducible template parameter

template<unsigned...> struct Tensor;
template<unsigned... Dims1, unsigned... Dims2>
auto compose(Tensor<Dims1...>, Tensor<Dims2...>);
// OK: the tensor dimensions can be deduced

最后一个例子是一个带有推导返回类型的函数声明。

类和变量模板的部分特化声明可以有多个参数包,这与它们的主要模板对应物不同。这是因为部分专业化是通过与用于函数模板的推导过程几乎相同的推导过程来选择的。

1
2
3
4
5
6
template<typename...> Typelist;
template<typename X, typename Y> struct Zip;
template<typename... Xs, typename... Ys>
struct Zip<Typelist<Xs...>, Typelist<Ys...>>;
// OK: partial specialization uses deduction to determine
// theXs and Ys substitutions

也许并不奇怪,类型参数包不能在其自己的参���子句中扩展。例如:
1
2
template<typename... Ts, Ts... vals> struct StaticValues {};
// ERROR: Ts cannot be expanded in its own parameter list

但是,嵌套模板可以创建类似的有效情况:

1
2
3
4
template<typename... Ts> struct ArgList {
template<Ts... vals> struct Vals {};
};
ArgList<int, char, char>::Vals<3, 'x', 'y'> tada;

默认模板参数

任何不是模板形参包的模板形参都可以配备默认实参,尽管它必须与相应的实参相匹配(例如,类型形参不能有非类型默认实参)。默认参数不能依赖于它自己的参数,因为参数的名称在默认参数之后才在范围内。但是,它可能取决于以前的参数:

1
2
template<typename T, typename Allocator = allocator<T>>
class List;

仅当还为后续参数提供了默认参数时,类模板、变量模板或别名模板的模板参数才能具有默认模板参数。(默认函数调用参数存在类似的约束。)后续的默认值通常在同一个模板声明中提供,但它们也可以在该模板的先前声明中声明。下面的例子清楚地说明了这一点:

1
2
3
4
5
6
7
8
template<typename T1, typename T2, typename T3, typename T4 = char, typename T5 = char>
class Quintuple; // OK

template<typename T1, typename T2, typename T3 = char, typename T4, typename T5>
class Quintuple; // OK: T4 and T5 already have defaults

template<typename T1 = char, typename T2, typename T3, typename T4, typename T5>
class Quintuple; // ERROR: T1 cannot have a default argument because T2 doesn’t have a default

函数模板的模板形参的默认模板实参不需要后续模板形参具有默认模板实参:

1
2
template<typename R = void, typename T>
R* addressof(T& value); // OK: if not explicitly specified, R will be void

默认模板参数不能重复:

1
2
3
4
5
template<typename T = void>
class Value;

template<typename T = void>
class Value; // ERROR: repeated default argument

许多地方不允许默认模板参数:

  • 部分特化:
1
2
3
4
5
template<typename T>
class C;...

template<typename T = int>
class C<T*>;
  • 参数包:
1
template<typename... Ts = int> struct X; // ERROR
  • 类模板成员的类外定义:
1
2
3
4
5
6
7
template<typename T> struct X
{
T f();
};
template<typename T = int> T X<T>::f() { // ERROR
...
}
  • 友类模板声明:
1
2
3
struct S {
template<typename = void> friend struct F;
};
  • 友元函数模板声明,除非它是一个定义并且在翻译单元的其他任何地方都没有出现它的声明:
1
2
3
4
5
6
7
struct S {
template<typename = void> friend void f(); // ERROR: not a definition
template<typename = void> friend void g() { //OK so far
}
};
template<typename> void g(); // ERROR: g() was given a default template argument
// when defined; no other declaration may exist here

模板参数

实例化模板时,模板参数由模板参数替换。可以使用几种不同的机制来确定参数:

  • 显式模板参数:模板名称后面可以跟用尖括号括起来的显式模板参数。生成的名称称为模板 ID。
  • 注入的类名:在具有模板参数 P1、P2、……的类模板 X 的范围内,该模板的名称 (X) 可以等同于模板ID X<P1, P2, ...>
  • 默认模板参数:如果默认模板参数可用,则可以从模板实例中省略显式模板参数。但是,对于类或别名模板,即使所有模板参数都有默认值,也必须提供(可能为空的)尖括号。
  • 参数推导:未显式指定的函数模板参数可以从调用中的函数调用参数的类型推导出来。在其他一些情况下也进行了演绎。如果可以推导出所有模板参数,则不需要在函数模板名称后指定尖括号。C++17 还引入了从变量声明或函数符号类型转换的初始化程序推导出类模板参数的能力。

函数模板参数

函数模板的模板参数可以显式指定,从模板的使用方式推导出来,或者作为默认模板参数提供。例如:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
T max (T a, T b)
{
return b < a ? a : b;
}
int main()
{
::max<double>(1.0, -3.0); // explicitly specify template argument
::max(1.0, -3.0); // template argument is implicitly deduced to be double
::max<int>(1.0, 3.0); // the explicit <int> inhibits thededuction;
// hence the result has type int
}

某些模板参数永远无法推导出来,因为它们对应的模板参数没有出现在函数参数类型中或出于某些其他原因。相应的参数通常放置在模板参数列表的开头,因此可以显式指定它们,同时允许推导其他参数。例如:

1
2
3
4
5
6
7
8
9
template<typename DstT, typename SrcT>
DstT implicit_cast (SrcT const& x) // SrcT can be deduced, but DstT cannot
{
return x;
}
int main()
{
double value = implicit_cast<double>(-1);
}

如果我们在此示例中颠倒了模板参数的顺序(换句话说,如果我们编写了template<typename SrcT, typename DstT>),则对implicit_cast的调用将必须显式指定两个模板参数。此外,此类参数不能有用地放置在模板参数包之后或出现在部分特化中,因为无法显式指定或推断它们。

1
2
3
template<typename ... Ts, int N>
void f(double (&)[N+1], Ts ... ps); // useless declaration because N
// cannot be specified or deduced

因为函数模板可以重载,显式提供函数模板的所有参数可能不足以标识单个函数:在某些情况下,它标识一组函数。以下示例说明了此观察的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename Func, typename T>
void apply (Func funcPtr, T x)
{
funcPtr(x);
}
template<typename T> void single(T);
template<typename T> void multi(T);
template<typename T> void multi(T*);
int main()
{
apply(&single<int>, 3); // OK
apply(&multi<int>, 7); // ERROR: no single multi<int>
}

在此示例中,第一次调用apply()有效,因为表达式&single<int>的类型是明确的。结果,很容易推导出Func参数的模板参数值。然而,在第二次调用中,&multi<int>可能是两种不同类型之一,因此在这种情况下无法推断出Func

此外,在函数模板中替换模板参数可能会导致尝试构造无效的 C++ 类型或表达式。考虑以下重载函数模板(RT1 和 RT2 是未指定的类型):

1
2
template<typename T> RT1 test(typename T::X const*);
template<typename T> RT2 test(...);

表达式test<int>对于两个函数模板中的第一个没有意义,因为 int 类型没有成员类型 X。但是,第二个模板没有这样的问题。因此,表达式&test<int>标识单个函数的地址。将 int 替换为第一个模板失败的事实并不会使表达式无效。

非类型参数

非类型模板参数是替代非类型参数的值。这样的值必须是以下事物之一:

  • 另一个具有正确类型的非类型模板参数。
  • 整数(或枚举)类型的编译时常量值。仅当相应参数具有与值的类型匹配的类型或值可以隐式转换为的类型而不缩小时,这才是可接受的。例如,可以为 int 参数提供 char 值,但赋值 500 对 8 位 char 参数无效。
  • 以内置一元&(“地址”)运算符开头的外部变量或函数的名称。对于函数和数组变量,& 可以省略。这样的模板参数匹配指针类型的非类型参数。C++17 放宽了这个要求,允许任何产生指向函数或变量的指针的常量表达式。
  • 前一种类型的参数但没有前导& 运算符是引用类型的非类型参数的有效参数。在这里,C++17 也放宽了约束,允许函数或变量使用任何常量表达式泛左值。
  • 指向成员常量的指针;换句话说,&C::m 形式的表达式,其中 C 是类类型,m 是非静态成员(数据或函数)。这仅匹配指向成员类型的非类型参数。再一次,在 C++17 中,实际的句法形式不再受到限制:允许任何对匹配的指向成员常量的指针求值的常量表达式。
  • 空指针常量是指针或成员指针类型的非类型参数的有效参数。

对于整型的非类型参数——可能是最常见的非类型参数——考虑到参数类型的隐式转换。随着 C++11 中 constexpr 转换函数的引入,这意味着转换前的参数可以具有类类型。

在 C++17 之前,当将参数与作为指针或引用的参数匹配时,不考虑用户定义的转换(一个参数的构造函数和转换运算符)和派生到基的转换,即使在其他情况下也是如此它们将是有效的隐式转换。使参数更 const 和/或更易变的隐式转换是可以的。以下是非类型模板参数的一些有效示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<typename T, T nontypeParam>
class C;
C<int, 33>* c1; // integer type
int a;
C<int*, &a>* c2; // address of an external variable

void f();
void f(int);
C<void (*)(int), f>* c3; // name of a function: overload resolution selects
// f(int) in this case; the & is implied
template<typename T> void templ_func();

C<void(), &templ_func<double>>* c4; // function template instantiations are functions
struct X {
static bool b;
int n;
constexpr operator int() const { return 42; }
};

C<bool&, X::b>* c5; // static class members are acceptable variable/function names
C<int X::*, &X::n>* c6; // an example of a pointer-to-member constant
C<long, X{}>* c7; // OK: X is first converted to int viaa constexpr conversion
// function and then to long via a standard integer conversion

模板参数的一般约束是编译器或链接器必须能够在构建程序时表达它们的值。直到程序运行才知道的值(例如,局部变量的地址)与构建程序时实例化模板的概念不兼容。即便如此,有一些常量值当前无效:

  • 浮点数字
  • 字符串文字(在 C++11 之前,也不允许空指针常量。)

字符串文字的问题之一是两个相同的文字可以存储在两个不同的地址。表达通过常量字符串实例化的模板的另一种(但繁琐)方法涉及引入一个额外的变量来保存字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<char const* str>
class Message {
...
};
extern char const hello[] = "Hello World!";
char const hello11[] = "Hello World!";
void foo()
{
static char const hello17[] = "Hello World!";
Message<hello> msg03; // OK in all versions
Message<hello11> msg11; // OK since C++11
Message<hello17> msg17; // OK since C++17
}

要求是声明为引用或指针的非类型模板参数可以是具有所有 C++ 版本中的外部链接、自 C++11 以来的内部链接或自 C++17 以来的任何链接的常量表达式。

1
2
3
4
5
6
7
8
9
10
template<typename T, T nontypeParam>class C;
struct Base {
int i;
} base;
struct Derived : public Base {
} derived;
C<Base*, &derived>* err1; // ERROR: derived-to-base conversions are not considered
C<int&, base.i>* err2; // ERROR: fields of variables aren’t considered to be variables
int a[10];
C<int*, &a[0]>* err3; // ERROR: addresses of array elements aren’t acceptable either

模板模板参数

模板模板参数通常必须是类模板或别名模板,其参数与它所替代的模板模板参数的参数完全匹配。在 C++17 之前,模板模板实参的默认模板实参被忽略(但如果模板模板形参具有默认实参,则在模板的实例化过程中会考虑它们)。C++17 放宽了匹配规则,只要求模板模板参数至少与相应的模板模板参数一样专门化。这使得以下示例在 C++17 之前无效:

1
2
3
4
5
6
7
8
9
10
11
#include <list>
// declares in namespace std:
// template<typename T, typename Allocator = allocator<T>>
// class list;
template<typename T1, typename T2, template<typename> class Cont> // Cont expects one parameter
class Rel {
...
};

Rel<int, double, std::list> rel; // ERROR before C++17: std::list has more than
// one template parameter

这个例子的问题是标准库的std::list模板有多个参数。第二个参数(描述分配器)具有默认值,但在 C++17 之前,将std::list与 Container 参数匹配时不考虑该值。

可变参数模板模板参数是上述 C++17 之前的“精确匹配”规则的一个例外,并提供了对此限制的解决方案:它们可以对模板模板参数进行更一般的匹配。模板模板参数包可以匹配模板模板参数中的零个或多个相同类型的模板参数:

1
2
3
4
5
6
7
8
#include <list>
template<typename T1, typename T2,
template<typename... > class Cont> // Cont expects any number of
class Rel { // type parameters
...
};
Rel<int, double, std::list> rel; // OK: std::list has two template parameters
// but can be used with one argument

模板中的名称

名称是大多数编程语言中的基本概念。它们是程序员可以引用先前构造的实体的方法。当 C++ 编译器遇到名称时,它必须“查找”以识别所引用的实体。从实现者的角度来看,C++ 在这方面是一门硬语言。考虑 C++ 语句 x*y;。如果 x 和 y 是变量的名称,则该语句是乘法,但如果 x 是类型的名称,则该语句将 y 声明为指向 x 类型的实体的指针。

这个小例子表明 C++(和 C 一样)是一种上下文相关的语言:一个结构在不知道其更广泛的上下文的情况下总是无法被理解。这与模板有什么关系?好吧,模板是必须处理多个更广泛上下文的构造:

  1. 模板出现的上下文,
  2. 模板实例化的上下文,
  3. 与模板参数相关联的上下文

因此,在 C++ 中必须非常小心地处理“名称”也就不足为奇了。

名称分类

C++ 以多种方式对名称进行分类——事实上,方式多种多样。幸运的是,您可以通过熟悉两个主要的命名概念来深入了解大多数 C++ 模板问题:

  1. 如果名称所属的范围使用范围解析运算符 (::) 或成员访问运算符 (. 或 ->) 明确表示,则名称是限定名称。例如,this->count是一个限定名,但count不是(即使普通的count实际上可能指的是一个类成员)。
  2. 如果名称以某种方式依赖于模板参数,则该名称是从属名称。例如,std::vector<T>::iterator通常是一个从属名称,如果T 是模板参数,但如果 T 是已知类型别名,则它是非依赖名称。

查找名称

在限定构造所暗示的范围内查找限定名称。如果该范围是一个类,则还可以搜索基类。但是,在查找限定名称时不考虑封闭范围。以下说明了这一基本原则:

1
2
3
4
5
6
7
8
9
10
11
12
int x;
class B {
public:
int i;
};
class D : public B {
};
void f(D* pd)
{
pd->i = 3; // finds B::i
D::x = 2; // ERROR: does not find ::x in the enclosing scope
}

相比之下,非限定名称通常在连续更封闭的范围内查找(尽管在成员函数定义中,类及其基类的范围在任何其他封闭范围之前搜索)。这称为普通查找。这是一个基本示例,显示了普通查找的主要思想:

1
2
3
4
5
6
7
8
9
extern int count; // #1
int lookup_example(int count) // #2
{
if (count < 0) {
int count = 1; // #3
lookup_example(count); // unqualified count refers to #3
}
return count + ::count; // the first (unqualified) count refers to #2;
} //the second (qualified) countrefers to #1

对非限定名称的查找最近的一个转折是它们有时可能会进行参数相关的查找 (ADL)。在继续详细介绍 ADL 之前,让我们激发该机制:

1
2
3
4
5
template<typename T>
T max (T a, T b)
{
return b < a ? a : b;
}

现在假设我们需要将此模板应用到另一个命名空间中定义的类型:

1
2
3
4
5
6
7
8
9
10
11
12
namespace BigMath {
class BigNumber {
...
};
bool operator < (BigNumber const&, BigNumber const&);
...
}
using BigMath::BigNumber;
void g (BigNumber const& a, BigNumber const& b)
{
BigNumber x = ::max(a,b);
}

这里的问题是max()模板不知道BigMath命名空间,但普通查找不会找到适用于BigNumber类型值的运算符<。如果没有一些特殊的规则,这会大大降低模板在存在 C++ 命名空间的情况下的适用性。ADL 是对这些“特殊规则”的 C++ 答案。

参数相关查找ADL

ADL 主要适用于看起来像是在函数调用或运算符调用中命名非成员函数的非限定名称。如果普通查找发现以下情况,ADL 不会发生

  • 成员函数的名称,
  • 变量的名称,
  • 类型的名称,或
  • 块作用域函数声明的名称。

如果要调用的函数的名称用括号括起来,ADL 也会被禁止。
否则,如果名称后跟括在括号中的参数表达式列表,ADL 会继续在名称空间和与调用参数类型“关联”的类中查找名称。这些关联的命名空间和相关类的精确定义在后面给出,但直观地它们可以被认为是与给定类型相当直接连接的所有命名空间和类。例如,如果类型是指向类 X 的指针,则关联的类和命名空间将包括 X 以及 X 所属的任何命名空间或类。

给定类型的关联命名空间和关联类集的精确定义由以下规则确定:

  • 对于内置类型,这是空集。
  • 对于指针和数组类型,关联命名空间和类的集合是基础类型的集合。
  • 对于枚举类型,关联的命名空间是声明枚举的命名空间。
  • 对于类成员,封闭类是关联类。
  • 对于类类型(包括联合类型),关联类的集合是类型本身、封闭类以及任何直接和间接基类。关联命名空间的集合是声明相关类的命名空间。如果类是类模板实例,则还包括模板类型参数的类型以及声明模板模板参数的类和命名空间。
  • 对于函数类型,相关命名空间和类的集合包括与所有参数类型相关的命名空间和类以及与返回类型相关的命名空间和类。
  • 对于指向X 类成员的指针类型,关联的命名空间和类集包括与X 关联的那些以及与成员类型关联的那些。然后,ADL 在所有关联的命名空间中查找名称,就好像该名称已被这些命名空间中的每一个依次限定一样,除了 using 指令被忽略。以下示例说明了这一点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
namespace X {
template<typename T> void f(T);
}

namespace N {
using namespace X;
enum E { e1 };
void f(E) {
std::cout << "N::f(N::E) called\n";
}
}
void f(int)
{
std::cout << "::f(int) called\n";
}
int main()
{
::f(N::e1); // qualified function name: no ADL
f(N::e1); // ordinary lookup finds ::f() and ADL finds N::f(),
} //the latter is preferred

解析模板

大多数编程语言的编译器的两个基本活动是标记化(也称为扫描或词法分析)和解析。标记化过程将源代码作为字符序列读取,并从中生成标记序列。例如,在看到字符序列int* p = 0;时,“tokenizer”将为关键字int、符号/运算符 、标识符 p、符号/运算符 =、整数文字 0 生成token描述,和一个符号/运算符;。然后,解析器将通过递归地将标记或先前找到的模式减少到更高级别的构造中来找到标记序列中的已知模式。例如,标记 0 是一个有效的表达式,后跟标识符 p 的组合`` 是一个有效的声明符,而后跟“=”的声明符和表达式“0”是一个有效的 init 声明符。最后,关键字 int 是一个已知的类型名称,并且当其后跟 init-declarator *p = 0 时,您将获得 p 的初始化声明。

非模板中的上下文敏感性

正如您可能知道或期望的那样,标记化比解析更容易。幸运的是,解析是一个已经发展了坚实理论的学科,并且许多有用的语言使用这个理论并不难解析。然而,该理论最适用于上下文无关语言,我们已经注意到 C++ 是上下文敏感的。为了处理这个问题,C++ 编译器将符号表耦合到标记器和解析器:当解析声明时,它被输入到符号表中。当标记器找到一个标识符时,它会查找它并在找到类型时注释结果标记。

例如,如果 C++ 编译器看到x*,分词器查找x。如果它找到一个类型,解析器会看到

1
2
identifier, type, x
symbol, *

并得出声明已开始的结论。但是,如果 x 不是类型,则解析器从分词器接收

1
2
identifier, nontype, x
symbol, *

并且该构造只能作为乘法进行有效解析。这些原则的细节取决于特定的实施策略,但要点应该在那里。以下表达式说明了上下文敏感性的另一个示例:

1
X<1> (0)

如果 X 是类模板的名称,则前面的表达式将整数 0 转换为从该模板生成的类型X<1>。如果 X 不是模板,那么前面的表达式等价于

1
(X<1)>0

换句话说,X 与 1 进行比较,并且比较的结果与 0 进行比较。虽然这样的代码很少使用,但它是有效的 C++。因此,C++ 解析器将查找出现在 < 之前的名称,并且仅当已知名称是模板的名称时,才将 < 视为尖括号;否则,< 被视为普通的小于运算符。

这种形式的上下文敏感性是选择尖括号来分隔模板参数列表的不幸结果。这是另一个这样的后果:

1
2
3
4
5
6
7
8
9
template<bool B>
class Invert {
public:
static bool const result = !B;
};
void g()
{
bool test = Invert<(1>0)>::result; // parentheses required!
}

如果省略Invert<(1>0)>中的括号,则大于号将被误认为是模板参数列表的结束。这将使代码无效,因为编译器会将其读取为等效于((Invert<1>))0>::result。分词器也不能幸免尖括号符号的问题。例如,在

1
2
List<List<int>> a;
// ^-- no space between right angle brackets

两个>字符组合成一个右移标记>>,因此标记器永远不会将其视为两个单独的标记。C++ 实现必须将尽可能多的连续字符收集到一个标记中。

从 C++11 开始,C++ 标准专门修改了这种情况。

实例化

模板实例化是从通用模板定义生成类型、函数和变量的过程。C++ 模板实例化的概念是基本的,但也有些复杂。这种复杂性的根本原因之一是模板生成的实体的定义不再局限于源代码中的单个位置。模板的位置、使用模板的位置以及定义模板参数的位置都对实体的含义起作用。

在本章中,我们将解释如何组织源代码以启用正确的模板使用。此外,我们调查了最流行的 C++ 编译器用于处理模板实例化的各种方法。尽管所有这些方法在语义上都应该是等价的,但了解编译器实例化策略的基本原理还是很有用的。在构建实际软件时,每种机制都有其一组小怪癖,相反,每一种都会影响标准 C++ 的最终规范。

按需实例化

当 C++ 编译器遇到模板特化的使用时,它将通过用所需的参数替换模板参数来创建该特化。这是自动完成的,不需要客户端代码(或模板定义,就此而言)的指示。这种按需实例化功能将 C++ 模板与其他早期编译语言(如 Ada 或 Eiffel;其中一些语言需要显式实例化指令,而另一些使用运行时调度机制来完全避免实例化过程)中的类似设施区分开来。它有时也称为隐式或自动实例化。

按需实例化意味着编译器通常需要在使用时访问模板及其某些成员的完整定义(换句话说,不仅仅是声明)。考虑以下微小的源代码文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T> class C; // #1 declaration only
C<int>* p = 0; // #2 fine: definition of C<int> not needed
template<typename T>
class C {
public:
void f(); // #3 member declaration
}; // #4 class template definition completed
void g (C<int>& c) // #5 use class template declaration only
{
c.f(); // #6 use class template definition;
} // will need definition of
C::f()
// in this translation unit
template<typename T>
void C<T>::f() //required definition due to #6
{}

在源代码中的第 1 点,只有模板的声明可用,而不是定义(这样的声明有时称为前向声明)。与普通类的情况一样,我们不需要类模板的定义可见来声明对该类型的指针或引用,就像在第 #2 点所做的那样。例如,函数g()的参数类型不需要模板 C 的完整定义。但是,只要组件需要知道模板特化的大小或访问此类特化的成员,整个类模板定义必须是可见的。这就解释了为什么在源代码中的#6 处,必须看到类模板定义;否则,编译器无法验证该成员是否存在且可访问(不是私有的或受保护的)。此外,还需要成员函数定义,因为调用点 #6 需要存在C<int>::f()。这是另一个需要实例化前一个类模板的表达式,因为C<void>的大小是需要:

1
C<void>* p = new C<void>;

在这种情况下,需要实例化,以便编译器可以确定C<void>的大小,new-expression 需要该大小来确定要分配多少存储空间。您可能会观察到,对于这个特定的模板,用 X 代替 T 的参数类型不会影响模板的大小,因为在任何情况下,C<X>都是一个空类。但是,编译器不需要通过分析模板定义来避免实例化(并且所有编译器都会在实践中执行实例化)。此外,在此示例中还需要实例化来确定C<void>是否具有可访问的默认构造函数,并确保C<void>不声明成员运算符 new 或 delete。访问类模板成员的需要并不总是非常明确可见 在源代码中。例如,C++ 重载需要对候选函数参数的类类型的可见性:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class C {
public:
C(int); // a constructor that can be called with a single parameter
}; // may be used for implicit conversions
void candidate(C<double>); // #1
void candidate(int) { } // #2
int main()
{
candidate(42); // both previous function declarations can be called
}

调用Candidate(42)将解析为点 #2 处的重载声明。但是,也可以实例化点 #1 处的声明以检查它是否是调用的可行候选者(在这种情况下,因为单参数构造函数可以将 42 隐式转换为C<double>类型的右值)。请注意,如果编译器可以在没有它的情况下解析调用,则允许(但不是必需)执行此实例化(本示例中可能就是这种情况,因为不会在完全匹配上选择隐式转换)。另请注意,C<double>的实例化可能会触发错误,这可能会令人惊讶。

惰性实例化

到目前为止的示例说明了与使用非模板类时的需求没有根本区别的需求。许多用途需要一个完整的类类型。对于模板的情况,编译器将从类模板定义中生成这个完整的定义。现在出现了一个相关的问题:有多少模板被实例化了?一个模糊的答案如下:只有真正需要的量。换句话说,编译器在实例化模板时应该是“惰性的”。让我们看看这种懒惰到底意味着什么。

部分和全部实例化

正如我们所见,编译器有时不需要替换类或函数模板的完整定义。例如:

1
2
template<typename T> T f (T p) { return 2*p; }
decltype(f(2)) x = 2;

在此示例中,由decltype(f(2))指示的类型不需要函数模板f()的完整实例化。因此,编译器只允许替换f()的声明,而不是它的“主体”。这有时称为部分实例化。
类似地,如果引用类模板的实例而不需要该实例是完整类型,则编译器不应执行该类模板实例的完整实例化。考虑以下示例:

1
2
3
4
template<typename T> class Q {
using Type = typename T::Type;
};
Q<int>* p = 0; // OK: the body of Q<int> is not substituted

在这里,Q<int>的完整实例化会触发错误,因为当TintT::Type没有意义。但是因为在这个例子中Q<int>不需要是完整的,所以没有执行完整的实例化并且代码是好的(尽管可疑)。

变量模板也有“完整”与“部分”实例化的区别。以下示例说明了这一点:

1
2
template<typename T> T v = T::default_value();
decltype(v<int>) s; // OK: initializer of v<int> not instantiated

v<int>的完整实例化会引发错误,但如果我们只需要变量模板实例的类型,则不需要这样做。有趣的是,别名模板没有这种区别:没有两种方法可以替代它们。在 C++ 中,当谈到“模板实例化”而不具体说明完全或部分实例化时,前者是有意的。也就是说,默认情况下,实例化是完全实例化。

实例化组件

当一个类模板被隐式(完全)实例化时,其成员的每个声明也被实例化,但相应的定义不是(即,成员被部分实例化)。有几个例外。首先,如果类模板包含匿名联合,则该联合定义的成员也会被实例化。另一个例外发生在虚拟成员函数中。它们的定义可能会或可能不会作为实例化类模板的结果而被实例化。在实例化模板时,会单独考虑默认函数调用参数。具体来说,除非调用实际使用默认参数的函数(或成员函数),否则它们不会被实例化。另一方面,如果使用覆盖默认值的显式参数调用函数,则不会实例化默认参数。同样,除非需要,否则不会实例化异常规范和默认成员初始化程序。让我们一起举一些例子来说明一些这些原则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<typename T>
class Safe {
};
template<int N>
class Danger {
int arr[N]; // OK here, although would fail for N<=0
};
template<typename T, int N>class Tricky {
public:
void noBodyHere(Safe<T> = 3); // OK until usage of default value results in an error
void inclass() {
Danger<N> noBoomYet; // OK until inclass() is used with N<=0
}
struct Nested {
Danger<N> pfew; // OK until Nested is used with N<=0
};
union { //due anonymous union:
Danger<N> anonymous; // OK until Tricky is instantiated with N<=0
int align;
};
void unsafe(T (*p)[N]); // OK until Tricky is instantiated with N<=0
void error() {
Danger<-1> boom; // always ERROR (which not all compilers detect)
}
};

标准 C++ 编译器将检查这些模板定义以检查语法和一般语义约束。这样做时,它会在检查涉及模板参数的约束时“假设最好”。例如,成员Danger::arr中的参数 N 可以为零或负数(这将是无效的),但假设不是这种情况。inclass()struct Nested和匿名联合的定义是因此不成问题。同理,成员unsafe(T (*p)[N])的声明也不成问题,只要 N 是未替换的模板形参即可。membernoBodyHere()的声明是可疑的,因为模板Safe<>不能用整数初始化,但假设是Safe<T>的通用定义实际上不需要默认参数或Safe<T>将被专门化以启用整数值初始化。但是,即使没有实例化模板,成员函数error()的定义也是错误的,因为使用Danger<-1>需要完整定义类Danger<-1>,并且生成该类会尝试定义一个负大小的数组。有趣的是,虽然标准明确指出此代码无效,但它也允许编译器在未实际使用模板实例时不诊断错误。也就是说,由于Tricky<T,N>::error()不用于任何具体的 T 和 N,因此不需要编译器针对这种情况发出错误。

例如,在撰写本文时,GCC 和 Visual C++ 并未诊断此错误。现在让我们分析当我们添加以下定义时会发生什么:

1
Tricky<int, -1> inst;

这会导致编译器(完全)通过在模板Tricky<>的定义中用int代替T-1代替N来实例化Tricky<int, -1>。并不是所有的成员定义都需要,但是默认构造函数和析构函数(在这种情况下都是隐式声明的)肯定会被调用,因此它们的定义必须以某种方式可用(在我们的示例中就是这种情况,因为它们是隐式生成的)。如上所述,Tricky<int, -1>的成员被部分实例化(即,它们的声明被替换):该过程可能会导致错误。例如,unsafe(T (*p) [N])的声明创建了一个包含负数元素的数组类型,这是一个错误。同样,成员匿名现在会触发错误,因为类型Danger<-1>无法完成。相反,成员inclass()struct Nested的定义尚未实例化,因此它们需要完整类型Danger<-1>不会发生错误。

如前所述,在实例化模板时,实际上还应提供虚拟成员的定义。否则,很可能会发生链接器错误。例如:

1
2
3
4
5
6
7
8
9
10
template<typename T>
class VirtualClass {
public:
virtual ~VirtualClass() {}
virtual T vmem(); // Likely ERROR if instantiated without definition
};
int main()
{
VirtualClass<int> inst;
}

最后是对operator->的讨论。考虑:

1
2
3
4
5
template<typename T>
class C {
public:
T operator-> ();
};

通常,operator->必须返回一个指针类型或operator->应用到的另一个类类型。这表明C<int>的完成会触发错误,因为它为operator->声明了int的返回类型。但是,由于某些自然类模板定义会触发这些类型的定义,语言规则更加灵活。用户定义的operator->只需要返回一个类型,如果该运算符实际上是通过重载决议选择的,则另一个(例如,内置的)operator->适用于该类型。即使在模板之外也是如此(尽管宽松的行为在这些情况下不太有用)。因此,这里的声明不会触发错误,即使 int 被替换为返回类型。

C++实例化模型

模板实例化是通过适当替换模板参数从相应的模板实体中获取常规类型、函数或变量的过程。这听起来可能相当简单,但实际上需要正式确定许多细节。

两阶段查找

在第 13 章中,我们看到解析模板时无法解析依赖名称。相反,在实例化点再次查找它们。但是,不依赖的名称会被尽早查找,以便在第一次看到模板时可以诊断出许多错误。这就引出了两阶段查找的概念:第一阶段是模板的解析,第二阶段是它的实例化:

  1. 在第一阶段,在解析模板时,使用普通查找规则和(如果适用)参数相关查找 (ADL) 规则查找非依赖名称。使用普通查找规则查找未限定的依赖名称(它们是依赖的,因为它们看起来像具有依赖参数的函数调用中的函数名称),但在执行附加查找之前,查找结果不被认为是完整的第二阶段(当模板被实例化时)。
  2. 在第二阶段,在称为实例化点 (POI) 的点实例化模板时,会查找相关的限定名称(模板参数替换为该特定实例化的模板参数),并且额外的 ADL 是对在第一阶段使用普通查找查找的非限定从属名称执行。

对于不合格的从属名称,初始普通查找(虽然不完整)用于确定名称是否为模板。考虑以下示例:

1
2
3
4
5
6
7
8
9
10
11
12
namespace N {
template<typename> void g() {}
enum E { e };
}
template<typename> void f() {}
template<typename T> void h(T P) {
f<int>(p); // #1
g<int>(p); // #2 ERROR
}
int main() {
h(N::e); // calls template h with T = N::E
}

在第 #1 行中,当看到名称 f 后跟 < 时,编译器必须确定该 < 是尖括号还是小于号。这取决于是否知道 f 是模板的名称;在这种情况下,普通查找会找到 f 的声明,它确实是一个模板,因此使用尖括号解析成功。

但是,第 #2 行会产生错误,因为使用普通查找没有找到模板 g; < 因此被视为小于号,在本例中这是一个语法错误。如果我们能解决这个问题,我们最终会在为T = N::E实例化 h 时使用 ADL 找到模板 N::g(因为 N 是与 E 关联的命名空间),但我们无法做到这一点,直到我们成功解析 h 的通用定义。

实例化点

我们已经说明,在模板客户端的源代码中,C++ 编译器必须能够访问模板实体的声明或定义。当代码构造以这样的方式引用模板特化时创建实例化点 (POI),即需要实例化相应模板的定义以创建该特化。POI 是源中可以插入替换模板的点。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyInt {
public:
MyInt(int i);
};
MyInt operator - (MyInt const&);
bool operator > (MyInt const&, MyInt const&);
using Int = MyInt;
template<typename T>
void f(T i)
{
if (i>0) {
g(-i);
}
}// #1
void g(Int)
{
// #2
f<Int>(42); // point of call
// #3
} // #4

当 C++ 编译器看到调用f<Int>(42)时,它知道需要将模板f实例化为用MyInt替换的T:创建一个 POI。点 #2 和 #3 非常接近调用点,但它们不能是 POI,因为 C++ 不允许我们在那里插入::f<Int>(Int)的定义。第 1 点和第 4 点之间的本质区别在于,在第 4 点,函数g(Int)是可见的,因此可以解决依赖于模板的调用g(-i)。但是,如果点 #1 是 POI,则无法解析该调用,因为g(Int)尚不可见。幸运的是,C++ 将函数模板特化引用的 POI 定义为紧跟在最近的命名空间范围声明或包含该引用的定义之后。在我们的示例中,这是第 4 点。

您可能想知道为什么这个示例涉及类型MyInt而不是simpleint。答案在于在 POI 执行的第二次查找只是一个 ADL。因为 int 没有关联的命名空间,所以 POI 查找不会发生,也不会找到函数 g。因此,如果我们将 Int 的类型别名声明替换为using Int = int;,前面的示例将不再编译。以下示例遇到了类似的问题:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
void f1(T x)
{
g1(x); // #1
}
void g1(int)
{}
int main()
{
f1(7); // ERROR: g1 not found!
}
// #2 POI for f1<int>(int)

调用f1(7)f1<int>(int)就在main()之外的点 #2 创建一个 POI。在这个实例化中,关键问题是函数g1的查找。当第一次遇到模板f1的定义时,注意到非限定名称g1是依赖的,因为它是带有依赖参数的函数调用中的函数名称(参数 x 的类型取决于模板参数 T)。因此,使用普通查找规则在点 #1 查找g1; 但是,此时看不到g1。在点 #2,POI,函数在关联的命名空间和类中再次查找,但唯一的参数类型是 int,它没有关联的命名空间和类。因此,即使在 POI 上的普通查找会找到 g1,也永远找不到 g1。变量模板的实例化点与函数模板的处理类似。对于类模板特化,情况有所不同,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
class S {
public:
T m;
};
// #1
unsigned long h()
{
// #2
return (unsigned long)sizeof(S<int>);
// #3
}// #4

同样,函数作用域点#2 和#3 不能是 POI,因为命名空间作用域类S<int>的定义不能出现在那里(并且模板通常不能出现在函数作用域中)。如果我们要遵循函数模板实例的规则,POI 将在点 #4 ,但是表达式sizeof(S<int>)是无效的,因为 S 的大小直到点 #4 才能确定 到达。因此,对生成的类实例的引用的 POI 被定义为紧接在包含对该实例的引用的最近的命名空间范围声明或定义之前的点。在我们的示例中,这是点 #1 。

当模板被实际实例化时,可能会出现对额外实例化的需求。考虑一个简短的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
class S {
public:
using I = int;
};
// #1
template<typename T>
void f()
{
S<char>::I var1 = 41;
typename S<T>::I var2 = 42;
}
int main()
{
f<double>();
}// #2 : #2a , #2b

我们前面的讨论已经确定f<double>()的 POI 位于 #2 处。函数模板f()还引用了类特化S<char>,其 POI 因此位于点 #1 。它也引用了S<T>,但是因为它仍然是依赖的,所以我们现在不能真正实例化它。但是,如果我们在点 #2 实例化f<double>(),我们注意到我们还需要实例化S<double>的定义。此类次要或可传递 POI 的定义略有不同。对于功能模板,辅助 POI 与主 POI 完全相同。对于类实体,次要 POI 紧接在(在最近的封闭命名空间范围内)主要 POI 之前。在我们的示例中,这意味着f<double>()的 POI 可以放置在点 #2b 处,而就在它之前——在点 #2a——是S<double>的辅助 POI。请注意这与S<char>的 POI 有何不同。一个翻译单元通常包含同一个实例的多个 POI。对于类模板实例,仅保留每个翻译单元中的第一个 POI,而忽略后面的 POI(它们并不真正被视为 POI)。对于函数和变量模板的实例,保留所有 POI。在任何一种情况下,ODR 都要求在任何保留的 POI 上发生的实例化是等效的,但 C++ 编译器不需要验证和诊断违反此规则的情况。这允许 C++ 编译器只选择一个非类 POI 来执行实际实例化,而不必担心另一个 POI 可能会导致不同的实例化。

在实践中,大多数编译器将大多数函数模板的实际实例化延迟到翻译单元的末尾。某些实例化不能延迟,包括需要实例化来确定推导的返回类型的情况以及函数为 constexpr 并且必须评估以产生恒定结果的情况.一些编译器在第一次使用内联函数时会立即实例化内联函数。这有效地将相应模板专业化的 POI 移动到翻译单元的末尾,这是 C++ 标准允许的替代 POI。

编译期if段

C++增加了编译期if,它还在实例化过程中引入了一个新问题。

1
2
3
4
5
6
7
8
9
10
template<typename T> bool f(T p) {
if constexpr (sizeof(T) <= sizeof(long long)) {
return p>0;
} else {
return p.compare(0) > 0;
}
}
bool g(int n) {
return f(n); // OK
}

编译时 if 是一个 if 语句,其中 if 关键字紧跟constexpr关键字(如本例所示)。后面的带括号的条件必须有一个常量布尔值(到 bool 的隐式转换包含在该考虑中)。因此,编译器知道将选择哪个分支;另一个分支称为丢弃的分支。特别有趣的是,在模板(包括通用 lambda)的实例化过程中,丢弃的分支不会被实例化。这对于我们的示例有效是必要的:我们用 T = int 实例化 f(T),这意味着 else 分支被丢弃。如果它没有被丢弃,它将被实例化,并且我们会遇到表达式p.compare(0)的错误(当 p 是一个简单整数时它是无效的)。在 C++17 及其 constexpr if 语句之前,避免此类错误需要显式模板特化或重载以实现类似效果。

上面的例子,在 C++14 中,可能实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<bool b> struct Dispatch { //only to be instantiated when b is false
static bool f(T p) { //(due to next specialization for true)
return p.compare(0) > 0;
}
};
template<> struct Dispatch<true> {
static bool f(T p) {
return p > 0;
}
};
template<typename T> bool f(T p) {
return Dispatch<sizeof(T) <= sizeof(long long)>::f(p);
}
bool g(int n) {
return f(n); // OK
}

显然,constexpr if替代方案更清楚、更简洁地表达了我们的意图。但是,它需要实现来细化实例化单元:虽然以前函数定义总是作为一个整体实例化,但现在必须可以禁止部分实例化。constexpr if的另一个非常方便的用法是表示处理函数参数包所需的递归。

1
2
3
4
5
6
7
8
template<typename Head, typename... Remainder>
void f(Head&& h, Remainder&&... r) {
doSomething(std::forward<Head>(h));
if constexpr (sizeof...(r) != 0) {
// handle the remainder recursively (perfectly forwarding the arguments):
f(std::forward<Remainder>(r)...);
}
}

如果没有constexpr if语句,这需要f()模板的额外重载以确保递归终止。

即使在非模板上下文中,constexpr if语句也有一些独特的效果:

1
2
3
4
5
6
void h();
void g() {
if constexpr (sizeof(int) == 1) {
h();
}
}

在大多数平台上,g()中的条件为假,因此对h()的调用被丢弃。因此,h()根本不需要定义(当然,除非它在其他地方使用)。如果在此示例中省略了关键字constexpr,则缺少h()的定义通常会在链接时引发错误。

在标准库中

C++ 标准库包含许多模板,这些模板通常只与少数基本类型一起使用。例如,std::basic_string类模板最常与 char(因为std::stringstd::basic_string<char>的类型别名)或wchar_t一起使用,尽管可以用其他类似字符的方式实例化它。因此,标准库实现通常会为这些常见情况引入显式实例化声明。例如:

1
2
3
4
5
6
7
8
9
namespace std {
template<typename charT, typename traits = char_traits<charT>,
typename Allocator = allocator<charT>>
class basic_string {
...
};
extern template class basic_string<char>;
extern template class basic_string<wchar_t>;
}

模板参数推导

在每次调用函数模板时显式指定模板参数(例如,concat<std::string, int>(s, 3))会很快导致代码笨拙。幸运的是,C++ 编译器通常可以使用称为模板参数推导的强大过程自动确定预期的模板参数。尽管模板参数推导最初是为了简化函数模板的调用而开发的,但它后来被扩展以适用于其他几种用途,包括从它们的初始值设定项中确定变量的类型。

推导过程

基本推导过程将函数调用的参数类型与函数模板的相应参数化类型进行比较,并尝试得出对一个或多个推导参数的正确替换。每个参数-参数对都是独立分析的,如果最终得出的结论不同,则推理过程失败。考虑以下示例:

1
2
3
4
5
6
template<typename T>
T max (T a, T b)
{
return b < a ? a : b;
}
auto g = max(1, 1.0);

这里第一个调用参数是 int 类型,所以我们最初的max()模板的参数T被初步推导出为int。然而,第二个调用参数是双精度的,因此对于这个参数,T应该是双精度的:这与前面的结论相冲突。请注意,我们说“扣除过程失败”,而不是“程序无效”。毕竟,对于另一个名为max的模板,推演过程可能会成功(函数模板可以像普通函数一样被重载)。

如果所有推导的模板参数都是一致确定的,如果在函数声明的其余部分中替换参数导致无效构造,则推导过程仍然会失败。例如:

1
2
3
4
5
6
7
8
9
template< typename T>
typename T::ElementT at (T a, int i)
{
return a[i];
}
void f (int* p)
{
int x = at(p, 7);
}

这里T被推断为int*(T出现的参数类型只有一种,所以显然没有分析冲突)。但是,在返回类型T::ElementT中用int*代替T显然是无效的 C++,推演过程失败。

我们仍然需要探索参数-参数匹配是如何进行的。我们根据将类型 A(从调用参数类型派生)与参数化类型 P(从调用参数声明派生)匹配来描述它。如果调用参数是用引用声明符声明的,则 P 被认为是引用的类型,A 是参数的类型。然而,否则,P 是声明的参数类型,而 A 是通过将数组和函数类型退化为指针类型从参数类型中获得的,忽略const 和 volatile 限定符。例如:

1
2
3
4
5
6
7
8
9
template<typename T> void f(T); // parameterized type P is T
template<typename T> void g(T&); // parameterized type P is also T
double arr[20];
int const seven = 7;f(arr); // nonreference parameter: T is double*
g(arr); // reference parameter: T is double[20]
f(seven); // nonreference parameter: T is int
g(seven); // reference parameter: T is int const
f(7); // nonreference parameter: T is int
g(7); // reference parameter: T is int => ERROR: can’t pass 7 to int&

对于调用f(arr)arr的数组类型退化为double*类型,这是为T推导出的类型。在f(seven)中,const限定被去除,因此T被推导出为int。相反,调用g(x)T推导出为double[20]类型(不发生退化)。类似地,g(seven)有一个int const类型的左值参数,并且因为在匹配引用参数时不会删除 const 和 volatile 限定符,所以T被推导出为int const。但是,请注意g(7)会将T推导出为int(因为非类右值表达式从不具有 const 或 volatile 限定类型),并且调用将失败,因为参数无法传递给int&类型的参数。

当参数是字符串文字时,绑定到引用参数的参数不会发生退化这一事实可能令人惊讶。重新考虑使用引用声明的max()模板:

1
2
template<typename T>
T const& max(T const& a, T const& b);

可以合理地预期,对于表达式max("Apple", "Pie"),T 被推导出为char const*。但是,“Apple”的类型是char const[6],而“Pie”的类型是char const[4]。不会发生数组到指针的退化(因为推导涉及参考参数),因此T必须同时是char[6]char[4]才能成功推导。那当然是不可能的。

推断的上下文

比“T”复杂得多的参数化类型可以匹配给定的参数类型。以下是一些仍然相当基本的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T>
void f1(T*);template<typename E, int N>
void f2(E(&)[N]);
template<typename T1, typename T2, typename T3>
void f3(T1 (T2::*)(T3*));
class S {
public:
void f(double*);
};

void g (int*** ppp)
{
bool b[42];
f1(ppp); // deduces T to be int**
f2(b); // deduces E to be bool and N to be 42
f3(&S::f); // deduces T1 = void, T2 = S, and T3 = double
}

复杂类型声明是从更基本的构造(指针、引用、数组和函数声明符;指向成员声明符的指针;模板标识符等)构建的,匹配过程从顶层构造开始,并通过组合进行递归元素。公平地说,大多数类型声明构造都可以通过这种方式匹配,这些被称为推导上下文。但是,一些构造不是推断的上下文。例如:

  • 限定类型名称。例如,像Q<T>::X这样的类型名称永远不会用于推断模板参数 T。
  • 不只是非类型参数的非类型表达式。例如,像S<I+1>这样的类型名称永远不会用于推断 I。也不会通过匹配int(&)[sizeof(S<T>)]类型的参数来推断 T。这些限制应该不足为奇,因为推导通常不是唯一的(甚至是有限的),尽管这种限定类型名称的限制有时很容易被忽略。非推导的上下文并不自动暗示程序有错误,甚至分析的参数不能参与类型推导。为了说明这一点,请考虑以下更复杂的示例:
1
2
3
4
5
6
7
8
9
10
11
12
template<int N>class X {
public:
using I = int;
void f(int) {
}
};
template<int N>
void fppm(void (X<N>::*p)(typename X<N>::I));;
int main()
{
fppm(&X<33>::f); // fine: N deduced to be 33
}

在函数模板fppm()中,子构造X<N>::I是非推导上下文。但是,成员指针类型的成员类组件X<N>是一个可推导的上下文,并且当从它推导的参数 N 插入到非推导上下文中时,获得与实际参数&X<33>::f的类型兼容的类型。因此,推论在该参数-参数对上成功。

相反,可以为完全从推断的上下文构建的参数类型推断出矛盾。例如,假设适当声明的类模板 X 和 Y:

1
2
3
4
5
6
7
template<typename T>
void f(X<Y<T>, Y<T>>);
void g()
{
f(X<Y<int>, Y<int>>()); // OK
f(X<Y<int>, Y<char>>()); // ERROR: deduction fails
}

第二次调用函数模板f()的问题是两个参数为参数 T 推导了不同的参数,这是无效的。(在这两种情况下,函数调用参数都是通过调用类模板 X 的默认构造函数获得的临时对象。)

特殊推导情况

从函数调用的实参和函数模板的形参中获取不到用于推导的对(A, P)有几种情况。第一种情况发生在获取函数模板的地址时。在这种情况下,P 是函数模板声明的参数化类型,A 是初始化或分配给指针的函数类型。例如:

1
2
3
template<typename T>
void f(T, T);
void (*pf)(char, char) = &f;

在此示例中,P 为void(T, T),A 为void(char, char)。用char代替 T 推演成功,并且 pf 被初始化为特化f<char>的地址。类似地,函数类型用于 P 和 A 用于其他一些特殊情况:

  • 确定重载函数模板之间的偏序
  • 将显式特化与函数模板匹配
  • 将显式实例化与模板匹配
  • 将友元函数模板特化与模板匹配
  • 将放置操作符deleteoperator delete[]与相应的放置操作符newoperator new[]模板匹配

另一种特殊情况发生在转换函数模板中。例如:

1
2
3
4
class S {
public:
template<typename T> operator T&();
};

在这种情况下,获得对 (P, A) 就好像它涉及我们尝试转换的类型的参数和作为转换函数的返回类型的参数类型。以下代码说明了一种变体:

1
2
3
4
5
void f(int (&)[20]);
void g(S s)
{
f(s);
}

在这里,我们尝试将S转换为int (&)[20]。因此类型Aint[20],类型 P 是 T。推演成功,T 被int[20]替换。

初始化列表

当函数调用的参数是初始化列表时,该参数没有特定类型,因此通常不会从给定的对 (A, P) 中执行推导,因为没有 A。例如:

1
2
3
4
5
#include <initializer_list>
template<typename T> void f(T p);
int main() {
f({1, 2, 3}); // ERROR: cannot deduce T from a braced list
}

但是,如果参数类型 P 在删除引用和 const 和 volatile 限定符后,对于某些具有可推导模式的类型 P’ 等价于std::initializer_list<P'>,则推断过程仅当所有元素都具有相同类型时才成功:

1
2
3
4
5
6
7
#include <initializer_list>
template<typename T> void f(std::initializer_list<T>);
int main()
{
f({2, 3, 5, 7, 9}); // OK: T is deduced to int
f({’a’, ’e’, ’i’, ’o’, ’u’, 42}); //ERROR: T deduced to both char and int
}

类似地,如果参数类型 P 是对具有可推导模式的某些类型 P’ 的数组类型的引用,则通过将 P’ 与初始化器列表中每个元素的类型进行比较来进行推导,仅当所有元素具有相同的类型。此外,如果具有可推导的模式(即,仅命名非类型模板参数),则被推导为列表中的元素数。

参数包

推导过程将每个参数与每个参数匹配以确定模板参数的值。 然而,在对可变参数模板执行模板实参推导时,形参和实参之间的 1:1 关系不再成立,因为形参包可以匹配多个实参。 在这种情况下,相同的参数包 (P) 与多个参数 (A) 匹配,每次匹配都会为 P 中的任何模板参数包生成附加值:

1
2
3
4
5
6
template<typename First, typename... Rest>
void f(First first, Rest... rest);
void g(int i, double j, int* k)
{
f(i, j, k); // deduces First to int, Rest to {double, int*}
}

这里,第一个函数参数的推导很简单,因为它不涉及任何参数包。第二个函数参数rest是一个函数参数包。它的类型是一个包扩展 (Rest...),其模式是Rest类型:该模式用作P,与第二个和第三个调用参数的类型 A 进行比较。当与第一个这样的 A(double 类型)进行比较时,模板参数包Rest中的第一个值被推导出为double。类似地,当与第二个这样的 A(类型int*)进行比较时,模板参数包 Rest 中的第二个值被推导出为int*。因此,推导确定参数包Rest 的值是序列{double, int*}

将该推导的结果和第一个函数参数的推导替换为函数类型void(int, double, int*),它与调用站点的参数类型匹配。因为函数参数包的推导使用扩展的模式进行比较,所以模式可以任意复杂,并且可以从每个参数类型确定多个模板参数和参数包的值。考虑函数h1()h2()的推演行为,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T, typename U> class pair { };
template<typename T, typename... Rest>
void h1(pair<T, Rest> const&...);
template<typename... Ts, typename... Rest>
void h2(pair<Ts, Rest> const&...);
void foo(pair<int, float> pif, pair<int, double> pid, pair<double, double> pdd)
{
h1(pif, pid); // OK: deduces T to int, Rest to {float, double}
h2(pif, pid); // OK: deduces Ts to {int, int}, Rest to {float, double}
h1(pif, pdd); // ERROR: T deduced to int from the 1st arg, but to double from the 2nd
h2(pif, pdd); // OK: deduces Ts to {int, double}, Rest to {float, double}
}

对于h1()h2(),P 是一个引用类型,它被调整为引用的非限定版本(pair<T, Rest>pair<Ts, Rest>),以针对每个参数类型进行推导。由于所有参数和参数都是类模板对的特化,因此模板参数被比较。对于h1(),第一个模板参数 (T) 不是参数包,因此它的值是为每个参数独立推导的。如果推导不同,如第二次调用h1(),则推导失败。对于h1()h2() (Rest)中的第二对模板参数,以及h2() (Ts)中的第一对参数,推导从 A 中的每个参数类型确定模板参数包的连续值.

参数包的推导不限于参数-参数对来自调用参数的函数参数包。事实上,只要包展开位于函数参数列表或模板参数列表的末尾,就会使用此推导。例如,考虑对简单 Tuple 类型的两个类似操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename... Types> class Tuple { };
template<typename... Types>
bool f1(Tuple<Types...>, Tuple<Types...>);

template<typename... Types1, typename... Types2>bool f2(Tuple<Types1...>, Tuple<Types2...>);
void bar(Tuple<short, int, long> sv, Tuple<unsigned short, unsigned, unsigned long> uv)
{
f1(sv, sv); // OK: Types is deduced to {short, int, long}
f2(sv, sv); // OK: Types1 is deduced to {short, int, long},
// Types2 is deduced to {short, int, long}
f1(sv, uv); // ERROR: Types is deduced to {short, int, long} from the 1st arg, but
// to {unsigned short, unsigned, unsigned long} from the 2nd
f2(sv, uv); // OK: Types1 is deduced to {short, int, long},
// Types2 is deduced to {unsigned short, unsigned, unsigned long}
}

f1()f2()中,模板参数包是通过将嵌入在Tuple类型(例如,h1()的类型)中的包扩展模式与由提供的Tuple类型的每个模板参数进行比较来推导出的调用参数,推导出相应模板参数包的连续值。函数f1()在两个函数参数中使用相同的模板参数包类型,确保只有当两个函数调用参数具有与其类型相同的Tuple特化时,推导才会成功。另一方面,函数f2()对每个函数参数中的Tuple类型使用不同的参数包,因此函数调用参数的类型可以不同——只要两者都是 Tuple 的特化。

模板的多态性

多态在C++中它主要由继承和虚函数实现。由于这一机制主要(至少是一部分)在运行期间起作用,因此我们称之为动态多态(dynamic polymorphism)。模板也允许我们用单个统一符号将不同的特定行为关联起来,不过该关联主要发生在编译期间,我们称之为静态多态(static polymorphism)。

动态多态(dynamic polymorphism)

由于历史原因,C++在最开始的时候只支持通过继承和虚函数实现的多态。在此情况下,多态设计的艺术性主要体现在从一些相关的对象类型中提炼出一组统一的功能,然后将它们声明成一个基类的虚函数接口。

这一设计方式的范例之一是一种用来维护多种几何形状、并通过某些方式将其渲染的应用。在这样一种应用中,我们可以发现一个抽线基类(abstract base class,ABC),在其中声明了适用于几何对象的统一的操作和属性。其余适用于特定几何对象的类都从它做了继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include "coord.hpp"
// common abstract base class GeoObj for geometric objects
class GeoObj {
public:
// draw geometric object:
virtual void draw() const = 0;
// return center of gravity of geometric object:
virtual Coord center_of_gravity() const = 0;
...
virtual ~GeoObj() = default;
};
// concrete geometric object class Circle
// - derived from GeoObj
class Circle : public GeoObj {
public:
virtual void draw() const override;
virtual Coord center_of_gravity() const override;
...
};
// concrete geometric object class Line
// - derived from GeoObj
class Line : public GeoObj {
public:
virtual void draw() const override;
virtual Coord center_of_gravity() const override;
...
};

在创建了具体的对象之后,客户端代码可以通过指向公共基类的指针或者引用,使用虚函数的派发机制来操作它们。在通过基类的指针或者引用调用一个虚函数的时候,所调用的函数将是指针或者引用所指对象的真正类型中的相应函数。在我们的例子中,具体的代码可以被简写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "dynahier.hpp"
#include <vector>
// draw any GeoObj
void myDraw (GeoObj const& obj)
{
obj.draw(); // call draw() according to type of object
}
// compute distance of center of gravity between two GeoObjs
Coord distance (GeoObj const& x1, GeoObj const& x2)
{
Coord c = x1.center_of_gravity() - x2.center_of_gravity();
return c.abs(); // return coordinates as absolute values
} // draw heterogeneous collection of GeoObjs
void drawElems (std::vector<GeoObj*> const& elems)
{
for (std::size_type i=0; i<elems.size(); ++i) {
elems[i]->draw(); // call draw() according to type of element
}
}
int main(){
Line l;
Circle c, c1, c2;
myDraw(l); // myDraw(GeoObj&) => Line::draw()
myDraw(c); // myDraw(GeoObj&) => Circle::draw()
distance(c1,c2); // distance(GeoObj&,GeoObj&)
distance(l,c); // distance(GeoObj&,GeoObj&)
std::vector<GeoObj*> coll; // heterogeneous collection
coll.push_back(&l); // insert line
coll.push_back(&c); // insert circle
drawElems(coll); // draw different kinds of GeoObjs
}

关键的多态接口是函数draw()center_of_gravity(),都是虚成员函数。上述例子在函数mydraw()distance(),以及drawElems()中展示了这两个虚函数的用法。而后面这几个函数使用的都是公共基类GeoObj。这一方式的结果是,在编译期间并不能知道将要被真正调用
的函数。但是,在运行期间,则会基于各个对象的完整类型来决定将要调用的函数。因此,取决于集合对象的真正类型,适当的操作将会被执行:如果mydraw()处理的是Line的对象,表达式obj.draw()将调用Line::draw(),如果处理的是Circle的对象,那么就会调用Circle::draw()

能够处理异质集合中不同类型的对象,或许是动态多态最吸引人的特性。这一概念在drawElems()函数中得到了体现:表达式elems[i]->draw()会调用不同的成员函数,具体情况取决于元素的动态类型。

静态多态

模板也可以被用来实现多态。不同的是,它们不依赖于对基类中公共行为的分解。取而代之的是,这一“共性(commonality)”隐式地要求不同的“形状(shapes)”必须支持使用了相同语法的操作(比如,相关函数的名字必须相同)。在定义上,具体的class之间彼此相互独立。在用这些具体的class去实例化模板的时候,这一多态能力得以实现。

比如,上一节中的myDraw():

1
2
3
4
5
void myDraw (GeoObj const& obj) // GeoObj is abstract base
class
{
obj.draw();
}

也可以被实现成下面这样:

1
2
3
4
5
template<typename GeoObj>
void myDraw (GeoObj const& obj) // GeoObj is template parameter
{
obj.draw();
}

比较myDraw()的两种实现,可以发现其主要的区别是将GeoObj用作模板参数而不是公共基类。但是,在表象之下还有很多区别。比如,使用动态多态的话,在运行期间只有一个myDraw()函数,但是在使用模板的情况下,却会有多种不同的函数,例如myDraw<Line>()myDraw<Circle>()

我们可能希望用static多态重新实现上一节中的完整例子。首先,我们不再使用有层级结构的几何类,而是直接使用一些彼此独立的几何类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "coord.hpp"
// concrete geometric object class Circle
// - not derived from any class
class Circle {
public:
void draw() const;
Coord center_of_gravity() const;
};
// concrete geometric object class Line
// - not derived from any class
class Line {
public:
void draw() const;
Coord center_of_gravity() const;
...
};

现在,可以像下面这样使用这些类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include "statichier.hpp"
#include <vector>
// draw any GeoObj
template<typename GeoObj>
void myDraw (GeoObj const& obj)
{
obj.draw(); // call draw() according to type of object
}
// compute distance of center of gravity between two GeoObjs
template<typename GeoObj1, typename GeoObj2>
Coord distance (GeoObj1 const& x1, GeoObj2 const& x2)
{
Coord c = x1.center_of_gravity() - x2.center_of_gravity();
return c.abs(); // return coordinates as absolute values
}

// draw homogeneous collection of GeoObjs
template<typename GeoObj>
void drawElems (std::vector<GeoObj> const& elems)
{
for (unsigned i=0; i<elems.size(); ++i) {
elems[i].draw(); // call draw() according to type of element
}
}
int main()
{
Line l;
Circle c, c1, c2;
myDraw(l); // myDraw<Line>(GeoObj&) => Line::draw()
myDraw(c); // myDraw<Circle>(GeoObj&) => Circle::draw()
distance(c1,c2); //distance<Circle,Circle>(GeoObj1&,GeoObj2&)
distance(l,c); // distance<Line,Circle>(GeoObj1&,GeoObj2&)
// std::vector<GeoObj*> coll; //ERROR: no heterogeneous collection possible
std::vector<Line> coll; // OK: homogeneous collection possible
coll.push_back(l); // insert line
drawElems(coll); // draw all lines
}

myDraw()类似,我们不能够再将GeoObj作为具体的参数类型用于distance()。我们引入了两个模板参数,GeoObj1和GeoObj2,来支持不同类型的集合对象之间的距离计算:

1
distance(l,c); // distance<Line,Circle>(GeoObj1&,GeoObj2&)

但是使用这种方式,我们将不再能够透明地处理异质容器。这也正是static多态中的static部分带来的限制:所有的类型必须在编译期可知。不过,我们可以很容易的为不同的集合对象类型引入不同的集合。这样就不再要求集合的元素必须是指针类型,这对程序性能和类型安全都会有帮助。

动态多态VS静态多态

让我们来对这两种多态性形式进行分类和比较。

Static和dynamic多态提供了对不同C++编程术语的支持:

  • 通过继承实现的多态是有界的(bounded)和动态的(dynamic):
    • 有界的意思是,在设计公共基类的时候,参与到多态行为中的类型的相关接口就已经确定(该概念的其它一些术语是侵入的(invasive和intrusive))。
    • 动态的意思是,接口的绑定是在运行期间执行的。
  • 通过模板实现的多态是无界的(unbounded)和静态的(static):
    • 无界的意思是,参与到多态行为中的类型的相关接口是不可预先确定的
    • 静态的意思是,接口的绑定是在编译期间执行的

因此,严格来讲,在C++中,动态多态和静态多态分别是有界动态多态和无界静态多态的缩写。在其它语言中还会有别的组合(比如在Smakktalk中的无界动态多态)。但是在C++语境中,更简洁的动态多态和静态多态也不会带来困扰。

C++中的动态多态有如下优点:

  • 可以很优雅的处理异质集合。
  • 可执行文件的大小可能会比较小(因为它只需要一个多态函数,不像静态多态那样,需要为不同的类型进行各自的实例化)。
  • 代码可以被完整的编译;因此没有必须要被公开的代码(在发布模板库时通常需要发布模板的源代码实现)。

作为对比,下面这些可以说是C++中static多态的优点:

  • 内置类型的集合可以被很容易的实现。更通俗地说,接口的公共性不需要通过公共基类实现。
  • 产生的代码可能会更快(因为不需要通过指针进行重定向,先验的(priori)非虚函数通常也更容易被inline)。
  • 即使某个具体类型只提供了部分的接口,也可以用于静态多态,只要不会用到那些没有被实现的接口即可。

通常认为静态多态要比动态多态更类型安全(type safe),因为其所有的绑定都在编译期间进行了检查。例如,几乎不用担心将一个通过模板实例化得到的、类型不正确的对象插入到一个已有容器中(编译期间会报错)。但是,对于一个存储了指向公共基类的指针的容器,其所存储的指针却有可能指向一个不同类型的对象。
在实际中,当相同的接口后面隐藏着不同的语义假设时,模板实例化也会带来一些问题。比如,当关联运算符operator +被一个没实现其所需的关联操作的类型实例化时,就会遇到错误。在实际中,对于基于继承的设计层次,很少会遇到这一类的语义不匹配,这或许是因为相应的接口规格得到了较好的说明。

使用concepts

针对使用了模板的静态多态的一个争议是,接口的绑定是通过实例化相应的模板执行的。也就是说没有可供编程的公共接口或者公共class。取而代之的是,如果所有实例化的代码都是有效的,那么对模板的任何使用也都是有效的。否则,就会导致难以理解的错误信息,或者是产生了有效的代码却导致了意料之外的行为。

基于这一原因,C++语言的设计者们一直在致力于实现一种能够为模板参数显式地提供(或者是检查)接口的能力。在C++中这一接口被称为concept。它代表了为了能够成功的实例化模板,模板参数必须要满足的一组约束条件。

Concept可以被理解成静态多态的一类“接口”。在我们的例子中,可能会像下面这样:

1
2
3
4
5
6
7
#include "coord.hpp"
template<typename T>
concept GeoObj = requires(T x) {
{ x.draw() } -> void;
{ x.center_of_gravity() } -> Coord;
...
};

在这里我们使用关键字concept定义了一个GeoObj concept,它要求一个类型要有可被调用的成员函数draw()center_of_gravity(),同时也对它们的返回类型做了限制。现在我们可以重写样例模板中的一部分代码,以在其中使用requires子句要求模板参数满足GeoObj concept:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include "conceptsreq.hpp"
#include <vector>
// draw any GeoObj
template<typename T>
requires GeoObj<T>
void myDraw (T const& obj)
{
obj.draw(); // call draw() according to type of object
}
// compute distance of center of gravity between two GeoObjs
template<typename T1, typename T2>
requires GeoObj<T1> && GeoObj<T2>
Coord distance (T1 const& x1, T2 const& x2)
{
Coord c = x1.center_of_gravity() - x2.center_of_gravity();
return c.abs(); // return coordinates as absolute values
}

// draw homogeneous collection of GeoObjs
template<typename T>
requires GeoObj<T>
void drawElems (std::vector<T> const& elems)
{
for (std::size_type i=0; i<elems.size(); ++i) {
elems[i].draw(); // call draw() according to type of element
}
}

对于那些可以参与到静态多态行为中的类型,该方法依然是非侵入的:

1
2
3
4
5
6
7
8
// concrete geometric object class Circle
// - not derived from any class or implementing any interface
class Circle {
public:
void draw() const;
Coord center_of_gravity() const;
...
};

也就是说,这一类类型的定义中依然不包含特定的基类,或者require子句,而且它们也依然可以是基础数据类型或者来自独立框架的类型。

泛型编程(Generic Programming)

在C++的语境中,泛型编程有时候也被定义成模板编程(而面向对象编程被认为是基于虚函数的编程)。在这个意义上,几乎任何C++模板的使用都可以被看作泛型编程的实例。但是,开发者通常认为泛型编程还应包含如下这一额外的要素:

  • 该模板必须被定义于一个框架中,且必须能够适用于大量的、有用的组合。

到目前为止,在该领域中最重要的一个贡献是标准模板库(Standard Template Library, STL)。STL的设计者们找到了一种可以用于任意线性集合、称之为迭代器(iterators)抽象概念。从本质上来说,容器操作中针对于集合的某些方面已经被分解到迭代器的功能中,这样就可以不用去给所有的线性容器都提供一些诸如max_element()的操作,容器本身只要提供一个能够遍历序列中数值的迭代器类型,以及一些能够创建这些迭代器的成员函数就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace std {
template<typename T, ...>
class vector {
public:
using const_iterator = ...; // implementation-specific iterator
... // type for constantvectors
const_iterator begin() const; // iterator for start of collection
const_iterator end() const; // iterator for end of collection
...
};
template<typename T, ...>
class list {
public:
using const_iterator = ...; // implementation-specific iterator
... // type for constant lists
const_iterator begin() const; // iterator for start of collection
const_iterator end() const; // iterator for end of collection
...
};
}

现在就可以通过调用泛型操作max_element()(以容器的beginning和end伟参数)来寻找任意集合中的最大值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <vector>
#include <list>
#include <algorithm>
#include <iostream>
#include "MyClass.hpp"
template<typename T>
void printMax (T const& coll){
// compute position of maximum value
auto pos = std::max_element(coll.begin(),coll.end());
// print value of maximum element of coll (if any):
if (pos != coll.end()) {
std::cout << *pos << "\n";
}
else {
std::cout << "empty" << "\n";
}
}
int main()
{
std::vector<MyClass> c1;
std::list<MyClass> c2;
...
printMax(c1);
printMax(c2);
}

泛型的关键是迭代器,它由容器提供并被算法使用。这样之所以可行,是因为迭代器提供了特定的、可以被算法使用的接口。这些接口通常被称为concept,它代表了为了融入该框架,模板必须满足的一组限制条件。此外,该概念还可用于其它一些操作和数据结构。

原则上,类似于STL方法的一类功能都可以用动态多态实现。但是在实际中,由于迭代器的concept相比于虚函数的调用过于轻量级,因此多态这一方法的用途有限。基于虚函数添加一个接口层,很可能会将我们的操作性能降低一个数量级(甚至更多)。泛型编程之所以实用,正是因为它依赖于静态多态,这样就可以在编译期间就决定具体的接口。另一方面,需要在编译期间解析出接口的这一要求,又催生出了一些与面向对象设计原则(object oriented principles)不同的新原则。

萃取的实现

萃取(或者叫萃取模板,traits/traits template)是C++编程的组件,它们对管理那些在设计工业级应用模板时所需要管理的多余参数很有帮助。

一个例子:对一个序列求和

计算一个序列中所有元素的和是一个很常规的任务。也正是这个简单的问题,给我们提供了一个很好的、可以用来介绍各种不同等级的萃取应用的例子。

固定的萃取(Fixed Traits)

让我们先来考虑这样一种情况:待求和的数据存储在一个数组中,然后我们有一个指向数组中第一个元素的指针,和一个指向最后一个元素的指针。由于本书介绍的是模板,我们自然也希望写出一个适用于各种类型的模板。下面是一个看上去很直接的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef ACCUM_HPP
#define ACCUM_HPP
template<typename T>
T accum (T const* beg, T const* end)
{
T total{}; // assume this actually creates a zero value
while (beg != end) {
total += *beg;
++beg;
}
return total;
}
#endif //ACCUM_HPP

例子中唯一有些微妙的地方是,如何创建一个类型正确的零值(zero value)来作为求和的起始值。

这就意味着这个局部的total对象要么被其默认值初始化,要么被零(zero)初始化(对应指针是用nullptr初始化,对应bool值是用false初始化)。为了引入我们的第一个萃取模板,考虑下面这一个使用了accum()的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "accum1.hpp"
#include <iostream>
int main()
{
// create array of 5 integer values
int num[] = { 1, 2, 3, 4, 5 };
// print average value
std::cout << "the average value of the integer values is " << accum(num, num+5) / 5 << "\n";
// create array of character values
char name[] = "templates";
int length = sizeof(name)-1;
// (try to) print average character value
std::cout << "the average value of the characters in \"" << name << "\" is " << accum(name, name+length) / length << "\n";
}

在例子的前半部分,我们用accum()对5个整型遍历求和:

1
2
3
int num[] = { 1, 2, 3, 4, 5 };
...
accum(num0, num+5)

接着就可以用这些变量的和除变量的数目得到平均值。

例子的第二部分试图为单词“templates”中所有的字符做相同的事情。结果应该是a到z之间的某一个值。在当今的大多数平台上,这个值都是通过ASCII码决定的: a被编码成97,z被编码成122。因此我们可能会期望能够得到一个介于97和122之间的返回值。但是在我们的平台上,程序的输出却是这样的:

1
2
the average value of the integer values is 3
the average value of the characters in "templates" is -5

问题在于我们的模板是被char实例化的,其数值范围即使是被用来存储相对较小的数值的和也是不够的。很显然,为了解决这一问题我们应该引入一个额外的模板参数AccT,并将其用于返回值total的类型。但是这会给模板的用户增加负担:在调用这一模板的时候,他们必须额外指定一个类型。对于上面的例子,我们可能需要将其写称这个样子:

1
accum<int>(name,name+5)

这并不是一个过于严苛的要求,但是确实是可以避免的。一个可以避免使用额外的模板参数的方式是,在每个被用来实例化accum()的T和与之对应的应该被用来存储返回值的类型之间建立某种联系。这一联系可以被认为是T的某种属性。正如下面所展示的一样,可以通过模板的偏特化建立这种联系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename T>
struct AccumulationTraits;
template<>
struct AccumulationTraits<char> {
using AccT = int;
};
template<>
struct AccumulationTraits<short> {
using AccT = int;
};
template<>
struct AccumulationTraits<int> {
using AccT = long;
};
template<>
struct AccumulationTraits<unsigned int> {
using AccT = unsigned long;
};
template<>
struct AccumulationTraits<float> {
using AccT = double;
};

AccumulationTraits模板被称为萃取模板,因为它是提取了其参数类型的特性。(通常而言可以有不只一个萃取,也可以有不只一个参数)。我们选择不对这一模板进行泛型定义,因为在不了解一个类型的时候,我们无法为其求和的类型做出很好的选择。但是,可能有人会辩解说T类型本身就是最好的待选类型(很显然对于我们前面的例子不是这样)。

有了这些了解之后,我们可以将accum()按照下面的方式重写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#ifndef ACCUM_HPP
#define ACCUM_HPP
#include "accumtraits2.hpp"
template<typename T>
auto accum (T const* beg, T const* end)
{
// return type is traits of the element type
using AccT = typename AccumulationTraits<T>::AccT;
AccT total{}; // assume this actually creates a zero value
while (beg != end) {
total += *beg;
++beg;
}
return total;
}
#endif //ACCUM_HPP

此时程序的输出就和我们所预期一样了:

1
2
the average value of the integer values is 3
the average value of the characters in "templates" is 108

考虑到我们为算法加入了很好的检查机制,总体而言这些变化不算太大。而且,如果要将accum()用于新的类型的话,只要对AccumulationTraits再进行一次显式的偏特化,就会得到一个AccT。值得注意的是,我们可以为任意类型进行上述操作:基础类型,声明在其它库
中的类型,以及其它诸如此类的类型。

值萃取(Value Traits)

到目前为止我们看到的萃取,代表的都是特定“主”类型的额外的类型信息。在本节我们将会看到,这一“额外的信息”并不仅限于类型信息。还可以将常量以及其它数值类和一个类型关联起来。

在最原始的accum()模板中,我们使用默认构造函数对返回值进行了初始化,希望将其初始化为一个类似零(zero like)的值:

1
2
3
AccT total{}; // assume this actually creates a zero value
...
return total;

很显然,这并不能保证一定会生成一个合适的初始值。因为AccT可能根本就没有默认构造函数。

萃取可以再一次被用来救场。对于我们的例子,我们可以为AccumulationTraits添加一个新的值萃取(value trait,似乎翻译成值特性会更好一些):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T>
struct AccumulationTraits;
template<>
struct AccumulationTraits<char> {
using AccT = int;
static AccT const zero = 0;
};
template<>
struct AccumulationTraits<short> {
using AccT = int;
static AccT const zero = 0;
};
template<>
struct AccumulationTraits<int> {
using AccT = long;
static AccT const zero = 0;
};

在这个例子中,新的萃取提供了一个可以在编译期间计算的,const的zero成员。此时,accum()的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#ifndef ACCUM_HPP
#define ACCUM_HPP
#include "accumtraits3.hpp"
template<typename T>
auto accum (T const* beg, T const* end)
{
// return type is traits of the element type
using AccT = typename AccumulationTraits<T>::AccT;
AccT total = AccumulationTraits<T>::zero; // init total by trait value
while (beg != end) {
total += *beg;
++beg;
}
return total;
}
#endif // ACCUM_HPP

在上述代码中,存储求和结果的临时变量的初始化依然很直观:

1
AccT total = AccumulationTraits<T>::zero;

这一实现的一个不足之处是,C++只允许我们在类中对一个整形或者枚举类型的static const数据成员进行初始化。

Constexpr的static数据成员会稍微好一些,允许我们对float类型以及其它字面值类型进行类内初始化:

1
2
3
4
5
template<>
struct AccumulationTraits<float> {
using Acct = float;
static constexpr float zero = 0.0f;
};

但是无论是const还是constexpr都禁止对非字面值类型进行这一类初始化。比如,一个用户定义的任意精度的BigInt类型,可能就不是字面值类型,因为它可能会需要将一部分信息存储在堆上(这会阻碍其成为一个字面值类型),或者是因为我们所需要的构造函数不是constexpr的。下面这个实例化的例子就是错误的:

1
2
3
4
5
6
7
8
9
10
class BigInt {
BigInt(long long);
...
};
...
template<>
struct AccumulationTraits<BigInt> {
using AccT = BigInt;
static constexpr BigInt zero = BigInt{0}; // ERROR: not a literal type
};

一个比较直接的解决方案是,不再\在类中定义值萃取(只做声明):

1
2
3
4
5
template<>
struct AccumulationTraits<BigInt> {
using AccT = BigInt;
static BigInt const zero; // declaration only
};

然后在源文件中对其进行初始化,像下面这样:

1
BigInt const AccumulationTraits<BigInt>::zero = BigInt{0};

这样虽然可以工作,但是却有些麻烦(必须在两个地方同时修改代码),这样可能还会有些低效,因为编译期通常并不知晓在其它文件中的变量定义。在C++17中,可以通过使用inline变量来解决这一问题:

1
2
3
4
5
template<>
struct AccumulationTraits<BigInt> {
using AccT = BigInt;
inline static BigInt const zero = BigInt{0}; // OK since C++17
};

在C++17之前的另一种解决办法是,对于那些不是总是生成整型值的值萃取,使用inline成员函数。同样的,如果成员函数返回的是字面值类型,可以将该函数声明为constexpr的。比如,我们可以像下面这样重写AccumulationTraits:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
template<typename T>
struct AccumulationTraits;
template<>
struct AccumulationTraits<char> {
using AccT = int;
static constexpr AccT zero() {
return 0;
}
};
template<>
struct AccumulationTraits<short> {
using AccT = int;
static constexpr AccT zero() {
return 0;
}
};
template<>
struct AccumulationTraits<int> {
using AccT = long;
static constexpr AccT zero() {
return 0;
}
};
template<>
struct AccumulationTraits<unsigned int> {
using AccT = unsigned long;
static constexpr AccT zero() {
return 0;
}
};
template<>
struct AccumulationTraits<float> {
using AccT = double;
static constexpr AccT zero() {
return 0;
}
};

然后针我们自定义的类型对这些萃取进行扩展:
1
2
3
4
5
6
7
template<>
struct AccumulationTraits<BigInt> {
using AccT = BigInt;
static BigInt zero() {
return BigInt{0};
}
};

在应用端,唯一的区别是函数的调用语法(不像访问一个static数据成员那么简洁):

1
AccT total = AccumulationTraits<T>::zero(); // init total by trait function

很明显,萃取可以不只是类型。在我们的例子中,萃取可以是一种能够提供所有在调用accum()时所需的调用参数的信息的技术。这是萃取这一概念的关键:萃取为泛型编程提供了一种配置(configure)具体元素(通常是类型)的手段。

参数化的萃取

在前面几节中,在accum()里使用的萃取被称为固定的(fixed),这是因为一旦定义了解耦合萃取,在算法中它就不可以被替换。但是在某些情况下,这一类重写(overriding)行为却又是我们所期望的。比如,我们可能碰巧知道某一组float数值的和可以被安全地存储在一个float变量中,而这样做可能又会带来一些性能的提升。

为了解决这一问题,可以为萃取引入一个新的模板参数AT,其默认值由萃取模板决定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef ACCUM_HPP
#define ACCUM_HPP
#include "accumtraits4.hpp"
template<typename T, typename AT = AccumulationTraits<T>>
auto accum (T const* beg, T const* end)
{
typename AT::AccT total = AT::zero();
while (beg != end) {
total += *beg;
++beg;
}
return total;
}
#endif //ACCUM_HPP

采用这种方式,一部分用户可以忽略掉额外模板参数,而对于那些有着特殊需求的用户,他们可以指定一个新的类型来取代默认类型。但是可以推断,大部分的模板用户永远都不需要显式的提供第二个模板参数,因为我们可以为第一个模板参数的每一种(通过推断得到的)类型都配置一个合适的默认值。

萃取还是策略以及策略类

到目前为止我们并没有区分累积(accumulation)和求和(summation)。但是我们也可以相像其它种类的累积。比如,我们可以对一组数值求积。或者说,如果这些值是字符串的话,我们可以将它们连接起来。即使是求一个序列中最大值的问题,也可以转化成一个累积问题。在所有这些例子中,唯一需要变得的操作是accum()中的total += *beg。我们可以称这一操作为累积操作的一个策略(policy)。

下面是一个在accum()中引入这样一个策略的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifndef ACCUM_HPP
#define ACCUM_HPP
#include "accumtraits4.hpp"
#include "sumpolicy1.hpp"
template<typename T,
typename Policy = SumPolicy,
typename Traits = AccumulationTraits<T>>
auto accum (T const* beg, T const* end)
{
using AccT = typename Traits::AccT;
AccT total = Traits::zero();
while (beg != end) {
Policy::accumulate(total, *beg);
++beg;
}
return total;
}
#endif //ACCUM_HPP

在这一版的accum()中,SumPolicy是一个策略类,也就是一个通过预先商定好的接口,为算法实现了一个或多个策略的类。SumPolicy可以被实现成下面这样:

1
2
3
4
5
6
7
8
9
10
#ifndef SUMPOLICY_HPP
#define SUMPOLICY_HPP
class SumPolicy {
public:
template<typename T1, typename T2>
static void accumulate (T1& total, T2 const& value) {
total += value;
}
};
#endif //SUMPOLICY_HPP

如果提供一个不同的策略对数值进行累积的话,我们可以计算完全不同的事情。比如考虑下面这个程序,它试图计算一组数值的乘积:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "accum6.hpp"
#include <iostream>
class MultPolicy {
public:
template<typename T1, typename T2>
static void accumulate (T1& total, T2 const& value) {
total *= value;
}
};
int main()
{
// create array of 5 integer values
int num[] = { 1, 2, 3, 4, 5 };
// print product of all values
std::cout << "the product of the integer values is " <<
accum<int,MultPolicy>(num, num+5) << "\n";
}

但是这个程序的输出却和我们所期望的有所不同:

1
the product of the integer values is 0

问题出在我们对初始值的选取:虽然0能很好的满足求和的需求,但是却不适用于求乘积(初始值0会让乘积的结果也是0)。这说明不同的萃取和策略可能会相互影响,也恰好强调了仔细设计模板的重要性。

在这种情况下,我们可能会认识到,累积循环的初始值应该是累计策略的一部分。这个策略可以使用也可以不使用其zero()萃取。其它一些方法也应该被记住:不是所有的事情都要用萃取和策略才能够解决的。比如,C++标准库中的std::accumulate()就将其初始值当作了第三个参数。

萃取和策略:有什么区别

可以设计一个合适的例子来证明策略只是萃取的一个特例。相反地,也可以认为萃取只是编码了一个特定的策略。

引入了萃取技术的Nathan Myers则建议使用如下更为开放的定义:

  • 萃取类:一个用来代替模板参数的类。作为一个类,它整合了有用的类型和常量;作为一个模板,它为实现一个可以解决所有软件问题的“额外的中间层”提供了方法。

总体而言,我们更倾向于使用如下(稍微模糊的)定义:

  • 萃取代表的是一个模板参数的本质的、额外的属性。
  • 策略代表的是泛型函数和类型(通常都有其常用地默认值)的可以配置的行为。

为了进一步阐明两者之间可能的差异,我们列出了如下和萃取有关的观察结果:

  • 萃取在被当作固定萃取(fixed traits)的时候会比较有用(比如,当其不是被作为模板参数传递的时候)。
  • 萃取参数通常都有很直观的默认参数(很少被重写,或者简单的说是不能被重写)。
  • 萃取参数倾向于紧密的依赖于一个或者多个主模板参数。
  • 萃取在大多数情况下会将类型和常量结合在一起,而不是成员函数。
  • 萃取倾向于被汇集在萃取模板中。

对于策略类,我们有如下观察结果:

  • 策略类如果不是被作为模板参数传递的话,那么其作用会很微弱。
  • 策略参数不需要有默认值,它们通常是被显式指定的(虽有有些泛型组件通常会使用默认策略)。
  • 策略参数通常是和其它模板参数无关的。
  • 策略类通常会包含成员函数。
  • 策略可以被包含在简单类或者类模板中。

但是,两者之间并没有一个清晰的界限。比如,C++标准库中的字符萃取就定义了一些函数行为(比如比较,移动和查找字符)。通过替换这些萃取,我们定义一个大小写敏感的字符类型,同时又可以保留相同的字符类型。因此,虽然它们被称为萃取,但是它们的一些属性和策略确实有联系的。

成员模板还是模板模板参数?

为了实现累积策略(accumulation policy),我们选择将SumPolicy和MultPolicy实现为有成员模板的常规类。另一种使用类模板设计策略类接口的方式,此时就可以被当作模板模板参数使用。比如,我们可以将SumPolicy重写为如下模板:

1
2
3
4
5
6
7
8
9
10
#ifndef SUMPOLICY_HPP
#define SUMPOLICY_HPP
template<typename T1, typename T2>
class SumPolicy {
public:
static void accumulate (T1& total, T2 const& value) {
total += value;
}
};
#endif //SUMPOLICY_HPP

此时就可以调整Accum,让其使用一个模板模板参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#ifndef ACCUM_HPP#define ACCUM_HPP
#include "accumtraits4.hpp"
#include "sumpolicy2.hpp"
template<typename T, template<typename,typename> class Policy = SumPolicy,
typename Traits = AccumulationTraits<T>>
auto accum (T const* beg, T const* end)
{
using AccT = typename Traits::AccT;
AccT total = Traits::zero();
while (beg != end) {
Policy<AccT,T>::accumulate(total, *beg);
++beg;
}
return total;
}
#endif//ACCUM_HPP

相同的转化也可以被用于萃取参数。

通过模板模板参数访问策略类的主要优势是,让一个策略类通过一个依赖于模板参数的类型携带一些状态信息会更容易一些(比如static数据成员)。(在我们的第一个方法中,static数据成员必须要被嵌入到一个成员类模板中。)但是,模板模板参数方法的一个缺点是,策略类必须被实现为模板,而且模板参数必须和我们的接口所定义的参数一样。这可能会使萃取本身的表达相比于非模板类变得更繁琐,也更不自然。

结合多个策略以及/或者萃取

该如何给这些模板参数排序?一个简单的策略是,根据参数默认值被选择的可能型进行递增排序(也就是说,越是有可能使用一个参数的默认值,就将其排的越靠后)。比如说,萃取参数通常要在策略参数后面。

如果我们不介意增加代码的复杂性的话,还有一种可以按照任意顺序指定非默认参数的方法。

通过普通迭代器实现累积

在结束萃取和策略的介绍之前,最好再看下另一个版本的accum()的实现,在该实现中添加了处理泛化迭代器的能力(不再只是简单的指针),这是为了支持工业级的泛型组件。有意思的是,我们依然可以用指针来调用这一实现,因为C++标准库提供了迭代器萃取。此时我们就可以像下面这样定义我们最初版本的accum()了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef ACCUM_HPP
#define ACCUM_HPP
#include <iterator>
template<typename Iter>
auto accum (Iter start, Iter end)
{
using VT = typename std::iterator_traits<Iter>::value_type;
VT total{}; // assume this actually creates a zero value
while (start != end) {
total += *start;
++start;
}
return total;
}
#endif //ACCUM_HPP

这里的std::iterator_traits包含了所有迭代器相关的属性。由于存在一个针对指针的偏特化,这些萃取可以很方便的被用于任意常规的指针类型。标准库对这一特性的支持可能会像下面这样:

1
2
3
4
5
6
7
8
9
10
namespace std {
template<typename T>
struct iterator_traits<T*> {
using difference_type = ptrdiff_t;
using value_type = T;
using pointer = T*;
using reference = T&;
using iterator_category = random_access_iterator_tag ;
};
}

但是,此时并没有一个适用于迭代器所指向的数值的累积的类型;因此我们依然需要设计自己的AccumulationTraits。

类型函数

最初的示例说明我们可以基于类型定义行为。传统上我们在C和C++里定义的函数可以被更明确的称为值函数(value functions):它们接收一些值作为参数并返回一个值作为结果。对于模板,我们还可以定义类型函数(type functions):它们接收一些类型作为参数并返回一个类型或者常量作为结果。一个很有用的内置类型函数是sizeof,它返回了一个代表了给定类型大小(单位是byte)的常数。类模板依然可以被用作类型函数。此时类型函数的参数是模板参数,其结果被提取为成员类型或者成员常量。比如,sizeof运算符可以被作为如下接口提供:

1
2
3
4
5
6
7
8
9
10
#include <cstddef>
#include <iostream>
template<typename T>
struct TypeSize {
static std::size_t const value = sizeof(T);
};
int main()
{
std::cout << "TypeSize<int>::value = " << TypeSize<int>::value << "\n";
}

这看上去可能没有那么有用,因为我们已经有了一个内置的sizeof运算符,但是请注意此处的TypeSize<T>是一个类型,它可以被作为类模板参数传递。或者说,TypeSize是一个模板,也可以被作为模板模板参数传递。

元素类型

假设我们有很多的容器模板,比如std::vector<>std::list<>,也可以包含内置数组。我们希望得到这样一个类型函数,当给的一个容器类型时,它可以返回相应的元素类型。这可以通过偏特化实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <vector>
#include <list>
template<typename T>
struct ElementT; // primary template
template<typename T>
struct ElementT<std::vector<T>> { //partial specialization for std::vector
using Type = T;
};
template<typename T>
struct ElementT<std::list<T>> { //partial specialization for std::list
using Type = T;
};
...
template<typename T, std::size_t N>
struct ElementT<T[N]> { //partial specialization for arrays of known bounds
using Type = T;
};
template<typename T>
struct ElementT<T[]> { //partial specialization for arrays of unknown bounds
using Type = T;
};
...

注意此处我们应该为所有可能的数组类型提供偏特化。我们可以像下面这样使用这些类型函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "elementtype.hpp"
#include <vector>
#include <iostream>
#include <typeinfo>
template<typename T>
void printElementType (T const& c)
{
std::cout << "Container of " <<
typeid(typename ElementT<T>::Type).name() << " elements.\n";
}
int main()
{
std::vector<bool> s;
printElementType(s);
int arr[42];
printElementType(arr);
}

偏特化的使用使得我们可以在容器类型不知道具体类型函数存在的情况下去实现类型函数。但是在某些情况下,类型函数是和其所适用的类型一起被设计的,此时相关实现就可以被简化。比如,如果容器类型定义了value_type成员类型(标准库容器都会这么做),我们就可以有如下实现:

1
2
3
4
template<typename C>
struct ElementT {
using Type = typename C::value_type;
};

这个实现可以是默认实现,它不会排除那些针对没有定义成员类型value_type的容器的偏特化实现。

虽然如此,我们依然建议为类模板的类型参数提供相应的成员类型定义,这样在泛型代码中就可以更容易的访问它们(和标准库容器的处理方式类似)。下面的代码体现了这一思想:

1
2
3
4
5
6
7
template<typename T1, typename T2, ...>
class X {
public:
using ... = T1;
using ... = T2;
...
};

那么类型函数的作用体现在什么地方呢?它允许我们根据容器类型参数化一个模板,但是又不需要提供代表了元素类型和其它特性的参数。比如,相比于使用

1
2
template<typename T, typename C>
T sumOfElements (C const& c);

这一需要显式指定元素类型的模板(sumOfElements<int> list),我们可以定义这样一个模板:

1
2
template<typename C>
typename ElementT<C>::Type sumOfElements (C const& c);

其元素类型是通过类型函数得到的。

注意观察萃取是如何被实现为已有类型的扩充的;也就是说,我们甚至可以为基本类型和封闭库的类型定义类型函数。

在上述情况下,ElementT被称为萃取类,因为它被用来访问一个已有容器类型的萃取(通常而言,在这样一个类中可以有多个萃取)。因此萃取类的功能并不仅限于描述容器参数的特性,而是可以描述任意“主参数”的特性。

为了方便,我们可以为类型函数创建一个别名模板。比如,我们可以引入:

1
2
template<typename T>
using ElementType = typename ElementT<T>::Type;

这可以让sumOfEkements的定义变得更加简单:

1
2
template<typename C>
ElementType<C> sumOfElements (C const& c);

转换萃取(Transformation Traits)

除了可以被用来访问主参数类型的某些特性,萃取还可以被用来做类型转换,比如为某个类型添加或移除引用、 const以及volatile限制符。

删除引用

比如,我们可以实现一个RemoveReferenceT萃取,用它将引用类型转换成其底层对象或者函数的类型,对于非引用类型则保持不变:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
struct RemoveReferenceT {
using Type = T;
};
template<typename T>
struct RemoveReferenceT<T&> {
using Type = T;
};
template<typename T>
struct RemoveReferenceT<T&&> {
using Type = T;
};

同样地,引入一个别名模板可以简化上述萃取的使用:

1
2
template<typename T>
using RemoveReference = typename RemoveReference<T>::Type;

当类型是通过一个有时会产生引用类型的构造器获得的时候,从一个类型中删除引用会很有意义。

添加引用

我们也可以给一个已有类型添加左值或者右值引用:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
struct AddLValueReferenceT {
using Type = T&;
};
template<typename T>
using AddLValueReference = typename AddLValueReferenceT<T>::Type;
template<typename T>
struct AddRValueReferenceT {
using Type = T&&;
};
template<typename T>
using AddRValueReference = typename AddRValueReferenceT<T>::Type;

引用折叠的规则在这一依然适用。比如对于AddLValueReference<int &&>,返回的类型是int&,因为我们不需要对它们进行偏特化实现。

如果我们只实现AddLValueReferenceT和AddRValueReferenceT,而又不对它们进行偏特化的话,最方便的别名模板可以被简化成下面这样:

1
2
3
4
template<typename T>
using AddLValueReferenceT = T&;
template<typename T>
using AddRValueReferenceT = T&&;

此时不通过类模板的实例化就可以对其进行实例化(因此称得上是一个轻量级过程)。但是这样做是由风险的,因此我们依然希望能够针对特殊的情况对这些模板进行特例化。比如,如果适用上述简化实现,那么我们就不能将其用于void类型。一些显式的特化实现可以被用来处理这些情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<>
struct AddLValueReferenceT<void> {
using Type = void;
};
template<>
struct AddLValueReferenceT<void const> {
using Type = void const;
};
template<>
struct AddLValueReferenceT<void volatile> {
using Type = void volatile;
};
template<>
struct AddLValueReferenceT<void const volatile> {
using Type = void const volatile;
};

有了这些偏特化之后,上文中的别名模板必须被实现为类模板的形式,这样才能保证相应的篇特换在需要的时候被正确选取(因为别名模板不能被特化)。

C++标准库中也提供了与之相应的类型萃取:std::add_lvalue_reference<>std::add_rvalue_reference<>。该标准模板也包含了对void类型的特化。

移除限制符

转换萃取可以分解或者引入任意种类的复合类型,并不仅限于引用。比如,如果一个类型中存在const限制符,我们可以将其移除:

1
2
3
4
5
6
7
8
9
10
template<typename T>
struct RemoveConstT {
using Type = T;
};
template<typename T>
struct RemoveConstT<T const> {
using Type = T;
};
template<typename T>
using RemoveConst = typename RemoveConstT<T>::Type;

而且,转换萃取可以是多功能的,比如创建一个可以被用来移除const和volatile的RemoveCVT萃取:

1
2
3
4
5
6
7
#include "removeconst.hpp"
#include "removevolatile.hpp"
template<typename T>
struct RemoveCVT : RemoveConstT<typename RemoveVolatileT<T>::Type>
{};
template<typename T>
using RemoveCV = typename RemoveCVT<T>::Type;

RemoveCVT中有两个需要注意的地方。第一个需要注意的地方是,它同时使用了RemoveConstT和相关的RemoveVolitleT,首先移除类型中可能存在的volatile,然后将得到了类型传递给RemoveConstT。第二个需要注意的地方是,它没有定义自己的和RemoveConstT中Type类似的成员,而是通过使用元函数转发(metafunction forwarding)从RemoveConstT中继承了Type成员。这里元函数转发被用来简单的减少RemoveCVT中的类型成员。但是,即使是对于没有为所有输入都定义了元函数的情况,元函数转发也会很有用。

RemoveCVT的别名模板可以被进一步简化成:

1
2
template<typename T>
using RemoveCV = RemoveConst<RemoveVolatile<T>>;

同样地,这一简化只适用于RemoveCVT没有被特化的情况。但是和AddLValueReference以及AddRValueReference的情况不同的是,我们想不出一种对其进行特化的原因。

C++标准库也提供了与之对应的std::remove_volatile<>std::remove_const<>,以及std::remove_cv<>

退化(Decay)

为了使对转换萃取的讨论变得更完整,我们接下来会实现一个模仿了按值传递参数时的类型转化行为的萃取。该类型转换继承自C语言,这意味着参数类型会发生退化(数组类型退化成指针类型,函数类型退化成指向函数的指针类型),而且会删除相应的顶层const,volatile以及引用限制符(因为在解析一个函数调用时,会会忽略掉参数类型中的顶层限制符)。下面的程序展现了按值传递的效果,它会打印出经过编译器退化之后的参数类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <typeinfo>
#include <type_traits>
template<typename T>
void f(T)
{}
template<typename A>
void printParameterType(void (*)(A))
{
std::cout << "Parameter type: " << typeid(A).name() << "\n";
std::cout << "- is int: " <<std::is_same<A,int>::value << "\n";
std::cout << "- is const: " <<std::is_const<A>::value << "\n";
std::cout << "- is pointer: " <<std::is_pointer<A>::value << "\n";
}
int main()
{
printParameterType(&f<int>);
printParameterType(&f<int const>);
printParameterType(&f<int[7]>);
printParameterType(&f<int(int)>);
}

在程序的输出中,除了int参数保持不变外,其余int constint[7],以及int(int)参数分别退化成了intint*,以及int(*)(int)。我们可以实现一个与之功能类似的萃取。为了和C++标准库中的std::decay保持匹配,我们称之为DecayT。它的实现结合了上文中介绍的多种技术。首先我们对非数组、非函数的情况进行定义,该情况只需要删除const和volatile限制符即可:

1
2
3
template<typename T>
struct DecayT : RemoveCVT<T>
{};

然后我们处理数组到指针的退化,这需要用偏特化来处理所有的数组类型(有界和无界数组):

1
2
3
4
5
6
7
8
template<typename T>
struct DecayT<T[]> {
using Type = T*;
};
template<typename T, std::size_t N>
struct DecayT<T[N]> {
using Type = T*;
};

最后来处理函数到指针的退化,这需要应对所有的函数类型,不管是什么返回类型以及有多数参数。为此,我们适用了变参模板:

1
2
3
4
5
6
7
8
template<typename R, typename... Args>
struct DecayT<R(Args...)> {
using Type = R (*)(Args...);
};
template<typename R, typename... Args>
struct DecayT<R(Args..., ...)> {
using Type = R (*)(Args..., ...);
};

注意,上面第二个偏特化可以匹配任意使用了C-style可变参数的函数。下面的例子展示了DecayT主模板以及其全部四种偏特化的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <typeinfo>
#include <type_traits>
#include "decay.hpp"
template<typename T>
void printDecayedType()
{
using A = typename DecayT<T>::Type;
std::cout << "Parameter type: " << typeid(A).name() << "\n";
std::cout << "- is int: " << std::is_same<A,int>::value << "\n";
std::cout << "- is const: " << std::is_const<A>::value << "\n";
std::cout << "- is pointer: " << std::is_pointer<A>::value << "\n";
}
int main()
{
printDecayedType<int>();
printDecayedType<int const>();
printDecayedType<int[7]>();
printDecayedType<int(int)>();
}

和往常一样,我们也提供了一个很方便的别名模板:

1
2
template typename T>
using Decay = typename DecayT<T>::Type;

预测型萃取

到目前为止,我们学习并开发了适用于单个类型的类型函数:给定一个类型,产生另一些相关的类型或者常量。但是通常而言,也可以设计基于多个参数的类型函数。这同样会引出另外一种特殊的类型萃取—类型预测(产生一个bool数值的类型函数)。

IsSameT将判断两个类型是否相同:

1
2
3
4
5
6
7
8
template<typename T1, typename T2>
struct IsSameT {
static constexpr bool value = false;
};
template<typename T>
struct IsSameT<T, T> {
static constexpr bool value = true;
};

这里的主模板说明通常我们传递进来的两个类型是不同的,因此其value成员是false。但是,通过使用偏特化,当遇到传递进来的两个相同类型的特殊情况,value成员就是true的。比如,如下表达式会判断传递进来的模板参数是否是整型:

1
if (IsSameT<T, int>::value) ...

对于产生一个常量的萃取,我们没法为之定义一个别名模板,但是可以为之定义一个扮演可相同角色的constexpr的变量模板:

1
2
template<typename T1, typename T2>
constexpr bool isSame = IsSameT<T1, T2>::value;

true_type和false_type

通过为可能的输出结果true和false提供不同的类型,我们可以大大的提高对IsSameT的定义。事实上,如果我们声明一个BoolConstant模板以及两个可能的实例TrueType和FalseType:

1
2
3
4
5
6
7
template<bool val>
struct BoolConstant {
using Type = BoolConstant<val>;
static constexpr bool value = val;
};
using TrueType = BoolConstant<true>;
using FalseType = BoolConstant<false>;

就可以基于两个类型是否匹配,让相应的IsSameT分别继承自TrueType和FalseType:

1
2
3
4
5
#include "boolconstant.hpp"
template<typename T1, typename T2>
struct IsSameT : FalseType{};
template<typename T>
struct IsSameT<T, T> : TrueType{};

现在IsSameT<T, int>的返回类型会被隐式的转换成其基类TrueType或者FalseType,这样就不仅提供了相应的value成员,还允许在编译期间将相应的需求派发到对应的函数实现或者类模板的偏特化上。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include "issame.hpp"
#include <iostream>
template<typename T>
void fooImpl(T, TrueType)
{
std::cout << "fooImpl(T,true) for int called\n";
}
template<typename T>
void fooImpl(T, FalseType)
{
std::cout << "fooImpl(T,false) for other type called\n";
}
template<typename T>
void foo(T t)
{
fooImpl(t, IsSameT<T,int>{}); // choose impl. depending on whether T is int
}
int main()
{
foo(42); // calls fooImpl(42, TrueType)
foo(7.7); // calls fooImpl(42, FalseType)
}

这一技术被称为标记派发(tag dispatching)。注意在BoolConstant的实现中还有一个Type成员,这样就可以通过它为IsSameT引入一个
别名模板:

1
2
template<typename T>
using isSame = typename IsSameT<T>::Type;

这里的别名模板可以和之前的变量模板isSame并存。

通常而言,产生bool值的萃取都应该通过从诸如TrueType和FalseType的类型进行派生来支持标记派发。但是为了尽可能的进行泛化,应该只有一个类型代表true,也应该只有一个类型代表false,而不是让每一个泛型库都为bool型常量定义它自己的类型。幸运的是,从C++11开始C++标准库在<type_traits>中提供了相应的类型:std::true_typestd::false_type。在C++11和C++14中其定义如下:

1
2
3
4
namespace std {
using true_type = integral_constant<bool, true>;
using false_type = integral_constant<bool, false>;
}

在C++17中,其定义如下:

1
2
3
4
namespace std {
using true_type = bool_constant<true>;
using false_type = bool_constant<false>;
}

其中bool_constant的定义如下:

1
2
3
4
namespace std {
template<bool B>
using bool_constant = integral_constant<bool, B>;
}

返回结果类型萃取

另一个可以被用来处理多个类型的类型函数的例子是返回值类型萃取。在编写操作符模板的时候它们会很有用。为了引出这一概念,我们来写一个可以对两个Array容器求和的函数模板:

1
2
template<typename T>
Array<T> operator+ (Array<T> const&, Array<T> const&);

这看上去很好,但是由于语言本身允许我们对一个char型数值和一个整形数值求和,我们自然也很希望能够对Array也执行这种混合类型(mixed-type)的操作。这样我们就要处理该如何决定相关模板的返回值的问题:

1
2
template<typename T1, typename T2>
Array<???> operator+ (Array<T1> const&, Array<T2> const&);

一个可以解决上述问题的方式就是返回值类型模板:

1
2
3
template<typename T1, typename T2>
Array<typename PlusResultT<T1, T2>::Type>
operator+ (Array<T1> const&, Array<T2> const&);

如果有便捷别名模板可用的话,还可以将其写称这样:

1
2
3
template<typename T1, typename T2>
Array<PlusResult<T1, T2>>
operator+ (Array<T1> const&, Array<T2> const&);

其中的PlusResultT萃取会自行判断通过+操作符对两种类型(可能是不同类型)的数值求和所得到的类型:

1
2
3
4
5
6
template<typename T1, typename T2>
struct PlusResultT {
using Type = decltype(T1() + T2());
};
template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;

这一萃取模板通过使用decltype来计算表达式T1()+T2()的类型,将决定结果类型这一艰巨的工作(包括处理类型增进规则(promotion rules)和运算符重载)留给了编译器。

但是对于我们的例子而言,decltype却保留了过多的信息。比如,我们的PlusResultT可能会返回一个引用类型,但是我们的Array模板却很可能不是为引用类型设计的。更为实际的例子是,重载的operator+可能会返回一个const类型的数值:

1
2
class Integer { ... };
Integer const operator+ (Integer const&, Integer const&);

对两个Array<Integer>的值进行求和却得到了一个存储了Integer const数值的Array,这很可能不是我们所期望的结果。事实上我们所期望的是将返回值类型中的引用和限制符移除之后所得到的类型,正如我们在上一小节所讨论的那样:

1
2
3
template<typename T1, typename T2>
Array<RemoveCV<RemoveReference<PlusResult<T1, T2>>>>
operator+ (Array<T1> const&, Array<T2> const&);

这一萃取的嵌套形式在模板库中很常见,在元编程中也经常被用到。

到目前为止,数组的求和运算符可以正确地计算出对两个元素类型可能不同的Array进行求和的结果类型。但是上述形式的PlusResultT却对元素类型T1和T2施加了一个我们所不期望的限制:由于表达式T1() + T2()试图对类型T1和T2的数值进行值初始化,这两个类型必须要有可访问的、未被删除的默认构造函数(或者是非class类型)。Array类本身可能并没有要求其元素类型可以被进行值初始化,因此这是一个额外的、不必要的限制。

declval

好在我们可以很简单的在不需要构造函数的情况下计算+表达式的值,方法就是使用一个可以为一个给定类型T生成数值的函数。为了这一目的,C++标准提供了std::declval<>。在<utility>中其定义如下:

1
2
3
4
namespace std {
template<typename T>
add_rvalue_reference_t<T> declval() noexcept;
}

表达式declval<>可以在不需要使用默认构造函数(或者其它任意操作)的情况下为类型T生成一个值。该函数模板被故意设计成未定义的状态,因为我们只希望它被用于decltype,sizeof或者其它不需要相关定义的上下文中。它有两个很有意思的属性:

  • 对于可引用的类型,其返回类型总是相关类型的右值引用,这能够使declval适用于那些不能够正常从函数返回的类型,比如抽象类的类型(包含纯虚函数的类型)或者数组类型。因此当被用作表达式时,从类型TT&&的转换对declval<T>()的行为是没有影响的:其结果都是右值(如果T是对象类型的话),对于右值引用,其结果之所以不会变是因为存在引用塌缩。
  • 在noexcept异常规则中提到,一个表达式不会因为使用了declval而被认成是会抛出异常的。当declval被用在noexcept运算符上下文中时,这一特性会很有帮助

有了declval,我们就可以不用在PlusResultT中使用值初始化了:

1
2
3
4
5
6
7
#include <utility>
template<typename T1, typename T2>
struct PlusResultT {
using Type = decltype(std::declval<T1>() + std::declval<T2>());
};
template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;

返回值类型萃取提供了一种从特定操作中获取准确的返回值类型的方式,在确定函数模板的返回值的类型的时候,它会很有用。

基于SFINAE的萃取(SFINAE-Based Traits)

SFINAE会将在模板参数推断过程中,构造无效类型和表达式的潜在错误(会导致程序出现语法错误)转换成简单的推断错误,这样就允许重载解析继续在其它待选项中间做选择。虽然SFINAE最开始是被用来避免与函数模板重载相关的伪错误,我们也可以用它在编译期间判断特定类型和表达式的有效性。比如我们可以通过萃取来判断一个类型是否有某个特定的成员,是否支持某个特定的操作,或者该类型本身是不是一个类。

基于SFINAE的两个主要技术是:用SFINAE排除某些重载函数,以及用SFINAE排除某些偏特化。

用SFINAE排除某些重载函数

我们触及到的第一个基于SFINAE的例子是将SFINAE用于函数重载,以判断一个类型是否是默认可构造的,对于可以默认构造的类型,就可以不通过值初始化来创建对象。也就是说,对于类型T,诸如T()的表达式必须是有效的。一个基础的实现可能会像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "issame.hpp"
template<typename T>
struct IsDefaultConstructibleT {
private:
// test() trying substitute call of a default constructor for
//T passed as U :
template<typename U, typename = decltype(U())>
static char test(void*);// test() fallback:
template<typename>
static long test(...);
public:
static constexpr bool value =
IsSameT<decltype(test<T>(nullptr)), char>::value;
};

通过函数重载实现一个基于SFINAE的萃取的常规方式是声明两个返回值类型不同的同名(test())重载函数模板:

1
2
template<...> static char test(void*);
template<...> static long test(...);

第一个重载函数只有在所需的检查成功时才会被匹配到(后文会讨论其实现方式)。第二个重载函数是用来应急的:它会匹配任意调用,但是由于它是通过”…”(省略号)进行匹配的,因此其它任何匹配的优先级都比它高。

返回值value的具体值取决于最终选择了哪一个test函数:

1
2
static constexpr bool value
= IsSameT<decltype(test<...>(nullptr)), char>::value;

如果选择的是第一个test()函数,由于其返回值类型是char,value会被初始化为isSame<char, char>,也就是true。否则,value会被初始化为isSame<long, char>,也就是false。

现在,到了该处理我们所需要检测的属性的时候了。目标是只有当我们所关心的测试条件被满足的时候,才可以使第一个test()有效。在这个例子中,我们想要测试的条件是被传递进来的类型T是否是可以被默认构造的。为了实现这一目的,我们将T传递给U,并给第一个test()声明增加一个无名的(dummy)模板参数,该模板参数被一个只有在这一转换有效的情况下才有效的构造函数进行初始化。在这个例子中,我们使用的是只有当存在隐式或者显式的默认构造函数U()时才有效的表达式。我们对U()的结果施加了deltype操作,这样就可以用其结果初始化一个类型参数了。

第二个模板参数不可以被推断,因为我们不会为之传递任何参数。而且我们也不会为之提供显式的模板参数。因此,它只会被替换,如果替换失败,基于SFINAE,相应的test()声明会被丢弃掉,因此也就只有应急方案可以匹配相应的调用。

因此,我们可以像下面这样使用这一萃取:

1
2
3
4
5
IsDefaultConstructibleT<int>::value //yields true
struct S {
S() = delete;
};
IsDefaultConstructibleT<S>::value //yields false

但是需要注意,我们不能在第一个test()声明里直接使用模板参数T:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
struct IsDefaultConstructibleT {
private:
// ERROR: test() uses T directly:
template<typename, typename = decltype(T())>
static char test(void*);
// test() fallback:
template<typename>
static long test(...);
public:
static constexpr bool value
= IsSameT<decltype(test<T>(nullptr)), char>::value;
};

但是这样做并不可以,因为对于任意的T,所有模板参数为T的成员函数都会被执行模板参数替换,因此对一个不可以默认构造的类型,这些代码会遇到编译错误,而不是忽略掉第一个test()。通过将类模板的模板参数T传递给函数模板的参数U,我们就只为第二个test()的重载创建了特定的SFINAE上下文。

另一种基于SFINAE的萃取的实现策略

远在1998年发布第一版C++标准之前,基于SFINAE的萃取的实现就已经成为了可能。该方法的核心一致都是实现两个返回值类型不同的重载函数模板:

1
2
template<...> static char test(void*);
template<...> static long test(...);

但是,在最早的实现技术中,会基于返回值类型的大小来判断使用了哪一个重载函数(也会用到0和enum,因为在当时nullptr和constexpr还没有被引入):

1
enum { value = sizeof(test<...>(0)) == 1 };

在某些平台上,sizeof(char)的值可能会等于sizeof(long)的值。基于此,我们希望能够确保test()的返回值类型在所有的平台上都有不同的值。比如,在定义了:

1
2
using Size1T = char;
using Size2T = struct { char a[2]; };

或者:

1
2
using Size1T = char(&)[1];
using Size2T = char(&)[2];

之后,可以像下面这样定义test()的两个重载版本:

1
2
template<...> static Size1T test(void*); // checking test()
template<...> static Size2T test(...); // fallback

这样,我们要么返回Size1T,其大小为1,要么返回Size2T,在所有的平台上其值都至少是2。使用了上述某一种方式的代码目前依然很常见。但是要注意,传递给test()的调用参数的类型并不重要。我们所要保证的是被传递的参数和所期望的类型能够匹配。比如,可以将其定义成能够接受整型常量42的形式:

1
2
3
4
template<...> static Size1T test(int); // checking test()
template<...> static Size2T test(...); // fallback
...
enum { value = sizeof(test<...>(42)) == 1 };

用SFINAE排除偏特化

另一种实现基于SFINAE的萃取的方式会用到偏特化。这里,我们同样可以使用上文中用来判断类型T是否是可以被默认初始化的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "issame.hpp"
#include <type_traits> //defines true_type and false_type
//别名模板,helper to ignore any number of template parameters:
template<typename ...> using VoidT = void;
// primary template:
template<typename, typename = VoidT<>>
struct IsDefaultConstructibleT : std::false_type
{ };
// partial specialization (may be SFINAE"d away):
template<typename T>
struct IsDefaultConstructibleT<T, VoidT<decltype(T())>> :
std::true_type
{ };

和上文中优化之后的IsDefaultConstructibleT预测萃取类似,我们让适用于一般情况的版本继承自std::false_type,因为默认情况下一个类型没有size_type成员。此处一个比较有意思的地方是,第二个模板参数的默认值被设定为一个辅助别名模板VoidT。这使得我们能够定义各种使用了任意数量的编译期类型构造的偏特化。针对我们的例子,只需要一个类型构造:

1
decltype(T())

这样就可以检测类型T是否是可以被默认初始化的。如果对于某个特定的类型T,其默认构造函数是无效的,此时SIFINEAE就是使该偏特化被丢弃掉,并最终使用主模板。否则该偏特化就是有效的,并且会被选用。

在C++17中,C++标准库引入了与VoidT对应的类型萃取std::void_t<>。在C++17之前,向上面那样定义我们自己的std::void_t是很有用的,甚至可以将其定义在std命名空间里:

1
2
3
4
5
6
#include <type_traits>
#ifndef __cpp_lib_void_t
namespace std {
template<typename...> using void_t = void;
}
#endif

从C++14开始,C++标准委员会建议通过定义预先达成一致的特征宏(feature macros)来标识那些标准库的内容以及被实现了。这并不是标准的强制性要求,但是实现者通常都会遵守这一建议,以为其用户提供方便。__cpp_lib_void_t就是被建议用来标识在一个库中是否实现了std::void_t的宏,所以在上面的code中我们将其用于了条件判断。

将泛型Lambdas用于SFINAE

无论使用哪一种技术,在定义萃取的时候总是需要用到一些样板代码:重载并调用两个test()成员函数,或者实现多个偏特化。接下来我们会展示在C++17中,如何通过指定一个泛型lambda来做条件测试,将样板代码的数量最小化。作为开始,先介绍一个用两个嵌套泛型lambda表达式构造的工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <utility>
// helper: checking validity of f (args...) for F f and Args... args:
template<typename F, typename... Args, typename = decltype(std::declval<F>() (std::declval<Args&&>()...))>
std::true_type isValidImpl(void*);
// fallback if helper SFINAE"d out:
template<typename F, typename... Args>
std::false_type isValidImpl(...);
// define a lambda that takes a lambda f and returns whether calling f with args is valid

inline constexpr
auto isValid = [](auto f) {
return [](auto&&... args) {
return decltype(isValidImpl<decltype(f),
decltype(args)&&...>(nullptr)){};
};
};
// helper template to represent a type as a value
template<typename T>
struct TypeT {
using Type = T;
};
// helper to wrap a type as a value
template<typename T>
constexpr auto type = TypeT<T>{};

// helper to unwrap a wrapped type in unevaluated contexts
template<typename T>
T valueT(TypeT<T>); // no definition needed

先从isValid的定义开始:它是一个类型为lambda闭包的constexpr变量。声明中必须要使用一个占位类型(placeholder type,代码中的auto),因为C++没有办法直接表达一个闭包类型。在C++17之前,lambda表达式不能出现在const表达式中,因此上述代码只有在C++17中才有效。因为isValid是闭包类型的,因此它可以被调用,但是它被调用之后返回的依然是一个闭包类型,返回结果由内部的lambda表达式生成。

在深入讨论内部的lambda表达式之前,先来看一个isValid的典型用法:

1
2
constexpr auto isDefaultConstructible
= isValid([](auto x) -> decltype((void)decltype(valueT(x))() {});

我们已经知道isDefaultConstructible的类型是闭包类型,而且正如其名字所暗示的那样,它是一个可以被用来测试某个类型是不是可以被默认构造的函数对象。也就是说,isValid是一个萃取工厂(traits factory):它会为其参数生成萃取,并用生成的萃取对对象进行测试。

辅助变量模板type允许我们用一个值代表一个类型。对于通过这种方式获得的数值x,我们可以通过使用decltype(valueT(x))得到其原始类型,这也正是上面被传递给isValid的lambda所做的事情。如果提取的类型不可以被默认构造,我们要么会得到一个编译错误,要么相关联的声明就会被SFINAE掉(得益于isValid的具体定义,我们代码中所对应的情况是后者)。可以像下面这样使用isDefaultConstructible:

1
2
isDefaultConstructible(type<int>) //true (int is defaultconstructible)
isDefaultConstructible(type<int&>) //false (references are not default-constructible)

为了理解各个部分是如何工作的,先来看看当isValid的参数f被绑定到isDefaultConstructible的泛型lambda参数时,isValid内部的lambda表达式会变成什么样子。通过对isValid的定义进行替换,我们得到如下等价代码:

1
2
3
4
5
constexpr auto isDefaultConstructible= [](auto&&... args) {
return decltype(isValidImpl<decltype([](auto x) ->
decltype((void)decltype(valueT(x))())),
decltype(args)&&...> (nullptr)){};
};

如果我们回头看看第一个isValidImpl()的定义,会发现它还有一个如下形式的默认模板参数:

1
decltype(std::declval<F>()(std::declval<Args&&>()...))>

它试图对第一个模板参数的值进行调用,而这第一个参数正是isDefaultConstructible定义中的lambda的闭包类型,调用参数为传递给isDefaultConstructible的(decltype(args)&&...)类型的值。由于lambda中只有一个参数x,因此args就需要扩展成一个参数;在我们上面的static_assert例子中,参数类型为TypeT<int>或者TypeT<int&>。对于TypeT<int&>的情况,decltype(valueT(x))的结果是int&,此时decltype(valueT(x))()是无效的,因此在第一个isValidImpl()的声明中默认模板参数的替换会失败,从而该isValidImpl()声明会被SFINAE掉。这样就只有第二个声明可用,且其返回值类型为std::false_type。整体而言,在传递type<int&>的时候,isDefaultConstructible会返回false_type。而如果传递的是type<int>的话,替换不会失败,因此第一个isValidImpl()的声明会被选择,返回结果也就是true_type类型的值。

我们的isDefaultConstructible萃取和之前的萃取在实现上有一些不同,主要体现在它需要执行函数形式的调用,而不是指定模板参数。这可能是一种更为刻度的方式,但是也可以按照之前的方式实现:

1
2
template<typename T>using IsDefaultConstructibleT
= decltype(isDefaultConstructible(std::declval<T>()));

虽然这是传统的模板声明方式,但是它只能出现在namespace作用域内,然而isDefaultConstructible的定义却很可能被在一个块作用域内引入。

到目前为止,这一技术看上去好像并没有那么有竞争力,因为无论是实现中涉及的表达式还是其使用方式都要比之前的技术复杂得多。但是,一旦有了isValid,并且对其进行了很好的理解,有很多萃取都可以只用一个声明实现。比如,对是否能够访问名为first的成员进行测试,就非常简洁:

1
2
constexpr auto hasFirst
= isValid([](auto x) -> decltype((void)valueT(x).first) {});

SFINAE友好的萃取

通常,类型萃取应该可以在不使程序出现问题的情况下回答特定的问题。基于SFINAE的萃取解决这一难题的方式是“小心地将潜在的问题捕获进一个SFINAE上下文中”,将可能出现的错误转变成相反的结果。

但是,到目前为止我们所展示的一些萃取在应对错误的时候表现的并不是那么好。回忆一下之前关于PlusResultT的定义:

1
2
3
4
5
6
7
template<typename T1, typename T2>
struct PlusResultT {
using Type = decltype(std::declval<T1>() + std::declval<T2>());
};

template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;

在这一定义中,用到+的上下文并没有被SFINAE保护。因此,如果程序试着对不支持+运算符的类型执行PlusResultT的话,那么PlusResultT计算本身就会使成勋遇到错误,比如下面这个例子中,试着为两个无关类型A和B的数组的求和运算声明返回类型的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T>
class Array {
...
};
// declare + for arrays of different element types:
template<typename T1, typename T2>
Array<typename PlusResultT<T1, T2>::Type> operator+ (Array<T1> const&,
Array<T2> const&);
很显然,如果没有为数组元素定义合适的+运算符的话,使用PlusResultT<>就会遇到错误。
class A {
};
class B {
};
void addAB(Array<A> arrayA, Array<B> arrayB) {
auto sum = arrayA + arrayB; // ERROR: fails in instantiation of PlusResultT<A, B>
...
}

这里的问题并不是错误会发生在代码明显有问题的地方(没办法对元素类型分别为A和B的数组进行求和),而是错误会发生在对operator+进行模板参数推断的时候,在很深层次的PlusResultT<A,B>的实例化中。这会导致一个很值得注意的结果:即使我们为A和B的数组重载一个求和函数,程序依然可能会遇到编译错误,因为C++不指定如果另一个重载更好的话,一个函数模板中的类型是否真的实例化。

1
2
3
4
5
6
7
8
9
10
// declare generic + for arrays of different element types:
template<typename T1, typename T2>
Array<typename PlusResultT<T1, T2>::Type>
operator+ (Array<T1> const&, Array<T2> const&);
// overload + for concrete types:
Array<A> operator+(Array<A> const& arrayA, Array<B> const& arrayB);
void addAB(Array<A> const& arrayA, Array<B> const& arrayB) {
auto sum = arrayA + arrayB; // ERROR?: depends on whether the compiler
... // instantiates PlusResultT<A,B>
}

如果编译器可以在不对第一个operator+模板声明进行推断和替换的情况下,就能够判断出第二个operator+声明会更加匹配的话,上述代码也不会有问题。

但是,在推断或者替换一个备选函数模板的时候,任何发生在类模板定义的实例化过程中的事情都不是函数模板替换的立即上下文(immediate context),SFINAE也不会保护我们不会在其中构建无效类型或者表达式。此时并不会丢弃这一函数模板待选项,而是会立即报出试图在PlusResult<>中为A和B调用operator+的错误:

1
2
3
4
template<typename T1, typename T2>
struct PlusResultT {
using Type = decltype(std::declval<T1>() + std::declval<T2> ());
}

为了解决这一问题,我们必须要将PlusResultT变成SFINAR友好的,也就是说需要为之提供更恰当的定义,以使其即使会在decltype中遇到错误,也不会诱发编译错误。参考在之前章节中介绍的HassLessT,我们可以通过定义一个HasPlusT萃取,来判断给定的类型是有一个可用的+运算符:

1
2
3
4
5
6
7
8
9
10
#include <utility> // for declval
#include <type_traits> // for true_type, false_type, and void_t primary template:
template<typename, typename, typename = std::void_t<>>
struct HasPlusT : std::false_type
{};
// partial specialization (may be SFINAE"d away):
template<typename T1, typename T2>
struct HasPlusT<T1, T2, std::void_t<decltype(std::declval<T1>() + std::declval<T2> ())>>
: std::true_type
{};

如果其返回结果为true,PlusResultT就可以使用现有的实现。否则,PlusResultT就需要一个安全的默认实现。对于一个萃取,如果对某一组模板参数它不能生成有意义的结果,那么最好的默认行为就是不为其提供Type成员。这样,如果萃取被用于SFINAE上下文中(比如之前代码中array类型的operator+的返回值类型),缺少Type成员会导致模板参数推断出错,这也正是我们所期望的、 array类型的operator+模板的行为。下面这一版PlusResultT的实现就提供了上述的行为:

1
2
3
4
5
6
7
8
#include "hasplus.hpp"
template<typename T1, typename T2, bool = HasPlusT<T1, T2>::value>
struct PlusResultT { //primary template, used when HasPlusT yields true
using Type = decltype(std::declval<T1>() + std::declval<T2>());
};
template<typename T1, typename T2>
struct PlusResultT<T1, T2, false> { //partial specialization, used otherwise
};

在这一版的实现中,我们引入了一个有默认值的模板参数,它会使用上文中的HasPlusT来判断前面的两个模板参数是否支持求和操作。然后我们对于第三个模板参数的值为false的情况进行了偏特化,而且在该偏特化中没有任何成员,从而避免了我们所描述过的问题。对与支持求和操作的情况,第三个模板参数的值是true,因此会选用主模板,也就是定义了Type成员的那个模板。这样就保证了只有对支持+操作的类型,PlusResultT才会提供返回类型。

再次考虑Array<A>Array<B>的求和:如果使用最新的PlusResultT实现,那么PlusResultT<A, B>的实例化将不会有Type成员,因为不能够对A和B进行求和。因此对应的operator+模板的返回值类型是无效的,该函数模板也就会被SFINAE掉。这样就会去选择专门为Array<A>Array<B>指定的operator+的重载版本。

作为一般的设计原则,在给定了合理的模板参数的情况下,萃取模板永远不应该在实例化阶段出错。其实先方式通常是执行两次相关的检查:

  1. 一次是检查相关操作是否有效
  2. 一次是计算其结果

在PlusResultT中我们已经见证了这一原则,在那里我们通过调用HasPlusT<>来判断PlusResultImpl<>中对operator+的调用是否有效。

IsConvertibleT

细节很重要。因此基于SIFINAE萃取的常规方法在实际中会变得更加复杂。为了展示这一复杂性,我们将定义一个能够判断一种类型是否可以被转化成另外一种类型的萃取,比如当我们期望某个基类或者其某一个子类作为参数的时候。·就可以判断其第一个类型参数是否可以被转换成第二个类型参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <type_traits> // for true_type and false_type
#include <utility> // for declval
template<typename FROM, typename TO>
struct IsConvertibleHelper {
private:
// test() trying to call the helper aux(TO) for a FROM passed as F :
static void aux(TO);
template<typename F, typename T,
typename = decltype(aux(std::declval<F>()))>
static std::true_type test(void*);
// test() fallback:
template<typename, typename>
static std::false_type test(...);
public:
using Type = decltype(test<FROM>(nullptr));
};
template<typename FROM, typename TO>
struct IsConvertibleT : IsConvertibleHelper<FROM, TO>::Type {
};
template<typename FROM, typename TO>
using IsConvertible = typename IsConvertibleT<FROM, TO>::Type;
template<typename FROM, typename TO>
constexpr bool isConvertible = IsConvertibleT<FROM, TO>::value;

我们在一个辅助类中定义了两个名为test()的返回值类型不同的重载函数,并为该辅助类声明了Type成员类型:

1
2
3
4
5
6
7
8
template<...> static std::true_type test(void*);
template<...> static std::false_type test(...);
...
using Type = decltype(test<FROM>(nullptr));
...
template<typename FROM, typename TO>
struct IsConvertibleT : IsConvertibleHelper<FROM, TO>::Type {
};

和往常一样,第一个test()只有在所需的检查成功的时候才会被匹配到,第二个test()则是应急方案。因此问题的关键就是让第一个test()只有在类型FROM可以被转换成TO的时候才有效。为了实现这一目的,我们再次给第一个test()分配了一个dummy(并且无名)的模板参数,并将其初始化成只有当转换又消失才有效的内容。该模板参数不可以被推断,我们也不会为之提供显式的模板参数。因此它会被替换,而且当替换失败之后,该test()声明会被丢弃掉。

请再次注意,下面这种声明是不可以的:

1
2
3
static void aux(TO);
template<typename = decltype(aux(std::declval<FROM>()))>
static char test(void*);

这样当成员函数模板被解析的时候,FROM和TO都已经完全确定了,因此对一组不适合做相应转换的类型,在调用test()之前就会立即触发错误。由于这一原因,我们引入了作为成员函数模板参数的F:

1
2
3
static void aux(TO);
template<typename F, typename = decltype(aux(std::declval<F> ()))>
static char test(void*);

并在value的初始化中将FROM类型用作调用test()时的显式模板参数:

1
2
static constexpr bool value
= isSame<decltype(test<FROM>(nullptr)), char>;

请注意这里是如何在不调用任何构造函数的情况下,通过使用std::declval生成一个类型的值的。如果这个值可以被转换成TO,对aux()的调用就是有效的,相应的test()调用也就会被匹配到。否则,会触发SFINAE错误,导致应急test()被调用。然后,我们就可以像下面这样使用该萃取了:

1
2
3
4
IsConvertibleT<int, int>::value //yields true
IsConvertibleT<int, std::string>::value //yields false
IsConvertibleT<char const*, std::string>::value //yields true
IsConvertibleT<std::string, char const*>::value //yields false

处理特殊情况

下面3种情况还不能被上面的IsConvertibleT正确处理:

  1. 向数组类型的转换要始终返回false,但是在上面的代码中,aux()声明中的类型为TO的参数会退化成指针类型,因此对于某些FROM类型,它会返回true。
  2. 向指针类型的转换也应该始终返回false,但是和1中的情况一样,上述实现只会将它们当作退化后的类型。
  3. 向(被const/volatile修饰)的void类型的转换需要返回true。但是不幸的是,在TO是void的时候,上述实现甚至不能被正确实例化,因为参数类型不能包含void类型(而且aux()的定义也用到了这一参数)。

对于这几种情况,我们需要对它们进行额外的偏特化。但是,为所有可能的与const以及volatile的组合情况都分别进行偏特化是很不明智的。相反,我们为辅助类模板引入了一个额外的模板参数:

1
2
3
4
5
6
7
8
9
10
template<typename FROM, typename TO, bool = IsVoidT<TO>::value ||
IsArrayT<TO>::value || IsFunctionT<TO>::value>

struct IsConvertibleHelper {
using Type = std::integral_constant<bool, IsVoidT<TO>::value && IsVoidT<FROM>::value>;
};
template<typename FROM, typename TO>
struct IsConvertibleHelper<FROM,TO,false> {
... //previous implementation of IsConvertibleHelper here
};

额外的bool型模板参数能够保证,对于上面的所有特殊情况,都会最终使用主辅助萃取(而不是偏特化版本)。如果我们试图将FROM转换为数组或者函数,或者FROM是void而TO不是,都会得到false_type的结果,不过对于FROM和TO都是false_type的情况,它也会返回false_type。其它所有的情况,都会使第三个模板参数为false,从而选择偏特化版本的实现(对应于我们之前介绍的实现)。

探测成员(Detecting Members)

另一种对基于SFINAE的萃取的应用是,创建一个可以判断一个给定类型T是否含有名为X的成员(类型或者非类型成员)的萃取。

探测类型成员

首先定义一个可以判断给定类型T是否含有类型成员size_type的萃取:

1
2
3
4
5
6
7
8
9
10
11
12
#include <type_traits>
// defines true_type and false_type
// helper to ignore any number of template parameters:
template<typename ...> using VoidT = void;
// primary template:
template<typename, typename = VoidT<>>
struct HasSizeTypeT : std::false_type
{};
// partial specialization (may be SFINAE"d away):
template<typename T>
struct HasSizeTypeT<T, VoidT<typename T::size_type>> : std::true_type
{} ;

和往常已有,对于预测萃取,我们让一般情况派生自std::false_type,因为某些情况下一个类型是没有size_type成员的。在这种情况下,我们只需要一个条件:

1
2
3
4
5
6
7
8
9
10
typename T::size_type
···

该条件只有在T含有类型成员size_type的时候才有效,这也正是我们所想要做的。如果对于某个类型T,该条件无效,那么SFINAE会使偏特化实现被丢弃,我们就退回到主模板的情况。否则,偏特化有效并且会被有限选取。可以像下面这样使用萃取:
```C++
std::cout << HasSizeTypeT<int>::value; // false
struct CX {
using size_type = std::size_t;
};
std::cout << HasSizeType<CX>::value; // true

需要注意的是,如果类型成员size_type是private的,HasSizeTypeT会返回false,因为我们的萃取模板并没有访问该类型的特殊权限,因此typename T::size_type是无效的(触发SFINAE)。也就是说,该萃取所做的事情是测试我们是否能够访问类型成员size_type。

探测任意类型成员

在定义了诸如HasSizeTypeT的萃取之后,我们会很自然的想到该如何将该萃取参数化,以对任意名称的类型成员做探测。

不幸的是,目前这一功能只能通过宏来实现,因为还没有语言机制可以被用来描述“潜在”的名字。当前不使用宏的、与该功能最接近的方法是使用泛型lambda。

如下的宏可以满足我们的需求:

1
2
3
4
5
6
7
8
9
#include <type_traits> // for true_type, false_type, and void_t
#define DEFINE_HAS_TYPE(MemType) \
template<typename, typename = std::void_t<>> \
struct HasTypeT_##MemType \
: std::false_type {
}; \
template<typename T> \
struct HasTypeT_##MemType<T, std::void_t<typename T::MemType>> \
: std::true_type { } // ; intentionally skipped

每一次对DEFINE_HAS_TYPE(MemberType)的使用都相当于定义了一个新的HasTypeT_MemberType萃取。比如,我们可以用之来探测一个类型是否有value_type或者char_type类型成员:

1
2
3
4
5
6
7
8
9
10
11
12
#include "hastype.hpp"
#include <iostream>
#include <vector>
DEFINE_HAS_TYPE(value_type);
DEFINE_HAS_TYPE(char_type);
int main()
{
std::cout << "int::value_type: " << HasTypeT_value_type<int>::value << "\n";
std::cout << "std::vector<int>::value_type: " << HasTypeT_value_type<std::vector<int>>::value << "\n";
std::cout << "std::iostream::value_type: " << HasTypeT_value_type<std::iostream>::value << "\n";
std::cout << "std::iostream::char_type: " << HasTypeT_char_type<std::iostream>::value << "\n";
}

探测非类型成员

可以继续修改上述萃取,以让其能够测试数据成员和(单个的)成员函数:

1
2
3
4
5
6
7
8
9
#include <type_traits> // for true_type, false_type, and void_t
#define DEFINE_HAS_MEMBER(Member) \
template<typename, typename = std::void_t<>> \
struct HasMemberT_##Member \
: std::false_type { }; \
template<typename T> \
struct HasMemberT_##Member<T,
std::void_t<decltype(&T::Member)>> \
: std::true_type { } // ; intentionally skipped

&::Member无效的时候,偏特化实现会被SFINAE掉。为了使条件有效,必须满足如下条件:

  • Member必须能够被用来没有歧义的识别出T的一个成员(比如,它不能是重载成员你函数的名字,也不能是多重继承中名字相同的成员的名字)。
  • 成员必须可以被访问。
  • 成员必须是非类型成员以及非枚举成员(否则前面的&会无效)。
  • 如果T::Member是static的数据成员,那么与其对应的类型必须没有提供使得&T::Member无效的operator&(比如,将operator&设成不可访问的)。

所有以上条件都满足之后,我们可以像下面这样使用该模板:

1
2
3
4
5
6
7
8
9
10
11
12
#include "hasmember.hpp"
#include <iostream>
#include <vector>
#include <utility>
DEFINE_HAS_MEMBER(size);
DEFINE_HAS_MEMBER(first);
int main()
{
std::cout << "int::size: " << HasMemberT_size<int>::value << "\n";
std::cout << "std::vector<int>::size: " << HasMemberT_size<std::vector<int>>::value << "\n";
std::cout << "std::pair<int,int>::first: " << HasMemberT_first<std::pair<int,int>>::value << "\n";
}

修改上面的偏特化实现以排除那些&T::Member不是成员指针的情况(比如排除static数据成员的情况)并不会很难。类似地,也可以限制该偏特化仅适用于数据成员或者成员函数。

注意,HasMember萃取只可以被用来测试是否存在“唯一”一个与特定名称对应的成员。如果存在两个同名的成员的话,该测试也会失败,比如当我们测试某些重载成员函数是否存在的时候:

1
2
DEFINE_HAS_MEMBER(begin);
std::cout << HasMemberT_begin<std::vector<int>>::value; // false

用泛型Lambda探测成员

下面这个例子展示了定义可以检测数据或者类型成员是否存在(比如first或者size_type),或者有没有为两个不同类型的对象定义operator <的萃取的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "isvalid.hpp"
#include<iostream>
#include<string>
#include<utility>
int main()
{
using namespace std;
cout << boolalpha;
// define to check for data member first:
constexpr auto hasFirst = isValid([](auto x) -> decltype((void)valueT(x).first) {});
cout << "hasFirst: " << hasFirst(type<pair<int,int>>) << "\n"; // true
// define to check for member type size_type:
constexpr auto hasSizeType = isValid([](auto x) -> typename decltype(valueT(x))::size_type { });

struct CX {
using size_type = std::size_t;
};
cout << "hasSizeType: " << hasSizeType(type<CX>) << "\n"; // true
if constexpr(!hasSizeType(type<int>)) {
cout << "int has no size_type\n";
}
// define to check for <:
constexpr auto hasLess = isValid([](auto x, auto y) -> decltype(valueT(x) < valueT(y)) {});
cout << hasLess(42, type<char>) << "\n"; //yields true
cout << hasLess(type<string>, type<string>) << "\n"; //yields true
cout << hasLess(type<string>, type<int>) << "\n"; //yields false
cout << hasLess(type<string>, "hello") << "\n"; //yields true
}

请再次注意,hasSizeType通过使用std::decay将参数x中的引用删除了,因为我们不能访问引用中的类型成员。如果不这么做,该萃取(对于引用类型)会始终返回false,从而导致第二个重载的isValidImpl<>被使用。

为了能够使用统一的泛型语法(将类型用于模板参数),我们可以继续定义额外的辅助工具。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "isvalid.hpp"
#include<iostream>
#include<string>
#include<utility>
constexpr auto hasFirst = isValid([](auto&& x) -> decltype((void)&x.first) {});
template<typename T>
using HasFirstT = decltype(hasFirst(std::declval<T>()));
constexpr auto hasSizeType = isValid([](auto&& x) -> typename std::decay_t<decltype(x)>::size_type {});

template<typename T>
using HasSizeTypeT = decltype(hasSizeType(std::declval<T>()));

constexpr auto hasLess = isValid([](auto&& x, auto&& y) -> decltype(x < y) { });
template<typename T1, typename T2>
using HasLessT = decltype(hasLess(std::declval<T1>(), std::declval<T2>()));

int main()
{
using namespace std;
cout << "first: " << HasFirstT<pair<int,int>>::value << "\n";
// true
struct CX {
using size_type = std::size_t;
};
cout << "size_type: " << HasSizeTypeT<CX>::value << "\n"; // true
cout << "size_type: " << HasSizeTypeT<int>::value << "\n"; // false
cout << HasLessT<int, char>::value << "\n"; // true
cout << HasLessT<string, string>::value << "\n"; // true
cout << HasLessT<string, int>::value << "\n"; // false
cout << HasLessT<string, char*>::value << "\n"; // true
}

现在可以像下面这样使用HasFirstT

1
HasFirstT<std::pair<int,int>>::value

它会为一个包含两个int的pair调用hasFirst,其行为和之前的讨论一致。

其它的萃取技术

最后让我们来介绍其它一些在定义萃取时可能会用到的方法。

If-Then-Else

在上一小节中,PlusResultT的定义采用了和之前完全不同的实现方法,该实现方法依赖于另一个萃取(HasPlusT)的结果。我们可以用一个特殊的类型模板IfThenElse来表达这一if-then-else的行为,它接受一个bool型的模板参数,并根据该参数从另外两个类型参数中间做选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef IFTHENELSE_HPP
#define IFTHENELSE_HPP
// primary template: yield the second argument by default and rely on
// a partial specialization to yield the third argument
// if COND is false
template<bool COND, typename TrueType, typename FalseType>
struct IfThenElseT {
using Type = TrueType;
};
// partial specialization: false yields third argument
template<typename TrueType, typename FalseType>
struct IfThenElseT<false, TrueType, FalseType> {
using Type = FalseType;
};
template<bool COND, typename TrueType, typename FalseType>
using IfThenElse = typename IfThenElseT<COND, TrueType, FalseType>::Type;
#endif //IFTHENELSE_HPP

下面的例子展现了该模板的一种应用,它定义了一个可以为给定数值选择最合适的整形类型的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <limits>
#include "ifthenelse.hpp"
template<auto N>
struct SmallestIntT {
using Type = typename IfThenElseT<N <= std::numeric_limits<char> ::max(), char,
typename IfThenElseT<N <=
std::numeric_limits<short> ::max(), short,
typename IfThenElseT<N <=
std::numeric_limits<int> ::max(), int,
typename IfThenElseT<N <=
std::numeric_limits<long>::max(), long,
typename IfThenElseT<N <=
std::numeric_limits<long long>::max(), long long, //then
void //fallback
>::Type
>::Type
>::Type
>::Type
>::Type;
};

需要注意的是,和常规的C++ if-then-else语句不同,在最终做选择之前,then和else分支中的模板参数都会被计算,因此两个分支中的代码都不能有问题,否则整个程序就会有问题。考虑下面这个例子,一个可以为给定的有符号类型生成与之对应的无符号类型的萃取。已经有一个标准萃取(std::make_unsigned)可以做这件事情,但是它要求传递进来的类型是有符号的整形,而且不能是bool类型;否则它将使用未定义行为的结果。

这一萃取不够安全,因此最好能够实现一个这样的萃取,当可能的时候,它就正常返回相应的无符号类型,否则就原样返回被传递进来的类型(这样,当传递进来的类型不合适时,也能避免触发未定义行为)。下面这个简单的实现是不行的:

1
2
3
4
5
6
// ERROR: undefined behavior if T is bool or no integral type:
template<typename T>
struct UnsignedT {
using Type = IfThenElse<std::is_integral<T>::value
&& !std::is_same<T,bool>::value, typename std::make_unsigned<T>::type, T>;
};

因为在实例化UnsingedT<bool>的时候,行为依然是未定义的,编译期依然会试图从下面的代码中生成返回类型:

1
typename std::make_unsigned<T>::type

为了解决这一问题,我们需要再引入一层额外的间接层,从而让IfThenElse的参数本身用类型函数去封装结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// yield T when using member Type:
template<typename T>
struct IdentityT {
using Type = T;
};
// to make unsigned after IfThenElse was evaluated:
template<typename T>
struct MakeUnsignedT {
using Type = typename std::make_unsigned<T>::type;
};
template<typename T>
struct UnsignedT {
using Type = typename IfThenElse<std::is_integral<T>::value
&& !std::is_same<T,bool>::value,
MakeUnsignedT<T>,
IdentityT<T>
>::Type;
};

在这一版UnsignedT的定义中,IfThenElse的类型参数本身也都是类型函数的实例。只不过在最终IfThenElse做出选择之前,类型函数不会真正被计算。而是由IfThenElse选择合适的类型实例(MakeUnsignedT或者IdentityT)。最后由::Type对被选择的类型函数实例进行计算,并生成结果Type。

此处值得强调的是,之所以能够这样做,是因为IfThenElse中未被选择的封装类型永远不会被完全实例化。下面的代码也不能正常工作:

1
2
3
4
5
6
7
8
template<typename T>
struct UnsignedT {
using Type = typename IfThenElse<std::is_integral<T>::value
&& !std::is_same<T,bool>::value,
MakeUnsignedT<T>::Type,
T
>::Type;
};

我们必须要延后对MakeUnsignedT<T>使用::Type,也就是意味着,我们同样需要为else分支中的T引入IdentyT辅助模板,并同样延后对其使用::Type

我们同样不能在当前语境中使用如下代码:

1
2
template<typename T>
using Identity = typename IdentityT<T>::Type;

我们当然可以定义这样一个别名模板,在其它地方它可能也很有用,但是我们唯独不能将其用于IfThenElse的定义中,因为任意对Identity<T>的使用都会立即触发对IdentityT<T>的完全实例化,不然无法获取其Type成员。

在C++标准库中有与IfThenElseT模板对应的模板。使用这一标准库模板实现的UnsignedT萃取如下:

1
2
3
4
5
6
7
8
template<typename T>
struct UnsignedT {
using Type = typename std::conditional_t<std::is_integral<T>::value
&& !std::is_same<T,bool>::value,
MakeUnsignedT<T>,
IdentityT<T>
>::Type;
};

探测不抛出异常的操作

我们可能偶尔会需要判断某一个操作会不会抛出异常。比如,在可能的情况下,移动构造函数应当被标记成noexcept的,意思是它不会抛出异常。但是,某一特定class的move constructor是否会抛出异常,通常决定于其成员或者基类的移动构造函数会不会抛出异常。

比如对于下面这个简单类模板(Pair)的移动构造函数:

1
2
3
4
5
6
7
8
9
10
template<typename T1, typename T2>
class Pair {
T1 first;
T2 second;
public:
Pair(Pair&& other)
: first(std::forward<T1>(other.first)),
second(std::forward<T2>(other.second)) {
}
};

当T1或者T2的移动操作会抛出异常时,Pair的移动构造函数也会抛出异常。如果有一个叫做IsNothrowMoveConstructibleT的萃取,就可以在Pair的移动构造函数中通过使用noexcept将这一异常的依赖关系表达出来:

1
2
3
4
5
6
Pair(Pair&& other)
noexcept(IsNothrowMoveConstructibleT<T1>::value &&
IsNothrowMoveConstructibleT<T2>::value)
: first(std::forward<T1>(other.first)),
second(std::forward<T2>(other.second))
{}

现在剩下的事情就是去实现IsNothrowMoveConstructibleT萃取了。我们可以直接用noexcept运算符实现这一萃取,这样就可以判断一个表达式是否被进行nothrow修饰了:

1
2
3
4
5
6
#include <utility> // for declval
#include <type_traits> // for bool_constant
template<typename T>
struct IsNothrowMoveConstructibleT
: std::bool_constant<noexcept(T(std::declval<T>()))>
{};

这里使用了运算符版本的noexcept,它会判断一个表达式是否会抛出异常。由于其结果是bool型的,我们可以直接将它用于std::bool_constant<>基类的定义(std::bool_constant也被用来定义std::true_typestd::false_type)。

但是该实现还应该被继续优化,因为它不是SFINAE友好的:如果它被一个没有可用移动或者拷贝构造函数的类型(这样表达式T(std::declval<T&&>())就是无效的)实例化,整个程序就会遇到问题:

1
2
3
4
5
6
class E {
public:
E(E&&) = delete;
};
...
std::cout << IsNothrowMoveConstructibleT<E>::value; // compiletime ERROR

在这种情况下,我们所期望的并不是让整个程序奔溃,而是获得一个false类型的值。就像在第19.4.4节介绍的那样,在真正做计算之前,必须先对被用来计算结果的表达式的有效性进行判断。在这里,我们要在检查移动构造函数是不是noexcept之前,先对其有效性进行判断。因此,我们要重写之前的萃取实现,给其增加一个默认值是void的模板参数,并根据移动构造函数是否可用对其进行偏特化:

1
2
3
4
5
6
7
8
9
10
11
12
#include <utility> // for declval
#include <type_traits> // for true_type, false_type, and bool_constant<>
// primary template:
template<typename T, typename = std::void_t<>>
struct IsNothrowMoveConstructibleT : std::false_type
{ };
// partial specialization (may be SFINAE"d away):
template<typename T>
struct IsNothrowMoveConstructibleT<T,
std::void_t<decltype(T(std::declval<T>()))>>
: std::bool_constant<noexcept(T(std::declval<T>()))>
{};

如果在偏特化中对std::void_t<...>的替换有效,那么就会选择该偏特化实现,在其父类中的noexcept(...)表达式也可以被安全的计算出来。否则,偏特化实现会被丢弃(也不会对其进行实例化),被实例化的也将是主模板(产生一个std::false_type的返回值)。

值得注意的是,除非真正能够调用移动构造函数,否则我们无法判断移动构造函数是不是会抛出异常。也就是说,移动构造函数仅仅是public和未被标识为delete的还不够,还要求对应的类型不能是抽象类(但是抽象类的指针或者引用却可以)。因此,该类型萃取被命名为IsNothrowMoveConstructible,而不是HasNothrowMoveConstructor。对于其它所有的情况,我们都需要编译期支持。

萃取的便捷性

一个关于萃取的普遍不满是它们相对而言有些繁琐,因为对类型萃取的使用通需要提供一个::Type尾缀,而且在依赖上下文中(dependent context),还需要一个typename前缀,两者几成范式。当同时使用多个类型萃取时,会让代码形式变得很笨拙,就如同在我们的operator+例子中一样,如果想正确的对其进行实现,需要确保不会返回const或者引用类型:

1
2
3
4
template<typename T1, typename T2>
Array< typename RemoveCVT<typename RemoveReferenceT<typename
PlusResultT<T1, T2>::Type >::Type >::Type>
operator+ (Array<T1> const&, Array<T2> const&);

通过使用别名模板(alias templates)和变量模板(variable templates),可以让对产生类型或者数值的萃取的使用变得很方便。但是也需要注意,在某些情况下这一简便方式并不使用,我们依然要使用最原始的类模板。我们已经讨论过一个这一类的例子(MemberPointerToIntT),但是更详细的讨论还在后面。

别名模板和萃取

别名模板为降低代码繁琐性提供了一种方法。相比于将类型萃取表达成一个包含了Type类型成员的类模板,我们可以直接使用别名模板。比如,下面的三个别名模板封装了之前的三种类型萃取:

1
2
3
4
5
6
template<typename T>
using RemoveCV = typename RemoveCVT<T>::Type;
template<typename T>
using RemoveReference = typename RemoveReferenceT<T>::Type;
template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;

有了这些别名模板,我们可以将operator+的声明简化成:

1
2
3
template<typename T1, typename T2>
Array<RemoveCV<RemoveReference<PlusResultT<T1, T2>>>>
operator+ (Array<T1> const&, Array<T2> const&);

这一版本的实现明显更简洁,也让人更容易分辨其组成。这一特性使得别名模板非常适用于某些类型萃取。

但是,将别名模板用于类型萃取也有一些缺点:

  1. 别名模板不能够被进行特化,但是由于很多编写萃取的技术都依赖于特化,别名模板最终可能还是需要被重新导向到类模板。
  2. 有些萃取是需要由用户进行特化的,比如描述了一个求和运算符是否是可交换的萃取,此时在很多使用都用到了别名模板的情况下,对类模板进行特换会很让人困惑。
  3. 对别名模板的使用最会让该类型被实例化(比如,底层类模板的特化),这样对于给定类型我们就很难避免对其进行无意义的实例化。对最后一点的另外一种表述方式是,别名模板不可以和元函数转发一起使用。

变量模板和萃取

对于返回数值的萃取需要使用一个::value(或者类似的成员)来生成萃取的结果。在这种情况下,constexpr修饰的变量模板提供了一种简化代码的方法。比如,下面的变量模板封装了IsSameT萃取和IsConvertibleT萃取:

1
2
3
4
template<typename T1, typename T2>
constexpr bool IsSame = IsSameT<T1,T2>::value;
template<typename FROM, typename TO>
constexpr bool IsConvertible = IsConvertibleT<FROM, TO>::value;

此时我们可以将这一类代码:

1
if (IsSameT<T,int>::value || IsConvertibleT<T,char>::value) ...

简化成:

1
if (IsSame<T,int> || IsConvertible<T,char>) ...

类型分类

如果能够知道一个模板参数的类型是内置类型,指针类型,class类型,或者是其它什么类型,将会很有帮助。在接下来的章节中,我们定义了一组类型萃取,通过它们我们可以判断给定类型的各种特性。这样我们就可以单独为特定的某些类型编写代码:

1
2
3
if (IsClassT<T>::value) {
...
}

或者是将其用于编译期if以及某些为了萃取的便利性而引入的特性:

1
2
3
if constexpr (IsClass<T>) {
...
}

或者时将其用于偏特化:

1
2
3
4
5
6
7
8
template<typename T, bool = IsClass<T>>
class C { //primary template for the general case
...
};
template<typename T>
class C<T, true> { //partial specialization for class types
...
};

此外,诸如IsPointerT<T>::value一类的表达式的结果是bool型常量,因此它们也将是有效的非类型模板参数。这样,就可以构造更为高端和强大的模板,这些模板可以被基于它们的类型参数的特性进行特化。

判断基础类型

作为开始,我们先定义一个可以判断某个类型是不是基础类型的模板。默认情况下,我们认为类型不是基础类型,而对于基础类型,我们分别进行了特化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <cstddef> // for nullptr_t
#include <type_traits> // for true_type, false_type, and bool_constant<>
// primary template: in general T is not a fundamental type template<typename T>
struct IsFundaT : std::false_type {
};
// macro to specialize for fundamental types
#define MK_FUNDA_TYPE(T) \
template<> struct IsFundaT<T> : std::true_type { \
};
MK_FUNDA_TYPE(void)
MK_FUNDA_TYPE(bool)
MK_FUNDA_TYPE(char)
MK_FUNDA_TYPE(signed char)
MK_FUNDA_TYPE(unsigned char)
MK_FUNDA_TYPE(wchar_t)
MK_FUNDA_TYPE(char16_t)
MK_FUNDA_TYPE(char32_t)
MK_FUNDA_TYPE(signed short)
MK_FUNDA_TYPE(unsigned short)
MK_FUNDA_TYPE(signed int)
MK_FUNDA_TYPE(unsigned int)
MK_FUNDA_TYPE(signed long)
MK_FUNDA_TYPE(unsigned long)
MK_FUNDA_TYPE(signed long long)
MK_FUNDA_TYPE(unsigned long long)
MK_FUNDA_TYPE(float)
MK_FUNDA_TYPE(double)
MK_FUNDA_TYPE(long double)
MK_FUNDA_TYPE(std::nullptr_t)
#undef MK_FUNDA_TYPE

主模板定义了常规情况。也就是说,通常而言IfFundaT<T>::value会返回false:

1
2
3
4
template<typename T>
struct IsFundaT : std::false_type {
static constexpr bool value = false;
};

对于每一种基础类型,我们都进行了特化,因此IsFundaT<T>::value的结果也都会返回true。为了简单,我们定义了一个可以扩展成所需代码的宏。比如:

1
MK_FUNDA_TYPE(bool)

会扩展成:

1
2
3
template<> struct IsFundaT<bool> : std::true_type {
static constexpr bool value = true;
};

下面的例子展示了该模板的一种可能的应用场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "isfunda.hpp"
#include <iostream>
template<typename T>
void test (T const&)
{
if (IsFundaT<T>::value) {
std::cout << "T is a fundamental type" << "\n";}
else {
std::cout << "T is not a fundamental type" << "\n";
}
}
int main()
{
test(7);
test("hello");
}

其输出如下:

1
2
T is a fundamental type
T is not a fundamental type

采用同样会的方式,我们也可以定义类型函数IsIntegralT和IsFloatingT来区分哪些类型是整形标量类型以及浮点型标量类型。

C++标准库采用了一种更为细粒度的方法来测试一个类型是不是基础类型。它先定义了主要的类型种类,每一种类型都被匹配到一个相应的种类,然后合成诸如std::is_integralstd::is_fundamental类型种类。

判断复合类型

复合类型是由其它类型构建出来的类型。简单的复合类型包含指针类型,左值以及右值引用类型,指向成员的指针类型(pointer-to-member types),和数组类型。它们是由一种或者两种底层类型构造的。Class类型以及函数类型同样也是复合类型,但是它们可能是由任意数量的类型组成的。在这一分类方法中,枚举类型同样被认为是复杂的符合类型,虽然它们不是由多种底层类型构成的。简单的复合类型可以通过偏特化来区分。

我们从指针类型这一简单的分类开始:

1
2
3
4
5
6
7
template<typename T>
struct IsPointerT : std::false_type { //primary template: by default not a pointer
};
template<typename T>
struct IsPointerT<T*> : std::true_type { //partial specialization for pointers
using BaseT = T; // type pointing to
};

主模板会捕获所有的非指针类型,和往常一样,其值为fase的value成员是通过基类std::false_type提供的,表明该类型不是指针。偏特化实现会捕获所有的指针类型(T*),其为true的成员value表明该类型是一个指针。偏特化实现还额外提供了类型成员BaseT,描述了指针所指向的类型。注意该类型成员只有在原始类型是指针的时候才有,从其使其变成SFINAE友好的类型萃取。

C++标准库也提供了相对应的萃取std::is_pointer<>,但是没有提供一个成员类型来描述指针所指向的类型。

相同的方法也可以被用来识别左值引用:

1
2
3
4
5
6
7
template<typename T>
struct IsLValueReferenceT : std::false_type { //by default no lvalue reference
};
template<typename T>
struct IsLValueReferenceT<T&> : std::true_type { //unless T is lvalue references
using BaseT = T; // type referring to
};

以及右值引用:

1
2
3
4
5
6
7
template<typename T>
struct IsRValueReferenceT : std::false_type { //by default no rvalue reference
};
template<typename T>
struct IsRValueReferenceT<T&&> : std::true_type { //unless T is rvalue reference
using BaseT = T; // type referring to
};

它俩又可以被组合成IsReferenceT<>萃取:

1
2
3
4
5
6
7
8
9
10
#include "islvaluereference.hpp"
#include "isrvaluereference.hpp"
#include "ifthenelse.hpp"
template<typename T>
class IsReferenceT
: public IfThenElseT<IsLValueReferenceT<T>::value,
IsLValueReferenceT<T>,
IsRValueReferenceT<T>
>::Type {
};

在这一实现中,我们用IfThenElseTISLvalueReference<T>IsRValueReferenceT<T>中选择基类,这里还用到了元函数转发。如果T是左值引用,我们会从IsLReference<T>做继承,并通过继承得到相应的value和BaseT成员。否则,我们就从IsRValueReference<T>做继承,它会判断一个类型是不是右值引用。

C++标准库也提供了相应的std::is_lvalue_reference<>std::is_rvalue_reference<>萃取,还有std::is_reference<>。同样的,这些萃取也没有提供代表其所引用的类型的类型成员。

在定义可以判断数组的萃取时,让人有些意外的是偏特化实现中的模板参数数量要比主模板多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <cstddef>
template<typename T>
struct IsArrayT : std::false_type { //primary template: not an array
};
template<typename T, std::size_t N>
struct IsArrayT<T[N]> : std::true_type { //partial specialization for arrays
using BaseT = T;
static constexpr std::size_t size = N;
};
template<typename T>
struct IsArrayT<T[]> : std::true_type { //partial specialization for unbound arrays
using BaseT = T;
static constexpr std::size_t size = 0;
};

在这里,多个额外的成员被用来描述被用来分类的数组的信息:数组的基本类型和大小(被用来标识未知大小的数组的尺寸)。

C++标准库提供了相应的std::is_array<>来判断一个类型是不是数组。除此之外,诸如std::rank<>std::extent<>之类的萃取还允许我们去查询数组的维度以及某个维度的大小。

也可以用相同的方式处理指向成员的指针:

1
2
3
4
5
6
7
8
template<typename T>
struct IsPointerToMemberT : std::false_type { //by default no pointer-to-member
};
template<typename T, typename C>
struct IsPointerToMemberT<T C::*> : std::true_type { //partial specialization
using MemberT = T;
using ClassT = C;
};

这里额外的成员(MemberT和ClassT)提供了与成员的类型以及class的类型相关的信息。 C++标准库提供了更为具体的萃取,std::is_member_object_pointer<>std::is_member_function_pointer<>std::is_member_pointer<>

识别函数类型

函数类型比较有意思,因为它们除了返回类型,还可能会有任意数量的参数。因此,在匹配一个函数类型的偏特化实现中,我们用一个参数包来捕获所有的参数类型,就如同我们在对DecayT所做的那样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "../typelist/typelist.hpp"
template<typename T>
struct IsFunctionT : std::false_type { //primary template: no function
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params...)> : std::true_type
{ //functions
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = false;
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params..., ...)> : std::true_type { //variadic functions
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = true;
};

上述实现中函数类型的每一部分都被暴露了出来:返回类型被Type标识,所有的参数都被作为ParamsT捕获进了一个typelist中(在第24章有关于typelist的介绍),而可变参数(…)表示的是当前函数类型使用的是不是C风格的可变参数。

不幸的是,这一形式的IsFunctionT并不能处理所有的函数类型,因为函数类型还可以包含const和volatile修饰符,以及左值或者右值引用修饰符,在C++17之后,还有noexcept修饰符。比如:

1
using MyFuncType = void (int&) const;

这一类函数类型只有在被用于非static成员函数的时候才有意义,但是不管怎样都算得上是函数类型。而且,被标记为const的函数类型并不是真正意义上的const类型,因此RemoveConst并不能将const从函数类型中移除。因此,为了识别有限制符的函数类型,我们需要引入一大批额外的偏特化实现,来覆盖所有可能的限制符组合(每一个实现都需要包含C风格和非C风格的可变参数情况)。这里,我们只展示所有偏特化实现中的5中情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
template<typename R, typename... Params>
struct IsFunctionT<R (Params...) const> : std::true_type {
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = false;
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params..., ...) volatile> : std::true_type {
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = true;
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params..., ...) const volatile> : std::true_type {
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = true;
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params..., ...) &> : std::true_type {
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = true;
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params..., ...) const&> : std::true_type {
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = true;
};

当所有这些都准备完毕之后,我们就可以识别除class类型和枚举类型之外的所有类型了。我们会在接下来的章节中除了这两种例外情况。C++标准库也提供了相应的std::is_function<>萃取。

判断class类型

和到目前为止我们已经处理的各种复合类型不同,我们没有相应的偏特化模式来专门匹配class类型。也不能像处理基础类型一样一一列举所有的class类型。相反,我们需要用一种间接的方法来识别class类型,为此我们需要找出一些适用于所有class类型的类型或者表达式(但是不能适用于其它类型)。

Class中可以被我们用来识别class类型的最为方便的特性是:只有class类型可以被用于指向成员的指针类型(pointer-to-member types)的基础。也就是说,对于X Y::*一类的类型结构,Y只能是class类型。下面的IsClassT<>就利用了这一特性(将X随机选择为int):

1
2
3
4
5
6
7
8
9
#include <type_traits>
template<typename T, typename = std::void_t<>>
struct IsClassT : std::false_type { //primary template: by default no
class
};
template<typename T>
struct IsClassT<T, std::void_t<int T::*>> // classes can have pointer-to-member
: std::true_type {
};

C++语言规则指出,lambda表达式的类型是“唯一的,未命名的,非枚举class类型”。因此在将IsClassT萃取用于lambda表达时,我们得到的结果是true:

1
2
auto l = []{};
static_assert<IsClassT<decltype(l)>::value, "">; //succeeds

需要注意的是,int T::*表达式同样适用于unit类型(更具C++标准,枚举类型也是class类型)。

C++标准库提供了std::is_class<>std::is_union萃取。但是,这些萃取需要编译期进行专门的支持,因为目前还不能通过任何核心的语言技术(standard core language techniques)将class和struct从union类型中分辨出来。

识别枚举类型

目前通过我们已有的萃取技术还唯一不能识别的类型是枚举类型。我们可以通过编写基于SFINAE的萃取来实现这一功能,这里首先需要测试是否可以像整形类型(比如int)进行显式转换,然后依次排除基础类型,class类型,引用类型,指针类型,还有指向成员的指针类型(这些类型都可以被转换成整形类型,但是都不是枚举类型)。但是也有更简单的方法,因为我们发现所有不属于其它任何一种类型的类型就是枚举类型,这样就可以像下面这样实现该萃取:

1
2
3
4
5
6
7
8
9
10
template<typename T>
struct IsEnumT {
static constexpr bool value = !IsFundaT<T>::value
&& !IsPointerT<T>::value &&
!IsReferenceT<T>::value
&& !IsArrayT<T>::value &&
!IsPointerToMemberT<T>::value
&& !IsFunctionT<T>::value &&
!IsClassT<T>::value;
};

C++标准库提供了相对应的std::is_enum<>萃取。通常,为了提高编译性能,编译期会直接提供这一类萃取,而不是将其实现为其它的样子。

策略萃取(Policy Traits)

到目前为止,我们例子中的萃取模板被用来判断模板参数的特性:它们代表的是哪一种类型,作用于该类型数值的操作符的返回值的类型,以及其它特性。这一类萃取被称为特性萃取(property traits)。

最为对比,某些萃取定义的是该如何处理某些类型。我们称之为策略萃取(policy traits)。这里会对之前介绍的策略类(policy class,我们已经指出,策略类和策略萃取之间的界限并不青霞)的概念进行回顾,但是策略萃取更倾向于是模板参数的某一独有特性(而策略类却通常和其它模板参数无关)。

虽然特性萃取通常都可以被实现为类型函数,策略萃取却通常将策略包装进成员函数中。为了展示这一概念,先来看一下一个定义了特定策略(必须传递只读参数)的类型函数。

只读参数类型

在C++和C中,函数的调用参数(call parameters)默认情况下是按照值传递的。这意味着,调用函数计算出来的参数的值,会被拷贝到由被调用函数控制的位置。对于比较大的结构体,这一拷贝的成本会非常高,因此对于这一类结构体最好能够将其按照常量引用(reference-to-const)或者是C中的常量指针(pointer-to-const)进行传递。对于小的结构体,到底该怎样实现目前还没有定论,从性能的角度来看,最好的机制依赖于代码所运行的具体架构。在大多数情况下这并没有那么关键,但是某些情况下,即使是对小的结构体我们也要仔细应对。

正如之前暗示的那样,这一类问题通常应当用策略萃取模板(一个类型函数)来处理:该函数将预期的参数类型T映射到最佳的参数类型T或者是T const&。作为第一步的近似,主模板会将大小不大于两个指针的类型按值进行传递,对于其它所有类型都按照常量引用进行传递:

1
2
3
4
5
6
template<typename T>
struct RParam {
using Type = typename IfThenElseT<sizeof(T) <=2*sizeof(void*),
T,
T const&>::Type;
};

另一方面,对于那些另sizeof运算符返回一个很小的值,但是拷贝构造函数成本却很高的容器类型,我们可能需要分别对它们进行特化或者偏特化,就像下面这样:

1
2
3
4
template<typename T>
struct RParam<Array<T>> {
using Type = Array<T> const&;
};

由于这一类类型在C++中很常见,如果只将那些拥有简单拷贝以及移动构造函数的类型按值进行传递,当需要考虑性能因素时,再选择性的将其它一些class类型加入按值传递的行列(C++标准库中包含了std::is_trivially_copy_constructiblestd::is_trivially_move_constructible类型萃取)。

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef RPARAM_HPP
#define RPARAM_HPP
#include "ifthenelse.hpp"
#include <type_traits>
template<typename T>
struct RParam {
using Type = IfThenElse<(sizeof(T) <= 2*sizeof(void*)
&& std::is_trivially_copy_constructible<T>::value
&& std::is_trivially_move_constructible<T>::value),
T,
T const&>;
};
#endif //RPARAM_HPP

无论采用哪一种方式,现在该策略都可以被集成到萃取模板的定义中,客户也可以用它们去实现更好的效果。比如,假设我们有两个class,对于其中一个class我们指明要按值传递只读参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "rparam.hpp"
#include <iostream>
class MyClass1 {
public:
MyClass1 () {
}

MyClass1 (MyClass1 const&) {
std::cout << "MyClass1 copy constructor called\n";}
};

class MyClass2 {
public:
MyClass2 () { }
MyClass2 (MyClass2 const&) {
std::cout << "MyClass2 copy constructor called\n";
}
};
// pass MyClass2 objects with RParam<> by value
template<>
class RParam<MyClass2> {
public:
using Type = MyClass2;
};

现在,我们就可以定义将PParam<>用于只读参数的函数了,并对其进行调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "rparam.hpp"
#include "rparamcls.hpp"
// function that allows parameter passing by value or by reference
template<typename T1, typename T2>
void foo (typename RParam<T1>::Type p1, typename RParam<T2>::Type p2)
{
...
}
int main()
{
MyClass1 mc1;
MyClass2 mc2;
foo<MyClass1,MyClass2>(mc1,mc2);
}

不幸的是,PParam的使用有一些很大的缺点。第一,函数的声明很凌乱。第二,可能也是更有异议的地方,就是在调用诸如foo()一类的函数时不能使用参数推断,因为模板参数只出现在函数参数的限制符中。因此在调用时必须显式的指明所有的模板参数。一个稍显笨拙的权宜之计是:使用提供了完美转发的inline封装函数(inline wrapper function),但是需要假设编译器将省略inline函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "rparam.hpp"
#include "rparamcls.hpp"
// function that allows parameter passing by value or by reference
template<typename T1, typename T2>
void foo_core (typename RParam<T1>::Type p1, typename RParam<T2>::Type p2)
{
...
}

// wrapper to avoid explicit template parameter passing
template<typename T1, typename T2>
void foo (T1 && p1, T2 && p2)
{
foo_core<T1,T2>(std::forward<T1>(p1),std::forward<T2>(p2));
}
int main()
{
MyClass1 mc1;
MyClass2 mc2;
foo(mc1,mc2); // same as foo_core<MyClass1,MyClass2> (mc1,mc2)
}

在标准库中的情况

在C++11中,类型萃取变成了C++标准库中固有的一部分。它们或多或少的构成了在本章中讨论的所有的类型函数和类型萃取。但是,对于它们中的一部分,比如个别的操作探测,以及有过讨论的std::is_union,目前都还没有已知的语言解决方案。而是由编译器为这些萃取提供了支持。同样的,编译器也开始支持一些已经由语言本身提供了解决方案的萃取,这主要是为了减少编译时间。

C++标准库也定义了一些策略和属性萃取:

  • 类模板std::char_traitsstd::string和I/O stream当作策略萃取使用。
  • 为了将算法简单的适配于标准迭代器的种类,标准库提供了一个很简单的std::iterator_traits属性萃取模板。
  • 模板std::numeric_limits作为属性萃取模板也会很有帮助。
  • 最后,为标准库容器类型进行的内存分配是由策略萃取类处理的(参见std::shared_ptr的实现)。从C++98开始,标准库专门为了这一目的提供了std::allocator模板。从C++11开始,标准库引入了std::allocator_traits模板,这样就能够修改内存分配器的策略或者行为了。

基于类型属性的重载

函数重载使得相同的函数名能够被多个函数使用,只要能够通过这些函数的参数类型区分它们就行。比如:

1
2
void f (int);
void f (char const*);

对于函数模板,可以在类型模式上进行重载,比如针对指向T的指针或者Array<T>

1
2
template<typename T> void f(T*);
template<typename T> void f(Array<T>);

在类型萃取的概念流行起来之后,很自然地会想到基于模板参数对函数模板进行重载。比如:

1
2
template<typename Number> void f(Number); // only for numbers
template<typename Container> void f(Container);// only for containers

但是,目前C++还没有提供任何可以直接基于类型属性进行重载的方法。事实上,上面的两个模板声明的是完全相同的函数模板,而不是进行了重载,因为在比较两个函数模板的时候不会比较模板参数的名字。

算法特化

函数模板重载的一个动机是,基于算法适用的类型信息,为算法提供更为特化的版本。考虑一个交换两个数值的swap()操作:

1
2
3
4
5
6
7
template<typename T>
void swap(T& x, T& y)
{
T tmp(x);
x = y;
y = tmp;
}

这一实现用到了三次拷贝操作。但是对于某些类型,可以有一种更为高效的swap()实现,比如对于存储了指向具体数组内容的指针和数组长度的Array<T>:

1
2
3
4
5
6
template<typename T>
void swap(Array<T>& x, Array<T>& y)
{
swap(x.ptr, y.ptr);
swap(x.len, y.len);
}

俩种swap()实现都可以正确的交换两个Array<T>对象的内容。但是,后一种实现方式的效率要高很多,因为它利用了Array<T>中额外的特性。因此后一种实现方式要(在概念上)比第一种实现方式更为“特化”,这是因为它只为适用于前一种实现的类型的一个子集提供了交换操作。幸运的是,基于函数模板的部分排序规则,第二种函数模板也是更为特化的,在有更为特化的版本(也更高效)可用的时候,编译器会优先选择该版本,在其不适用的时候,会退回到更为泛化的版本(可能会不那么高效)。

对于特定类型的迭代器(比如提供了随机访问操作的迭代器),我们可以为该操作提供一个更为高效的实现方式:

1
2
3
4
template<typename RandomAccessIterator, typename Distance>
void advanceIter(RandomAccessIterator& x, Distance n) {
x += n; // constant time
}

但是不幸的是,同时定义以上两种函数模板会导致编译错误,正如我们在序言中介绍的那样,这是因为只有模板参数名字不同的函数模板是不可以被重载的。

标记派发(Tag Dispatching)

算法特化的一个方式是,用一个唯一的、可以区分特定变体的类型来标记(tag)不同算法变体的实现。比如为了解决上述advanceIter()中的问题,可以用标准库中的迭代器种类标记类型,来区分advanceIter()算法的两个变体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename Iterator, typename Distance>
void advanceIterImpl(Iterator& x, Distance n,
std::input_iterator_tag)
{
while (n > 0) { //linear time
++x;
--n;
}
}

template<typename Iterator, typename Distance>
void advanceIterImpl(Iterator& x, Distance n,
std::random_access_iterator_tag)
{
x += n; // constant time
}

然后,通过advanceIter()函数模板将其参数连同与之对应的tag一起转发出去:

1
2
3
4
5
6
template<typename Iterator, typename Distance>
void advanceIter(Iterator& x, Distance n)
{
advanceIterImpl(x, n, typename
std::iterator_traits<Iterator>::iterator_category())
}

萃取类模板std::iterator_traits通过其成员类型iterator_category返回了迭代器的种类。迭代器种类是前述_tag类型中的一种,它指明了相关类型的具体迭代器种类。在C++标准库中,可用的tags被定义成了下面这样,在其中使用了继承来反映出一个用tag表述的种类是不是从另一个种类派生出来的:

1
2
3
4
5
6
7
8
namespace std {
struct input_iterator_tag { };
struct output_iterator_tag { };
struct forward_iterator_tag : public input_iterator_tag { };
struct bidirectional_iterator_tag : public forward_iterator_tag
{ };
struct random_access_iterator_tag : public bidirectional_iterator_tag { };
}

有效使用标记派发(tag dispatching)的关键在于理解tags之间的内在关系。我们用来标记两个advanceIterImpl变体的标记是std::input_iterator_tagstd::random_access_iterator_tag,而由于std::random_access_iterator_tag继承自std::input_iterator_tag,对于随机访问迭代器,会优先选择更为特化的advanceIterImpl()变体(使用了std::random_access_iterator_tag的那一个)。因此,标记派发依赖于将单一的主函数模板的功能委托给一组_impl变体,这些变体都被进行了标记,因此正常的函数重载机制会选择适用于特定模板参数的最为特化的版本。

当被算法用到的特性具有天然的层次结构,并且存在一组为这些标记提供了值的萃取机制的时候,标记派发可以很好的工作。而如果算法特化依赖于专有(ad hoc)类型属性的话(比如依赖于类型T是否含有拷贝赋值运算符),标记派发就没那么方便了。对于这种情况,我们需要一个更强大的技术。

Enable/Disable函数模板

算法特化需要提供可以基于模板参数的属性进行选择的、不同的函数模板。不幸的是,无论是函数模板的部分排序规则还是重载解析,都不能满足更为高阶的算法特化的要求。C++标准库为之提供的一个辅助工具是std::enable_if。本节将介绍通过引入一个对应的模板别名,实现该辅助工具的方式,为了避免名称冲突,我们将称之称为EnableIf

std::enable_if一样,EnableIf模板别名也可以被用来基于特定的条件enable(或disable)特定的函数模板。比如,随机访问版本的advanceIter()算法可以被实现成这样:

1
2
3
4
5
6
7
template<typename Iterator>
constexpr bool IsRandomAccessIterator = IsConvertible< typename std::iterator_traits<Iterator>::iterator_category, std::random_access_iterator_tag>;
template<typename Iterator, typename Distance>
EnableIf<IsRandomAccessIterator<Iterator>>
advanceIter(Iterator& x, Distance n){
x += n; // constant time
}

这里使用了基于EnableIf的偏特化,在迭代器是随机访问迭代器的时候启用特定的advanceIter()变体。EnableIf包含两个参数,一个是标示着该模板是否应该被启用的bool型条件参数,另一个是在第一个参数为true时,EnableIf应该包含的类型。

EnableIf的实现非常简单:

1
2
3
4
5
6
7
8
9
template<bool, typename T = void>
struct EnableIfT {
};
template< typename T>
struct EnableIfT<true, T> {
using Type = T;
};
template<bool Cond, typename T = void>
using EnableIf = typename EnableIfT<Cond, T>::Type;

EnableIf会扩展成一个类型,因此它被实现成了一个别名模板(alias template)。我们希望为之使用偏特化,但是别名模板(alias template)并不能被偏特化。幸运的是,我们可以引入一个辅助类模板(helper class template)EnableIfT,并将真正要做的工作委托给它,而别名模板EnableIf所要做的只是简单的从辅助模板中选择结果类型。当条件是true的时候,EnableIfT<...>::Type(也就是EnableIf<...>)的计算结果将是第二个模板参数T。当条件是false的时候,EnableIf不会生成有效的类型,因为主模板EnableIfT没有名为Type的成员。通常这应该是一个错误,但是在SFINAE中它只会导致模板参数推断失败,并将函数模板从待选项中移除。

对于advanceIter()EnableIf的使用意味着只有当Iterator参数是随机访问迭代器的时候,函数模板才可以被使用(而且返回类型是void),而当Iterator不是随机访问迭代器的时候,函数模板则会被从待选项中移除。我们可以将EnableIf理解成一种在模板参数不满足特定需
求的时候,防止模板被实例化的防卫手段。由于advanceIter()需要一些只有随机访问迭代器才有操作,因此只能被随机访问迭代器实例化。有时候这样使用EnableIf也不是绝对安全的,此时EnableIf可以被用来帮助尽早的发现这一类错误。

我们还需要“去激活(de-activate)”不够特化的模板,因为在两个模板都适用的时候,编译期没有办法在两者之间做决断(order),从而会报出一个模板歧义错误。幸运的是,实现这一目的方法并不复杂:我们为不够特化的模板使用相同模式的EnableIf,只是适用相反的判断条件。这样,就可以确保对于任意Iterator类型,都只有一个模板会被激活。因此,适用于非随机访问迭代器的advanceIter()会变成下面这样:

1
2
3
4
5
6
7
8
9
template<typename Iterator, typename Distance>
EnableIf<!IsRandomAccessIterator<Iterator>>
advanceIter(Iterator& x, Distance n)
{
while (n > 0) {//linear time
++x;
--n;
}
}

提供多种特化版本

上述模式可以被继续泛化以满足有两种以上待选项的情况:可以为每一个待选项都配备一个EnableIf,并且让它们的条件部分,对于特定的模板参数彼此互斥。这些条件部分通常会用到多种可以用类型萃取(type traits)表达的属性。

比如,考虑另外一种情况,第三种advanceIter()算法的变体:允许指定一个负的距离参数,以让迭代器向“后”移动。很显然这对一个“输入迭代器(input itertor)”是不适用的,对一个随机访问迭代器却是适用的。但是,标准库也包含一种双向迭代器(bidirectional iterator)的概念,这一类迭代器可以向后移动,但却不要求必须同时是随机访问迭代器。实现这一情况需要稍微复杂一些的逻辑:每个函数模板都必须使用一个包含了在所有函数模板间彼此互斥EnableIf条件,这些函数模板代表了同一个算法的不同变体。这样就会有下面一组条件:

  • 随机访问迭代器:适用于随机访问的情况(常数时间复杂度,可以向前或向后移动)
  • 双向迭代器但又不是随机访问迭代器:适用于双向情况(线性时间复杂度,可以向前或向后移动)
  • 输入迭代器但又不是双向迭代器:适用于一般情况(线性时间复杂度,只能向前移动)

相关函数模板的具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iterator>
// implementation for random access iterators:
template<typename Iterator, typename Distance>
EnableIf<IsRandomAccessIterator<Iterator>>
advanceIter(Iterator& x, Distance n) {
x += n; // constant time
}
template<typename Iterator>
constexpr bool IsBidirectionalIterator = IsConvertible< typename
std::iterator_traits<Iterator>::iterator_category,
std::bidirectional_iterator_tag>;
// implementation for bidirectional iterators:

template<typename Iterator, typename Distance>
EnableIf<IsBidirectionalIterator<Iterator>
&& !IsRandomAccessIterator<Iterator>>
advanceIter(Iterator& x, Distance n) {
if (n > 0) {
for ( ; n > 0; ++x, --n) { //linear time
}
} else {
for ( ; n < 0; --x, ++n) { //linear time
}
}
}
// implementation for all other iterators:
template<typename Iterator, typename Distance>
EnableIf<!IsBidirectionalIterator<Iterator>>
advanceIter(Iterator& x, Distance n) {
if (n < 0) {
throw "advanceIter(): invalid iterator category for negative n";
}
while (n > 0) { //linear time
++x;
--n;
}
}

通过让每一个函数模板的EnableIf条件与其它所有函数模板的条件互相排斥,可以保证对于一组参数,最多只有一个函数模板可以在模板参数推断中胜出。

上述例子已体现出通过EnableIf实现算法特化的一个缺点:每当一个新的算法变体被加入进来,就需要调整所有算法变体的EnableIf条件,以使得它们之间彼此互斥。作为对比,当通过标记派发(tag dispatching)引入一个双向迭代器的算法变体时,则只需要使用标记std::bidirectional_iterator_tag重载一个advanceIterImpl()即可。

标记派发(tag dispatching)和EnableIf两种技术所适用的场景有所不同:一般而言,标记派发可以基于分层的tags支持简单的派发,而EnableIf则可以基于通过使用类型萃取(type trait)获得的任意一组属性来支持更为复杂的派发。

EnableIf所之何处

EnableIf通常被用于函数模板的返回类型。但是,该方法不适用于构造函数模板以及类型转换模板,因为它们都没有被指定返回类型。而且,使用EnableIf也会使得返回类型很难被读懂。对于这一问题,我们可以通过将EnableIf嵌入一个默认的模板参数来解决,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iterator>
#include "enableif.hpp"
#include "isconvertible.hpp"
template<typename Iterator>
constexpr bool IsInputIterator = IsConvertible< typename
std::iterator_traits<Iterator>::iterator_category,
std::input_iterator_tag>;
template<typename T>
class Container {
public:
// construct from an input iterator sequence:
template<typename Iterator, typename =
EnableIf<IsInputIterator<Iterator>>>
Container(Iterator first, Iterator last);
// convert to a container so long as the value types are convertible:
template<typename U, typename = EnableIf<IsConvertible<T, U>>>
operator Container<U>() const;
};

但是,这样做也有一个问题。如果我们尝试再添加一个版本的重载的话,会导致错误:

1
2
3
4
5
6
7
8
// construct from an input iterator sequence:
template<typename Iterator,
typename = EnableIf<IsInputIterator<Iterator>
&& !IsRandomAccessIterator<Iterator>>>
Container(Iterator first, Iterator last);
template<typename Iterator, typename = EnableIf<IsRandomAccessIterator<Iterator>>>
Container(Iterator first, Iterator last); // ERROR: redeclaration
//of constructor template

问题在于这两个模板唯一的区别是默认模板参数,但是在判断两个模板是否相同的时候却又不会考虑默认模板参数。

该问题可以通过引入另外一个模板参数来解决,这样两个构造函数模板就有数量不同的模板参数了:

1
2
3
4
5
6
7
8
9
// construct from an input iterator sequence:
template<typename Iterator, typename =
EnableIf<IsInputIterator<Iterator>
&& !IsRandomAccessIterator<Iterator>>>
Container(Iterator first, Iterator last);
template<typename Iterator, typename =
EnableIf<IsRandomAccessIterator<Iterator>>, typename = int> // extra
dummy parameter to enable both constructors
Container(Iterator first, Iterator last); //OK now

编译期if

值得注意的是,C++17的constexpr if特性使得某些情况下可以不再使用EnableIf。比如在C++17中可以像下面这样重写advanceIter():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<typename Iterator, typename Distance>
void advanceIter(Iterator& x, Distance n) {
if constexpr(IsRandomAccessIterator<Iterator>) {
// implementation for random access iterators:
x += n; // constant time
} else if constexpr(IsBidirectionalIterator<Iterator>) {
// implementation for bidirectional iterators:
if (n > 0) {
for ( ; n > 0; ++x, --n) { //linear time for positive n
}
} else {
for ( ; n < 0; --x, ++n) { //linear time for negative n
}
}
} else {
// implementation for all other iterators that are at least input iterators:
if (n < 0) {
throw "advanceIter(): invalid iterator category for negative n";
}
while (n > 0) { //linear time for positive n only
++x;
--n;
}
}
}

这样会更好一些。更为特化的代码分支只会被那些支持它们的类型实例化。因此,对于使用了不被所有的迭代器都支持的代码的情况,只要它们被放在合适的constexpr if分支中,就是安全的。

但是,该方法也有其缺点。只有在泛型代码组件可以被在一个函数模板中完整的表述时,这一使用constexpr if的方法才是可能的。在下面这些情况下,我们依然需要EnableIf:

  • 需要满足不同的“接口”需求
  • 需要不同的class定义
  • 对于某些模板参数列表,不应该存在有效的实例化。

对于最后一种情况,下面这种做法看上去很有吸引力:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(T p) {
if constexpr (condition<T>::value) {
// do something here...
}
else {
// not a T for which f() makes sense:
static_assert(condition<T>::value, "can't call f() for such a T");
}
}

Concepts

上述技术到目前为止都还不错,但是有时候却稍显笨拙,它们可能会占用很多的编译器资源,以及在某些情况下,可能会产生难以理解的错误信息。因此某些泛型库的作者一直都在盼望着一种能够更简单、直接地实现相同效果的语言特性。为了满足这一需求,一个被称为conceptes的特性很可能会被加入到C++语言中。比如,我们可能希望被重载的container的构造函数可以像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename T>
class Container {
public:
//construct from an input iterator sequence:
template<typename Iterator>
requires IsInputIterator<Iterator>
Container(Iterator first, Iterator last);

// construct from a random access iterator sequence:
template<typename Iterator>
requires IsRandomAccessIterator<Iterator>
Container(Iterator first, Iterator last);

// convert to a container so long as the value types are convertible:
template<typename U>
requires IsConvertible<T, U>
operator Container<U>() const;
};

其中requires条款描述了使用当前模板的要求。如果某个要求不被满足,那么相应的模板就不会被当作备选项考虑。因此它可以被当作EnableIf这一想法的更为直接的表达方式,而且是被语言自身支持的。

Requires条款还有另外一些优于EnableIf的地方。约束包容(constraint subsumption)为只有requires不同的模板进行了排序,这样就不再需要标记派发了(tag dispatching)。而且,requires条款也可以被用于非模板。比如只有在T的对象可以被<运算符比较的时候,才为容器提供sort()成员函数:

1
2
3
4
5
6
7
template<typename T>
class Container {
public:
requires HasLess<T>
void sort() {
}
};

类的特化

类模板的偏特化可以被用来提供一个可选的、为特定模板参数进行了特化的实现,这一点和函数模板的重载很相像。而且,和函数模板的重载类似,如果能够基于模板参数的属性对各种偏特化版本进行区分,也会很有意义。考虑一个以key和value的类型为模板参数的泛型Dictionary类模板。只要key的类型提供了operator==()运算符,就可以实现一个简单(但是低效)的Dictionary:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename Key, typename Value>
class Dictionary
{
private:
vector<pair<Key const, Value>> data;
public:
//subscripted access to the data:
value& operator[](Key const& key)
{
// search for the element with this key:
for (auto& element : data) {
if (element.first == key){
return element.second;
}
}
// there is no element with this key; add one
data.push_back(pair<Key const, Value>(key, Value()));
return data.back().second;
}
};

如果key的类型提供了operator <()运算符的话,则可以基于标准库的map容器提供一种相对高效的实现方式。类似的,如果key的类型提供了哈希操作的话,则可以基于标准库的unordered_map提供一种更为高效的实现方式。

启用/禁用类模板

启用/禁用类模板的不同实现方式的方法是使用类模板的偏特化。为了将EnableIf用于类模板的偏特化,需要先为Dictionary引入一个未命名的、 默认的模板参数:

1
2
3
4
5
template<typename Key, typename Value, typename = void>
class Dictionary
{
... //vector implementation as above
};

这个新的模板参数将是我们使用EnableIf的入口,现在它可以被嵌入到基于map的偏特化Dictionary的模板参数例表中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
template<typename Key, typename Value>
class Dictionary<Key, Value, EnableIf<HasLess<Key>>>
{
private:
map<Key, Value> data;
public:
value& operator[](Key const& key) {
return data[key];
}
};
···

和函数模板的重载不同,我们不需要对主模板的任意条件进行禁用,因为对于类模板,任意偏特化版本的优先级都比主模板高。但是,当我们针对支持哈希操作的另一组keys进行特化时,则需要保证不同偏特化版本间的条件是互斥的:
```C++
template<typename Key, typename Value, typename = void>
class Dictionary
{
... // vector implementation as above
};
template<typename Key, typename Value>
class Dictionary<Key, Value, EnableIf<HasLess<Key> && !HasHash<Key>>> {
{
... // map implementation as above
};
template typename Key, typename Value>
class Dictionary Key, Value, EnableIf HasHash Key>>>
{
private:
unordered_map Key, Value> data;
public:
value& operator[](Key const& key) {
return data[key];
}
};

类模板的标记派发

同样地,标记派发也可以被用于在不同的模板特化版本之间做选择。为了展示这一技术,我们定义一个类似于之前章节中介绍的advanceIter()算法的函数对象类型Advance<Iterator>,它同样会以一定的步数移动迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// primary template (intentionally undefined):
template<typename Iterator,
typename Tag = BestMatchInSet< typename
std::iterator_traits<Iterator> ::iterator_category,
std::input_iterator_tag,
std::bidirectional_iterator_tag,
std::random_access_iterator_tag>>

class Advance;
// general, linear-time implementation for input iterators:
template<typename Iterator>
class Advance<Iterator, std::input_iterator_tag>
{
public:
using DifferenceType = typename std::iterator_traits<Iterator>::difference_type;
void operator() (Iterator& x, DifferenceType n) const
{
while (n > 0) {
++x;
--n;
}
}
};
// bidirectional, linear-time algorithm for bidirectional iterators:
template<typename Iterator>
class Advance<Iterator, std::bidirectional_iterator_tag>
{
public:
using DifferenceType =typename
std::iterator_traits<Iterator>::difference_type;
void operator() (Iterator& x, DifferenceType n) const
{
if (n > 0) {
while (n > 0) {
++x;
--n;
}
} else {
while (n < 0) {
--x;
++n;
}
}
}
};
// bidirectional, constant-time algorithm for random access iterators:
template<typename Iterator>
class Advance<Iterator, std::random_access_iterator_tag>
{
public:
using DifferenceType =
typename std::iterator_traits<Iterator>::difference_type;
void operator() (Iterator& x, DifferenceType n) const
{
x += n;
}
};

这一实现形式和函数模板中的标记派发很相像。但是,比较困难的是BestMatchInSet的实现,它主要被用来为一个给定的迭代器选择选择最匹配tag。本质上,这个类型萃取所做的是,当给定一个迭代器种类标记的值之后,要判断出该从以下重载函数中选择哪一个,并返回其参数类型:

1
2
3
void f(std::input_iterator_tag);
void f(std::bidirectional_iterator_tag);
void f(std::random_access_iterator_tag);

模拟重载解析最简单的方式就是使用重载解析,就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// construct a set of match() overloads for the types in Types...:
template<typename... Types>
struct MatchOverloads;
// basis case: nothing matched:
template<>
struct MatchOverloads<> {
static void match(...);
};
// recursive case: introduce a new match() overload:
template<typename T1, typename... Rest>
struct MatchOverloads<T1, Rest...> : public MatchOverloads<Rest...>
{
static T1 match(T1); // introduce overload for T1
using MatchOverloads<Rest...>::match;// collect overloads from bases
};
// find the best match for T in Types...
template<typename T, typename... Types>
struct BestMatchInSetT {
using Type = decltype(MatchOverloads<Types...>::match(declval<T> ()));
};
template<typename T, typename... Types>
using BestMatchInSet = typename BestMatchInSetT<T, Types...>::Type;

MatchOverloads模板通过递归继承为输入的一组Types中的每一个类型都声明了一个match()函数。每一次递归模板MatchOverloads偏特化的实例化都为列表中的下一个类型引入了一个新的match()函数。然后通过使用using声明将基类中的match()函数引入当前作用域。当递归地使用该模板的时候,我们就有了一组和给定类型完全对应的match()函数的重载,每一个重载函数返回的都是其参数的类型。然后BestMatchInSetT模板会将T类型的对象传递给一组match()的重载函数,并返回最匹配的match()函数的返回类型。如果没有任何一个match()函数被匹配上,那么返回基本情况对应的void(使用省略号来捕获任意参数)将代表出现了匹配错误。总结来讲,BestMatchInSetT将函数重载的结果转化成了类型萃取,这样可以让通过标记派发,在不同的模板偏特化之间做选择的情况变得相对容易一些。

实例化安全的模板

EnableIf技术的本质是:只有在模板参数满足某些条件的情况下才允许使用某个模板或者某个偏特化模板。比如,最为高效的advanceIter()算法会检查迭代器的参数种类是否可以被转化成std::random_access_iterator_tag,也就意味着各种各样的随机访问迭代器都适用于该算
法。

如果我们将这一概念发挥到极致,将所有模板用到的模板参数的操作都编码进EnableIf的条件,会怎样呢?这样一个模板的实例化永远都不会失败,因为那些没有提供EnableIf所需操作的模板参数会导致一个推断错误,而不是任由可能会出错的实例化继续进行。我们称这一类模板为“实例化安全(instantiation-safe )”的模板,接下来会对其进行简单介绍。先从一个计算两个数之间的最小值的简单模板min()开始。我们可能会将其实现成下面这样:

1
2
3
4
5
6
7
8
template<typename T>
T const& min(T const& x, T const& y)
{
if (y < x) {
return y;
}
return x;
}

这个模板要求类型为T的两个值可以通过<运算符进行比较,并将比较结果转换成bool类型给if语句使用。可以检查类型是否支持<操作符,并计算其返回值类型的类型萃取。为了方便,我们此处依然列出LessResultT的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <utility> // for declval()
#include <type_traits> // for true_type and false_type
template<typename T1, typename T2>
class HasLess {
template<typename T> struct Identity;
template<typename U1, typename U2>
static std::true_type
test(Identity<decltype(std::declval<U1>() < std::declval<U2>())>*);
template<typename U1, typename U2>
static std::false_type
test(...);
public:
static constexpr bool value = decltype(test<T1, T2> (nullptr))::value;
};

template<typename T1, typename T2, bool HasLess>
class LessResultImpl {
public:
using Type = decltype(std::declval<T1>() < std::declval<T2>());
};
template<typename T1, typename T2>
class LessResultImpl<T1, T2, false> {
};
template<typename T1, typename T2>
class LessResultT : public LessResultImpl<T1, T2, HasLess<T1, T2>::value> {
};
template<typename T1, typename T2>
using LessResult = typename LessResultT<T1, T2>::Type;

现在就可以通过将该萃取和IsConvertible一起使用,使min()变成实例化安全的:

1
2
3
4
5
6
7
8
9
10
11
#include "isconvertible.hpp"
#include "lessresult.hpp"
template<typename T>
EnableIf<IsConvertible<LessResult<T const&, T const&>, bool>, T const&>
min(T const& x, T const& y)
{
if (y < x) {
return y;
}
return x;
}

通过各种实现了不同<运算符的类型来调用min(),要更能说明问题一些,就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include"min.hpp"
struct X1 { };
bool operator< (X1 const&, X1 const&) { return true; }

struct X2 { };
bool operator<(X2, X2) { return true; }

struct X3 { };
bool operator<(X3&, X3&) { return true; }

struct X4 { };
struct BoolConvertible {
operator bool() const { return true; } // implicit conversion to bool
};

struct X5 { };
BoolConvertible operator< (X5 const&, X5 const&)
{
return BoolConvertible();
}

struct NotBoolConvertible { // no conversion to bool
};
struct X6 { };
NotBoolConvertible operator< (X6 const&, X6 const&)
{
return NotBoolConvertible();
}

struct BoolLike {
explicit operator bool() const { return true; } // explicit conversion to bool
};

struct X7 { };
BoolLike operator< (X7 const&, X7 const&) { return BoolLike(); }
int main()
{
min(X1(), X1()); // X1 can be passed to min()
min(X2(), X2()); // X2 can be passed to min()
min(X3(), X3()); // ERROR: X3 cannot be passed to min()
min(X4(), X4()); // ERROR: X4 cannot be passed to min()
min(X5(), X5()); // X5 can be passed to min()
min(X6(), X6()); // ERROR: X6 cannot be passed to min()
min(X7(), X7()); // UNEXPECTED ERROR: X7 cannot be passed to min()
}

在编译上述程序的时候,要注意虽然针对min()函数会报出4个错误(X3,X4,X6,以及X7),但它们都不是从min()的函数体中报出来的(如果不是实例化安全的话,则会从函数体中报出错误)。相反,编译器只会抱怨说没有合适的min()函数,因为唯一的选择已经被SFINAE排除了。

我们需要一个可以判断某个类型是否是“语境上可以转换成bool”的萃取技术。控制流程语句对该萃取技术的实现没有帮助,因为语句不可以出现在SFINAE上下文中,同样的,可以被任意类型重载的逻辑操作也不可以。幸运的是,三元运算符?:是一个表达式,而且不可以被重载,因此它可以被用来测试一个类型是否是“语境上可以转换成bool”的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <utility> // for declval()
#include <type_traits> // for true_type and false_type
template<typename T>
class IsContextualBoolT {
private:
template<typename T> struct Identity;
template<typename U>
static std::true_type test(Identity<decltype(declval<U>()? 0 : 1)>*);
template<typename U>
static std::false_type test(...);
public:
static constexpr bool value = decltype(test<T> (nullptr))::value;
};
template<typename T>
constexpr bool IsContextualBool = IsContextualBoolT<T>::value;

有了这一萃取,我们就可以实现一个使用了正确的EnableIf条件且实例化安全的min()了:

1
2
3
4
5
6
7
8
9
10
11
#include "iscontextualbool.hpp"
#include "lessresult.hpp"
template<typename T>
EnableIf<IsContextualBool<LessResult<T const&, T const&>>, T const&>
min(T const& x, T const& y)
{
if (y < x) {
return y;
}
return x;
}

将各种各样的条件检查,组合进描述了类型种类(比如前向迭代器)的萃取技术,并将这些萃取技术一起放在EnableIf的条件检查中,这一使min()变得实例化安全的技术可以被推广到用于描述其它重要模板的条件。

在标准库中的情况

C++标准库为输入,输出,前向,双向以及随机访问迭代器提供了迭代器标记,我们对这些都已经做了展示。这些迭代器标记是标准迭代器萃取std::iterator_traits技术以及施加于迭代器的需求的一部分,因此它们可以被安全得用于标记派发。

C++11标准库中的std::enable_if模板提供了和我们所展示的EnableIf相同的行为。唯一的不同是标准库用了一个小写的成员类型type,而我们使用的是Type。

算法的偏特化在C++标准库中被用在了很多地方。比如,std::advance()以及std::distance()基于其迭代器参数的种类的不同,都有很多变体。虽然很多标准库的实现都倾向于使用标记派发(tag dispatch),但是最近其中一些实现也已经使用std::enable_if来进行算法特化了。而且,很多的C++标准库的实现,在内部也都用这些技术去实现各种标准库算法的偏特化。比如,当迭代器指向连续内存且它们所指向的值有拷贝赋值运算符的时候,std::copy()可以通过调用std::memory()std::memmove()来进行偏特化。同样的,std::fill()也可以通过调用std::memset进行优化,而且在知晓一个类型有一个普通的析构函数(trivial destructor)的情况下,很多算法都可以避免去调用析构函数。C++标准并没有对这些算法特化的实现方式进行统一(比如统一采用std::advance()std::distance()的方式),但是实现者还是为了性能
而选择类似的方式。

正如第8.4节介绍的那样,C++标准库强烈的建议在其所需要施加的条件中使用std::enable_if<>或者其它类似SFINAE的技术。比如,std::vector就有一个允许其从迭代器序列进行构造的构造函数模板:

1
2
3
template<typename InputIterator>
vector(InputIterator first, InputIterator second,
allocator_type const& alloc = allocator_type());

它要求“当通过类型InputIterator调用构造函数的时候,如果该类型不属于输入迭代器(input iterator),那么该构造函数就不能参与到重载解析中”。这一措辞并没有精确到足以使当前最高效的技术被应用到实现当中,但是在其被引入到标准中的时候,std::enable_if<>确实被寄予了这一期望。

模板和继承

空基类优化

C++中的类经常是“空”的,也就是说它们的内部表征在运行期间不占用内存。典型的情况是那写只包含类型成员,非虚成员函数,以及静态数据成员的类。而非静态数据成员,虚函数,以及虚基类,在运行期间则是需要占用内存的。然而即使是空的类,其所占用的内存大小也不是零。如果愿意的话,运行下面的程序可以证明这一点:

1
2
3
4
5
6
7
#include <iostream>
class EmptyClass {
};
int main()
{
std::cout << "sizeof(EmptyClass):" << sizeof(EmptyClass) << "\n";
}

在某些平台上,这个程序会打印出1。在少数对class类型实施了严格内存对齐要求的平台上,则可能会打印出其它结果(典型的结果是4)。

布局原则

C++的设计者有很多种理由不去使用内存占用为零的class。比如,一个存储了内存占用为零的class的数组,其内存占用也将是零,这样的话常规的指针运算规则都将不在适用。假设ZeroSizedT是一个内存占用为零的类型:

1
2
3
ZeroSizedT z[10];
...
&z[i] - &z[j] //compute distance between pointers/addresses

正常情况下,上述例子中的结果可以用两个地址之间的差值,除以该数组中元素类型的大小得到,但是如果元素所占用内存为零的话,上述结论显然不再成立。虽然在C++中没有内存占用为零的类型,但是C++标准却指出,在空class被用作基类的时候,如果不给它分配内存并不会导致其被存储到与其它同类型对象或者子对象相同的地址上,那么就可以不给它分配内存。下面通过一些例子来看看实际应用中空基类优化(empty class optimization,EBCO)的意义。考虑如下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
class Empty {
using Int = int;// type alias members don"t make a class nonempty
};
class EmptyToo : public Empty {
};
class EmptyThree : public EmptyToo {
};
int main()
{
std::cout << "sizeof(Empty): " << sizeof(Empty) << "\n";
std::cout << "sizeof(EmptyToo): " << sizeof(EmptyToo) << "\n";
std::cout << "sizeof(EmptyThree): " << sizeof(EmptyThree) << "\n";
}

如果你所使用的编译器实现了EBCO的话,它打印出来的三个class的大小将是相同的,但是它们的结果也都不会是零。这意味着在EmptyToo中,Empty没有被分配内存。注意一个继承自优化后的空基类(且只有这一个基类)的空类依然是空的。这就解释了为什么EmptyThree的大小和Empty相同。如果你所用的编译器没有实现EBCO的话,那么它打印出来的各个class的大小将不同。

考虑一种EBCO不适用的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
class Empty {
using Int = int; // type alias members don"t make a class nonempty
};
class EmptyToo : public Empty {
};
class NonEmpty : public Empty, public EmptyToo {
};
int main(){
std::cout <<"sizeof(Empty): " << sizeof(Empty) <<"\n";
std::cout <<"sizeof(EmptyToo): " << sizeof(EmptyToo) <<"\n";
std::cout <<"sizeof(NonEmpty): " << sizeof(NonEmpty) <<"\n";
}

可能有点意外的是,NonEmpty不再是一个空的类。毕竟它以及它的基类都没有任何数据成员。但是NonEmpty的基类Empty和EmptyToo不可以被分配到相同的地址上,因为这会导致EmptyToo的基类Empty和NonEmpty的基类Empty被分配到相同的地址。或者说两个类型相同的子对象会被分配到相同的地址上,而这在C++布局规则中是不被允许的。你可能会想到将其中一个Empty基类的子对象放在偏移量为“0字节”的地方,将另一个放在偏移量为“1字节”的地方,但是完整的NonEmpty对象的内存占用依然不能是1字节,因为在一个包含了两个NonEmpty对象的数组中,第一个元素的Empty子对象不能和第二个元素中的Empty子对象占用相同的地址。

EBCO之所以会有这一限制,是因为我们希望能够通过比较两个指针来确定它们所指向的是不是同一个对象。由于指针在程序中几乎总是被表示为单纯的地址,因此就需要我们来确保两个不同的地址(比如指针的值)指向的总是两个不同的对象。

将数据成员实现为基类

EBCO和数据成员之间没有对等关系,因为(其中一个问题是)它会在用指针指向数据成员的表示上造成一些问题。结果就是,在有些情况下会期望将其实现为一个private的基类,这样粗看起来就可以将其视作成员变量。但是,这样做也并不是没有问题。由于模板参数经常会被空class类型替换,因此在模板上下文中这一问题要更有意思一些,但是通常我们不能依赖这一规则。如果我们对类型参数一无所知,就不能很容易的使用EBCO。考虑下面的例子:

1
2
3
4
5
6
7
template<typename T1, typename T2>
class MyClass {
private:
T1 a;
T2 b;
...
};

其中的一个或者两个模板参数完全有可能被空class类型替换。如果真是这样,那么MyClass<T1, T2>这一表达方式可能不是最优的选择,它可能会为每一个MyClass<T1,T2>的实例都浪费一个字的内存。这一内存浪费可以通过把模板参数作为基类使用来避免:

1
2
3
template<typename T1, typename T2>
class MyClass : private T1, private T2 {
};

但是这一直接的替代方案也有其自身的缺点:

  • 当T1或者T2被一个非class类型或者union类型替换的时候,该方法不再适用。
  • 在两个模板参数被同一种类型替换的时候,该方法不再适用(虽然这一问题简单地通过增加一层额外的继承来解决,参见513页)。
  • 用来替换T1或者T2的类型可能是final的,此时尝试从其派生出新的类会触发错误。

即使这些问题能够很好的解决,也还有一个严重的问题存在:给一个class添加一个基类,可能会从根本上改变该class的接口。对于我们的MyClass类,由于只有很少的接口会被影响到,这可能看上去不是一个重要的问题。但是正如在本章接下来的内容中将要看到的,从一个模板参数做继承,会影响到一个成员函数是否可以是virtual的。很显然,EBCO的这一适用方式会带来各种各样的问题。

当已知模板参数只会被class类型替换,以及需要支持另一个模板参数的时候,可以使用另一种更实际的方法。其主要思想是通过使用EBCO将可能为空的类型参数与别的参数“合并”。比如,相比于这样:

1
2
3
4
5
6
7
template<typename CustomClass>
class Optimizable {
private:
CustomClass info; // might be empty
void* storage;
...
};

一个模板开发者会使用如下方式:

1
2
3
4
5
6
template<typename CustomClass>
class Optimizable {
private:
BaseMemberPair<CustomClass, void*> info_and_storage;
...
};

虽然还没有看到BaseMemberPari的具体实现方式,但是可以肯定它的引入会使Optimizable的实现变得更复杂。但是很多的模板开发者都反应,相比于复杂度的增加,它带来的性能提升是值得的。BaseMemberPair的实现可以非常简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#ifndef BASE_MEMBER_PAIR_HPP
#define BASE_MEMBER_PAIR_HPP
template<typename Base, typename Member>
class BaseMemberPair : private Base {
private:
Member mem;
public:// constructor
BaseMemberPair (Base const & b, Member const & m)
: Base(b), mem(m) {
} // access base class data via first()
Base const& base() const {
return static_cast<Base const&>(*this);
}
Base& base() {
return static_cast<Base&>(*this);
} // access member data via second()
Member const& member() const {
return this->mem;
}
Member& member() {
return this->mem;
}
};
#endif // BASE_MEMBER_PAIR_HPP

相应的实现需要使用base()member()成员函数来获取被封装的(或者被执行了内存优化的)数据成员。

The Curiously Recurring Template Pattern (CRTP)

另一种模式是CRTP。这一个有着奇怪名称的模式指的是将派生类作为模板参数传递给其某个基类的一类技术。该模式的一种最简单的C++实现方式如下:

1
2
3
4
5
6
7
template<typename Derived>
class CuriousBase {
...
};
class Curious : public CuriousBase<Curious> {
...
};

上面的CRTP的例子使用了非依赖性基类(nondependent base class):Curious不是一个模板类,因此它对在依赖性基类中遇到的名称可见性问题是免疫的。但是这并不是CRTP的固有特征。事实上,我们同样可以使用下面的这一实现方式:

1
2
3
4
5
6
7
8
template<typename Derived>
class CuriousBase {
...
};
template<typename T>
class CuriousTemplate : public CuriousBase<CuriousTemplate<T>> {
...
};

将派生类通过模板参数传递给其基类,基类可以在不使用虚函数的情况下定制派生类的行为。这使得CRTP对那些只能被实现为成员函数的情况(比如构造函数,析构函数,以及下表运算符)或者依赖于派生类的特性的情况很有帮助。

一个CRTP的简单应用是将其用于追踪从一个class类型实例化出了多少对象。这一功能也可以通过在构造函数中递增一个static数据成员、并在析构函数中递减该数据成员来实现。但是给不同的class都提供相同的代码是一件很无聊的事情,而通过一个基类(非CRTP)实现这一功能又会将不同派生类实例的数目混杂在一起。事实上,可以实现下面这一模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <cstddef>
template<typename CountedType>
class ObjectCounter {
private:
inline static std::size_t count = 0; // number of existing objects
protected:
// default constructor
ObjectCounter() {
++count;
} // copy constructor
ObjectCounter (ObjectCounter<CountedType> const&) {
++count;
} // move constructor
ObjectCounter (ObjectCounter<CountedType> &&) {
++count;
} // destructor
~ObjectCounter() {
--count;
}
public:
// return number of existing objects:
static std::size_t live() {
return count;
}
};

注意这里为了能够在class内部初始化count成员,使用了inline。在C++17之前,必须在class模板外面定义它:

1
2
3
4
5
6
7
8
9
template<typename CountedType>
class ObjectCounter {
private:
static std::size_t count; // number of existing objects
...
};
// initialize counter with zero:
template<typename CountedType>
std::size_t ObjectCounter<CountedType>::count = 0;

当我们想要统计某一个class的对象(未被销毁)数目时,只需要让其派生自ObjectCounter即可。比如,可以按照下面的方式统计MyString的对象数目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "objectcounter.hpp"
#include <iostream>
template<typename CharT>
class MyString : public ObjectCounter<MyString<CharT>> {
...
};
int main()
{
MyString<char> s1, s2;
MyString<wchar_t> ws;
std::cout << "num of MyString<char>: "
<< MyString<char>::live() << '\n';
std::cout << "num of MyString<wchar_t>: "
<< ws.live() << '\n';
}

The Barton-Nackman Trick

该技术产生的动力之一是:在当时,函数模板的重载是严重受限的,而且namespace在当时也不为大多数编译器所支持。

为了说明这一技术,假设我们有一个需要为之定义operator ==的类模板Array。一个可能的方案是将该运算符定义为类模板的成员,但是由于其第一个参数(绑定到this指针上的参数)和第二个参数的类型转换规则不同。由于我们希望operator ==对其参数是对称的,因此更倾向与将其定义为某一个namespace中的函数。一种很直观的实现方式可能会像下面这样:

1
2
3
4
5
6
7
8
9
template<typename T>
class Array {
public:
...
};
template<typename T>bool operator== (Array<T> const& a, Array<T> const& b)
{
...
}

不过如果函数模板不可以被重载的话,这会引入一个问题:在当前作用域内不可以再声明其它的operator ==模板,而其它的类模板却又很可能需要这样一个类似的模板。通过将operator ==定义成class内部的一个常规友元函数解决了这一问题:

1
2
3
4
5
6
7
8
9
10
template<typename T>
class Array {
static bool areEqual(Array<T> const& a, Array<T> const& b);
public:
...
friend bool operator== (Array<T> const& a, Array<T> const& b)
{
return areEqual(a, b);
}
};

假设我们用float实例化了该Array类。作为实例化的结果,该友元运算符函数也会被连带声明,但是请注意该函数本身并不是一个函数模板的实例。作为实例化过程的一个副产品,它是一个被注入到全局作用域的常规非模板函数。由于它是非模板函数,即使在重载函数模板的功能被引入之前,也可以用其它的operator ==对其进行重载。由于这样做避免了去定义一个适用于所有类型Toperator ==(T, T)模板,称为restricted template expansion。

由于operator== (Array<T> const&, Array<T> const&)被定义在一个class的定义中,它会被隐式地当作inline函数,因此我们决定将其实现委托给一个static成员函数(不需要是inline的)。

运算符的实现

在给一个类重载运算符的时候,通常也需要重载一些其它的(当然也是相关的)运算符。比如,一个实现了operator ==的类,通常也会实现operator !=,一个实现了operator <的类,通常也会实现其它的关系运算符(>,<=,>=)。在很多情况下,这些运算符中只有一个运算符的定义比较有意思,其余的运算符都可以通过它来定义。例如,类X的operator !=可以通过使用operator ==来定义:

1
2
3
bool operator!= (X const& x1, X const& x2) {
return !(x1 == x2);
}

对于那些operator !=的定义类似的类型,可以通过模板将其泛型化:

1
2
3
4
template<typename T>
bool operator!= (T const& x1, T const& x2) {
return !(x1 == x2);
}

事实上,在C++标准库的<utility>头文件中已经包含了类似的定义。但是,一些别的定义在标准化过程中则被放到了namespace std::rel_ops中,因为当时可以确定如果让它们在std中可见的话,会导致一些问题。

虽然上述第一个问题可以通过SFINAE技术解决,这样的话这个!= operator的定义只会在某种类型有合适的== operator时才会被进行相应的实例化。但是第二个问题依然存在:相比于用户定义的需要进行从派生类到基类的转化的!= operator,上述通用的!=operator定义总是会被优先选择,这有时会导致意料之外的结果。

另一种基于CRTP的运算符模板形式,则允许程序去选择泛型的运算符定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename Derived>
class EqualityComparable
{
public:
friend bool operator!= (Derived const& x1, Derived const& x2)
{
return !(x1 == x2);
}
};
class X : public EqualityComparable<X>
{
public:
friend bool operator== (X const& x1, X const& x2) {
// implement logic for comparing two objects of type X
}
};
int main()
{
X x1, x2;
if (x1 != x2) { }
}

EqualityComparable<>为了基于派生类中定义的operator==给其派生类提供operator !=,使用了CRTP。事实上这一定义是通过friend
函数定义的形式提供的,这使得两个参数在类型转换时的operator !=行为一致。

Mixins

考虑一个包含了一组点的简单Polygon类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point
{
public:
double x, y;
Point() : x(0.0), y(0.0) { }
Point(double x, double y) : x(x), y(y) { }
};
class Polygon
{
private:
std::vector<Point> points;
public:
... //public operations
};

如果可以扩展与每个Point相关联的一组信息的话(比如包含特定应用中每个点的颜色,或者给每个点加个标签),那么Polygon类将变得更为实用。实现该扩展的一种方式是用点的类型对Polygon进行参数化:

1
2
3
4
5
6
7
8
template<typename P>
class Polygon
{
private:
std::vector<P> points;
public:
... //public operations
};

用户可以通过继承创建与Point类似,但是包含了特定应用所需数据,并且提供了与Point相同的接口的类型:

1
2
3
4
5
6
7
8
class LabeledPoint : public Point
{
public:
std::string label;
LabeledPoint() : Point(), label("") { }
LabeledPoint(double x, double y) : Point(x, y), label("") {
}
};

这一实现方式有其自身的缺点。比如,首先需要将Point类型暴露给用户,这样用户才能从它派生出自己的类型。而且LablePoint的作者也需要格外小心地提供与Point完全一样的接口(比如,继承或者提供所有与Point相同的构造函数),否则在Polygon中使用LabledPoint的时候会遇到问题。这一问题在Point随Polygon模板版本发生变化时将会变得更加严重:

  • 如果给Point新增一个构造函数,就需要去更新所有的派生类。

Mixins是另一种可以客制化一个类型的行为但是不需要从其进行继承的方法。事实上,Mixins反转了常规的继承方向,因为新的类型被作为类模板的基类“混合进”了继承层级中,而不是被创建为一个新的派生类。这一方式允许在引入新的数据成员以及某些操作的时候,不需要去复制相关接口。一个支持了mixins的类模板通常会接受一组任意数量的class,并从之进行派生:

1
2
3
4
5
6
7
8
template<typename... Mixins>
class Point : public Mixins...
{
public:
double x, y;
Point() : Mixins()..., x(0.0), y(0.0) { }
Point(double x, double y) : Mixins()..., x(x), y(y) { }
};

现在,我们就可以通过将一个包含了label的基类“混合进来(mix in)”来生成一个LabledPoint:

1
2
3
4
5
6
7
class Label
{
public:
std::string label;
Label() : label("") { }
};
using LabeledPoint = Point<Label>;

甚至是“mix in”几个基类:

1
2
3
4
5
6
class Color
{
public:
unsigned char red = 0, green = 0, blue = 0;
};
using MyPoint = Point<Label, Color>;

有了这个基于mixin的Point,就可以在不改变其接口的情况下很容易的为Point引入额外的信息,因此Polygon的使用和维护也将变得相对简单一些。为了访问相关数据和接口,用户只需进行从Point到它们的mixin类型(Label或者Color)之间的隐式转化即可。而且,通过提供给Polygon类模板的mixins,Point类甚至可以被完全隐藏:

1
2
3
4
5
6
7
8
template<typename... Mixins>
class Polygon
{
private:
std::vector<Point<Mixins...>> points;
public:
... //public operations
};

当需要对模板进行少量客制化的时候,Mixins会很有用,比如在需要用用户指定的数据去装饰内部存储的对象时,使用mixins就不需要将内部数据类型和接口暴露出来并写进文档。

Curious Mixins

一个CRTP-mixin版本的Point可以被下称下面这样:

1
2
3
4
5
6
7
8
template<template<typename>... Mixins>
class Point : public Mixins<Point>...
{
public:
double x, y;
Point() : Mixins<Point>()..., x(0.0), y(0.0) { }
Point(double x, double y) : Mixins<Point>()..., x(x), y(y) { }
};

这一实现方式需要对那些将要被混合进来(mix in)的类做一些额外的工作,因此诸如Label和Color一类的class需要被调整成类模板。但是,现在这些被混合进来的class的行为可以基于其降要被混合进的派生类进行调整。比如,我们可以将前述的ObjectCounter模板混合进Point,这样就可以统计在Polygon中创建的点的数目。

Parameterized Virtuality

Minxins还允许我们去间接的参数化派生类的其它特性,比如成员函数的虚拟性。下面的简单例子展示了这一令人称奇的技术:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
class NotVirtual {
};
class Virtual {
public:
virtual void foo() {
}
};
template<typename... Mixins>
class Base : public Mixins...
{
public:
// the virtuality of foo() depends on its declaration
// (if any) in the base classes Mixins...
void foo() {
std::cout << "Base::foo()" << "\n";
}
};
template<typename... Mixins>
class Derived : public Base<Mixins...> {
public:
void foo() {
std::cout << "Derived::foo()" << "\n";
}
};
int main()
{
Base<NotVirtual>* p1 = new Derived<NotVirtual>;
p1->foo(); // calls Base::foo()
Base<Virtual>* p2 = new Derived<Virtual>;
p2->foo(); // calls Derived::foo()
}

该技术提供了这样一种工具,使用它可以设计出一个既可以用来实例化具体的类,也可以通过继承对其进行扩展的类模板。但是,要获得一个可以为某些更为特化的功能产生一个更好的基类的类,仅仅是针对某些成员函数进行虚拟化还是不够的。这一类开发方法需要更为基础的设计决策。更为实际的做法是设计两个不同的工具(类或者类模板层级),而不是将它们集成进一个模板层级。

Named Template Arguments

不少模板技术有时会导致类模板包含很多不同的模板类型参数。但是,其中一些模板参数通常都会有合理的默认值。其中一种这一类模板的定义方式可能会向下面这样:

1
2
3
4
5
6
7
template<typename Policy1 = DefaultPolicy1,
typename Policy2 = DefaultPolicy2,
typename Policy3 = DefaultPolicy3,
typename Policy4 = DefaultPolicy4>
class BreadSlicer {
...
};

可以想象,在使用这样一个模板时通常都可以使用模板参数的默认值。但是,如果需要指定某一个非默认参数的值的话,那么也需要指定该参数前面的所有参数的值(虽然使用的可能是它们的默认值)。

很显然,我们更倾向于使用BreadSlicer<Policy3 = Custom>的形式,而不是BreadSlicer<DefaultPolicy1, DefaultPolicy2, Custom>。在下面的内容在,我们开发了一种几乎可以完全实现以上功能的技术。

我们的技术方案是将默认类型放在一个基类中,然后通过派生将其重载。相比与直接指定类型参数,我们会通过辅助类(helper classes)来提供相关信息。比如我们可以将其写成这样BreadSlicer<Policy3_is<Custom>>。由于每一个模板参数都可以表述任一条款,默认值就不能不同。或者说,在更高的层面上,每一个模板参数都是等效的:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename PolicySetter1 = DefaultPolicyArgs,
typename PolicySetter2 = DefaultPolicyArgs,
typename PolicySetter3 = DefaultPolicyArgs,
typename PolicySetter4 = DefaultPolicyArgs>
class BreadSlicer {
using Policies = PolicySelector<PolicySetter1,
PolicySetter2,
PolicySetter3,
PolicySetter4>;
// use Policies::P1, Policies::P2, ... to refer to the various policies
...
};

剩余的挑战就是该如何设计PolicySelector模板了。必须将不同的模板参数融合进一个单独的类型,而且这个类型需要用那个没有指定默认值的类型去重载默认的类型别名成员。可以通过继承实现这一融合:

1
2
3
4
5
6
7
8
9
10
11
12
// PolicySelector<A,B,C,D> creates A,B,C,D as base classes
// Discriminator<> allows having even the same base class more than once
template<typename Base, int D>
class Discriminator : public Base {
};
template<typename Setter1, typename Setter2,
typename Setter3, typename Setter4>
class PolicySelector : public Discriminator<Setter1,1>,
public Discriminator<Setter2,2>,
public Discriminator<Setter3,3>,
public Discriminator<Setter4,4>
{ };

注意此处对中间的Discriminator模板的使用。其要求不同的Setter类型是类似的(不能使用多个类型相同的直接基类。而非直接基类,则可以使用和其它基类类似的类型)。

正如之前提到的,我们将全部的默认值收集到基类中:

1
2
3
4
5
6
7
8
// name default policies as P1, P2, P3, P4
class DefaultPolicies {
public:
using P1 = DefaultPolicy1;
using P2 = DefaultPolicy2;
using P3 = DefaultPolicy3;
using P4 = DefaultPolicy4;
};

但是,如果我们最终会从该基类继承很多次的话,需要额外小心的避免歧义。因此,此处需要确保对基类使用虚继承:

1
2
3
4
// class to define a use of the default policy values
// avoids ambiguities if we derive from DefaultPolicies more than once
class DefaultPolicyArgs : virtual public DefaultPolicies {
};

最后,我们也需要一些模板来重载掉那些默认的策略值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename Policy>
class Policy1_is : virtual public DefaultPolicies {
public:
using P1 = Policy; // overriding type alias
};
template<typename Policy>
class Policy2_is : virtual public DefaultPolicies {
public:
using P2 = Policy; // overriding type alias
};
template<typename Policy>
class Policy3_is : virtual public DefaultPolicies {
public:
using P3 = Policy; // overriding type alias
};
template<typename Policy>
class Policy4_is : virtual public DefaultPolicies {
public:
using P4 = Policy; // overriding type alias};
}

有了Discriminator<>类模板的帮助,这就会产生一种层级关系,在其中所有的模板参数都是基类。重要的一点是,所有的这些基类都有一个共同的虚基类DefaultPolicies,也正是它定义了P1,P2,P3和P4的默认值。但是P3在某一个派生类中被重新定义了(比如在Policy3_is<>中)。根据作用域规则,该定义会隐藏掉在基类中定义的相应定义。这样,就不会有歧义了。

在模板BreadSlicer中,可以使用Policies::P3的形式引用以上4中策略。比如:

1
2
3
4
5
6
7
8
9
template<...>
class BreadSlicer {
...
public:
void print () {
Policies::P3::doPrint();
}
...
};

桥接static和dynamic多态

本章将介绍在C++中把static多态和dynamic多态桥接起来的方式,该方式具备了各种模型的部分优点:比较小的可执行代码量,几乎全部的动态多态的编译期特性,以及(允许内置类型无缝工作的)静态多态的灵活接口。作为例子,我们将创建一个简化版的std::function<>模板。

函数对象,指针,以及std:function<>

在给模板提供定制化行为的时候,函数对象会比较有用。比如,下面的函数模板列举了从0到某个值之间的所有整数,并将每一个值都提供给了一个已有的函数对象f:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <vector>
#include <iostream>
template<typename F>
void forUpTo(int n, F f){
for (int i = 0; i != n; ++i)
{
f(i); // call passed function f for i
}
}
void printInt(int i)
{
std::cout << i << "";
}
int main()
{
std::vector<int> values;
// insert values from 0 to 4:
forUpTo(5,
[&values](int i) {
values.push_back(i);
}
);

// print elements:
forUpTo(5, printInt); // prints 0 1 2 3 4
std::cout << "\n";
}

其中forUpTo()函数模板适用于所有的函数对象,包括lambda,函数指针,以及任意实现了合适的operator()运算符或者可以转换为一个函数指针或引用的类,而且每一次对forUpTo()的使用都很可能产生一个不同的函数模板实例。上述例子中的函数模板非常小,但是如果该模板非常大的话,这些不同应用导致的实例化很可能会导致代码量的增加。

一个缓解代码量增加的方式是将函数模板转变为非模板的形式,这样就不再需要实例化。比如,我们可能会使用函数指针:

1
2
3
4
5
6
void forUpTo(int n, void (*f)(int))
{
for (int i = 0; i != n; ++i) {
f(i); // call passed function f for i
}
}

但是,虽然在给其传递printInt()的时候该方式可以正常工作,给其传递lambda却会导致错误:

1
2
3
4
5
6
forUpTo(5, printInt); //OK: prints 0 1 2 3 4
forUpTo(5,
[&values](int i) { //ERROR: lambda not convertible to a function pointer
values.push_back(i);
}
);

标准库中的类模板std::functional<>则可以用来实现另一种类型的forUpTo():

1
2
3
4
5
6
7
#include <functional>
void forUpTo(int n, std::function<void(int)> f)
{
for (int i = 0; i != n; ++i) {
f(i) // call passed function f for i
}
}

std::functional<>的模板参数是一个函数类型,该类型体现了函数对象所接受的参数类型以及其所需要产生的返回类型,非常类似于表征了参数和返回类型的函数指针。

这一形式的forUpTo()提供了static多态的一部分特性:适用于一组任意数量的类型(包含函数指针,lambda,以及任意实现了适当operator()运算符的类),同时又是一个只有一种实现的非模板函数。为了实现上述功能,它使用了一种称之为类型消除(type erasure)的技术,该技术将static和dynamic多态桥接了起来。

广义函数指针

std::functional<>类型是一种高效的、广义形式的C++函数指针,提供了与函数指针相同的基本操作:

  • 在调用者对函数本身一无所知的情况下,可以被用来调用该函数。
  • 可以被拷贝,move以及赋值。
  • 可以被另一个(函数签名一致的)函数初始化或者赋值。
  • 如果没有函数与之绑定,其状态是“null”。

但是,与C++函数指针不同的是,std::functional<>还可以被用来存储lambda,以及其它任意实现了合适的operator()的函数对象,所有这些情况对应的类型都可能不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "functionptr.hpp"
#include <vector>
#include <iostream>
void forUpTo(int n, FunctionPtr<void(int)> f)
{
for (int i = 0; i != n; ++i)
{
f(i); // call passed function f for i
}
}

void printInt(int i)
{
std::cout << i << "";
}
int main()
{
std::vector<int> values;
// insert values from 0 to 4:
forUpTo(5,[&values](int i) {
values.push_back(i);
});
// print elements:
forUpTo(5, printInt); // prints 0 1 2 3 4
std::cout << "\n";
}

FunctionPtr的接口非常直观的提供了构造,拷贝,move,析构,初始化,以及从任意函数对象进行赋值,还有就是要能够调用其底层的函数对象。接口中最有意思的一部分是如何在一个类模板的偏特化中对其进行完整的描述,该偏特化将模板参数(函数类型)分解为其组成部分(返回类型以及参数类型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// primary template:
template<typename Signature>
class FunctionPtr;
// partial specialization:
template<typename R, typename... Args>
class FunctionPtr<R(Args...)>
{
private:
FunctorBridge<R, Args...>* bridge;
public:
// constructors:
FunctionPtr() : bridge(nullptr) {
}

FunctionPtr(FunctionPtr const& other); // see functionptrcpinv.hpp
FunctionPtr(FunctionPtr& other)
: FunctionPtr(static_cast<FunctionPtr const&>(other)) {
}
FunctionPtr(FunctionPtr&& other) : bridge(other.bridge) {
other.bridge = nullptr;
}

//construction from arbitrary function objects:
template<typename F> FunctionPtr(F&& f); // see functionptrinit.hpp

// assignment operators:
FunctionPtr& operator=(FunctionPtr const& other) {
FunctionPtr tmp(other);
swap(*this, tmp);
return *this;
}

FunctionPtr& operator=(FunctionPtr&& other) {
delete bridge;
bridge = other.bridge;
other.bridge = nullptr;
return *this;
}
//construction and assignment from arbitrary function objects:
template<typename F> FunctionPtr& operator=(F&& f) {
FunctionPtr tmp(std::forward<F>(f));
swap(*this, tmp);
return *this;
}
// destructor:
~FunctionPtr() {
delete bridge;
}
friend void swap(FunctionPtr& fp1, FunctionPtr& fp2) {
std::swap(fp1.bridge, fp2.bridge);
}
explicit operator bool() const {
return bridge == nullptr;
}
// invocation:
R operator()(Args... args) const; // see functionptr-cpinv.hpp
};

该实现包含了唯一一个非static的成员变量,bridge,它将负责被存储函数对象的储存和维护。该指针的所有权被绑定到了一个FunctionPtr的对象上,因此相关的大部分实现都只需要去操纵这个指针即可。

桥接接口

FunctorBridge类模板负责持有以及维护底层的函数对象,它被实现为一个抽象基类,为FunctionPtr的动态多态打下基础:

1
2
3
4
5
6
7
8
9
template<typename R, typename... Args>
class FunctorBridge
{
public:
virtual ~FunctorBridge() {
}
virtual FunctorBridge* clone() const = 0;
virtual R invoke(Args... args) const = 0;
};

FunctorBridge通过虚函数提供了用来操作被存储函数对象的必要操作:一个析构函数,一个用来执行copyclone()操作,以及一个用来调用底层函数对象的invoke()操作。不要忘记将clone()invoke()声明为const的成员函数。

有了这些虚函数,就可以继续实现拷贝构造函数和函数调用运算符了:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename R, typename... Args>
FunctionPtr<R(Args...)>::FunctionPtr(FunctionPtr const& other)
: bridge(nullptr)
{
if (other.bridge) {
bridge = other.bridge->clone();
}
}
template<typename R, typename... Args>
R FunctionPtr<R(Args...)>::operator()(Args&&... args) const
{
return bridge->invoke(std::forward<Args>(args)...);
}

类型擦除(Type Erasure)

FunctorBridge的每一个实例都是一个抽象类,因此其虚函数功能的具体实现是由派生类负责的。为了支持所有可能的函数对象(一个无界集合),我们可能会需要无限多个派生类。幸运的是,我们可以通过用其所存储的函数对象的类型对派生类进行参数化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename Functor, typename R, typename... Args>
class SpecificFunctorBridge : public FunctorBridge<R, Args...> {
Functor functor;
public:
template<typename FunctorFwd>
SpecificFunctorBridge(FunctorFwd&& functor)
: functor(std::forward<FunctorFwd>(functor)) {
}
virtual SpecificFunctorBridge* clone() const override {
return new SpecificFunctorBridge(functor);
}
virtual R invoke(Args&&... args) const override {
return functor(std::forward<Args>(args)...);
}
};

每一个SpecificFunctorBridge的实例都存储了函数对象的一份拷贝(类型为Functor),它可以被调用,拷贝,以及销毁(通过隐式调用析构函数)。SpecificFunctorBridge实例会在FunctionPtr被实例化的时候顺带产生,FunctionPtr的剩余实现如下:

1
2
3
4
5
6
7
8
9
template<typename R, typename... Args>
template<typename F>
FunctionPtr<R(Args...)>::FunctionPtr(F&& f)
: bridge(nullptr)
{
using Functor = std::decay_t<F>;
using Bridge = SpecificFunctorBridge<Functor, R, Args...>;
bridge = new Bridge(std::forward<F>(f));
}

注意,此处由于FunctionPtr的构造函数本身也被函数对象类型模板化了,该类型只为SpecificFunctorBridge的特定偏特化版本(以Bridge类型别名表述)所知。一旦新开辟的Bridge实例被赋值给数据成员bridge,由于从派生类到基类的转换(Bridge* --> FunctorBridge<R, Args...>*),特定类型F的额外信息将会丢失。类型信息的丢失,解释了为什么名称“类型擦除”经常被用于描述用来桥接static和dynamic多态的技术。

该实现的一个特点是在生成Functor的类型的时候使用了std::decay,这使得被推断出来的类型F可以被存储,比如它会将指向函数类型的引用decay成函数指针类型,并移除了顶层const,volatile和引用。

可选桥接(Optional Bridging)

上述FunctionPtr实现几乎可以被当作一个函数指针的非正式替代品适用。但是它并没有提供对下面这一函数指针操作的支持:检测两个FunctionPtr的对象是否会调用相同的函数。为了实现这一功能,需要在FunctorBridge中加入equals操作:

1
virtual bool equals(FunctorBridge const* fb) const = 0;

SpecificFunctorBridge中的具体实现如下:

1
2
3
4
5
6
7
8
9
10
virtual bool equals(FunctorBridge<R, Args...> const* fb) const override
{
if (auto specFb = dynamic_cast<SpecificFunctorBridge const*> (fb))
{
return functor == specFb->functor;
}

//functors with different types are never equal:
return false;
}

最后可以为FunctionPtr实现operator==,它会先检查对应内容是否是null,然后将比较委托给FunctorBridge:

1
2
3
4
5
6
7
8
9
10
11
friend bool
operator==(FunctionPtr const& f1, FunctionPtr const& f2) {
if (!f1 || !f2) {
return !f1 && !f2;
}
return f1.bridge->equals(f2.bridge);
}
friend bool
operator!=(FunctionPtr const& f1, FunctionPtr const& f2) {
return !(f1 == f2);
}

该实现是正确的,但是不幸的是,它也有一个缺点:如果FunctionPtr被一个没有实现合适的operator==的函数对象(比如lambdas)赋值,或者是被这一类对象初始化,那么这个程序会遇到编译错误。这可能会很让人意外,因为FunctionPtrsoperator==可能根本就没有被使用,却遇到了编译错误。而诸如std::vector之类的模板,只要它们的operator==没有被使用,它们就可以被没有相应operator==的类型实例化。

这一operator==相关的问题是由类型擦除导致的:因为在给FunctionPtr赋值或者初始化的时候,我们会丢失函数对象的类型信息,因此在赋值或者初始化完成之前,就需要捕捉到所有所需要知道的该类型的信息。该信息就包含调用函数对象的operator==所需要的信息,因为我们并不知道它在什么时候会被用到。

幸运的是,我们可以使用基于SFINAE的萃取技术,在调用operator==之前,确认它是否可用,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <utility> // for declval()
#include <type_traits> // for true_type and false_type
template<typename T>
class IsEqualityComparable
{
private:
// test convertibility of == and ! == to bool:
static void* conv(bool); // to check convertibility to bool
template<typename U>
static std::true_type test(decltype(conv(std::declval<U
const&>() == std::declval<U const&>())),
decltype(conv(!(std::declval<U const&>() == std::declval<U
const&>()))));
// fallback:
template<typename U>
static std::false_type test(...);
public:
static constexpr bool value = decltype(test<T>(nullptr,
nullptr))::value;
};

上述IsEqualityComparable技术使用表达式测试萃取的典型形式:两个test()重载,其中一个包含了被封装在decltype中的用来测试的表达式,另一个通过省略号接受任意数量的参数。第一个test()试图通过==去比较两个T const类型的对象,然后确保两个结果都可以被隐式的转换成bool,并将可以转换为bool的结果传递给operator!=()。如果两个运算符都正常的话,参数类型都将是void *

使用IsEqualityComparable,可以构建一个TryEquals类模板,它要么会调用==运算符,要么就在没有可用的operator==的时候抛出一个异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <exception>
#include "isequalitycomparable.hpp"
template<typename T, bool EqComparable =
IsEqualityComparable<T>::value>
struct TryEquals
{
static bool equals(T const& x1, T const& x2) {
return x1 == x2;
}
};
class NotEqualityComparable : public std::exception
{ };
template<typename T>
struct TryEquals<T, false>
{
static bool equals(T const& x1, T const& x2) {
throw NotEqualityComparable();
}
}

最后,通过在SpecificFunctorBridge中使用TryEquals,当被存储的函数对象类型一致,而且支持operator==的时候,就可以在FunctionPtr中提供对operator==的支持:

1
2
3
4
5
6
7
8
virtual bool equals(FunctorBridge<R, Args...> const* fb) const override
{
if (auto specFb = dynamic_cast<SpecificFunctorBridge const*>(fb)) {
return TryEquals<Functor>::equals(functor, specFb->functor);
}
//functors with different types are never equal:
return false;
}

性能考量

类型擦除技术提供了static和dynamic多态的一部分优点,但是并不是全部。尤其是,使用类型擦除技术产生的代码的性能更接近于动态多态,因为它们都是用虚函数实现了动态分配。因此某些static多态的传统优点(比如编译期将函数调用进行inline的能力)可能就被丢掉了。这一性能损失是否能够被察觉到,取决于具体的应用,但是通过比较被调用函数的运算量以及相关虚函数的运算量,有时候也很容易就能判断出来:如果二者比较接近,(比如FunctionPtr所作的只是对两个整数进行求和),类型擦除可能会比static多态要满很多。而如果函数调用执行的任务量比较大的话(比如访问数据库,对容器进行排列),那么type erasure带来的性能损失就很难被察觉到。

元编程

现代C++元编程的现状

C++元编程是随着时间发展逐渐成形的。我们先来分类讨论多种在现代C++中经常使用的元
编程方法。

值元编程(Value Metaprogramming)

在C++14中,一个在编译期计算平方根的函数可以被简单的写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<typename T>
constexpr T sqrt(T x)
{
// handle cases where x and its square root are equal as a special case to simplify
// the iteration criterion for larger x:
if (x <= 1) {
return x;
}
// repeatedly determine in which half of a [lo, hi] interval the square root of x is located,
// until the interval is reduced to just one value:
T lo = 0, hi = x;
for (;;) {
auto mid = (hi+lo)/2, midSquared = mid*mid;
if (lo+1 >= hi || midSquared == x) {
// mid must be the square root:
return mid;
}//continue with the higher/lower half-interval:
if (midSquared < x) {
lo = mid;
} else {
hi = mid;
}
}
}

该算法通过反复地选取一个包含x的平方根的中间值来计算结果(为了让收敛标准比较简单,对0和1的平方根做了特殊处理)。该sqrt()函数可以被在编译期或者运行期间计算:

1
2
3
4
5
static_assert(sqrt(25) == 5, ""); //OK (evaluated at compile time)
static_assert(sqrt(40) == 6, ""); //OK (evaluated at compile time)
std::array<int, sqrt(40)+1> arr; //declares array of 7 elements (compile time)
long long l = 53478;
std::cout << sqrt(l) << "\n"; //prints 231 (evaluated at run time)

在运行期间这一实现方式可能不是最高效的(在这里去开发机器的各种特性通常是值得的),但是由于该函数意在被用于编译期计算,绝对的效率并没有可移植性重要。

上面介绍的值元编程(比如在编译期间计算某些数值)偶尔会非常有用,但是在现代C++中还有另外两种可用的元编程方式(在C++14和C++17中):类型元编程和混合元编程。

类型元编程

考虑如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// primary template: in general we yield the given type:
template<typename T>
struct RemoveAllExtentsT {
using Type = T;
};
// partial specializations for array types (with and without bounds):
template<typename T, std::size_t SZ>
struct RemoveAllExtentsT<T[SZ]> {
using Type = typename RemoveAllExtentsT<T>::Type;
};
template<typename T>
struct RemoveAllExtentsT<T[]> {
using Type = typename RemoveAllExtentsT<T>::Type;
};
template<typename T>
using RemoveAllExtents = typename RemoveAllExtentsT<T>::Type;

这里RemoveAllExtents就是一种类型元函数(比如一个返回类型的计算设备),它会从一个类型中移除掉任意数量的顶层“数组层”。就像下面这样:

1
2
3
4
RemoveAllExtents<int[]> // yields int
RemoveAllExtents<int[5][10]> // yields int
RemoveAllExtents<int[][10]> // yields int
RemoveAllExtents<int(*)[5]> // yields int(*)[5]

元函数通过偏特化来匹配高层次的数组,递归地调用自己并最终完成任务。如果数值计算的功能只适用于标量,那么其应用会很受限制。幸运的是,几乎有所得语言都至少有一种数值容器,这可以大大的提高该语言的能力。对于元编程也是这样:增加一个“类型容器”会大大的提高其自身的适用范围。幸运的是,现代C++提供了可以用来开发类似容器的机制。

混合元编程

通过使用数值元编程和类型元编程,可以在编译期间计算数值和类型。但是最终我们关心的还是在运行期间的效果,因此在运行期间的代码中,我们将元程序用在那些需要类型和常量的地方。不过元编程能做的不仅仅是这些:我们可以在编译期间,以编程的方式组合一些有运行期效果的代码。我们称之为混合元编程。

下面通过一个简单的例子来说明这一原理:计算两个std::array的点乘结果。回忆一下,std::array是具有固定长度的容器模板,其声明如下:

1
2
3
namespace std {
template<typename T, size_t N> struct array;
}

其中Nstd::array的长度。假设有两个类型相同的std::array对象,其点乘结果可以通过如下方式计算:

1
2
3
4
5
6
7
8
9
template<typename T, std::size_t N>
auto dotProduct(std::array<T, N> const& x, std::array<T, N> const& y)
{
T result{};
for (std::size_t k = 0; k<N; ++k) {
result += x[k]*y[k];
}
return result;
}

如果对for循环进行直接编译的话,那么就会生成分支指令,相比于直接运行如下命令,这在一些机器上可能会增加运行成本:

1
2
3
4
5
result += x[0]*y[0];
result += x[1]*y[1];
result += x[2]*y[2];
result += x[3]*y[3];
...

幸运的是,现代编译器会针对不同的平台做出相应的最为高效的优化。但是为了便于讨论,下面重新实现一版不需要loop的dotProduct():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename T, std::size_t N>
struct DotProductT {
static inline T result(T* a, T* b)
{
return *a * *b + DotProduct<T, N-1>::result(a+1,b+1);
}
};
// partial specialization as end criteria
template<typename T>
struct DotProductT<T, 0> {
static inline T result(T*, T*) {
return T{};
}
};
template<typename T, std::size_t N>
auto dotProduct(std::array<T, N> const& x, std::array<T, N> const& y)
{
return DotProductT<T, N>::result(x.begin(), y.begin());
}

新的实现将计算放在了类模板DotProductT中。这样做的目的是为了使用类模板的递归实例化来计算结果,并能够通过部分特例化来终止递归。注意例子中DotProductT的每一次实例化是如何计算点乘中的一项结果、以及所有剩余结果的。对于std::arrat<T,N>,会对主模板进行N次实例化,对部分特例化的模板进行一次实例化。为了保证效率,编译期需要将每一次对静态成员函数result()的调用内联(inline)。

这段代码的主要特点是它融合了编译期计算(这里通过递归的模板实例化实现,这决定了代码的整体结构)和运行时计算(通过调用result(),决定了具体的运行期间的效果)。我们之前提到过,“类型容器”可以大大提高元编程的能力。我们同样看到固定长度的array在混合元编程中也非常有用。但是混合元编程中真正的“英雄容器”是tuple(元组)。Tuple是一串数值,且其中每个值的类型可以分别指定。C++标准库中包含了支持这一概念的类模板std::tuple。比如:

1
std::tuple<int, std::string, bool> tVal{42, "Answer", true};

定义的变量tVal包含了三个类型分别为int, std::string和bool的值。因为tuple这一类容器在现代C++编程中非常重要。tVal的类型和下面
这个简单的struct类型非常类似:

1
2
3
4
5
struct MyTriple {
int v1;
std::string v2;
bool v3;
};

既然对于array类型和(简单)的struct类型,我们有比较灵活的std::arraystd::tuple与之对应,那么你可能会问,与简单的union对应的类似类型是否对混合元编程也很有益。答案是“yes”。C++标准库在C++17中为了这一目的引入了std::variant模板。

由于std::tuple和`std::variant都是异质类型(与struct类似),使用这些类型的混合元编程有时也被称为“异质元编程”。

将混合元编程用于“单位类型”

另一个可以展现混合元编程威力的例子是那些实现了不同单位类型的数值之间计算的库。相应的数值计算发生在程序运行期间,而单位计算则发生在编译期间。

下面会以一个极度精简的例子来做讲解。我们将用一个基于主单位的分数来记录相关单位。比如如果时间的主单位是秒,那么就用1/1000表示1微秒,用60/1表示一分钟。因此关键点就是要定义一个比例类型,使得每一个数值都有其自己的类型:

1
2
3
4
5
6
template<unsigned N, unsigned D = 1>
struct Ratio {
static constexpr unsigned num = N; // numerator
static constexpr unsigned den = D; // denominator
using Type = Ratio<num, den>;
};

现在就可以定义在编译期对两个单位进行求和之类的计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
// implementation of adding two ratios:
template<typename R1, typename R2>
struct RatioAddImpl
{
private:
static constexpr unsigned den = R1::den * R2::den;
static constexpr unsigned num = R1::num * R2::den + R2::num * R1::den;
public:
typedef Ratio<num, den> Type;
};
// using declaration for convenient usage:
template<typename R1, typename R2>
using RatioAdd = typename RatioAddImpl<R1, R2>::Type;

这样就可以在编译期计算两个比率之和了:

1
2
3
4
5
6
using R1 = Ratio<1,1000>;
using R2 = Ratio<2,3>;
using RS = RatioAdd<R1,R2>; //RS has type Ratio<2003,2000>
std::cout << RS::num << "/"<< RS::den << "\n"; //prints 2003/3000
using RA = RatioAdd<Ratio<2,3>,Ratio<5,7>>; //RA has type Ratio<29,21>
std::cout << RA::num << "/"<< RA::den << "\n"; //prints 29/21

然后就可以为时间段定义一个类模板,用一个任意数值类型和一个Ratio<>实例化之后的类型作为其模板参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// duration type for values of type T with unit type U:
template<typename T, typename U = Ratio<1>>
class Duration {
public:
using ValueType = T;
using UnitType = typename U::Type;
private:
ValueType val;
public:
constexpr Duration(ValueType v = 0)
: val(v) {
}
constexpr ValueType value() const {
return val;
}
};

比较有意思的地方是对两个Durations求和的operator+运算符的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
// adding two durations where unit type might differ:
template<typename T1, typename U1, typename T2, typename U2>
auto constexpr operator+(Duration<T1, U1> const& lhs, Duration<T2, U2> const& rhs)
{
// resulting type is a unit with 1 a nominator and
// the resulting denominator of adding both unit type fractions
using VT = Ratio<1,RatioAdd<U1,U2>::den>;
// resulting value is the sum of both values
// converted to the resulting unit type:
auto val = lhs.value() * VT::den / U1::den * U1::num +
rhs.value() * VT::den / U2::den * U2::num;
return Duration<decltype(val), VT>(val);
}

这里参数所属的单位类型可以不同,比如分别为U1和U2。然后可以基于U1和U2计算最终的时间段,其类型为一个新的分子为1的单位类型。基于此,可以编译如下代码:

1
2
3
4
5
6
int x = 42;
int y = 77;
auto a = Duration<int, Ratio<1,1000>>(x); // x milliseconds
auto b = Duration<int, Ratio<2,3>>(y); // y 2/3 seconds
auto c = a + b; //computes resulting unit type 1/3000 seconds
//and generates run-time code for c = a*3 + b*2000

此处“混合”的效果体现在,在计算c的时候,编译器会在编译期决定结果的单位类型Ratio<1,3000>,并产生出可以在程序运行期间计算最终结果的代码(结果会被根据单位类型进行调整)。

由于数值类型是由模板参数决定的,因此可以将int甚至是异质类型用于Duration类:

1
2
3
4
auto d = Duration<double, Ratio<1,3>>(7.5); // 7.5 1/3 seconds
auto e = Duration<int, Ratio<1>>(4); // 4 seconds
auto f = d + e; //computes resulting unit type 1/3 seconds
// and generates code for f = d + e*3

而且如果相应的数值在编译期是已知的话,编译器甚至可以在编译期进行以上计算(因为上文中的operator+constexpr)。

反射元编程的维度

上文中介绍了基于constexpr的“值元编程”和基于递归实例化的“类型元编程”。这两种在现代C++中可用的选项采用了明显不同的方式来驱动计算。事实证明“值元编程”也可以通过模板的递归实例化来实现,在引入C++11的constexpr函数之前,这也正是其实现方式。比如下面的代码使用递归实例化来计算一个整数的平方根:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// primary template to compute sqrt(N)
template<int N, int LO=1, int HI=N>
struct Sqrt {
// compute the midpoint, rounded up
static constexpr auto mid = (LO+HI+1)/2;
// search a not too large value in a halved interval
static constexpr auto value = (N<mid*mid) ?
Sqrt<N,LO,mid-1>::value : Sqrt<N,mid,HI>::value;
};
// partial specialization for the case when LO equals HI
template<int N, int M>
struct Sqrt<N,M,M> {
static constexpr auto value = M;
};

这里元函数的输入是一个非类型模板参数,而不是一个函数参数,用来追踪中间值边界的“局部变量”也是非类型模板参数。显然这个方法远不如constexpr函数友好,但是我接下来依然会探讨这段代码是如何消耗编译器资源的。

无论如何,我们已经看到元编程的计算引擎可以有多种潜在的选择。但是计算不是唯一的一个我们应该在其中考虑相关选项的维度。一个综合的元编程解决方案应该在如下3个维度中间做选择:

  • 计算维度(Compution)
  • 反射维度(Reflection)
  • 生成维度(Generation)

反射维度指的是以编程的方式检测程序特性的能力。生成维度指的是为程序生成额外代码的能力。

我们已经见过计算维度中的两个选项:递归实例化和constexpr计算。目前已有的类型萃取是基于模板实例化的,而且C++总是会提供额外的语言特性或者是“固有的”库元素来在编译期生成包含反射信息的类模板实例。这一方法和基于模板递归实例化进行的计算比较相似。但是不幸的是,类模板实例会占用比较多的编译器内存,而且这部分内存要直到编译结束才会被释放(否则的话编译时间会大大延长)。

递归实例化的代价

现在来分析Sqrt<>模板。主模板是由模板参数N(被计算平方根的值)和其它两个可选参数触发的、常规的递归计算。两个可选的参数分别是结果的上限和下限。如果只用一个参数调用该模板,那么其平方根最小是1,最大是其自身。递归会按照二分查找的方式进行下去。在模板内部会计算value是在从LO到HI这个区间的上半部还是下半部。这一分支判断是通过运算符?:实现的。如果mid2比N大,那么就继续在上半部分查找,否在就在下半部分查找。

偏特例化被用来在LO和HI的值都为M的时候结束递归,这个值也就是我们最终所要计算的结果。

实例化模板的成本并不低廉:即使是比较适中的类模板,其实例依然有可能占用数KB的内存,而且这部分被占用的内存在编译完成之前不可以被回收利用。我们先来分析一个使用了Sqrt模板的简单程序:

1
2
3
4
5
6
7
8
9
#include <iostream>
#include "sqrt1.hpp"
int main()
{
std::cout << "Sqrt<16>::value = " << Sqrt<16>::value << "\n";
std::cout << "Sqrt<25>::value = " << Sqrt<25>::value << "\n";
std::cout << "Sqrt<42>::value = " << Sqrt<42>::value << "\n";
std::cout << "Sqrt<1>::value = " << Sqrt<1>::value << "\n";
}

表达式Sqrt<16>::value被扩展成Sqrt<16,1,16>::value。在模板内部,元程序按照如下方式计算Sqrt<16,1,16>::value的值:

1
2
3
4
mid = (1+16+1)/2 = 9
value = (16<9*9) ? Sqrt<16,1,8>::value : Sqrt<16,9,16>::value
= (16<81) ? Sqrt<16,1,8>::value : Sqrt<16,9,16>::value
= Sqrt<16,1,8>::value

接着这个值会被以Sqrt<16,1,8>::value的形式计算,其会被接着展开为:

1
2
3
4
mid = (1+8+1)/2 = 5
value = (16<5*5) ? Sqrt<16,1,4>::value : Sqrt<16,5,8>::value
= (16<25) ? Sqrt<16,1,4>::value : Sqrt<16,5,8>::value
= Sqrt<16,1,4>::value

追踪所有的实例化过程

上文中主要分析了被用来计算16的平方根的实例化过程。但是当编译期计算:(16<=8*8) ? Sqrt<16,1,8>::value : Sqrt<16,9,16>::value的时候,它并不是只计算真正用到了的分支,同样也会计算没有用到的分支Sqrt<16,9,16>。而且,由于代码试图通过运算符::访问最终实例化出来的类的成员,该类中所有的成员都会被实例化。也就是说Sqrt<16,9,16>的完全实例化会导致Sqrt<16,9,12>Sqrt<16,13,16>都会被完全实例化。仔细分析以上过程,会发现最终会实例化出很多的实例,数量上几乎是N的两倍。

幸运的是,有一些技术可以被用来降低实例化的数目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "ifthenelse.hpp"
// primary template for main recursive step
template<int N, int LO=1, int HI=N>
struct Sqrt {
// compute the midpoint, rounded up
static constexpr auto mid = (LO+HI+1)/2;
// search a not too large value in a halved interval
using SubT = IfThenElse<(N<mid*mid),
Sqrt<N,LO,mid-1>,
Sqrt<N,mid,HI>>;
static constexpr auto value = SubT::value;
};
// partial specialization for end of recursion criterion
template<int N, int S>
struct Sqrt<N, S, S> {
static constexpr auto value = S;
};

IfThenElse模板被用来基于一个布尔常量在两个类型之间做选择。如果布尔型常量是true,那么会选择第一个类型,否则就选择第二个类型。一个比较重要的、需要记住的点是:为一个类模板的实例定义类型别名,不会导致C++编译器去实例化该实例。因此使用如下代码时:

1
2
3
using SubT = IfThenElse<(N<mid*mid),
Sqrt<N,LO,mid-1>,
Sqrt<N,mid,HI>>;

既不会完全实例化Sqrt<N,LO,mid-1>也不会完全实例化Sqrt<N,mid,HI>。在调用SubT::value的时候,只有真正被赋值给SubT的那一个实例才会被完全实例化。和之前的方法相比,这会让实例化的数量和log2N成正比:当N比较大的时候,这会大大降低元程序实例化的成本。

计算完整性

从以上的Sqrt<>的例子可以看出,一个模板元程序可能会包含以下内容:

  • 状态变量:模板参数
  • 循环结构:通过递归实现
  • 执行路径选择:通过条件表达式或者偏特例化实现
  • 整数运算

递归实例化和递归模板参数

考虑如下递归模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T, typename U>
struct Doublify {
};
template<int N>
struct Trouble {
using LongType = Doublify<typename Trouble<N-1>::LongType,
typename Trouble<N-1>::LongType>;
};
template<>
struct Trouble<0> {
using LongType = double;
};

Trouble<10>::LongType ouch;

Trouble<10>::LongType的使用并不是简单地触发形如Trouble<9>, Trouble<8>, …,Trouble<0>地递归实例化,还会用越来越复杂地类型实例化Doublify。表展示了其快速地增长方式:

类型别名 底层类型
Trouble<0>::LongType double
Trouble<1>::LongType Doublify
Trouble<2>::LongType Doublify,Doublify>
Trouble<3>::LongType Doublify,Doublify>,,Doublify>>

就如从表23.1中看到的那样,Trouble<N>::LongType类型的复杂度与N成指数关系。在早期C++中,这一编码方式的实现和模板签名template-id的长度成正比。这些编译器会使用大于10,000个字符来表达Trouble<N>::LongType

新的C++实现使用了一种聪明的压缩技术来大大降低名称编码(比如对于Trouble<N>::LongType,只需要用数百个字符)的增长速度。如果没有为某些模板实例生成低层级的代码,那么相关类型的名字就是不需要的,新的编译器就不会为这些类型产生名字。除此之外,其它情况都没有改善,因此在组织递归实例化代码的时候,最好不要让模板参数也嵌套递归。

枚举值还是静态常量

在早期C++中,枚举值是唯一可以用来在类的声明中、创建可用于类成员的“真正的常量”(也称常量表达式)的方式。比如通过它们可以定义Pow3元程序来计算3的指数:

1
2
3
4
5
6
7
8
9
10
// primary template to compute 3 to the Nth
template<int N>
struct Pow3 {
enum { value = 3 * Pow3<N-1>::value };
};
// full specialization to end the recursion
template<>
struct Pow3<0> {
enum { value = 1 };
};

在C++98标准中引入了类内静态常量初始化的概念,因此Pow3元程序可以被写成这样:

1
2
3
4
5
6
7
8
9
10
// primary template to compute 3 to the Nth
template<int N>
struct Pow3 {
static int const value = 3 * Pow3<N-1>::value;
};
// full specialization to end the recursion
template<>
struct Pow3<0> {
static int const value = 1;
};

但是上面代码中有一个问题:静态常量成员是左值。因此如果我们有如下函数:

1
void foo(int const&);

然后我们将元程序的结果传递给它:

1
foo(Pow3<7>::value);

编译器需要传递Pow3<7>::value的地址,因此必须实例化静态成员并为之开辟内存。这样该计算就不是一个纯正的“编译期”程序了。

枚举值不是左值(也就是说它们没有地址)。因此当将其按引用传递时,不会用到静态内存。几乎等效于将被计算值按照字面值传递。因此本书第一版建议在这一类应用中使用枚举值,而不是静态常量。

不过在C++中,引入了constexpr静态数据成员,并且其使用不限于整型类型。这并没有解决上文中关于地址的问题,但是即使如此,它也是用来产生元程序结果的常规方法。其优点是,它可以有正确的类型(相对于人工的枚举类型而言),而且当用auto声明静态成员的类型时,可以对其类型进行推断。C++17则引入了inline的静态数据成员,这解决了上面提到的地址问题,而且可以和constexpr一起使用。

类型列表(Typelists)

类型列表剖析

类型列表指的是一种代表了一组类型,并且可以被模板元编程操作的类型。它提供了典型的列表操作方法:遍历列表中的元素,添加元素或者删除元素。但是类型列表和大多数运行期间的数据结构都不同(比如std::list),它的值不允许被修改。向类型列表中添加一个元素并不会修改原始的类型列表,只是会创建一个新的、包含了原始类型列表和新添加元素的类型列表。

类型列表通常是按照类模板特例的形式实现的,它将自身的内容(包含在模板参数中的类型以及类型之间的顺序)编码到了参数包中。一种将其内容编码到参数包中的类型列表的直接实现方式如下:

1
2
3
template<typename... Elements>
class Typelist
{};

Typelist中的元素被直接写成其模板参数。一个空的类型列表被写为Typelist<>,一个只包含int的类型列表被写为Typelist<int>。下面是一个包含了所有有符号整型的类型列表:

1
2
using SignedIntegralTypes =
Typelist<signed char, short, int, long, long long>;

操作这个类型列表需要将其拆分,通常的做法是将第一个元素(the head)从剩余的元素中分离(the tail)。比如Front元函数会从类型列表中提取第一个元素:

1
2
3
4
5
6
7
8
9
10
template<typename List>
class FrontT;
template<typename Head, typename... Tail>
class FrontT<Typelist<Head, Tail...>>
{
public:
using Type = Head;
};
template<typename List>
using Front = typename FrontT<List>::Type;

这样FrontT<SignedIntegralTypes>::Type(或者更简洁的记作FrontT<SignedIntegralTypes>)返回的就是signed char。同样PopFront元函数会删除类型列表中的第一个元素。在实现上它会将类型列表中的元素分为头(head)和尾(tail)两部分,然后用尾部的元素创建一个新的Typelist特例。

1
2
3
4
5
6
7
8
9
template<typename List>
class PopFrontT;
template<typename Head, typename... Tail>
class PopFrontT<Typelist<Head, Tail...>> {
public:
using Type = Typelist<Tail...>;
};
template<typename List>
using PopFront = typename PopFrontT<List>::Type;

PopFront<SignedIntegralTypes>会产生如下类型列表:

1
Typelist<short, int, long, long long>

同样也可以向类型列表中添加元素,只需要将所有已经存在的元素捕获到一个参数包中,然后在创建一个包含了所有元素的TypeList特例就行:

1
2
3
4
5
6
7
8
9
template<typename List, typename NewElement>
class PushFrontT;
template<typename... Elements, typename NewElement>
class PushFrontT<Typelist<Elements...>, NewElement> {
public:
using Type = Typelist<NewElement, Elements...>;
};
template<typename List, typename NewElement>
using PushFront = typename PushFrontT<List, NewElement>::Type;

和预期的一样,

1
PushFront<SignedIntegralTypes, bool>

会生成:

1
Typelist<bool, signed char, short, int, long, long long>

类型列表的算法

基础的类型列表操作Front,PopFront和PushFront可以被组合起来实现更有意思的列表操作。比如通过将PushFront作用于PopFront可以实现对第一个元素的替换:

1
2
using Type = PushFront<PopFront<SignedIntegralTypes>, bool>;
// equivalent to Typelist<bool, short, int, long, long long>

更近一步,我们可以按照模板原函数的实现方式,实现作用于类型列表的诸如搜索、转换和反转等操作。

索引(Indexing)

类型列表的一个非常基础的操作是从列表中提取某个特定的类型。接下来我们将这一操作推广到可以提取第Nth个元素。比如,为了提取给定类型列表中的第2个元素,可以这样:

1
using TL = NthElement<Typelist<short, int, long>, 2>;

这相当于将TL作为long的别名使用。NthElement操作的实现方式是使用一个递归的元程序遍历typelist中的元素,直到找到所需元素为止:

1
2
3
4
5
6
7
8
9
10
// recursive case:
template<typename List, unsigned N>
class NthElementT : public NthElementT<PopFront<List>, N-1>
{};
// basis case:
template<typename List>
class NthElementT<List, 0> : public FrontT<List>
{ };
template<typename List, unsigned N>
using NthElement = typename NthElementT<List, N>::Type;

首先来看由N = 0部分特例化出来的基本情况。这一特例化会通过返回类型列表中的第一个元素来终止递归。其方法是对FrontT<List>进行public继承,这样FrontT<List>作为类型列表中第一个元素的Type类型别名,就可以被作为NthElement的结果使用了。

作为模板主要部分的递归代码,会遍历类型列表。由于偏特化部分保证了N > 0,递归部分的代码会不断地从剩余列表中删除第一个元素并请求第N-1个元素。在我们的例子中:

1
NthElementT<Typelist<short, int, long>, 2>

继承自:
1
NthElementT<Typelist<int, long>, 1>

而它又继承自:

1
NthElementT<Typelist<long>, 0>

这里遇到了最基本的N = 0的情况,它继承自提供了最终结果Type的FrontT<Typelist<long>>

寻找最佳匹配

有些类型列表算法会去查找类型列表中的数据。例如可能想要找出类型列表中最大的类型(比如为了开辟一段可以存储类型列表中任意类型的内存)。这同样可以通过递归模板元程序实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename List>
class LargestTypeT;
// recursive case:
template<typename List>
class LargestTypeT
{
private:
using First = Front<List>;
using Rest = typename LargestTypeT<PopFront<List>>::Type;
public:
using Type = IfThenElse<(sizeof(First) >= sizeof(Rest)), First, Rest>;
};
// basis case:
template<>
class LargestTypeT<Typelist<>>
{
public:
using Type = char;
};
template<typename List>
using LargestType = typename LargestTypeT<List>::Type;

LargestType算法会返回类型列表中第一个最大的类型。比如对于Typelist<bool, int, long, short>,该算法会返回第一个大小和long相同的类型,可能是int也可能是long,取决于你的平台。

由于递归算法的使用,对LargestTypeT的调用次数会翻倍。它使用了first/rest的概念,分三步完成任务。在第一步中,它先只基于第一个元素计算出部分结果,在本例中是将第一个元素放置到First中。接下来递归地计算类型列表中剩余部分的结果,并将结果放置在Rest中。比如对于类型列表Typelist<bool, int, long, short>,在递归的第一步中First是bool,而Rest是该算法作用于Typelist<int, long, short>得到的结果。最后在第三步中综合First和Rest得到最终结果。此处,IfThenElse会选出列表中第一个元素(First)和到目前为止的最优解(Rest)中类型最大的那一个。>=的使用会倾向于选择第一个出现的最大的类型。

递归会在类型列表为空时终结。默认情况下我们将char用作哨兵类型来初始化该算法,因为任何类型都不会比char小。

注意上文中的基本情况显式的用到了空的类型列表Typelist<>。这样有点不太好,因为它可能会妨碍到其它类型的类型列表的使用。为了解决这一问题,引入了IsEmpty元函数,它可以被用来判断一个类型列表是否为空:

1
2
3
4
5
6
7
8
9
10
11
template<typename List>
class IsEmpty
{
public:
static constexpr bool value = false;
};
template<>
class IsEmpty<Typelist<>> {
public:
static constexpr bool value = true;
};

结合IsEmpty,可以像下面这样将LargestType实现成适用于任意支持了FrontPopFrontIsEmpty的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename List, bool Empty = IsEmpty<List>::value>
class LargestTypeT;
// recursive case:
template<typename List>
class LargestTypeT<List, false>
{
private:
using Contender = Front<List>;
using Best = typename LargestTypeT<PopFront<List>>::Type;
public:
using Type = IfThenElse<(sizeof(Contender) >= sizeof(Best)),Contender, Best>;
};
// basis case:
template<typename List>
class LargestTypeT<List, true>
{
public:
using Type = char;
};
template<typename List>
using LargestType = typename LargestTypeT<List>::Type;

默认的LargestTypeT的第二个模板参数Empty会检查一个类型列表是否为空。如果不为空,就递归地继续在剩余的列表中查找。如果为空,就会终止递归并返回作为初始结果的char。

向类型类表中追加元素

通过PushFront可以向类型列表的头部添加一个元素,并产生一个新的类型列表。除此之外我们还希望能够像在程序运行期间操作std::liststd::vector那样,向列表的末尾追加一个元素。对于我们的Typelist模板,为实现支持这一功能的PushBack,只需要PushFront做一点小的修改:

1
2
3
4
5
6
7
8
9
10
11
template<typename List, typename NewElement>
class PushBackT;

template<typename... Elements, typename NewElement>
class PushBackT<Typelist<Elements...>, NewElement>
{
public:
using Type = Typelist<Elements..., NewElement>;
};
template<typename List, typename NewElement>
using PushBack = typename PushBackT<List, NewElement>::Type;

不过和实现LargestType的算法一样,可以只用FrontPushFrontPopFrontIsEmpty等基础操作实现一个更通用的PushBack算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<typename List, typename NewElement, bool = IsEmpty<List>::value>
class PushBackRecT;
// recursive case:
template<typename List, typename NewElement>
class PushBackRecT<List, NewElement, false>
{
using Head = Front<List>;
using Tail = PopFront<List>;
using NewTail = typename PushBackRecT<Tail, NewElement>::Type;
public:
using Type = PushFront<Head, NewTail>;
};
// basis case:
template<typename List, typename NewElement>
class PushBackRecT<List, NewElement, true>
{
public:
using Type = PushFront<List, NewElement>;
};
// generic push-back operation:
template<typename List, typename NewElement>
class PushBackT : public PushBackRecT<List, NewElement> { };
template<typename List, typename NewElement>
using PushBack = typename PushBackT<List, NewElement>::Type;

PushBackRecT会自行管理递归。对于最基本的情况,用PushFrontNewElement添加到空的类型列表中。递归部分的代码则要有意思的多:它首先将类型列表分成首元素(Head)和一个包含了剩余元素的新的类型列表(Tail)。新元素则被追加到Tail的后面,这样递归的进行下去,就会生成一个NewTail。然后再次使用PushFront将Head添加到NewTail的头部,生成最终的类型列表。接下来以下面这个简单的例子为例展开递归的调用过程:

1
PushBackRecT<Typelist<short, int>, long>

在最外层的递归代码中,Head会被解析成short,Tail则被解析成Typelist<int>。然后递归到:

1
PushBackRecT<Typelist<int>, long>

其中Head会被解析成int,Tail则被解析成Typelist<>。然后继续递归计算:

1
PushBackRecT<Typelist<>, long>

这会触发最基本的情况并返回PushFront<Typelist<>, long>,其结果是Typelist<long>。然后返回上一层递归,将之前的Head添加到返回结果的头部:

1
PushFront<int, Typelist<long>>

它会返回Typelist<int, long>。然后继续返回上一层递归,将最外层的Head(short)添加到返回结果的头部:

1
PushFront<short, Typelist<int, long>>

然后就得到了最终的结果:

1
Typelist<short, int, long>

通用版的PushBackRecT适用于任何类型的类型列表。计算过程中它需要的模板实例的数量和类型列表的长度N成正比(如果类型列表的长度为N,那么PushBackRecT实例和PushFrontT实例的数目都是N+1,FrontTPopFront实例的数量为N)。

类型列表的反转

当类型列表的元素之间有某种顺序的时候,对于某些算法而言,如果能够反转该顺序的话,事情将会变得很方便。比如SignedIntegralTypes中元素是按整型大小的等级递增的。但是对其元素反转之后得到的Typelist<long, long, long, int, short, signed char>可能会更有用。下面的Reverse算法实现了相应的元函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename List, bool Empty = IsEmpty<List>::value>
class ReverseT;
template<typename List>
using Reverse = typename ReverseT<List>::Type;
// recursive case:
template<typename List>
class ReverseT<List, false>:public PushBackT<Reverse<PopFront<List>>,
Front<List>> { };

// basis case:
template<typename List>
class ReverseT<List, true>{
public:
using Type = List;
};

该元函数的基本情况是一个作用于空的类型列表的函数。递归的情况则将类型列表分割成第一个元素和剩余元素两部分。比如对于Typelist<short, int, long>,递归过程会先将第一个元素(short)从剩余元素(Typelist<int, long>)中分离开。然后递归得反转
列表中剩余的元素(生成Typelist<long, int>),最后通过调用PushBackT将首元素追加到被反转的列表的后面(生成Typelist<long, int, short>)。

结合Reverse,可以实现移除列表中最后一个元素的PopBackT操作:

1
2
3
4
5
6
7
template<typename List>
class PopBackT {
public:
using Type = Reverse<PopFront<Reverse<List>>>;
};
template<typename List>
using PopBack = typename PopBackT<List>::Type;

该算法先反转整个列表,然后删除首元素并将剩余列表再次反转,从而实现删除末尾元素的目的。

类型列表的转换

之前介绍的类型列表的相关算法允许我们从类型列表中提取任意元素,在类型列表中做查找,构建新的列表以及反转列表。但是我们还需要对类型列表中的元素执行一些其它的操作。比如可能希望对类型列表中的所有元素做某种转换,例如通过AddConst给列表中的元素加上const修饰符:

1
2
3
4
5
6
7
template<typename T>
struct AddConstT
{
using Type = T const;
};
template<typename T>
using AddConst = typename AddConstT<T>::Type;

为了实现这一目的,相应的算法应该接受一个类型列表和一个元函数作为参数,并返回一个将该元函数作用于类型列表中每个元素之后,得到的新的类型列表。比如:

1
Transform<SignedIntegralTypes, AddConstT>

返回的是一个包含了signed char constshort constint constlong constlong long const的类型列表。元函数被以模板参数模板的形式提供,它负责将一种类型转换为另一种类型。Transform算法本身和预期的一样是一个递归算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename List, template<typename T> class MetaFun, bool Empty = IsEmpty<List>::value>
class TransformT;
// recursive case:
template<typename List, template<typename T> class MetaFun>
class TransformT<List, MetaFun, false>
: public PushFrontT<typename TransformT<PopFront<List>,
MetaFun>::Type, typename MetaFun<Front<List>>::Type>
{};
// basis case:
template<typename List, template<typename T> class MetaFun>
class TransformT<List, MetaFun, true>
{
public:
using Type = List;
};
template<typename List, template<typename T> class MetaFun>
using Transform = typename TransformT<List, MetaFun>::Type;

此处的递归情况虽然句法比较繁琐,但是依然很直观。最终转换的结果是第一个元素的转换结果,加上对剩余元素执行执行递归转换后的结果。

类型列表的累加(Accumulating Typelists)

转换(Transform)算法在需要对类型列表中的元素做转换时很有帮助。通常将它和累加(Accumulate)算法一起使用,它会将类型列表中的所有元素组合成一个值。Accumulate算法以一个包含元素T1T2,…,TN的类型列表T,一个初始类型I,和一个接受两个类型作为参数的元函数F为参数,并最终返回一个类型。它的返回值是F (F (F (...F(I, T1), T2), ..., TN−1), TN ),其中在第ith步,F将作用于前i-1步的结果以及Ti

取决于具体的类型列表,F的选择以及初始值I的选择,可以通过Accumulate产生各种不同的输出。比如如果F可以被用来在两种类型中选择较大的那一个,Accumulate的行为就和LargestType差不多。而如果F接受一个类型列表和一个类型作为参数,并且将类型追加到类型列表的后面,其行为又和Reverse算法差不多。Accumulate的实现方式遵循了标准的递归元编程模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename List, template<typename X, typename Y> class F, typename I, bool = IsEmpty<List>::value>
class AccumulateT;
// recursive case:
template<typename List, template<typename X, typename Y> class F, typename I>
class AccumulateT<List, F, I, false> : public AccumulateT<PopFront<List>, F, typename F<I, Front<List>>::Type>
{};
// basis case:
template<typename List,
template<typename X, typename Y> class F, typename I>
class AccumulateT<List, F, I, true>
{
public:
using Type = I;
};
template<typename List,
template<typename X, typename Y> class F,
typename I>
using Accumulate = typename AccumulateT<List, F, I>::Type;

这里初始类型I也被当作累加器使用,被用来捕捉当前的结果。因此当递归到类型列表末尾的时候,递归循环的基本情况会返回这个结果。在递归情况下,算法将F作用于之前的结果(I)以及当前类型列表的首元素,并将F的结果作为初始类型继续传递,用于下一级对剩余列表的求和(Accumulating)。

有了Accumulate,就可以通过将PushFrontT作为元函数F,将空的类型列表(TypeList<T>)作为初始类型I,反转一个类型列表:

1
2
using Result = Accumulate<SignedIntegralTypes, PushFrontT, Typelist<>>;
// produces TypeList<long long, long, int, short, signed char>

如果要实现基于AccumulateLargestType(称之为LargestTypeAcc),还需要做一些额外的工作,因为首先要实现一个返回两种类型中类型较大的那一个的元函数:

1
2
3
4
5
6
7
8
9
10
11
template<typename T, typename U>
class LargerTypeT
: public IfThenElseT<sizeof(T) >= sizeof(U), T, U>
{ };

template<typename Typelist>
class LargestTypeAccT : public AccumulateT<PopFront<Typelist>, LargerTypeT,
Front<Typelist>>
{ };
template<typename Typelist>
using LargestTypeAcc = typename LargestTypeAccT<Typelist>::Type;

值得注意的是,由于这一版的LargestType将类型列表的第一个元素当作初始类型,因此其输入不能为空。我们可以显式地处理空列表的情况,要么是返回一个哨兵类型(char或者void),要么让该算法很好的支持SFINASE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T, typename U>
class LargerTypeT : public IfThenElseT<sizeof(T) >= sizeof(U), T, U>
{ };
template<typename Typelist, bool = IsEmpty<Typelist>::value>
class LargestTypeAccT;
template<typename Typelist>
class LargestTypeAccT<Typelist, false> : public AccumulateT<PopFront<Typelist>, LargerTypeT,
Front<Typelist>>
{ };
template<typename Typelist>
class LargestTypeAccT<Typelist, true>
{ };
template<typename Typelist>
using LargestTypeAcc = typename LargestTypeAccT<Typelist>::Type;

Accumulate是一个非常强大的类型列表算法,利用它可以实现很多种操作,因此可以将其看作类型列表操作相关的基础算法。

插入排序

作为最后一个类型列表相关的算法,我们来介绍插入排序。和其它算法类似,其递归过程会将类型列表分成第一个元素(Head)和剩余的元素(Tail)。然后对Tail进行递归排序,并将Head插入到排序后的类型列表中的合适的位置。该算法的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename List, template<typename T, typename U> class Compare, bool = IsEmpty<List>::value>
class InsertionSortT;

template<typename List, template<typename T, typename U> class Compare>
using InsertionSort = typename InsertionSortT<List, Compare>::Type;

// recursive case (insert first element into sorted list):
template<typename List, template<typename T, typename U> class Compare>
class InsertionSortT<List, Compare, false>
: public InsertSortedT<InsertionSort<PopFront<List>, Compare>,
Front<List>, Compare>
{};

// basis case (an empty list is sorted):
template<typename List, template<typename T, typename U> class Compare>
class InsertionSortT<List, Compare, true>
{
public:
using Type = List;
};

在对类型列表进行排序时,参数Compare被用来作比较。它接受两个参数并通过其value成员返回一个布尔值。将其用来处理空列表的情况会稍嫌繁琐。

插入排序算法的核心时元函数InsertSortedT,它将一个值插入到一个已经排序的列表中(插入到第一个可能的位置)并保持列表依然有序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include "identity.hpp"
template<typename List, typename Element, template<typename T, typename U> class Compare, bool =
IsEmpty<List>::value>
class InsertSortedT;
// recursive case:
template<typename List, typename Element, template<typename T,
typename U> class Compare>
class InsertSortedT<List, Element, Compare, false>
{
// compute the tail of the resulting list:
using NewTail = typename IfThenElse<Compare<Element,
Front<List>>::value, IdentityT<List>,
InsertSortedT<PopFront<List>,
Element, Compare>>::Type;

// compute the head of the resulting list:
using NewHead = IfThenElse<Compare<Element, Front<List>>::value,
Element, Front<List>>;
public:
using Type = PushFront<NewTail, NewHead>;
};
// basis case:
template<typename List, typename Element, template<typename T,
typename U> class Compare>
class InsertSortedT<List, Element, Compare, true>
: public PushFrontT<List, Element>
{};
template<typename List, typename Element,template<typename T, typename U> class Compare>
using InsertSorted = typename InsertSortedT<List, Element, Compare>::Type;

由于只有一个元素的列表是已经排好序的,因此相关代码不是很复杂。对于递归情况,基于元素应该被插入到列表头部还是剩余部分,其实现也有所不同。如果元素应该被插入到(已经排序的)列表第一个元素的前面,那么就用PushFront直接插入。否则,就将列表分成head和tail两部分,这样递归的尝试将元素插入到tail中,成功之后再用PushFront将head插入到tail的前面。

上述实现中包含了一个避免去实例化不会用到的类型的编译期优化,下面这个实现在技术上也是正确的:

1
2
3
4
5
template<typename List, typename Element, template<typename T,
typename U> class Compare>
class InsertSortedT<List, Element, Compare, false>
: public IfThenElseT<Compare<Element, Front<List>>::value, PushFront<List, Element>, PushFront<InsertSorted<PopFront<List>, Element, Compare>, Front<List>>>
{};

但是由于这种递归情况的实现方式会计算IfThenElseT的两个分支(虽然只会用到一个),其效率会受到影响。在这个实现中,在IfThenElseT的then分支中使用PushFront的成本非常低,但是在else分支中递归地使用InsertSorted的成本则很高。在我们的优化实现中,第一个IfThenElse会计算出列表的tail(NewTail)。其第二和第三个参数是用来计算特定结果的元函数。Then分支中的参数使用IdentityT来计算未被修改的List。Else分支中的参数用InsertSortedT来计算将元素插入到已排序列表之后的结果。在较高层面上,IdentityInsertSortedT两者中只有一个会被实例化,因此不会有太多的额外工作。

第二个IfThenElse会计算上面获得的list的head,其两个分支的计算代价都很低,因此都会被立即计算。最终的结果由NewHeadNewTail计算得到。

这一实现方案所需要的实例化数目,与被插入元素在一个已排序列表中的插入位置成正比。这表现为更高级别的插入排序属性:排序一个已经有序的列表,所需要实例化的数目和列表的长度成正比(如果已排序列表的排列顺序和预期顺序相反的话,所需要的实例化数目和列表长度的平方成正比)。

下面的程序会基于列表中元素的大小,用插入排序对其排序。比较函数使用了sizeof运算符并比较其结果:

1
2
3
4
5
6
7
8
9
10
template<typename T, typename U>
struct SmallerThanT {
static constexpr bool value = sizeof(T) < sizeof(U);
};
void testInsertionSort()
{
using Types = Typelist<int, char, short, double>;
using ST = InsertionSort<Types, SmallerThanT>;
std::cout << std::is_same<ST,Typelist<char, short, int, double>>::value << "\n";
}

非类型类型列表(Nontype Typelists)

通过类型列表,有非常多的算法和操作可以用来描述并操作一串类型。某些情况下,还会希望能够操作一串编译期数值,比如多维数组的边界,或者指向另一个类型列表中的索引。有很多种方法可以用来生成一个包含编译期数值的类型列表。一个简单的办法是定义一个类模板CTValue(compile time value),然后用它表示类型列表中某种类型的值:

1
2
3
4
5
template<typename T, T Value>
struct CTValue
{
static constexpr T value = Value;
};

用它就可以生成一个包含了最前面几个素数的类型列表:

1
2
3
using Primes = Typelist<CTValue<int, 2>, CTValue<int, 3>,
CTValue<int, 5>, CTValue<int, 7>,
CTValue<int, 11>>;

这样就可以对类型列表中的数值进行数值计算,比如计算这些素数的乘积。

首先MultiPlyT模板接受两个类型相同的编译期数值作为参数,并生成一个新的、类型相同的编译期数值:

1
2
3
4
5
6
7
8
9
template<typename T, typename U>
struct MultiplyT;
template<typename T, T Value1, T Value2>
struct MultiplyT<CTValue<T, Value1>, CTValue<T, Value2>> {
public:
using Type = CTValue<T, Value1 * Value2>;
};
template<typename T, typename U>
using Multiply = typename MultiplyT<T, U>::Type;

然后结合MultiplyT,下面的表达式就会返回所有Primes中素数的乘积:

1
Accumulate<Primes, MultiplyT, CTValue<int, 1>>::value

不过这一使用TypelistCTValue的方式过于复杂,尤其是当所有数值的类型相同的时候。可以通过引入CTTypelist模板别名来进行优化,它提供了一组包含在Typelist中、类型相同的数值:

1
2
template<typename T, T... Values>
using CTTypelist = Typelist<CTValue<T, Values>...>;

这样就可以使用CTTypelist来定义一版更为简单的Primes(素数):

1
using Primes = CTTypelist<int, 2, 3, 5, 7, 11>;

这一方式的唯一缺点是,别名终归只是别名,当遇到错误的时候,错误信息可能会一直打印到CTValueTypes中的底层Typelist,导致错误信息过于冗长。为了解决这一问题,可以定义一个能够直接存储数值的、全新的类型列表类Valuelist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<typename T, T... Values>
struct Valuelist {
};
template<typename T, T... Values>
struct IsEmpty<Valuelist<T, Values...>> {
static constexpr bool value = sizeof...(Values) == 0;
};
template<typename T, T Head, T... Tail>
struct FrontT<Valuelist<T, Head, Tail...>> {
using Type = CTValue<T, Head>;
static constexpr T value = Head;
};
template<typename T, T Head, T... Tail>
struct PopFrontT<Valuelist<T, Head, Tail...>> {
using Type = Valuelist<T, Tail...>;
};
template<typename T, T... Values, T New>
struct PushFrontT<Valuelist<T, Values...>, CTValue<T, New>> {
using Type = Valuelist<T, New, Values...>;
};
template<typename T, T... Values, T New>
struct PushBackT<Valuelist<T, Values...>, CTValue<T, New>> {
using Type = Valuelist<T, Values..., New>;
};

通过代码中提供的IsEmptyFrontTPopFrontTPushFrontTValuelist就可以被用于本章中介绍的各种算法了。PushBackT被实现为一种算法的特例化,这样做可以降低编译期间该操作的计算成本。比如Valuelist可以被用于前面定义的算法InsertionSort

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T, typename U>
struct GreaterThanT;
template<typename T, T First, T Second>
struct GreaterThanT<CTValue<T, First>, CTValue<T, Second>> {
static constexpr bool value = First > Second;
};
void valuelisttest()
{
using Integers = Valuelist<int, 6, 2, 4, 9, 5, 2, 1, 7>;
using SortedIntegers = InsertionSort<Integers, GreaterThanT>;
static_assert(std::is_same_v<SortedIntegers, Valuelist<int, 9, 7,
6, 5, 4, 2, 2, 1>>, "insertion sort failed");
}

注意在这里可以提供一种用字面值常量来初始化CTValue的功能,比如:

1
auto a = 42_c; // initializes a as CTValue<int,42>

可推断的非类型参数

在C++17中,可以通过使用一个可推断的非类型参数(结合auto)来进一步优化CTValue的实现:

1
2
3
4
5
template<auto Value>
struct CTValue
{
static constexpr auto value = Value;
};

这样在使用CTValue的时候就可以不用每次都去指定一个类型了,从而简化了使用方式:

1
using Primes = Typelist<CTValue<2>, CTValue<3>, CTValue<5>, CTValue<7>, CTValue<11>>;

在C++17中也可以对Valuelist执行同样的操作,但是结果可能不一定会变得更好。对一个非类型参数包进行类型推断时,各个参数可以不同:

1
2
3
4
template<auto... Values>
class Valuelist { };
int x;
using MyValueList = Valuelist<1,"a", true, &x>;

虽然这样一个列表可能也很有用,但是它和之前要求元素类型必须相同的Valuelist已经不一样了。虽然我们也可以要求其所有元素的类型必须相同,但是对于一个空的Valuelist<>而言,其元素类型却是未知的。

元组

本章将会讨论tuples,它采用了类似于class和struct的方式来组织数据。比如,一个包含int,double和std::string的tuple,和一个包含int,double以及std::string类型的成员的struct类似,只不过tuple中的元素是用位置信息(比如0,1,2)索引的,而不是通过名字。元组的位置接口,以及能够容易地从typelist构建tuple的特性,使得其相比于struct更适用于模板元编程技术。

另一种观点是将元组看作在可执行程序中,类型列表的一种表现。比如,类型列表Typelist<int, double, std::string>,描述了一串包含了int,double和std::string的、可以在编译期间操作的类型,而Tuple<int,double, std::string>则描述了可以在运行期间操作的、对int,double和std::string的存储。比如下面的程序就创建了这样一个tuple的实例:

1
2
3
4
5
template<typename... Types>
class Tuple {
... // implementation discussed below
};
Tuple<int, double, std::string> t(17, 3.14, "Hello, World!");

通常会使用模板元编程和typelist来创建用于存储数据的tuple。比如,虽然在上面的程序中随意地选择了int,double和std::string作为元素类型,我们也可以用元程序创建一组可被tuple存储的类型。

基本的元组设计

存储(Storage)

元组包含了对模板参数列表中每一个类型的存储。这部分存储可以通过函数模板get进行访问,对于元组t,其用法为get<I>(t)。比如,对于之前例子中的t,get<0>(t)会返回指向int 17的引用,而get<1>(t)返回的则是指向double 3.14的引用。

元组存储的递归实现是基于这样一个思路:一个包含了N > 0个元素的元组可以被存储为一个单独的元素(元组的第一个元素,Head)和一个包含了剩余N-1个元素(Tail)的元组,对于元素为空的元组,只需当作特例处理即可。因此一个包含了三个元素的元组Tuple<int, double, std::string>可以被存储为一个int和一个Tuple<double, std::string>

这个包含两个元素的元组又可以被存储为一个double和一个Tuple<std::string>,这个只包含一个元素的元组又可以被存储为一个std::string和一个空的元组Tuple<>。事实上,在类型列表算法的泛型版本中也使用了相同的递归分解过程,而且实际递归元组的存储实现也
以类似的方式展开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<typename... Types>
class Tuple;
// recursive case:
template<typename Head, typename... Tail>
class Tuple<Head, Tail...>
{
private:
Head head;
Tuple<Tail...> tail;
public:
// constructors:
Tuple() {
}
Tuple(Head const& head, Tuple<Tail...> const& tail): head(head), tail(tail) {
}...
Head& getHead() { return head; }
Head const& getHead() const { return head; }
Tuple<Tail...>& getTail() { return tail; }
Tuple<Tail...> const& getTail() const { return tail; }
};
// basis case:
template<>
class Tuple<> {
// no storage required
};

在递归情况下,Tuple的实例包含一个存储了列表首元素的head,以及一个存储了列表剩余元素的tail。基本情况则是一个没有存储内容的简单的空元组。而函数模板get则会通过遍历这个递归的结构来提取所需要的元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// recursive case:
template<unsigned N>
struct TupleGet {
template<typename Head, typename... Tail>
static auto apply(Tuple<Head, Tail...> const& t) {
return TupleGet<N-1>::apply(t.getTail());
}
};
// basis case:
template<>
struct TupleGet<0> {
template<typename Head, typename... Tail>
static Head const& apply(Tuple<Head, Tail...> const& t) {
return t.getHead();
}
};
template<unsigned N, typename... Types>
auto get(Tuple<Types...> const& t) {
return TupleGet<N>::apply(t);
}

注意,这里的函数模板get只是封装了一个简单的对TupleGet的静态成员函数调用。在不能对函数模板进行部分特例化的情况下,这是一个有效的变通方法,在这里针对非类型模板参数N进行了特例化。在N > 0的递归情况下,静态成员函数apply()会提取出当前tuple的tail,递减N,然后继续递归地在tail中查找所需元素。对于N=0的基本情况,apply()会返回当前tuple的head,并结束递归。

构造

除了前面已经定义的构造函数:

1
2
3
4
5
Tuple() {
}
Tuple(Head const& head, Tuple<Tail...> const& tail)
: head(head), tail(tail)
{ }

为了让元组的使用更方便,还应该允许用一组相互独立的值(每一个值对应元组中的一个元素)或者另一个元组来构造一个新的元组。从一组独立的值去拷贝构造一个元组,会用第一个数值去初始化元组的head,而将剩余的值传递给tail:

1
2
3
4
Tuple(Head const& head, Tail const&... tail)
: head(head), tail(tail...)
{
}

这样就可以像下面这样初始化一个元组了:

1
Tuple<int, double, std::string> t(17, 3.14, "Hello, World!");

不过这并不是最通用的接口:用户可能会希望用移动构造(move-construct)来初始化元组的一些(可能不是全部)元素,或者用一个类型不相同的数值来初始化元组的某个元素。因此我们需要用完美转发来初始化元组:

1
2
3
4
template<typename VHead, typename... VTail>
Tuple(VHead&& vhead, VTail&&... vtail)
: head(std::forward<VHead>(vhead)), tail(std::forward<VTail>(vtail)...)
{ }

下面的这个实现则允许用一个元组去构建另一个元组:

1
2
3
4
template<typename VHead, typename... VTail>
Tuple(Tuple<VHead, VTail...> const& other)
: head(other.getHead()), tail(other.getTail())
{ }

但是这个构造函数不适用于类型转换:给定上文中的t,试图用它去创建一个元素之间类型兼容的元组会遇到错误:

1
2
// ERROR: no conversion from Tuple<int, double, string> to long
Tuple<long int, long double, std::string> t2(t);

这是因为上面这个调用,会更匹配用一组数值去初始化一个元组的构造函数模板,而不是用一个元组去初始化另一个元组的构造函数模板。为了解决这一问题,就需要用到std::enable_if<>,在tail的长度与预期不同的时候就禁用相关模板:

1
2
3
4
5
6
7
template<typename VHead, typename... VTail, typename = std::enable_if_t<sizeof... (VTail)==sizeof... (Tail)>>
Tuple(VHead&& vhead, VTail&&... vtail)
: head(std::forward<VHead>(vhead)), tail(std::forward<VTail>(vtail)...)
{ }
template<typename VHead, typename... VTail, typename = std::enable_if_t<sizeof... (VTail)==sizeof... (Tail)>>
Tuple(Tuple<VHead, VTail...> const& other)
: head(other.getHead()), tail(other.getTail()) { }

函数模板makeTuple()会通过类型推断来决定所生成元组中元素的类型,这使得用一组数值创建一个元组变得更加简单:

1
2
3
4
5
template<typename... Types>
auto makeTuple(Types&&... elems)
{
return Tuple<std::decay_t<Types>...>(std::forward<Types> (elems)...);
}

这里再一次将std::decay<>和完美转发一起使用,这会将字符串常量和裸数组转换成指针,并去除元素的const和引用属性。比如:

1
makeTuple(17, 3.14, "Hello, World!")

生成的元组的类型是:

1
Tuple<int, double, char const*>

基础元组操作

比较

元组是包含了其它数值的结构化类型。为了比较两个元组,就需要比较它们的元素。因此可以像下面这样,定义一种能够逐个比较两个元组中元素的operator==

1
2
3
4
5
6
7
8
9
10
11
12
// basis case:
bool operator==(Tuple<> const&, Tuple<> const&)
{
// empty tuples are always equivalentreturn true;
}
// recursive case:
template<typename Head1, typename... Tail1,
typename Head2, typename... Tail2, typename = std::enable_if_t<sizeof...(Tail1)==sizeof...(Tail2)>>
bool operator==(Tuple<Head1, Tail1...> const& lhs, Tuple<Head2, Tail2...> const& rhs)
{
return lhs.getHead() == rhs.getHead() && lhs.getTail() == rhs.getTail();
}

和其它适用于类型列表和元组的算法类似,逐元素的比较两个元组,会先比较首元素,然后递归地比较剩余的元素,最终会调用operator的基本情况结束递归。运算符!=,<,>,以及>=的实现方式都与之类似。

输出

贯穿本章始终,我们一直都在创建新的元组类型,因此最好能够在执行程序的时候看到这些元组。下面的operator<<运算符会打印那些元素类型可以被打印的元组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
void printTuple(std::ostream& strm, Tuple<> const&, bool isFirst = true)
{
strm << ( isFirst ? "(": ")");
}
template<typename Head, typename... Tail>
void printTuple(std::ostream& strm, Tuple<Head, Tail...> const& t, bool isFirst = true)
{
strm << ( isFirst ? "(" : ", " );
strm << t.getHead();
printTuple(strm, t.getTail(), false);
}
template<typename ... Types>
std::ostream& operator<<(std::ostream& strm, Tuple<Types...> const& t)
{
printTuple(strm, t);
return strm;
}

这样就可以很容易地创建并打印元组了。比如:

1
std::cout << makeTuple(1, 2.5, std::string("hello")) << "\n";

会打印出:

1
(1, 2.5, hello)

元组的算法

元组是一种提供了以下各种功能的容器:可以访问并修改其元素的能力(通过get<>),创建新元组的能力(直接创建或者通过使用makeTuple<>创建),以及将元组分割成headtail的能力(通过使用getHead()getTail())。使用这些功能足以创建各种各样的元组算法,比如添加或者删除元组中的元素,重新排序元组中的元素,或者选取元组中元素的某些子集。

元组很有意思的一点是它既需要用到编译期计算也需要用到运行期计算。将某种算法作用与元组之后可能会得到一个类型迥异的元组,这就需要用到编译期计算。比如反转元组Tuple<int, double, string>会得到Tuple<string, double, int>

但是和同质容器的算法类似(比如作用域std::vector的std::reverse()),元组算法是需要在运行期间执行代码的,因此我们需要留意被产生出来的代码的效率问题。

将元组用作类型列表

通过使用一些部分特例化,可以将Tuple变成一个功能完整的Typelist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// determine whether the tuple is empty:
template<>
struct IsEmpty<Tuple<>> {
static constexpr bool value = true;
};
// extract front element:
template<typename Head, typename... Tail>
class FrontT<Tuple<Head, Tail...>> {
public:
using Type = Head;
};
// remove front element:
template<typename Head, typename... Tail>
class PopFrontT<Tuple<Head, Tail...>> {
public:
using Type = Tuple<Tail...>;
};
// add element to the front:
template<typename... Types, typename Element>
class PushFrontT<Tuple<Types...>, Element> {
public:
using Type = Tuple<Element, Types...>;
};
// add element to the back:
template<typename... Types, typename Element>
class PushBackT<Tuple<Types...>, Element> {
public:
using Type = Tuple<Types..., Element>;
};

这样就可以很方便的处理元组的类型了。比如:

1
2
3
4
Tuple<int, double, std::string> t1(17, 3.14, "Hello, World!");
using T2 = PopFront<PushBack<decltype(t1), bool>>;
T2 t2(get<1>(t1), get<2>(t1), true);
std::cout << t2;

会打印出:

1
(3.14, Hello, World!, 1)

很快就会看到,将typelist算法用于tuple,通常是为了确定tuple算法返回值的类型。

添加以及删除元素

对于Tuple,能否向其头部或者尾部添加元素,对开发相关的高阶算法而言是很重要的。和typelist的情况一样,向头部插入一个元素要远比向尾部插入一个元素要简单,因此我们从pushFront开始:

1
2
3
4
5
6
template<typename... Types, typename V>
PushFront<Tuple<Types...>, V>
pushFront(Tuple<Types...> const& tuple, V const& value)
{
return PushFront<Tuple<Types...>, V>(value, tuple);
}

将一个新元素(称之为value)添加到一个已有元组的头部,需要生成一个新的、以value为head、以已有tuple为tail的元组。返回结过的类型是Tuple<V, Types...>。不过这里我们选择使用typelist的算法PushFront来获得返回类型,这样做可以体现出tuple算法中编译期部分和运行期部分之间的紧密耦合关系:编译期的PushFront计算出了我们应该生成的运行期结果的类型。

将一个新元素添加到一个已有元组的末尾则会复杂得多,因为这需要遍历一个元组。

1
2
3
4
5
6
7
8
9
10
11
12
13
// basis case
template<typename V>
Tuple<V> pushBack(Tuple<> const&, V const& value)
{
return Tuple<V>(value);
}
// recursive case
template<typename Head, typename... Tail, typename V>
Tuple<Head, Tail..., V>
pushBack(Tuple<Head, Tail...> const& tuple, V const& value)
{
return Tuple<Head, Tail..., V>(tuple.getHead(), pushBack(tuple.getTail(), value));
}

对于基本情况,和预期的一样,会将值追加到一个长度为零的元组的后面。对于递归情况,则将元组分为head和tail两部分,然后将首元素以及将新元素追加到tail的后面得到结果组装成最终的结果。虽然这里我们使用的返回值类型是Tuple<Head, Tail..., V>,但是它和编译期的PushBack<Tuple<Hrad, Tail...>, V>是一样的。

同样地,popFront()也很容易实现:

1
2
3
4
5
template<typename... Types>
PopFront<Tuple<Types...>> popFront(Tuple<Types...> const& tuple)
{
return tuple.getTail();
}

现在我们可以像下面这样编写例子:

1
2
3
Tuple<int, double, std::string> t1(17, 3.14, "Hello, World!");
auto t2 = popFront(pushBack(t1, true));
std::cout << std::boolalpha << t2 << "\n";

打印结果为:

1
(3.14, Hello, World!, true)

元组的反转

元组的反转可以采用另一种递归的类型列表的反转方式实现:

1
2
3
4
5
6
7
8
9
10
11
// basis case
Tuple<> reverse(Tuple<> const& t)
{
return t;
}
// recursive case
template<typename Head, typename... Tail>
Reverse<Tuple<Head, Tail...>> reverse(Tuple<Head, Tail...> const& t)
{
return pushBack(reverse(t.getTail()), t.getHead());
}

基本情况比较简单,而递归情况则是递归地将head追加到反转之后的tail的后面。也就是说:

1
reverse(makeTuple(1, 2.5, std::string("hello")))

会生成一个包含了string(“hello”),2.5,和1的类型为Tuple<string, double, int>的元组。和类型列表类似,现在就可以简单地通过先反转元组,然后调用popFront(),然后再次反转元组实现popBack():

1
2
3
4
template<typename... Types>
PopBack<Tuple<Types...>> popBack(Tuple<Types...> const& tuple){
return reverse(popFront(reverse(tuple)));
}

索引列表

虽然上文中反转元组用到的递归方式是正确的,但是它在运行期间的效率却非常低。为了展现这一问题,引入下面这个可以计算其实例被copy次数的类:

1
2
3
4
5
6
7
8
9
10
template<int N>
struct CopyCounter
{
inline static unsigned numCopies = 0;
CopyCounter()
{ }
CopyCounter(CopyCounter const&) {
++numCopies;
}
};

然后创建并反转一个包含了CopyCounter实例的元组:

1
2
3
4
5
6
7
8
9
10
void copycountertest()
{
Tuple<CopyCounter<0>, CopyCounter<1>, CopyCounter<2>, CopyCounter<3>, CopyCounter<4>> copies;
auto reversed = reverse(copies);
std::cout << "0: " << CopyCounter<0>::numCopies << " copies\n";
std::cout << "1: " << CopyCounter<1>::numCopies << " copies\n";
std::cout << "2: " << CopyCounter<2>::numCopies << " copies\n";
std::cout << "3: " << CopyCounter<3>::numCopies << " copies\n";
std::cout << "4: " << CopyCounter<4>::numCopies << " copies\n";
}

这个程序会打印出:

1
2
3
4
5
0: 5 copies
1: 8 copies
2: 9 copies
3: 8 copies
4: 5 copies

这确实进行了很多次copy!在理想的实现中,反转一个元组时,每一个元素只应该被copy一次:从其初始位置直接被copy到目的位置。我们可以通过使用引用来达到这一目的,包括对中间变量的类型使用引用,但是这样做会使实现变得很复杂。

在反转元组时,为了避免不必要的copy,考虑一下我们该如何实现一个一次性的算法,来反转一个简单的、长度已知的元组(比如包含5个元素)。可以像下面这样只是简单地使用makeTuple()get():

1
2
auto reversed = makeTuple(get<4>(copies), get<3>(copies), get<2>(copies),
get<1>(copies), get<0>(copies));

这个程序会按照我们预期的那样进行,对每个元素只进行一次copy:

1
2
3
4
5
0: 1 copies
1: 1 copies
2: 1 copies
3: 1 copies
4: 1 copies

索引列表通过将一组元组的索引捕获进一个参数包,推广了上述概念,本例中的索引列表是4,3,2,1,0,这样就可以通过包展开进行一组get函数的调用。采用这种方法可以将索引列表的计算(可以采用任意复杂度的模板源程序)和使用(更关注运行期的性能)分离开。在C++14中引入的标准类型std::integer_sequence,通常被用来表示索引列表。

元组的展开

在需要将一组相关的数值存储到一个变量中时(不管这些相关数值的数量是多少、类型是什么),元组会很有用。在某些情况下,可能会需要展开一个元组(比如在需要将其元素作为独立参数传递给某个函数的时候)。作为一个简单的例子,可能需要将一个元组的元素传递给变参print()

1
2
Tuple<std::string, char const*, int, char> t("Pi", "is roughly", 3, "\n");
print(t...); //ERROR: cannot expand a tuple; it isn"t a parameter pack

正如例子中注释部分所讲的,这个“明显”需要展开一个元组的操作会失败,因为它不是一个参数包。不过我们可以使用索引列表实现这一功能。下面的函数模板apply()接受一个函数和一个元组作为参数,然后以展开后的元组元素为参数,去调用这个函数:

1
2
3
4
5
6
7
8
9
10
11
template<typename F, typename... Elements, unsigned... Indices>
auto applyImpl(F f, Tuple<Elements...> const& t,
Valuelist<unsigned, Indices...>) ->decltype(f(get<Indices>(t)...))
{
return f(get<Indices>(t)...);
}
template<typename F, typename... Elements, unsigned N = sizeof...(Elements)>
auto apply(F f, Tuple<Elements...> const& t) ->decltype(applyImpl(f, t, MakeIndexList<N>()))
{
return applyImpl(f, t, MakeIndexList<N>());
}

函数模板applyImpl()会接受一个索引列表作为参数,并用其将元组中的元素展开成一个适用于函数对象f的参数列表。而供用户直接使用的apply()则只是负责构建初始的索引列表。这样就可以将一个元组扩展成print()的参数了:

1
2
Tuple<std::string, char const*, int, char> t("Pi", "is roughly", 3, "\n");
apply(print, t); //OK: prints Pi is roughly 3

元组的优化

元组是一种基础的、潜在用途广泛的异质容器。因此有必要考虑下该怎么在运行期(存储和执行时间)和编译期(实例化的数量)对其进行优化。本节将介绍一些适用于上文中实现的元组的特定优化方案。

元组和EBCO

我们实现的元组,其存储方式所需要的存储空间,要比其严格意义上所需要的存储空间多。其中一个问题是,tail成员最终会是一个空的数值(因为所有非空的元组都会以一个空的元组作为结束),而任意数据成员又总会至少占用一个字节的内存。为了提高元组的存储效率,可以使用空基类优化(EBCO,empty base class optimization),让元组继承自一个尾元组(tail tuple),而不是将尾元组作为一个成员。比如:

1
2
3
4
5
6
7
8
9
10
11
12
// recursive case:
template<typename Head, typename... Tail>
class Tuple<Head, Tail...> : private Tuple<Tail...>
{
private:
Head head;
public:
Head& getHead() { return head; }
Head const& getHead() const { return head; }
Tuple<Tail...>& getTail() { return *this; }
Tuple<Tail...> const& getTail() const { return *this; }
};

这和BaseMemberPair使用的优化方式一致。不幸的是,这种方式有其副作用,就是颠倒了元组元素在构造函数中被初始化的顺序。在之前的实现中,head成员在tail成员前面,因此head总是会先被初始化。在新的实现方式中,tail则是以基类的形式存在,因此它会在head成员之前被初始化。

这一问题可以通过将head成员放入其自身的基类中,并让这个基类在基类列表中排在tail的前面来解决。该方案的一个直接实现方式是,引入一个用来封装各种元素类型的TupleElt模板,并让Tuple继承自它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
template<typename... Types>
class Tuple;
template<typename T>
class TupleElt
{
T value;
public:
TupleElt() = default;
template<typename U>
TupleElt(U&& other) : value(std::forward<U>(other) { }
T& get() { return value; }
T const& get() const { return value; }
};
// recursive case:
template<typename Head, typename... Tail>
class Tuple<Head, Tail...>
: private TupleElt<Head>, private Tuple<Tail...>
{
public:
Head& getHead() {
// potentially ambiguous
return static_cast<TupleElt<Head> *>(this)->get();
}
Head const& getHead() const {
// potentially ambiguous
return static_cast<TupleElt<Head> const*>(this)->get();
}
Tuple<Tail...>& getTail() { return *this; }
Tuple<Tail...> const& getTail() const { return *this; }
};
// basis case:
template<>
class Tuple<> {
// no storage required
};

虽然这一方式解决了元素初始化顺序的问题,但是却引入了一个更糟糕的问题:如果一个元组包含两个类型相同的元素(比如Tuple<int, int>),我们将不再能够从中提取元素,因为此时从Tuple<int, int>TupleElt<int>的转换(自派生类向基类的转换)不是唯一的(有歧义)。为了打破歧义,需要保证在给定的Tuple中每一个TupleElt基类都是唯一的。一个方式是将这个值的“高度”信息(也就是tail元组的长度信息)编码进元组中。元组最后一个元素的高度会被存储生0,倒数第一个元素的长度会被存储成1,以此类推:

1
2
3
4
5
6
7
8
9
10
template<unsigned Height, typename T>
class TupleElt {
T value;
public:
TupleElt() = default;
template<typename U>
TupleElt(U&& other) : value(std::forward<U>(other)) { }
T& get() { return value; }
T const& get() const { return value; }
};

通过这一方式,就能够实现一个即使用了EBCO优化,又能保持元素的初始化顺序,并支持包含相同类型元素的元组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<typename... Types>
class Tuple;
// recursive case:
template<typename Head, typename... Tail>
class Tuple<Head, Tail...>
: private TupleElt<sizeof...(Tail), Head>, private Tuple<Tail...>
{
using HeadElt = TupleElt<sizeof...(Tail), Head>;
public:
Head& getHead() {
return static_cast<HeadElt *>(this)->get();
}
Head const& getHead() const {
return static_cast<HeadElt const*>(this)->get();
}
Tuple<Tail...>& getTail() { return *this; }
Tuple<Tail...> const& getTail() const { return *this; }
};
// basis case:
template<>
class Tuple<> {
// no storage required
};

基于这一实现,下面的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <algorithm>
#include "tupleelt1.hpp"
#include "tuplestorage3.hpp"
#include <iostream>
struct A {
A() {
std::cout << "A()" << "\n";
}
};
struct B {
B() {
std::cout << "B()" << "\n";
}
};
int main()
{
Tuple<A, char, A, char, B> t1;
std::cout << sizeof(t1) << " bytes" << "\n";
}

会打印出:

1
2
3
4
A()
A()
B()
5 bytes

从中可以看出,EBCO使得内存占用减少了一个字节(减少的内容是空元组Tuple<>)。但是请注意A和B都是空的类,这暗示了进一步用EBCO进行优化的可能。如果能够安全的从其元素类型继承的话,那么就让TupleElt继承自其元素类型(这一优化不需要更改Tuple的定义):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <type_traits>
template<unsigned Height, typename T,
bool = std::is_class<T>::value && !std::is_final<T>::value>
class TupleElt;
template<unsigned Height, typename T>
class TupleElt<Height, T, false>
{
T value;
public:
TupleElt() = default;
template<typename U>
TupleElt(U&& other) : value(std::forward<U>(other)) { }
T& get() { return value; }
T const& get() const { return value; }
};
template<unsigned Height, typename T>
class TupleElt<Height, T, true> : private T
{
public:
TupleElt() = default;
template<typename U>
TupleElt(U&& other) : T(std::forward<U>(other)) { }
T& get() { return *this; }
T const& get() const { return *this; }
};

当提供给TupleElt的模板参数是一个可以被继承的类的时候,它会从该模板参数做private继承,从而也可以将EBCO用于被存储的值。有了这些变化,之前的程序会打印出:

1
2
3
4
A()
A()
B()
2 bytes

常数时间的get()

在使用元组的时候,get()操作的使用是非常常见的,但是其递归的实现方式需要用到线性次数的模板实例化,这会影响编译所需要的时间。幸运的是,基于在之前章节中介绍的EBCO,可以实现一种更高效的get,我们接下来会对其进行讨论。

主要的思路是,当用一个(基类类型的)参数去适配一个(派生类类型的)参数时,模板参数推导会为基类推断出模板参数的类型。因此,如果我们能够计算出目标元素的高度H,就可以不用遍历所有的索引,也能够基于从Tuple的特化结果向TupleElt<H,T>(T的类型由推断得到)的转化提取出相应的元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<unsigned H, typename T>
T& getHeight(TupleElt<H,T>& te)
{
return te.get();
}
template<typename... Types>
class Tuple;
template<unsigned I, typename... Elements>
auto get(Tuple<Elements...>& t) ->
decltype(getHeight<sizeof...(Elements)-I-1>(t))
{
return getHeight<sizeof...(Elements)-I-1>(t);
}

由于get<I>(t)接收目标元素(从元组头部开始计算)的索引I作为参数,而元组的实际存储是以高度H来衡量的(从元组的末尾开始计算),因此需要用H来计算I。真正的查找工作是由调用getHeight()时的参数推导执行的:由于H是在函数调用时显示指定的,因此它的值是确定的,这样就只会有一个TupleElt会被匹配到,其模板参数T则是通过推断得到的。

这里必须要将getHeight()声明Tuple的friend,否则将无法执行从派生类向private父类的转换。比如:

1
2
3
4
// inside the recursive case for class template Tuple:
template<unsigned I, typename... Elements>
friend auto get(Tuple<Elements...>& t)
-> decltype(getHeight<sizeof...(Elements)-I-1>(t));

由于我们已经将繁杂的索引匹配工作转移到了编译器的模板推断那里,因此这一实现方式只需要常数数量的模板实例化。

元组下标

理论上也可以通过定义operator[]来访问元组中的元素,这和在std::vector中定义operator[]的情况类似。不过和std::vector不同的是,元组中元素的类型可以不同,因此元组的operator[]必须是一个模板,其返回类型也需要随着索引的不同而不同。这反过来也就要求每一个索引都要有不同的类型,因为需要根据索引的类型来决定元素的类型。

使用类模板CTValue,可以将数值索引编码进一个类型中。将其用于Tuple下标运算符定义的代码如下:

1
2
3
4
template<typename T, T Index>
auto& operator[](CTValue<T, Index>) {
return get<Index>(*this);
}

然后就可以基于被传递的CTValue类型的参数,用其中的索引信息去执行相关的get<>()调用。上述代码的用法如下:

1
2
3
auto t = makeTuple(0, "1", 2.2f, std::string{"hello"});
auto a = t[CTValue<unsigned, 2>{}];
auto b = t[CTValue<unsigned, 3>{}];

变量ab分别会被Tuple t中的第三个和第四个参数初始化成相应的类型和数值。为了让常量索引的使用变得更方便,我们可以用constexpr实现一种字面常量运算符,专门用来直接从以_c结尾的常规字面常量,计算出所需的编译期数值字面常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include "ctvalue.hpp"
#include <cassert>
#include <cstddef>
// convert single char to corresponding int value at compile time:
constexpr int toInt(char c) {
// hexadecimal letters:
if (c >= "A"&& c <= "F") {
return static_cast<int>(c) - static_cast<int>("A") + 10;
}
if (c >= "a"&& c <= "f") {
return static_cast<int>(c) - static_cast<int>("a") + 10;
}
// other (disable "."for floating-point literals):
assert(c >= "0"&& c <= "9");
return static_cast<int>(c) - static_cast<int>("0");
}
// parse array of chars to corresponding int value at compile time:
template<std::size_t N>
constexpr int parseInt(char const (&arr)[N]) {
int base = 10; // to handle base (default: decimal)
int offset = 0; // to skip prefixes like 0x
if (N > 2 && arr[0] == "0") {
switch (arr[1]) {
case "x": //prefix 0x or 0X, so hexadecimal
case "X":
base = 16;
offset = 2;
break;
case "b": //prefix 0b or 0B (since C++14), so binary
case "B":
base = 2;offset = 2;
break;
default: //prefix 0, so octal
base = 8;
offset = 1;
break;
}
}
// iterate over all digits and compute resulting value:
int value = 0;
int multiplier = 1;
for (std::size_t i = 0; i < N - offset; ++i) {
if (arr[N-1-i] != "\"") { //ignore separating single quotes (e.g. in 1’ 000)
value += toInt(arr[N-1-i]) * multiplier;
multiplier *= base;
}
}
return value;
}
// literal operator: parse integral literals with suffix _c as sequence of chars:
template<char... cs>
constexpr auto operator"" _c() {
return CTValue<int, parseInt<sizeof...(cs)>({cs...})>{};
}

此处我们用到了这样一个事实,对于数值字面常量,可以用字面常量运算符推导出该字面常量的每一个字符,并将其用作字面常量运算符模板的参数。然后将这些字符传递给一个constexpr类型的辅助函数parseInt()(它可以计算出字符串序列的值,并将其按照CTValue类型返回)。比如:

  • 42_c生成CTValue<int,42>
  • 0x815_c生成CTValue<int,2069>
  • 0b1111’1111_c生成CTValue<int,255>

注意该程序不会处理浮点型字面值常量。对这种情况,相应的assert语句会触发编译期错误,因为这是一个运行期的特性,不能用在编译期上下文中。基于以上内容,可以像下面这样使用元组:

1
2
3
auto t = makeTuple(0, "1", 2.2f, std::string{"hello"});
auto c = t[2_c];
auto d = t[3_c];

高性能计算|硬件架构与基准测试

高性能计算」(High performance computing,HPC)指通常使用很多处理器(作为单个机器的一部分)或者某一集群中组织的几台计算机(作为单个计算资源操作)的计算系统和环境。有许多类型的 HPC 系统,其范围从标准计算机的大型集群,到高度专用的硬件。 大多数基于集群的 HPC 系统使用高性能网络互连,比如一些来自 InfiniBand 或 Myrinet 的网络互连。基本的网络拓扑和组织可以使用一个简单的总线拓扑,在性能很高的环境中,网状网络系统在主机之间提供较短的潜伏期,所以可改善总体网络性能和传输速率。

高性能计算架构史

当代计算机的原型最早可追溯到1943年的 Colossus,第二次世界大战期间,英国曾用该机器来破解截获纳粹德国的无线电报信息。两年后,美国研制了人类历史上第一台计算机 ENIAC ,用来快速计算炮兵射击表。

回顾计算机的历史,人们最早设计了「固定程序计算机」(Fixed Program Computer),如:算盘、计算器等,这种计算机的特点是无法编程,只能解决固定的问题,通用性较差。这显然不符合我们设计计算机的初衷,如何设计出可编程的计算机?

我们常说的「程序」,无非就是指一系列指令的集合。如果能将这些指令存储在计算机中,人们可以随意编程,计算机就会更加通用。可是程序应该怎么存储?历史上出现了两种不同的声音:

  • Harvard 架构:将程序和数据存储在不同的内存中。
  • Princeton 架构:将程序与数据共同存储在内存。

如果生活在当时,我一定会选择第一种,但天才的冯·诺依曼却并不认同。

我们先不急着介绍冯·诺依曼架构,而是看一下现代计算机体系架构。学过《深入理解计算机系统》的同学可能对现代计算机体系架构并不陌生,下图的PC指的是「程序计数器」(Program Counter),控制着整个 CPU 内部指令执行;「ALU」为算数/逻辑运行单元,负责高速计算。

在计算机中,将数据从处理器移动到 CPU 、磁盘控制器或屏幕的线路被称为「总线」(busses)。对我们来说最重要的是连接 CPU 和内存的「前端总线」(Front-Side Bus,FSB)。在当前较为流行的架构中,这被称为“「北桥」(north bridge)”,与连接外部设备(除了图形控制器)的“「南桥」(south bridge)”相对。总线通常比处理器的速度慢,这也是造成冯·诺依曼架构瓶颈的原因之一。

在本图中,连接主存和 CPU 的线路在整幅图的上方,而连接外部设备的总线位于整幅图的下方。我们可以诙谐地通过:“上北下南”的方式来记忆北桥和南桥。

传统的冯·诺依曼计算机是以运算器为中心,而当代计算机则是以存储器(内存和寄存器堆组成的存储器结构)为中心。冯·诺依曼架构(即 Princeton 架构)指出:程序和数据应当共同存储在内存中;而 Harvard 架构 指出:程序和数据应当分开存储。

冯·诺依曼架构(即 Princeton 架构)图:

Harvard 架构图:

尽管当代计算机是以冯·诺依曼架构为主,但这并不意味着 Harvard 架构是错的,事实上两者各有利弊。冯诺依曼架构的瓶颈为:运算器的速度太快, CPU 与内存之间的路径太窄,以至于内存无法及时给运算器提供“材料”,这正是影响性能的致命因素。

通常情况下,我们将冯·诺依曼架构的缺陷归结为「访存墙」(Memory Wall),即:

  • 计算机具有单一的线性内存,指令和数据只有在使用时才进行隐式区分;
  • 总性能受到内存的读写总线所能提供的延迟和带宽限制。

Harvard 架构设计的初衷正是为了减轻程序运行时 CPU 和存储器信息交换的瓶颈,其 CPU 通常具有较高的执行效率。目前,使用Harvard 架构的 CPU 和处理器有很多,除了所有的DSP处理器,还有摩托罗拉公司的MC68系列、Zilog公司的Z8系列、ATMEL公司的AVR系列和ARM公司的ARM9、ARM10和ARM11等。目前使用冯·诺依曼架构的 CPU 和微控制器也有很多,其中包括英特尔公司的8086及其他 CPU ,ARM公司的ARM7、MIPS公司的MIPS处理器也采用了冯·诺依曼架构。

让我们把目光拉回到现代计算机上。在 ENIAC 之后,大部分处理器都是将数据一个一个传入运算器中,这在某种程度上限制了运算器的发挥。为了追求更高性能,人们希望设计出一款运算器用一条指令就能运算多条数据的机器,即数据以向量的形式被处理。于是「向量机」(Vector Machine)应运而生。

与一次只能处理一个数据的标量处理器正相反,向量处理器可以在特定工作环境中极大地提升性能,尤其是在数值模拟或者相似领域。向量处理器最早出现于20世纪70年代早期,并在70年代到90年代期间成为超级计算机设计的主导方向。以Cray-1为例,基本信息如下:

处理器个数 处理器频率 内存大小 存储大小 性能
1 80MHz 8.39MB 303MB 160 MFLOPS

这使得它远远超过同时代的其他机器。

随后的70年代到90年代之间,人们又基于向量机设计出了「并行向量处理器」(Parallel Vector Processors,PVP),即同时布置多个向量机并通过共享内存实现交互。并行向量处理器最大的特点是系统中拥有多个 CPU (即处理器),同时每个处理器都是由专门定制的「向量处理器」(VP)组成。

具有代表性的例子就是Cray-2代多处理器计算机:

然而,由于90年代末常规处理器设计性能提升,而价格快速下降,基于向量处理器的超级计算机逐渐让出了主导地位,但这并不意味着向量处理器已经过时。

现在,绝大多数商业化的 CPU 实现都能够提供某种形式的向量处理的指令,用来处理多个(向量化的)数据集,也就是所谓的 SIMD(单一指令、多重数据)。常见的例子有VIS、MMX、SSE、AltiVec和AVX。向量处理技术也能在游戏主机硬件和图形加速硬件上看到。在2000年,IBM,东芝和索尼合作开发了Cell处理器,集成了一个标量处理器和八个向量处理器,应用在索尼的PlayStation 3游戏机和其他一些产品中。

随后,一些厂商又提出了「分布式并行机」(Parallel Processors,PP):通过高性能网络连接多个分布式存储节点,每个节点由商用微处理芯片组成。

以Intel Paragon XP/S 140 并行机为例:

处理器个数 处理器频率 内存大小 访存带宽 网络带宽 总性能
3680 50 MHz 128 MB 400 MB/s 175 MB/s 143 GFLOPS

与分布式并行机几乎同时代又出现了另一种并行计算机架构:「对称多处理机」(Symmetric Multiprocessors,SMP),它通过高性能网络连接 多个高性能微处理芯片,芯片之间通过共享内存交互。

以SUN Ultra E10000 多处理机为例:

处理器个数 处理器性能 处理器频率 内存大小 网络带宽 总性能
64 1 GFLOPS 250 MHz 64GB 12.8 GB/s 25 GFLOPS

所谓对称多处理器结构,是指服务器中多个 CPU 对称工作,无主次或从属关系。各 CPU 共享相同的物理内存,每个 CPU 访问内存中的任何地址所需时间是相同的,因此 SMP 也被称为「一致存储器访问结构」 (UMA : Uniform Memory Access)。对 SMP 服务器进行扩展的方式包括增加内存使用更快的 CPU增加CPU扩充 I/O(槽口数与总线数)以及添加更多的外部设备(通常是磁盘存储)。

上图中仅罗列出了一个节点包含一个 CPU 的情况,事实上,UMA架构中的节点通常为一个「插槽」(socket),一个插槽上可能有一个 CPU ,也可能有多个 CPU 。“几路几核”通常表示:表示有多少个插槽,每个插槽有多少核。

双路单核的UMA架构:

双路双核的UMA架构:

上述UMA架构的问题在于:每个插槽内各处理器只共享 L2 级缓存,但并不共享内存,这会导致处理器和内存之间速度不匹配等问题。如何改进这种弊端?我们在每个插槽内部共享一个内存,并令各个插槽内共享的缓存数据同步,即著名的「缓存一致性的非一致内存访问架构」(cache-coherent Nonuniform Memory Access,ccNUMA)架构。

双路四核的ccNUMA架构:

另有「分布式共享并行机」(Distributed Share Memory,DSM)通过高性能网络连接多个高性能微处理芯片,每个芯片拥有局部内存,但所有局部内存都能实现全局共享。

上图中仅指出了一个节点包含两个处理器的情况,然而在真实计算机架构中这要复杂得多。

高性能节点架构

高性能计算拓扑结构如图所示,从硬件结构上,高性能计算系统包含「计算节点」、「IO节点」、「登录节点」、「管理节点」、「高速网络」、「存储系统」等组成。

hpc_1

从体系结构看,除了以出色的性价比占据主流的集群系统外,传统的MPP系统仍然以其不可替代的架构和性能优势占据一席之地,且Cray、IBM、Fujitsu等主流MPP厂商的不同产品又可以细分为SMP、CC-NUMA、向量机等不同种类。不同体系结构的计算机在性能表现方面有着先天的区别。

计算节点是高性能集群中的最主要的计算能力的体现,目前,主流的计算节点有「同构节点」和「异构节点」两种类型。

  1. 同构计算节点是指集群中每个计算节点完全由 CPU 计算资源组成,目前,在一个计算节点上可以支持单路、双路、四路、八路等 CPU 计算节点。

Intel 和 AMD CPU 型号、参数详见:http://www.techpowerup.com/CPUdb

  1. 异构计算技术从 80 年代中期产生,由于它能经济有效地获取高性能计算能力、可扩展性好、计算资源利用率高、发展潜力巨大,目前已成为并行/分布计算领域中的研究热点之一。异构计算的目的一般是加速和节能。

目前,主流的异构计算有: CPU +GPU, CPU +MIC, CPU +FPGA

  • CPU +GPU 异构计算

在 CPU +GPU 异构计算中,用 CPU 进行复杂逻辑和事务处理等串行计算,用 GPU 完成大规模并行计算,即可以各尽其能,充分发挥计算系统的处理能力。由于 CPU +GPU 异构系统上,每个节点 CPU 的核数也比较多,也具有一定的计算能力,因此, CPU 除了做一些复杂逻辑和事务处理等串行计算,也可以与 GPU 一起做一部分并行计算,做到真正的 CPU +GPU 异构协同计算。 目前,主流的 GPU 厂商有 NVIDIA 和 AMD。

各 GPU 详细参数请查阅:http://www.techpowerup.com/gpudb/

  • CPU +MIC 异构计算

2012 年底,Intel 公司正式推出了基于集成众核(Many Integrated Core, MIC)架构的至强融核(Intel Xeon Phi)系列产品,用于解决高度并行计算问题。第一代 MIC 正式产品为 KNC(Knights Corner),该产品双精度性能达到每秒一万亿次以上。

各型号 MIC 卡详细参数请查阅:http://www.techpowerup.com/gpudb

不同架构模式的应用环境

随着超算应用的增多和对计算量需求的增大,CPU逐渐在某些领域中显露疲态。为解决这个问题,「异构运算」应运而生。异构是指与传统 CPU 不同架构的计算设备,如 GPU、MIC 等新型计算设备。这些新的计算设备通常拥有远高于 CPU 的并行计算能力。GPU 原本并非用于高性能计算,而是用于图形显示。但 GPU所特有的硬件架构,天然适合高并发度的计算。但利用 GPU 编程,需要将通用问题转化为图形显示问题,因此编程门槛较高,很少有人利用 GPU 进行高性能计算并行应用开发。

异构开发环境有「先进精简指令机」(advanced RISC machine,ARM)、「现场可编程门阵列」(field programmable gate array, FPGA)等非 x86 架构环境。

异构环境并非抛弃 CPU,而是 CPU 与心的计算原件相结合,并行进行计算。无论是 GPU 还是 MIC,都是以 PCI-E 接口与现有 x86 节点进行连接,以协处理器的方式与现有节点进行集成。因此在不破坏原有集群的情况下,增加一部分算力。当前,采用异构集群的高性能计算集群在 Top500 中越来越多,表明异构集群方案越来越多地被业界采用。

虽然在硬件方面,异构集群与传统集群相比并没有革命性的变化,通常可以在现有集群的基础上在节点内增加异构计算卡的方式,将传统同构集群转换为异构集群。但在软件方面,可能会有较大的改变。由于使用异构硬件的方法并不相同,因此需要针对 CPU 和 GPU/MIC 使用不同的软件环境和编写相应的代码。更重要的是,因为异构集群中计算设备的结构不同,所以需要针对新的情况,使用新的方法。针对新的 GPU/MIC 设备,将不能使用传统的 CPU 编程思想,而是转而使用高并行的 GPU/MIC 编程思想。这一思想的转变,有时比方式的转变更难以接受。

CPU 并行架构

一般来说,集群中每个节点都使用相同的配置,以方便管理和性能优化。节点一般为标准机架式服务器,配有双路或四路的多核服务器版 CPU(如Intel Xeon系列),每个核心搭配 4GB 内存或更多。节点之间采用以太网线(千兆网线或万兆网线)或InfiniBand网线,通过相应的交换机互相连接。节点与节点属于同一个内网,并各自拥有不重复的内网 IP 地址,通常集群与外网物理隔离,以保证集群的安全性。

由于上述高性能并行架构采用的是分布式内存结构,因此并行软件环境需要采用消息传递的网络通信方式,如:MPI。

MPI 环境配置步骤主要如下:

  1. 解压、安装:解压安装包后使用./configuremake 命令安装。
  2. 放权:为了能使多个机器上同时运行 MPI 程序,首先要允许启动 MPI 程序的机器能够顺利访问到其他机器。简单起见,最好在整个集群的每一个节点机傻姑娘都建立相同的账户名,使得 MPI 程序在相同的账户下运行。放权设置完成后,可以在任何一个节点,使用相同的账户名,不需要输入密码。
  3. 运行:程序在不同节点可以放置在不同目录下,但为了方便管理,建议放在同样的目录下,放置在共享存储时最佳。使用哪些节点、每个节点内开启几个 MPI 进程,是由配置文件决定的,每个应用可以采用不同的节点配置文件,以便根据不同饿的应用选择不同的方案,避免闲置计算资源或达不到最好的利用效果。

其他情况见下表:

软件部分 种类
编译器/调试工具 GNU、GDB
高性能函数库 Intel MKL(商业软件)
性能调试工具 gprof(编译时添加-pg
Intel VTune(商业软件)

CPU+MIC 异构

异构环境中 GPU 和MIC 是以附加板卡的形式附着于现有系统的,因此在硬件环境上,不需要对集群的整体结构进行任何改变,只需要在节点内部增加协处理器或将节点内部增加协处理器或将节点更换为可以使用协处理器的节点。

由于 GPU 和 MIC 在硬件环境构建中,对集群结构没有区别,因此以 MIC 为例说明异构系统的硬件环境,异构并行环境架构如图:

使用异构协处理器除了搭建传统高效能并行应用环境需要注意的几个问题以外,还有三点特别之处需要注意:

  1. 传统并行应用环境中节点,不一定有多余或合适的 PCI-E 插槽,也就不一定能够附加 GPU 或 MIC 卡。但是异构并行开发环境中,其中的节点必须是传统并行环境节点中拥有 PCI-E 接口,可以增加 GPU 或 MIC 卡的类型。
  2. 由于增加了协处理器,所以节点的功耗需求大大增加,通常每增加一块协处理器,需要增加300W左右的电源供应。
  3. 由于增加了协处理器,所以对节点的散热提出了更高的要求。在搭建异构集群时,不仅需要看节点是否能够支持协处理器,还要注意节点的散热能力。

通用软件环境与传统并行应用软件环境极为类似,不仅同样可以分为操作系统、并行环境等方面,而且异构模式下的通用软件环境,也基本与传统软件环境相同。

在并行环境方面,一般需要选择 OpenMP、MPI 等通用并行库,如果支持 InfiniBand硬件,则尽量使用InfiniBand驱动。

在并行运行环境方面,MIC 支持标准的 OpenMP 和 MPI 库,但是如果想充分利用 MIC 的特性,则需要安装使用 Intel 的MPI 库。

软件部分 种类
编程语言 C、C++、Fortran、OpenCL(最广泛)
编译器/调试工具 Intel 的 MIC 编译器/IDB
数学库 MIC 高性能数学库、Intel MKL
性能调优工具 Intel VTune(其他大部分Intel工具也都可以应用)

相关资源:

  1. IDF:英特尔信息技术峰会是由 Intel 公司主办的技术讲座,在美国、中国等7个地区举办,每年分春秋两次。 IDF 主要是由主题演讲、技术专题讲座和技术展示组成,主题演讲的演讲者军事 Intel 的高层人士,演讲的题目都是具有相当前瞻性。可以浏览相关网址搜索相关信息:IDF官网

  2. MIC 计算论坛:论坛官网

更多资源可以访问:英特尔开发者

CPU + GPU 异构

GPU 专用软件环境根据生产厂商不同,其环境也有所不同。但一般都需要硬件驱动、运行时库等几个基本部分。本部分以 CUDA 为例,讨论相关问题。

运行时库包含异构程序运行时需要用到的库的集合,这些库调用了驱动程序的一些接口,使程序能够运用 GPU 硬件进行计算。

需要特别注意的是,驱动程序必须在每个节点上安装,因为每个节点都有 GPU 卡,所以需要在每个节点的操作系统上配置驱动程序。而对于运行时库来说,仅在应用程序需要时才会用到,因此可以在共享目录中安装一份,以供全部节点使用。这种方式的好处是:减少安装和维护的工作量,可以统一管理,一次配置多次使用。但要注意的是对该目录下文件写权限的控制,以避免用户无意识地破坏文件,造成全局软件故障。

为适应广泛的市场需要,针对不同的市场进行划分,NVIDIA 及其合作伙伴共同开发了多种多样的编程方式,有 CUDA C/C++、CUDA Fortran、OpenACC、HMPP、CUDA-x86、OpenCL、JCuda、PyCUDA、Direct Compute、MATLAB、Microsoft C++ AMP等。

多线程编程语言可以分为两类,一种为编译指导,即在串行程序之前添加编译指令,指导编译器将串行指令自动编译为并行程序;另一种为显式线程模型,用户可以直接编写并行程序,显式地调用 GPU 线程。相对地,编译指导比较简单,有易于初学者的学习、方便串行程序并行化、开发周期短等优点。但是,因为编译器自动完成串行程序的并行化,所以对 GPU 的操作相对不够灵活。

Nsight 是 NVIDIA 开发的一套集成了编译、调试与性能分析功能的开发环境。可以从官网下载:Nsight官网

相关资源:

  1. CUDA Zone:适合所有开发者使用:CUDA Zone
  2. GTC:GPU全球会议,GTC 官网
  3. GPU 计算论坛:求助专家平台,GPU 计算论坛

主要测试

理论峰值性能

FLOPS 是指每秒浮点运算次数,Flops 用作计算机计算能力的评价系数。根据硬件配置和参数可以计算出高性能计算集群的理论性能。

  • CPU 理论性能计算方法(以 Intel CPU 为例):

  • GPU 理论性能计算方法(以 NVIDIA GPU 为例):

  • MIC 理论性能计算方法(以 Intel MIC 为例):

实测峰值性能

利用测试程序对系统进行整体计算能力进行评价。

  • Linapck 测试:采用主元高斯消去法求解双精度稠密线性代数方程组,结果按每秒浮点运算次数 (flops) 表示。
  • HPL:针对大规模并行计算系统的测试,其名称为 High Performance Linpack(HPL),是第一 个标准的公开版本并行 Linpack 测试软件包。用于 TOP500 与国内 TOP100 排名依据。

评价参数

  1. 系统效率 = 实测峰值/理论峰值

  2. 加速比:

Amdahl 定律指出:

Gustafson 定律指出:

在测试集群性能时,最重要的是保证测试过程中的稳定和可靠。通常需要关闭不必要的程序,且保证整个系统只进行基准测试,并对最终结果进行多次试验。如果修改程序运行参数,每次只修改一处。测评结果结束后,首先需要确认结果可信度,即同一测试每次运行结果差别是否在容许误差范围之内(一般< 5%)。确认结果可信后,需要对数据的结果进行处理,通常以图表方式进行可视化呈现。

高性能计算测评主要分为:单项测评整体计算性能测评领域应用性能测评典型应用性能测评四大类,这四大类均是当前常用的测评方法,并且因测试目的的不同在实际应用中各有侧重。

下面我们将介绍主要的测评方法和相应的基准程序,其中单项测评部分介绍内存性能测试程序Stream和网络通信测试程序OMB(OSU micro-benchmarks);整体计算性能测评部分介绍HPLHPCC;领域应用测评NPBIAPCM BenchmarksGraph 500 Benchmarks。典型应用测试因用户不同而千差万别,在此不做阐述。

内存性能测试程序Stream

Stream有C、Fortran语言两个版本,同时还提供MPI版本,其测试结果统一以MB/s来衡量,反应系统持续内存带宽大小。其运行十分简单,单线程串行版本在目标系统上正确编译后直接运行即可。若是运行并行版本(多线程、OpenMP或MPI),只需要参照同类并行程序进行编译和运行(例如MPI版本需要预先在系统上配好MPI库,使用mpif77编译stream_mpi.f文件,使用mpirun运行程序)。

尽管HPCC采用了MPI版的Stream测试,但多节点系统的访存性通常取决于网络,因此内存带宽测试对单机更为重要,我们常用的是:单机版Stream多线程版(包括Pthreads和OpenMP)

最新版的 Stream 可以从 Stream官网 下载获得,当前排名第一的是SGI公司的大型共享内存计算机Altix UV2000。

通信性能测试程序 OMB

测试节点之内通信的OMB支持 MPI、UPC 和 OpenSHMEM 三种通信模型,同时最新版本还提供了对 CUDA 和 OpenACC 的支持。其中最典型的和最常用的就是 MPI 通信性能测试。

OMB提供包括点对点通信集合通信单边通信在内的丰富测试,并且每个通信类型又提供延迟、带宽、多线程延迟、多线程带宽等多个输出。测试这可以对通信数据大小进行设置(如测试延迟使用 1B、4B、16B、256B 等不同消息大小,测试带宽时使用 4KB、64KB、256KB、1MB、4MB等不同消息的大小)。

OMB 采用常见的 GNU(configure & make)编译,运行时通常形如 osu_latencyosu_bw的运行参数来执行相应测试。

OMB可以通过其 OMB官网 进行下载。

浮点计算性能测试程序 HPL

HPL 测试通常求解一个稠密线性方程组 $Ax=b$ 所花费的时间来评价计算机的浮点计算性能。为了保证测评结果的公平性,HPL 不允许修改基本算法(采用 LU 分解的高斯消元法),即必须保证总浮点计算次数不变。对 $N\times N$ 的矩阵 $A$,求解 $Ax=b$ 的总浮点计算次数为 $(2/3 \times N^3 - 2\times N^2)$。因此,只要给出问题规模 $N$,测的系统计算时间 $T$,则 HPL 将测试该系统的浮点性能值为:

单位是 flops。

目前,HPL(Linpack)有 CPU 版、GPU 版和 MIC 版本,对应的测试 CPU 集群、GPU 集群和 MIC 集群的实际运行性能。Linpack 简单、直观、能反应系统的整个计算能力,能够较为简单的、有效的评价一个高性能计算机系统的整体计算能力。所以 Linpack 仍然是高性能计算系统评价的最为广泛的使用指标。但是高性能计算系统的计算类型丰富多样,仅仅通过衡量一个系统的求解稠密线性方程组的能力来衡量一个高性能系统的能力,显然是不客观的。

HPL 允许用户选择任意 $N$ 规模,并且在不改变总浮点计算次数和计算精度的前提下对算法或程序进行修改。这在一定程度上促使用户为了取得更优的 HPL 值而八仙过海。

常用的 HPL 优化策略如下:

  1. 选择尽可能大的 $N$,在系统内存耗尽之前, $N$ 越大,HPL 性能越高。
  2. HPL 的核心计算是矩阵乘(耗时通常在 90%以上),矩阵乘法采用分块算法实现,其中分块的大小对计算性能影响巨大,需综合系统 CPU 缓存大小等因素,通过小规模问题的实测,选择最佳的分块矩阵值。
  3. HPL 采用 MPI 进行并行计算,其中计算的进程以二维网格方式分布,需要设定处理器阵列排列方式和网格尺寸,这同样需要小规模数据测定获得最佳方案。
  4. LU 分解参数、MPI、BLAS数学库、编译选项、操作系统等众多其他因素同样对最终测试结果有影响,具体情况需要参考相关文献。

HPL 的安装需要编译器并行环境 MPI基本线性代数函数库(BLAS)支持,其中需要注意 BLAS 库的选择。当前常用的 BLAS 库有: GOTOOpenBLASAtlasMKLACML 等多个版本,不同系统上不同实现的性能可能会有较大差异,需要参照相关文献和实际测试版本。

最新版的 HPL 可以从 HPL官网 获得。

综合性能测试程序 HPCC

HPCC 基准是由若干知名的测试程序(包括单项测试和浮点性能测试)组成的,并可以选择了有鲜明时空局部性的典型测试程序,以期望对高性能计算机系统性能给出全面的评价。

HPCC 与 NPB 测试类似,目的仍然为了寻找一个更为全面的评价整个系统性能的测试工具。HPCC benchmark 包含如下 7 个测试:

  1. HPL:Linpack TPP基准,衡量解决线性方程组的浮点执行率。
  2. DGEMM :衡量双精度实数矩阵-矩阵乘法的浮点执行率。
  3. STREAM:一个简单的合成基准程序,测量可持续内存带宽(GB/s)和简单向量内核的相应计算率。
  4. PTRANS(并行矩阵转置):练习成对处理器同时相互通信的情况下的通信,是对网络总通信能力的测试。
  5. 随机访问:衡量内存的整数随机更新率 (GUPS)。
  6. 快速Fourier变换:衡量双精度复杂一维离散傅里叶变换的浮点执行率 (DFT)。
  7. 通信带宽和延迟:一组测试,用于测量一些同时进行的通信模式的延迟和带宽;基于b_eff(有效带宽基准)。

HPCC 尽管提供了远超过单个性能测评的程序(如 HPL)的丰富测试结果,但并未在高性能计算界获得广泛的支持和认可。其原因是多方面的,测试过程和结果过于复杂、无法给出易于比较的单一指标。不过,HPCC 仍就是一个出色的总能评定高性能计算机系统性能的基准测试软件。

HPCC 可以从 HPCC官网 处获得。

领域测试程序集 NPB

NPB 是一个科学计算领域的并行计算机性能测评基准程序,它含有8个不同的基准测试,都来自计算流体动力学的应用软件,每一个基准测试模拟并行应用的一种不同行为。因此,NPB 可以测试出集群系统上计算流体动力学(Computational Fluid Dynamics,CFD)并行应用程序的性能和可扩展性。

对于并行版本的 NPB ,需要根据系统的体系结构,在并行粒度数据结构通信机制处理器映射内存分配等方面进行有针对性的优化。 NPB 2以上的标准统一提供了用 MPI实现的并行程序。

NPB 套件由八个程序组成、以每秒百万次运算为单位输出结果。其中包括:整数排序 (IS)、快速 Fourier 变换 (FT)、多栅格基准测试 (MG)、共轭梯度 (CG) 基准测试、系数矩阵分解 (LU)、五对角方程 (SP)、块状三角 (BT) 求解、密集并行 (EP)

每个基准测试有6种规模:A、B、C、D、W(工作站)和 S(sample)。其中A 最小,D 最大。

测试所用的处理器数目也需要指定,NPB 的 8 个程序对处理器的数目有着不同的要求,BT 和 SP 要求处理器的数目是 $n^2$,LU、MG、CG、FT 和 IS 要求处理器的数目为 $2^n$ ($n$ 均为正整数),EP程序对处理器数目没有特殊要求。如果指定的处理器数目不符合要求,在编译时会有相应的错误提示。

更准确表现性能的测试方法,需要根据机器系统配置和研究的需要来选择合适的问题规模和处理器数目进行测试。

NPB 可以从 NPB官网 获取。

领域测试程序集 IAPCM Benchmarks

与美国的 NPB 类似,北京应用物理与计算数学研究所(IAPCM)作为中国主要的高性能计算应用研发和应用的机构之一,同样发布了自己的测试性能标准。测试结果为 IAPCM 开发、选用计算机提供了必要的评估资料,也为许多中国科研单位选购计算机提供了有关计算机性能的测试数据。

领域测试程序集 Graph500 Benchmark

Graph500 是对数据密集型应用的高性能计算系统排行榜,其依据的测试程序集即Graph500 基准测试包。和上述大部分浮点计算峰值不同,Graph500 主要利用图论区分析超级计算机在模拟生物、安全、社会以及类似复杂问题时的吞吐量,并进行排名。

Graph500 Benchmark 所计算的问题是在一个庞大的无向图中采用宽度优先算法进行搜索。测试包括两个计算核心:首先是生成带检索的图并以系数矩阵的 CSR(Compressed Sparse Row)或 CSC(Compressed sparse column)方式压缩存储;其次是采用并行BFS方法进行检索。目前有 6 种不同的数据规模可选。

  1. Toy 级,$2^{64}$ 个顶点,需要约 17GB 内存。
  2. Mini 级,$2^{29}$ 个顶点,需要约 137GB 内存。
  3. Small 级,$2^{36}$个顶点,需要约 1.1TB 内存。
  4. Medium 级,$2^{39}$个顶点,需要约 17.6TB 内存。
  5. Large 级,$2^{39}$个顶点,需要约 140TB 内存。
  6. Huge 级,$2^{42}$个顶点,需要约 1.1PB 内存。

Graph500 依据 GTEPS对系统进行排名,但前排名第一的是日本的超级计算机“京”,其性能为 17977.1 GTEPS。

浮点计算性能测试程序 HPCG

HPCG 高度共轭梯度基准测试,是现在主要测试超算性能测试程序之一,也是 TOP500 的一项重要指标。一般来讲 HPCG 的测试结果会比 HPL 低很多,常常只有百分几。

HPCG采用共轭梯度法求解大型系数矩阵方程组 $Ax=b$。实际上,这类方程源自非定常数非线性偏微分方程组,迭代求解过程中需要频繁地存取不规则数据,因此 HPCG 对计算机系统要求高带宽、低延时、高 CPU (核)主频。而具备这些特点的计算机通常腌制时间长、研制难度大且价格昂贵,即使在美国,也只有极少量的“领导级计算机”属于此类系统。

整体而言,HPCG 所代表的问题涉及面较窄,基于此的性能及准程序想要如同 HPL 那样去的广泛的应用和认可,仍有较长的路要走。

其他测试汇总

除了上述对集群整体能力的测试外,测试程序还包括一些对具体函数与节点间通信的测试,例如:

  • IMB(Intel MPI Benchmark):用来测试各种 MPI 函数的执行性能。
  • MPIGraph:IMB 能够全面的获取整个系统各个 MPI 函数的性能,但是节点数目众多时,如何能够快速的获得任意 2 点的互联通信性能,从而能够快速排除整个系统的网络故障,需要通过 MPIgraph 来实现。
  • IOZONE:IOZONE 为 Linux 操作系统下使用最为广泛的 IO 测试工具。
  1. 内存带宽理论值:

    Intel:

amd:

  1. 内存带宽测试值:

intel 5650(12 线程):

AMD 6136(16 线程):

高性能计算|集群功率与系统监控

Yurk(realyurk@gmail.com)整理

参考内容:《高性能计算》(张广勇)

​ 《超算竞赛导引》(科学出版社)

​ 《Introduction to High Performance Scientific Computing》(Victor Eijkhout)

部分资料来源网络,仅供个人学习使用

超级计算机的运算能力十分强大,能耗也十分惊人。在能源日益紧张和强调环保的今天,超级计算机的设计越来越强调效能比。对超级计算机进行功耗管理并不是简单地降低功耗或能耗,这需要从两个方面下手:一方面在满足性能前提下,优化功耗、能耗与性能的折中,提高系统的能效;一方面是设计发热更低、能源利用率更高的超级计算机部件和设计能耗更低的散热方式。

功耗监控

在服务器的主要部件中,处理器功耗占系统功耗的主体地位,管理处理器功耗的方法主要有:动态调频(dynamic voltage frequency scaling,DVFS)和处理器动态休眠技术

  1. 动态资源休眠(dynamic resource sleeping,DRS),即为了节能而休眠或关闭空闲的资源,如组件、设备或节点,需要时再将资源动态唤醒。目前的主流处理器都支持动态休眠技术,有的处理器还支持多种休眠状态。高级配置与电源接口(advanced configuration and power interface,ACPI)对处理器休眠状态(C状态)进行了明确的规范。此外,有的内存也支持动态关闭,外围设备互联(peripheral component interconnect,PCI)功耗管理规范也对设备的动态关闭进行了相关描述。超算集群还可以以节点为单位动态休眠相应节点。
  2. 动态速率调节(dynamic speed scaling,DSS),即动态调节设备的运行速率。并行计算存在大量的通信与同步,快速设备完成其承担的负载后必须等待慢速设备,此时快速设备的高速率是没有必要的,降低快速设备的速率,可以降低系统功耗而不损失系统性能,从而实现系统的能耗优化。处理器 DVFS 是典型的 DSS 机制,有的内存、磁盘也支持频率或转速的动态调节。

目前的商用处理器都支持 DRS 和 DSS 两种低功耗机制。真实系统中,处理器不可能总是处于繁忙状态。如果不采取任何功耗管理措施,处理器空闲时在操作系统控制下运行相关内核代码,循环等待,直到内核为其分配相应的用户负载。空闲处理器等待期间的指令运行造成了无谓的功耗浪费。

为此,处理器引入动态休眠技术,即空闲时停止执行指令,进入低功耗状态,直至需要时再被唤醒。

真实系统中,处理器不可能总是处于繁忙状态。如果不采取任何功耗管理措施,处理器空闲时将在操作系统控制下运行相关内核代码,循环等待,直到内核为其分配相应的用户负载。空闲处理器等待期间的指令运行造成了无谓的功耗浪费。

为此,处理器引入动态休眠技术,即空闲时停止执行指令,进入低功耗状态,直到需要时再被唤醒,超级计算机节点通常采用多核多处理器结构,并行计算中存在大量的通信与同步,这就为处理器休眠提供了潜在的机会。处理器可能支持多个休配状态,不同休眠状态的逻辑行为相同,但功耗不同,从功耗更低的休眠状态唤醒所需的时间和能耗的开销都更大。举例来说,Intel Xeon 处理器支持增强型空闲电源管理状态转换(enhanced haltstate)技术,除了正常运行状态 C0 外,还支持休眠状态 C1 和增强型深度休眠状态 CIE 等。Linux 内核引入 CPUldle 模块来管理空闲处理器,根据处理2空闲历史记录判断处理器是否休眠,并选择合适的休眠状态。

从些测试结果看,CIE完成大部分测试用例的时间比C1更长,能耗更少,这与两种体眠状态的特点一致。但是,对于计算非常密集的应用,唤醒时间的影响尤为显著,CIE尽管功耗更低,但执行时间增如得更多,能耗反而上升。

处理器调频对 系统能数的优化效果与应用的特征密切相关。对于通信密集的体现系统通信能力的应用,降频可能会带来10%以上能效优化,但对于计算密集的体现处理器浮点计算能力的应用,降频反而降低了能效。

没有任何频率对所 有应用都是能效最优的,应该根据月行应用中计算和通信的相对特征动态选择处理器性能。超级计算中,基于处理器性能调节机制来优化系统使耗还有很大的潜力可挖。 超算集群的能耗监控一般有两个层次, 第一个层次是整集群的监控。般以机柜为单位。每个机柜配备支持网络监控功耗功使的电源分配单元(power dstiution unit,PDU)。由监控软件汇总每个机柜 PDU 的功耗数据。这种能耗监控的数据是根据 PDU 整体的电压电流数据经过计算得出的,连接到该 PDU 的所有设备如交换机、存储等都可以监控到,也是预估电费的直接数据。

第二个层次是以节点为单位的监控, 既可以通过专业监控软件监控,也可以通过些简单的工具或操作系统(Operating System,OS)自带命令实现。这里不再赞述。

有的时候,选用一些低功耗的部件降低系统系统功耗,例如用固态硬盘(SSD)代替机械硬盘也是十分有效的策略。

应用特征与监测分析工具

常用特征如下:

组成 选配方案 考虑角度
算例 运行时间 算例规模、算例设置
软件 版本 软件版本
机型 计算节点:NX5440(刀片) 根据项目需求选择机型
CPU Intel E5-2600v3, E5-2600v2 CPU频率、高速缓存、QPI 频率、Turbo 配置、NUMA 配置
内存 DDR3/DDR4 1600/1866/2133 MHz 内存容量、内存频率、DIMM/Socket
存储/文件系统 I/O节点,NFS AS500G3
网络 InfiniBand InfiniBand、10GB 以太网
OS RHEL6.4、7.0 OS 版本

为了能够精确地反应应用软件的特征,应用特征提取的时间间隔往往非常短,通常以秒计,从而导致反应应用特征的数据量巨大。因此,高效的特征数据收集和数据的分析往往需要借助数据库,如 MySQL 等来完成。

浪潮 T-Eye监测系统官网

天眼(Teye)工具的全称是天眼高性能应用特征监控分析系统。它是由浪潮专业的高性能计算团队开发的一款卓越软件, 主要用于提取高性能应用程序在大规模集群上运行时对系统资源占用的情况,并实时反映应用程序的运行特征,从而帮助用户最大限度的在现有平台挖掘应用的计算潜力,进而为系统的优化、应用程序的优化以及应用算法的调整改进提供科学的指引方向。

天眼是一款可视化的工具软件, 它由集群的性能监控和指标提取端以及客户机的性能分析端两个工具软件组成。其中,天眼的性能监控和指标提取端工具主要用于实时监控和提取指定的性能指标,它基于BS架构,无需用户安装任何客户端,仅通过网页浏览器即可正常使用天眼软件来监控和提取关键性能指标。此外,天眼还具有体积小、易操作、数值监控精确、实时、资源占用率低等众多优点,即使在系统重负载情况下,天眼对系统资源的需求量也远远不足千分之一, 极大程度上保证了所反映的高性能应用程序运行特征的真实性。

超算系统的性能均衡

Amdahl 定律告诉人们,当CPU的性能提高10倍而忽略I/O的性能时,系统的性能只能提高5倍;当CPU的性能提高100倍而不改进I/O的性能时,系统的性能只能提高10倍。所以整个系统各部件间的性能平衡显得非常重要,否则如果某些部件的性能较差而成为系统瓶颈,不仅降低了整个系统的性能,同时也不能充分利用其他的部件。

从大的方面来看,超级计算机的系统平衡应该有两个层次。一个层次是体系结构层次上的,这主要是各个部件的硬件性能应达到一个平衡的状态:另一个层次是软件层次上的,主要是指操作系统对各种资源进行有效的管理,达到负载平衡(如不能让一些节点始终处于忙碌状态,而一些节点始终空闲),提高利用率。

超级计算机主要由计算部件存储部件互连部件构成,构建一个平衡的系统实质上就是协调这三大部件的主要性能,使系统在某种工作负载下各部件既无冗余又不产生瓶颈。

下面将从几个方向讨论构建负载均衡的集群系统

节点内配置均衡

CPU同构类型

对于节点内的配置,应尽可能保持一致,以提高数据交换能力和资源利用率。例如,节点内是双路的,则对于每一路处理器配置的内存应尽可能保持一致,以避免两个处理器对应内存差别太大,造成两个处理器的处理能力差距悬殊,导致整个节点处理能力下降。例如,对于双路处理器的节点,两个处理器均配置24GB的内存,比一个配置24GB,另外一个配置12GB的内存处理效果要好。

异构类型

使用 CPU 与 GPU 异构模式的计算系统,主机内存应大于等于 GPU 内存;创建统一的内存地址空间,让 CPU 和 GPU 完全共享内存以实现无缝的运作;CPU 核数与 GPU 个数保持数量的均衡;GPU内存带宽与主机内存带宽均衡,以减少数据交换带来的延迟。

节点间配置均衡

同类计算节点保持配置均衡,如内存大小相同CPU 数量及核数相同节点结构相同,要么都是同构,要么都是异构,避免同类节点结构不一样(如一个是同构架构,另-个是异构架构)导致数据处理能力降低的情况。

网络均衡

同类节点与节点间的网络连接采用统一的方式,包括同样的网络介质,如千兆以太网万兆以太网IB网络等。

交换机:要么都使用全交换方式,要么都使用半交换方式,否则会由于带宽的不一致,导致信息阻塞。

执行同一应用的节点配置均衡

执行同一应用的节点配置应保持一致, 这样在进行数据处理和交换时速度会更快。若由于客观原因实在不能保持一致,则通过集群管理软件来进行作业调度,作业提交后,可以均衡地提交到集群中配置相当的节点上进行运算,避免因某些节点存在瓶颈降低整个应用的运行效率。

不同设备间的均衡

计算节点与 I/O 节点和存储设备间应均衡匹配,高性能计算系统的存储系统,不仅起着备份数据的作用,在计算过程中,还起着提高读/写带宽的作用。

对某些应用主要是进行计算,存储数据和读/写数据不多,那么I/O节点机存储配置要求就不用太高。

但对于有些应用,节点上运行的应用比较复杂,涉及大量数据读/写及数据存储,如基因研究、石油勘察等数据之间关联性强(下一步计算要依赖上一步的运算结果)的应用,就需要对应的I/O和存储系统配置高。

功耗均衡

在系统构建时需要考虑到机房的实际情况,均衡分布设备,避免出现局部功耗太高或者局部功耗太低的情况。

在满足系统功能的前提下,尽可能将能耗高的设备和能耗低的设备、密度高的设备和密度低的设备组合搭配,如将刀片服务器和机架服务器放到同一个机柜中,避免不同部位间能耗相差太大,造成局部过热或者局部有资源浪费的情况。

另外,在机房中靠近制冷设备的地方可以考虑放置功耗大、密度大的设备等以合理地利用资源。

集群管理软件

集群管理系统

集群系统有五种特性:(1)高性价比;(2)资源共享;(3)灵活性和可扩展性;(4)实用性和 容错性;(5)可伸缩性。

目前,几大主流服务器厂商都提供了自己的集群管理系统,如浪潮的 Cluster Engine,曙光的 Gridview,HP 的 ServiceguardIBM Platform Cluster Manager 等等。集群管理系统主要提供以下的功能:

  1. 监控模块:监控集群中的节点、网络、文件、电源等资源的运行状态。动态信息、实况信息、历史信息、节点监控。可以监控整个集群的运行状态及各个参数。
  2. 用户管理模块:管理系统的用户组以及用户,可以对用户组以及用户进行查看,添加,删除和 编辑等操作。
  3. 网络管理模块:系统中的网络的管理。
  4. 文件管理:管理节点的文件,可以对文件进行上传、新建、打开、复制、粘贴、重命名、打包、删除和下载等操作。
  5. 电源管理模块:系统的自动和关闭等。
  6. 友好的图形交互界面:现在的集群管理系统都提供了图形交互界面,可以更方便的使用和管理集群。

集群作业调度系统

集群管理系统中最主要的模块为作业调度系统,目前,主流的作业调度系统都是基于PBS的实现。

PBS(Portable Batch System) 最初由 NASA 的 Ames 研究中心开发,主要为了提供一个能满足异构计算网络需要的软件包,用于灵活的批处理,特别是满足高性能计算的需要,如集群系统、 超级计算机和大规模并行系统。PBS 的主要特点有:代码开放,免费获取;支持批处理、交互式作业和串行、多种并行作业,如 MPI、PVM、HPF、MPL;PBS 是功能最为齐全,历史最悠久, 支持最广泛的本地集群调度器之一。PBS 的目前包括 openPBS,PBS Pro 和 Torque 三个主要分支。其中 OpenPBS 是最早的 PBS 系统,目前已经没有太多后续开发,PBS pro 是 PBS 的商业版本,功能最为丰富。Torque 是 Clustering 公司接过了 OpenPBS,并给与后续支持的一个开源版本。

PBS 主要有如下特征:

  • 易用性:为所有的资源提供统一的接口,易于配置以满足不同系统的需求,灵活的作业调度器允许不同系统采用自己的调度策略。
  • 移植性:符合 POSIX 1003.2 标准,可以用于 shell 和批处理等各种环境。
  • 适配性:可以适配与各种管理策略,并提供可扩展的认证和安全模型。支持广域网上的负载的动态分发和建立在多个物理位置不同的实体上的虚拟组织。
  • 灵活性:支持交互和批处理作业。torque PBS 提供对批处理作业和分散的计算节点 (Compute nodes) 的控制。

高性能计算|网络系统与存储系统

Yurk(realyurk@gmail.com)整理

参考内容:《高性能计算》(张广勇)

​ 《超算竞赛导引》(科学出版社)

​ 《Introduction to High Performance Scientific Computing》(Victor Eijkhout)

部分资料来源网络,仅供个人学习使用

高性能计算集群中一般采用专用高速网络,如 InfiniBand 网络,也有采用以太网(千兆网、万兆网)的系统。以太网性能较差,只适合于对网络要求比较低的应用中,如果每个节点配置两个以太网,可以采用双网卡绑定的方法提高性能,性能可以提高 50%∼80%。

InfiniBand 网络

InfiniBand(简称 IB)是一个统一的互联结构,既可以处理存储 I/O、网络 I/O,也能够处理进程间通信 (IPC)。它可以将磁盘阵列、SANs、LANs、服务器和集群服务器进行互联,也可以连接外部网络(比如 WAN、VPN、互联网)。设计 InfiniBand 的目的主要是用于企业数据中心,大型的或小型的。目标主要是实现高的可靠性、可用性、可扩展性和高的性能。InfiniBand 可以在相对短的距离内提供高带宽、低延迟的传输,而且在单个或多个互联网络中支持冗余的 I/O 通道,因此能保持数据中心在局部故障时仍能运转。

InfiniBand 的网络拓扑结构如上所示,其组成单元主要分为四类:

  1. HCA(Host Channel Adapter):连接内存控制器和 TCA 的桥梁
  2. TCA(Target Channel Adapter):将 I/O 设备 (例如网卡、SCSI 控制器) 的数字信号打包发送给HCA
  3. InfiniBand link:连接 HCA 和 TCA 的光纤,InfiniBand 架构允许硬件厂家以 1 条、4 条、12 条光纤 3 种方式连结 TCA 和 HCA
  4. 交换机和路由器:无论是 HCA 还是 TCA,其实质都是一个主机适配器,它是一个具备一定保护功能的可编程 DMA(Direct Memory Access,直接内存存取)引擎。

在高并发和高性能计算应用场景中,当客户对带宽和时延都有较高的要求时,可以采用 IB 组网:前端和后端网络均采用 IB 组网,或前端网络采用 10Gb 以太网,后端网络采用 IB。由于 IB 具有高带宽低延时高可靠以及满足集群无限扩展能力的特点,并采用 RDMA 技术和专用协议卸载引擎,所以能为存储客户提供足够的带宽和更低的响应时延。

IB 工作模式共有 7 种,分别为:(1)SRD(Single Data Rate):单倍数据率,即 8Gb/s;(2) DDR (Double Data Rate):双倍数据率,即 16Gb/s;(3)QDR (Quad Data Rate):四倍数据率, 即 32Gb/s;(4)FDR (Fourteen Data Rate):十四倍数据率,56Gb/s;(5)EDR (Enhanced Data Rate):100 Gb/s;(6)HDR (High Data Rate):200 Gb/s;(7)NDR (Next Data Rate):1000 Gb/s+。

IB 通信协议

InfiniBand 与 RDMA

InfiniBand 发展的初衷是把服务器中的总线给网络化。所以 InfiniBand 除了具有很强的网络性能以外还直接继承了总线的高带宽和低时延。大家熟知的在总线技术中采用的 DMA(Direct Memory Access) 技术在InfiniBand 中以 RDMA(Remote Direct Memory Access) 的形式得到了继承。这也使 InfiniBand 在与 CPU、内存及存储设备的交流方面天然地优于万兆以太网以及 Fibre Channel。可以想象在用 InfiniBand 构筑的服务器和存储器网络中任意一个服务器上的 CPU 可以轻松地通过 RDMA 去高速搬动其他服务器中的内存或存储器中的数据块,而这是 Fibre Channel 和万兆以太网所不可能做到的。

InfiniBand 与其他协议的关系

作为总线的网络化,InfiniBand 有责任将其他进入服务器的协议在 InfiniBand 的层面上整合并送入服务器。基于这个目的, 今天 Volatire 已经开发了 IP 到 InfiniBand 的路由器以及 Fibre Channel 到 InfiniBand 的路由器。这样一来客观上就使得几乎所有的网络协议都可以通过 InfiniBand 网络整合到服务器中去。这包括 Fibre Channel, IP/GbE, NAS, iSCSI 等等。另外 2007 年下半年 Voltaire 将推出万兆以太网到 InfiniBand 的路由器。这里有一个插曲: 万兆以太网在其开发过程中考虑过多种线缆形式。最后发现只有Infiniband 的线缆和光纤可以满足其要求。最后万兆以太网开发阵营直接采用了 InfiniBand 线缆作为其物理连接层。

InfiniBand 网络性能可以使用 IMB 测试程序进行测试,IB 通信协议使用方法见 MPI 介绍的章节。

基于 InfiniBand 的HPC 应用优化

MPI 规范的标准化工作是由 MPI 论坛完成的,其已经成为并行程序设计事实上的工业标准。最新的规范是 MPI3.0,基于 MPI 规范的实现软件包括 MPICH 和 OpenMPI。MPICH由美国阿贡国家实验室和密西西比州立大学联合开发,具有很好的可移植性。MVAPICH2、Intel MPI、Platform MPI 都是基于 MPICH 开发的。OpenMPI 由多家高校、研究机构、公司共同维护的开源 MPI 实现。

在 HPC 领域,并行应用程序通常基于 MPI 开发。因此要优化 HPC 应用程序,了解 MPI 实现的特性是非常关键的。

MPI 通信协议

MPI 通信协议大体可以分为两类:Eager 协议Rendezvous 协议

  1. Eager 协议:该模式下发送进程将主动发送信息到接收进程,而不会考虑接受进程是否有能力接受信息。这就要求接受进程预先准备足够的缓存空间来接受发送过来的信息。Eager 协议只有非常小的启动负荷,非常适合对延迟要求高的小消息发送。Eager 协议下,可以采用 InfiniBand Send/Recv 或 RDMA 方式发送消息来实现最佳性能。
  2. Rendezvous 协议:与 Eager 模式相反,该模式下 Rendezvous 协议会在接收端协调缓存来接受信息。通常适用于发送比较大的消息。该情况下,发送进程自己不能确认接收进程能够有足够的缓存来接受要发送的信息,必须要借助协议和接收端协调缓存之后才会发送信息。

Rendezvous 协议与 Eager 协议本身并不局限于 RDMA 操作,可以运行 Socket、RDMA Write 与 RDMA Read。然而 Socket 操作中需要多个消息拷贝过程从而大幅降低通信性能,并且无法实现计算与通信的重叠。RDMA Write 和 Read 通过零拷贝与内核旁路,实现更高性能的同时可以将计算通信操作同步叠加运行。

发送端首先发送 Rndz_start 控制指令到接收端,接收端随后返回另外一个控制指令 Rndz_reply,该指令包含接收端应用程序的缓存信息和访问其内存地址的 key 信息。发送端收到指令后调用 RMDA_Write 将数据直接写入接收端应用程序的缓存,消息发送完成之后,发送端会发出 Fin 指令到接收端告知自己已经将整个信息放入到接收端的应用缓存中。Rendezvous 模式的好处是在没有确切得知发送消息的信息之前,没有预先的 Pre-pin 缓存,因此它是相对于 Eager 模式更节约内存的一种方式。相对负面的是其多重操作会增加通信延迟。因此更适合传输相对占用内存的大消息

Eager 协议在消息大小小于 16KB(在 MVAPICH2 中的默认 Eager 阈值)时都可以提供更低的通信延迟,但在消息大小大于 Eager 阈值后,Rendezvous 模式的优势开始显现。

MPI 函数

前面介绍的 MPI 底层协议会对所有 MPI 通信产生影响。具体到上层的 MPI 函数还会设计另一层的优化。 MPI 函数分为集群(collective)通信与点对点(point to point)通信。不同的 MPI 实现对集群通信与点对点通信略有差异。因此针对不同的 MPI 实现所采取的优化方式也存在差异。

  1. 点对点通信:MPI 定义了超过 35 个点对点通信函数。最主要的包括 MPI_SendMPI_RecvMPI_SendrecvMPI_IsendMPI_IrecvMPI_ProbeMPI_IprobeMPI_TestMPI_TestallMPI_WaitMPI_Waitall 等。
  2. 集群通信MPI_AllgatherMPI_AllgathervMPI_AllreduceMPI_AlltoallMPI_AlltoallvMPI_BarrierMPI_BcastMPI_GatherMPI_GathervMPI_ReduceMPI_ScatterMPI_Scatterv 等 。

MPI 基于不同网络的性能对比

性能结果显示,从两台服务器开始,InfiniBand 就可以提供比以太网更高的性能。当在 8 个服务器节点时,InfiniBand 能够提供双倍于以太网的性能,随着节点数的增加,InfiniBand 相对于以太网的优势进一步扩大,在 16 个节点时,基于 InfiniBand 的 NAMD 性能是以太网性能的 1.5 倍。从 4 个节点开始,基于以太网的 NAMD 性能增长就非常缓慢。万兆以太网与 4万兆以太网提供相同的 NAMD 性能,其性能高于千兆以太网,但相对 InfiniBand 性能远远落后。要充分发挥 HPC 系统的性能与效率,InfiniBand 网络是不可替代的核心技术。

存储系统

存储网格

DAS

直接连接存储 (Direct Attached Storage,DAS),是指将外置存储设备通过连接电缆,直接连接到一台计算机上。

采用直接外挂存储方案的服务器结构如同 PC 架构,外部数据存储设备采用 SCSI 技术或者 FC(Fibre Channel) 技术,直接挂接在内部总线上,数据存储是整个服务器结构的一部分。DAS 这种直连方式,能够解决单台服务器的存储空间扩展和高性能传输的需求,并且单台外置存储系统的容量,已经从不到 1TB 发展到了 2TB。

开放系统的直连式存储 (Direct-Attached Storage,简称 DAS) 已经有近四十年的使用历史,随着用户数据的不断增长,尤其是数百 GB 以上时,其在备份、恢复、扩展、灾备等方面的问题变得日益困扰系统管理员。

DAS 的优缺点

直连式存储依赖服务器主机操作系统进行数据的 IO 读写和存储维护管理,数据备份和恢复要求占用服务器主机资源 (包括 CPU、系统 IO 等),数据流需要回流主机再到服务器连接着的磁带机 (库),数据备份通常占用服务器主机资源 20-30%,因此许多企业用户的日常数据备份常常在深夜或业务系统不繁忙时进行,以免影响正常业务系统的运行。直连式存储的数据量越大,备份和恢复的时间就越长,对服务器硬件的依赖性和影响就越大。

直连式存储与服务器主机之间的连接通道通常采用 SCSI 连接,带宽为 10MB/s、20MB/s、 40MB/s、80MB/s 等,随着服务器 CPU 的处理能力越来越强,存储硬盘空间越来越大,阵列的硬盘数量越来越多,SCSI 通道将会成为 IO 瓶颈; 服务器主机 SCSI ID 资源有限,能够建立的 SCSI 通道连接有限。 无论直连式存储还是服务器主机的扩展,从一台服务器扩展为多台服务器组成的群集 (Cluster),或存储阵列容量的扩展,都会造成业务系统的停机,从而给企业带来经济损失,对于银行、电信、传媒等行业 7×24 小时服务的关键业务系统,这是不可接受的。并且直连式存储或服务器主机的升级扩展,只能由原设备厂商提供,往往受原设备厂商限制。

NAS

网络附加存储 (Network Attached Storage,NAS),NAS 是一种专业的网络文件存储及文件备 份设备,它是基于局域网 (LAN) 的,采用 TCP/IP 协议,通过网络交换机连接存储系统和服务器主机,建立专用于数据存储的存储私网。以文件的输入/输出 (I/O) 方式进行数据传输。在 LAN 环境下,NAS 已经完全可以实现不同平台之间的数据共享,如 NT、UNIX、Mac、Linux 等平台的共享。一个 NAS 系统包括处理器,文件服务管理模块和多个磁盘驱动器 (用于数据的存储)。采用网页浏览器就可以对 NAS 设备进行直观方便的管理。

实际上 NAS 是一个带有瘦服务器 (Thin Server) 的存储设备,其作用类似于一个专用的文件服务器。这种专用存储服务器不同于传统的通用服务器,它去掉了通用的服务器原有的不适用大多数计算功能,而仅仅提供文件系统功能,用于存储服务,大大降低了存储设备的成本。与传统的服务器相比,NAS 不仅响应速度快,而且数据传输速率也较高。

NAS 具有较好的协议独立性,支持 UNIX、NetWare、Windows、OS/2 或 Internet Web 的数据访问,客户端也不需要任何专用的软件,安装简易,甚至可以充当其他主机的网络驱动器,可以方便地利用现有的管理工具进行管理。

NAS 可以通过交换机方便地接入到用户网络上,是一种即插即用的网络设备。

SAN

存储区域网络 (Storage Area Network,SAN),是指采用光纤信道 (Fibre Channel) 技术,通过光纤信道交换机连接服务器主机和存储阵列,建立专用于数据存储的区域网络。

SAN 是专门连接存储外围设备和服务器的网络。它通常包括服务器外部存储设备服务器适配器集线器交换机以及网络存储管理工具等。SAN 在综合了网络的灵活性、可管理性及可扩展性的同时,提高了网络的带宽和存储 I/O 的可靠性。它降低了存储管理费用,并平衡了开放式 系统服务器的存储能力和性能,为企业级存储应用提出了解决方案。SAN 独立于应用服务器网络系统之外,拥有几乎无限的存储能力,它采用高速的光纤信道作为传输媒介,FC(光纤信道,Fibre Channel)+SCSI 的应用协议作为存储访问协议,将存储系统网络化,实现了真正高速的共享存储。

DAS NAS SAN
传输类型 SCSI、FC IP IP、FC、SAS、IB
数据类型 数据块 文件 数据块
典型应用 任何 文件服务器 数据库应用
优点 磁盘与服务器分离
便于统一管理
不占用应用服务器资源
广泛支持操作系统,扩展容易
即插即用,安装简单方便
高扩展性、高可用性
数据集中,易于管理
缺点 连接距离短,数据分散,共享困难
存储空间利用率不高,扩展性有限
不适合存储量大的块级应用
数据备份及恢复占用网络带宽
相比 NAS 成本较高
安装和升级比 NAS 复杂

磁盘阵列 RAID

磁盘阵列(Redundant Arrays of Independent Disks,RAID),有“独立磁盘构成的具有冗余能力的阵列”之意。

磁盘阵列是由很多价格较便宜的磁盘,组合成一个容量巨大的磁盘组,利用个别磁盘提供数据所产生加成效果提升整个磁盘系统效能。利用这项技术,将数据切割成许多区段,分别存放在各个硬盘上。

磁盘阵列还能利用同位检查(Parity Check)的观念,在数组中任意一个硬盘故障时,仍可读出数据,在数据重构时,将数据经计算后重新置入新硬盘中。

  1. 优点:提高传输速率。RAID 通过在多个磁盘上同时存储和读取数据来大幅提高存储系统的数据吞吐量 (Throughput)。在 RAID 中,可以让很多磁盘驱动器同时传输数据,而这些磁盘驱动器在逻辑上又是一个磁盘驱动器,所以使用 RAID 可以达到单个磁盘驱动器几倍、几十倍甚至上百倍的速率。这也是 RAID 最初想要解决的问题。因为当时 CPU 的速度增长很快,而磁盘驱动器的数据传输速率无法大幅提高,所以需要有一种方案解决二者之间的矛盾。RAID 最后成功了。

    通过数据校验提供容错功能。普通磁盘驱动器无法提供容错功能,如果不包括写在磁盘上的 CRC(循环冗余校验)码的话。RAID 容错是建立在每个磁盘驱动器的硬件容错功能之上的,所以它提供更高的安全性。在很多 RAID 模式中都有较为完备的相互校验/恢复的措施,甚至是直接相互的镜像备份,从而大大提高了 RAID 系统的容错度,提高了系统的稳定冗余性。

  2. 缺点:RAID0 没有冗余功能,如果一个磁盘(物理)损坏,则所有的数据都无法使用。RAID1 磁盘的利用率最高只能达到 50%(使用两块盘的情况下),是所有 RAID 级别中最低的。 RAID0+1 以理解为是 RAID 0 和 RAID 1 的折中方案。RAID 0+1 可以为系统提供数据安全保障,但保障程度要比 Mirror 低而磁盘空间利用率要比 Mirror 高。

RAID 数据存取方式

  • 并行存取模式(Paralleled Access):把所有磁盘驱动器的主轴马达作为精密的控制,使每个磁盘的位置都彼此同步,然后对每一个磁盘驱动器做一个很短的 I/O 数据传送,使从主机来的每一个 I/O 指令,都平均分不到每一个磁盘驱动器,将阵列中每一个磁盘驱动器的性能发挥到最大。

    适合大型的、数据连续的以长时间顺序访问数据为特征的应用。

  • 独立存取模式(Independent Access):对每个磁盘驱动器的存取都是独立且没有顺序和时间间隔的限制,可同时接收多个 I/O Requests,每笔传输的数据量都比较小。适合数据存取频繁,每笔存取数据量较小的应用。

    RAID0,1,5,6 都采用独立存取模式。

RAID 0

  1. 无差错控制的条带化阵列(RAID 0)工作原理:

  1. 优点

    • I/O 负载平均分配到所有的驱动器;
    • 由于驱动器可以同时读写,性能在所有 RAID 级别中最高;
    • 磁盘利用率最高,设计、使用和配置简单。
  2. 缺点

    • 数据无冗余,一旦阵列中有一个驱动器故障,其中的数据将丢失。
  3. 应用范围
    • 视频生成和编辑、图像编辑等对传输带宽需求较大的应用领域。

RAID 1

  1. 镜像结构的阵列(RAID 1)工作原理:

  1. 优点
    • RAID 1 对存储的数据进行百分之百的备份,提供最高的数据安全保障;
    • 设计、使用和配置简单。
  2. 缺点
    • 磁盘空间利用率低,存储成本高;
    • 磁盘写性能提升不大。
  3. 应用范围
    • 可应用于金融、保险、证券、财务等对数据的可用性和安全性要求较高的应用领域。

RAID 5

  1. 分布式奇偶校验码的独立磁盘结构(RAID 5)工作原理:

  1. 优点

    • 高可用性;
    • 磁盘利用率较高;
    • 随机读写性能高,校验信息分布存储于各个磁盘,避免单个校验盘的写操作瓶颈。
  2. 缺点

    • 异或校验影响存储性能;
    • 硬盘重建的过程较为复杂;
    • 控制器设计复杂。
  3. 应用范围

    • 适合用在文件服务器、Email 服务器、WEB 服务器等输入/输出密集、读/写比率较高的应用环境。

RAID 6 P+Q

  1. 工作原理:RAID 6 P+Q 需要计算出两个校验数据 P 和 Q,当有两个数据丢失时,根据 P 和 Q 恢复出丢失的数据。校验数据 P 和 Q 是由以下公式计算得来的:

  1. 优点
    • 具有高可靠性;
    • 可同时允许两块磁盘失效;
    • 至少需要四块磁盘。
  2. 缺点

    • 采用两种奇偶校验消耗系统资源,系统负载较重;
    • 磁盘利用率比 RAID 5 更低;
    • 配置过于复杂。
  3. 应用范围

    • 适合用在对数据准确性和完整性要求极高的环境。

RAID 10

  1. 工作原理:RAID 10 是将镜像和条带进行组合的 RAID 级别,先进行 RAID 1 镜像然后再做 RAID 0。RAID 10 也是一种应用比较广泛的 RAID 级别。

  1. 优点

    • 高读取速度;
    • 高写入速度,写开销较小;
    • 特定情况下,可以允许 N/2 个硬盘同时损坏。
  2. 缺点

    • 磁盘利用率低,只有 1/2 的硬盘利用率,至少需要 4 块磁盘。
  3. 应用范围
    • 数据量大,安全性要求高的环境,如银行、金融等领域。
RAID 级别 RAID 0 RAID 1 RAID 5 RAID 10 RAID 6
容错性
冗余类型 镜像冗余 校验冗余 镜像冗余 双重校验冗余
可用空间 100% 50%* (N-1)/N 50%* (N-2)/N
读性能 普通
随机写性能 普通
连续写性能 普通
最少磁盘数 2个 2个 3个 4个 4个
应用场景: 传输带宽需求大的应用 安全性要求较高的应用 读/写比较率较高的应用 安全性要求高的应用 安全性要求高的应用

热备技术(HotSpare)

所谓热备份是在建立 RAID 磁盘阵列系统的时候,将其中一磁盘指定为热备磁盘,此热备磁盘在平常开不操作,当阵列中某一磁盘发生故障时,热备磁盘便取代故障磁盘,并自行将故障磁盘的数据重构在热备磁盘上。

热备盘分为:全局热备盘和局部热备盘。

  • 全局热备盘:针对整个磁盘阵列,对阵列中所有 RAID 组起作用。
  • 局部热备盘:只针对某一 RAID 组起作用。

因为反应快速,加上快取内存减少了磁盘的存取,所以数据重构很快即可完成,对系统的性能影响不大。对于要求不停机的大型数据处理中心或控制中心而言,热备份更是一项重要的功能,因为可避免晚间或无人守护时发生磁盘故障所引起的种种不便。

分布式文件系统

文件系统

  1. 本地文件系统:一种存储和组织计算机数据的方法,它使得对其存取和查找变得容易文件系统管理的存储资源直接连在本地节点上,如:ext2,ext3,ext4,NTFS
  2. 分布式文件系统:指文件系统管理的存储资源通过网络不节点相连分布式文件系统的设计是基于客户机/服务器模式,如:nfs
  3. 集群文件系统:由多个服务器节点组成的分布式文件系统,如:ISILON,LoongStore,Lustre
  4. 并行文件系统:所有客户端可以同时并发读写同一个文件,支持并行应用(如 MPI)如:Lustre,GPFS

基于集群的分布式架构

  1. 特点
    • 分布式文件系统的服务器直连各自存储;
    • MDS 管理元数据 RAID、卷管理、文件系统三者合一性能和容量同时扩展,规模可以很大。

  1. 典型案例
  • 国外商业产品:IBM GPFS,EMC ISILON,Panasas PanFS
  • 国外开源系统:Intel Lustre,Redhat GFS,Gluster Glusterfs Clemon PVFS,Sage Weil/Inktank Ceph,Apache HDFS
  • 国内产品:中科蓝鲸 BWFS,龙存 Loongstore,余庆 FastDFS,淘宝 TFS

性能评价方法

评价文件系统的方法(指标)有:带宽IOPS

用到的测试工具有:Linux dd,Iozone,Iometer,Mdtest,IOR

并行文件系统

名称 所属企业 版权状态
PVFS Clemson 大学 开源
Lustre Cluster File Systems Inc. 开源
GPFS IBM 开源
ParaStor 曙光 商业软件
GFS Red Hat 商业软件
PFS Intel 商业软件
GoogleFS Google 商业软件
HDFS Apache 开源(基于 java 的支持)
FastDFS 开源社区 主要用于互联网应用

PVFS

PVFS:Clemson 大学的并行虚拟文件系统(PVFS)项目用来为运行 Linux 操作系统的 PC 群集创建一个开放源码的并行文件系统。PVFS 已被广泛地用作临时存储的高性能的大型文件系统和并行 I/O 研究的基础架构。作为一个并行文件系统,PVFS 将数据存储到多个群集节点的已有的文件系统中,多个客户端可以同时访问这些数据。

PVFS 使用了三种类型的节点:

  • 管理节点(mgr):运行元数据服务器,处理所有的文件元数据(元数据是描述文件信息的文件);
  • I/O 节点(iod):运行 I/O 服务器,存储文件系统的文件数据,负责数据的存储和检索;
  • 计算节点:处理应用访问,利用 libpvfs 这一客户端的 I/O 库,从底层访问 PVFS 服务器。I/O 节点、计算节点是一个集群的节点可以提供其中一种功能,也可以同时提供其中的两种或全部三种功能。

PVFS 的运行机理:当打开、关闭、创建或删除一个文件时,计算节点上的一个应用通过 libpvfs 直接与元数据服务器通信。在管理节点定位到一个文件之后,它向这个应用返回文件的位置,然后使用 libpvfs 直接联系相应的 I/O 节点进行读写操作,不必与元数据服务器通信,从而大大提高了访问效率。

PVFS 存在的问题也十分显著:

  1. 集中的元数据管理成为整个系统的瓶颈,可扩展性受到一定限制。
  2. 系统容错率有待提高:数据没有采取相应的容错机制,并且没有文件锁,其可扩展性较差,应用规模很大时容易导致服务器崩溃。
  3. 系统可扩展性有待加强:PVFS 使用静态配置,不具备动态扩展性,如果要扩展一个 I/O 节点,系统必须停止服务,并且不能做到空间的合理利用。
  4. PVFS 目前还不是由商业公司最终产品话的商品,而是基于 GPL 开放授权的一种开放技术。虽然免费获取该技术使整体系统成本进一步降低,但由于没有商业公司作为发布方,该技术的后续升级维护等一系列服务,都难以得到保证。

Lustre

Lustre,一种并行分布式文件系统,通常用于大型计算机集群超级电脑。Lustre 是源自 Linux 和 Cluster 的混成词。最早在 1999 年,由皮特·布拉姆(Peter Braam)创建的集群文件系统公司(Cluster File Systems Inc.)开始研发,于 2003 年发布 Lustre 1.0。采用 GNU GPLv2 开源码授权。

Lustre 特点有:(1)运行在 linux 环境下,linux 应用广泛;(2)硬件平台无关性;(3)支持任何块设备存储设备;(4)成本低,不一定要运行在 SAN 上,没有 licence;(5)开源,社区支持良好,intel 企业服务;(6)统一的命名空间;(7)在线容量扩展;(8)灵活的数据分布管理;(9)支持在线的滚动升级;(10)支持 ACL;(11)分布式的配额。

Lustre 组成包含八个部分:(1)数据和元数据;(2)对象文件系统;(3)MDS(Metadata Server);(4)OSS(Object Storage Servers);(5)Clients;(6)MGS(Management Server);(7) Lustre 支持的本地文件系统;(8)Lustre支持的网络。

其中,元数据指的是关于数据的数据,例如数据的大小,数据的权限,属性等等。对象存储文件系统的核心是将数据和元数据分离,并且基于对象存储设备 (Object-based Storage Device,OSD) 构建存储系统,每个对象存储设备具有一定的智能,能够自动管理其上的数据分布。MDS 提供元数据服务,连接 MDT (Metadata Targets);OSS 提供数据服务连接 OST(Object Storage Targets)。Clients 挂在并使用文件系统,计算节点。MGS 提供配置信息服务连接到 MGT(Management Targets)。 Lustre 支持的本地文件系统,例如:ldiskfs,Zfs。Lustre 支持的网络为:IB,IP(千兆、万兆)。

C语言常见优化策略

基本优化

整体思路与误区

当前编译器的优化其实已经做了很多工作,很多时候我们想当然的任务更优的代码,实际上在编译器的优化下,它的汇编指令基本一致的。编译器优化功能对那些平铺直叙的代码更有效,避免在编码里面加入一些想当然的”花招“,这反而会影响编译器优化。
(性能优化优先级:系统设计>数据结构/算法选择>热点代码编码调整)

  1. 全局变量:全局变量绝不会位于寄存器中。使用指针或者函数调用,可以直接修改全局变量的值。因此,编译器不能将全局变量的值缓存在寄存器中,但这在使用全局变量时便需要额外的(常常是不必要的)读取和存储。所以,在重要的循环中我们不建议使用全局变量。如果函数过多的使用全局变量,比较好的做法是拷贝全局变量的值到局部变量,这样它才可以存放在寄存器。这种方法仅仅适用于全局变量不会被我们调用的任意函数使用。
  2. 用switch()函数替代if…else…
  3. 使用二分方式中断代码而不是让代码堆成一列
  4. 带参数的宏定义效率比函数高。简单的运算可以用宏定义来完成。
  5. 选择合适的算法数据结构:选择一种合适的数据结构很重要,如果在一堆随机存放的数中使用了大量的插入和删除指令,那使用链表要快得多。数组与指针语句具有十分密切的关系,一般来说,指针比较灵活简洁,而数组则比较直观,容易理解。对于大部分的编译器,使用指针比使用数组生成的代码更短,执行效率更高。在许多种情况下,可以用指针运算代替数组索引,这样做常常能产生又快又短的代码。与数组索引相比,指针一般能使代码速度更快,占用空间更少。
  6. 能使用指针操作的尽量使用指针操作,一般来说,指针比较灵活简洁,对于大部分的编译器,
    使用指针生成的代码更短,执行效率更高。
  7. 递归调用尽量换成内循环或者查表解决,因为频繁的函数调用也是很浪费资源的查表是数据结构中的一个概念。查表的前提是先建表。在C语言实现中,建表也就是将一系列的数据,或者有原始数据中提取出的特征值,存储到一定的数据结构中,如数组或链表中。查表的时候,就是对数组或链表查询的过程。常用的方式有如下几种:
    • 对于有序数组,可以采用折半查找的方式快速查询。
    • 对于链表,可以根据链表的构建方式,进行针对性查询算法的编写。
    • 大多数情况,可以通过遍历的方式进行查表。即从第一个元素开始,一直顺序查询到最后一个元素,逐一对比。
  8. 使用增量或减量操作符:++x;原因是增量符语句比赋值语句更快。
  9. 使用复合赋值表达式:x+=1;能够生成高质量的程序代码
  10. 代码中使用代码块可以及时回收不再使用的变量,提高性能。变量的作用域从定义变量的那一行代码开始,一直到所在代码块结束。
  11. 当一个函数被调用很多次,而且函数中某个变量值是不变的,应该将此变量声明为static(只会分配一次内存),可以提高程序效率。
  12. 循环:长循环在内,短循环在外。

移位实现乘除

实际上,只要是乘以或除以一个整数,均可以用移位的方法得到结果,如:

1
a=a*9

可以改为:

1
a=(a<<3)+a

采用运算量更小的表达式替换原来的表达式,下面是一个经典例子:

旧代码:

1
2
3
4
5
6
7
8
x = w % 8;
y = pow(x, 2.0);
z = y * 33;
for (i = 0;i < MAX;i++)
{
h = 14 * i;
printf("%d",h);
}

新代码:

1
2
3
4
5
6
7
8
x = w & 7;             /* 位操作比求余运算快 */
y = x * x; /* 乘法比平方运算快 */
z = (y << 5) + y; /* 位移乘法比乘法快 */
for (i = h = 0; i < MAX; i++)
{
h += 14; /* 加法比乘法快 */
printf("%d", h);
}

避免不必要的整数除法也是优化的策略。整数除法是整数运算中最慢的,所以应该尽可能避免。一种可能减少整数除法的地方是连除,这里除法可以由乘法代替。这个替换的副作用是有可能在算乘积时会溢出,所以只能在一定范围的除法中使用。

1
2
3
4
5
6
//不好的代码:
int i, j, k, m;
m = i / j / k;
//推荐的代码:
int i, j, k, m;
m = i / (j * k);

结构体成员的布局

  1. 数据类型的长度排序:把结构体的成员按照它们的类型长度排序,声明成员时把长的类型放在短的前面。编译器要求把长型数据类型存放在偶数地址边界。在申明一个复杂的数据类型(既有多字节数据又有单字节数据)时,应该首先存放多字节数据,然后再存放单字节数据,这样可以避免内存的空洞。编译器自动地把结构的实例对齐在内存的偶数边界。

  2. 把结构体填充成最长类型长度的整倍数:把结构体填充成最长类型长度的整倍数。照这样,如果结构体的第一个成员对齐了,所有整个结构体自然也就对齐了。下面的例子演示了如何对结构体成员进行重新排序:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    //不好的代码,普通顺序:
    struct
    {
    char a[5];
    long k;
    double x;
    }baz;

    //推荐的代码,新的顺序并手动填充了几个字节:
    struct
    {
    double x;
    long k;
    char a[5];
    char pad[7];
    }baz;
    //这个规则同样适用于类的成员的布局。
  3. 数据类型的长度排序本地变量:当编译器分配给本地变量空间时,它们的顺序和它们在源代码中声明的顺序一样,和上一条规则一样,应该把长的变量放在短的变量前面。如果第一个变量对齐了,其它变量就会连续的存放,而且不用填充字节自然就会对齐。有些编译器在分配变量时不会自动改变变量顺序,有些编译器不能产生4字节对齐的栈,所以4字节可能不对齐。下面这个例子演示了本地变量声明的重新排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
//不好的代码,普通顺序
short ga, gu, gi;
long foo, bar;
double x, y, z[3];
char a, b;
float baz;

//推荐的代码,改进的顺序
double z[3];
double x, y;
long foo, bar;
float baz;
short ga, gu, gi;
  1. 频繁使用的指针型参数拷贝到本地变量:避免在函数中频繁使用指针型参数指向的值。因为编译器不知道指针之间是否存在冲突,所以指针型参数往往不能被编译器优化。这样数据不能被存放在寄存器中,而且明显地占用了内存带宽。注意,很多编译器有“假设不冲突”优化开关(在VC里必须手动添加编译器命令行/Oa或/Ow),这允许编译器假设两个不同的指针总是有不同的内容,这样就不用把指针型参数保存到本地变量。否则,请在函数一开始把指针指向的数据保存到本地变量。如果需要的话,在函数结束前拷贝回去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//不好的代码:
/*假设 q != r*/
void isqrt(unsigned long a, unsigned long* q, unsigned long* r)
{
  *q = a;
  if (a > 0)
  {
    while (*q > (*r = a / *q))
    {
      *q = (*q + *r) >> 1
    }
  }
  *r = a - *q * *q;
}

//推荐的代码:
/*假设 q != r*/
void isqrt(unsigned long a, unsigned long* q, unsigned long* r)
{
  unsigned long qq, rr;
  qq = a;
  if (a > 0)
  {
    while (qq > (rr = a / qq))
    {
      qq = (qq + rr) >> 1
    }
  }
  rr = a - qq * qq;
  *q = qq;
  *r = rr;
}

循环优化

循环优化整体策略为循环展开循环合并循环顺序的交换

循环展开,降低循环层次或者次数

1
2
3
4
5
6
7
8
9
10
11
12
13
while(i < count){
a[i]=i;
i++;
}
//优化为:
while(i < count - 1){
a[i]=i;
a[i+1] = i+1;
i += 2;
}
if(i==count-1){
a[count-1]=count-1;
}

循环合并(计数器相同的),避免多次轮询

1
2
3
4
5
6
7
8
9
10
11
12
if(i = 0; i < index; i++){
do_type_a_work(i);
}
if(i = 0; i < index; i++){
do_type_b_work(i);
}

//优化为:
if(i = 0; i < index; i++){
do_type_a_work(i);
do_type_b_work(i);
}

循环顺序交换

循环内计算外提(每次计算不变),降低无效计算:

1
2
3
4
for(int i=0; i< get_max_index();i++){}
//优化为:
int max_index = get_max_index();
for(int i = 0; i < max_index; i++){}

循环内多级寻址外提,避免反复寻址跳转:

1
2
3
4
5
6
7
8
9
10
11
for(int i = 0; i < max_index; i++){
ainfo->bconfig.cset[i].index = index;
ainfo->bconfig.cset[i].flag = flag;
}

//优化:
set = ainfo->bconfig.cset;
for(int i = 0; i < max_index; i++){
set[i].index = index;
set[i].flag = flag;
}

循环内判断外提(某时刻结果不变),降低无效比较次数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (i = 0; i < index; i++) {
if (type==TYPE_A) {
do_type_a_work(i);
} else {
do_type_b_work(i);
}
}
// 优化:
if (type==TYPE_A) { // 提高性能的同时,影响了可维护性;
if (i = 0; i < index; i++) {
do_type_a_work(i);
}
} else {
if (i = 0; i < index; i++) {
do_type_b_work(i);
}
}

循环体使用int类型,多重循环:最忙的循环放最里面

1
2
3
4
5
6
7
8
9
10
11
for (column = 0; column < 100; column ++) {
for (row = 0; row < 5; row++) {
sum += table[row][column ];
}
}
// 优化
for (row = 0; row < 5; row++) {
for (column = 0; column < 100; column ++) {
sum += table[row][column ];
}
}
  1. 充分分解小的循环:要充分利用CPU的指令缓存,就要充分分解小的循环。特别是当循环体本身很小的时候,分解循环可以提高性能。注意:很多编译器并不能自动分解循环。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//不好的代码:
/*3D转化:把矢量 V 和 4x4 矩阵 M 相乘*/
for (i = 0; i < 4; i ++)
{
  r[i] = 0
  for (j = 0; j < 4; j ++)
  {
    r[i] += M[j][i]*V[j];
  }
}

//推荐的代码:
r[0] = M[0][0]*V[0] + M[1][0]*V[1] + M[2][0]*V[2] + M[3][0]*V[3];
r[1] = M[0][1]*V[0] + M[1][1]*V[1] + M[2][1]*V[2] + M[3][1]*V[3];
r[2] = M[0][2]*V[0] + M[1][2]*V[1] + M[2][2]*V[2] + M[3][2]*V[3];
r[3] = M[0][3]*V[0] + M[1][3]*V[1] + M[2][3]*V[2] + M[3][3]*v[3];
  1. 提取公共部分:对于一些不需要循环变量参加运算的任务可以把它们放到循环外面,这里的任务包括表达式、函数的调用、指针运算、数组访问等,应该将没有必要执行多次的操作全部集合在一起,放到一个init的初始化程序中进行。
  2. 延时函数
1
2
3
4
5
6
7
8
9
10
11
12
13
//通常使用的延时函数均采用自加的形式:
void delay (void)
{
unsigned int i;
for (i=0;i<1000;i++) ;
}

//将其改为自减延时函数:
void delay (void)
{
unsigned int i;
for (i=1000;i>0;i--) ;
}

两个函数的延时效果相似,但几乎所有的C编译对后一种函数生成的代码均比前一种代码少1~3个字节,
因为几乎所有的MCU均有为0转移的指令,采用后一种方式能够生成这类指令。在使用while循环时也一样,使用自减指令控制循环会比使用自加指令控制循环生成的代码更少1~3个字母。但是在循环中有通过循环变量“i”读写数组的指令时,使用预减循环有可能使数组超界,要引起注意.

  1. while循环和do…while循环:
1
2
3
4
5
6
7
8
//用while循环时有以下两种循环形式:
unsigned int i;
i=0;
while (i<1000)
{
i++;
//用户程序
}

或:

1
2
3
4
5
6
7
unsigned int i;
i=1000;
do
{
i--;
//用户程序
}while (i>0);

在这两种循环中,使用do…while循环编译后生成的代码的长度短于while循环。

  1. Switch语句中根据发生频率来进行case排序:Switch 可能转化成多种不同算法的代码。其中最常见的是跳转表和比较链/树。当switch用比较链的方式转化时,编译器会产生if-else-if的嵌套代码,并按照顺序进行比较,匹配时就跳转到满足条件的语句执行。所以可以对case的值依照发生的可能性进行排序,把最有可能的放在第一位,这样可以提高性能。此外,在case中推荐使用小的连续的整数,因为在这种情况下,所有的编译器都可以把switch 转化成跳转表。
  2. 将大的switch语句转为嵌套switch语句:当switch语句中的case标号很多时,为了减少比较的次数,
    明智的做法是把大switch语句转为嵌套switch语句。把发生频率高的case 标号放在一个switch语句中,
    并且是嵌套switch语句的最外层,发生相对频率相对低的case标号放在另一个switch语句中。比如,下面的程序段把相对发生频率低的情况放在缺省的case标号内。
  3. 循环转置:有些机器对JNZ(为0转移)有特别的指令处理,速度非常快,如果你的循环对方向不敏感,可以由大向小循环。
  4. 公用代码块:一些公用处理模块,为了满足各种不同的调用需要,往往在内部采用了大量的if-then-else结构,这样很不好,判断语句如果太复杂,会消耗大量的时间的,应该尽量减少公用代码块的使用。
    (任何情况下,空间优化和时间优化都是对立的)。当然,如果仅仅是一个(3==x)之类的简单判断,
    适当使用一下,也还是允许的。记住,优化永远是追求一种平衡,而不是走极端。
  5. 提升循环的性能:要提升循环的性能,减少多余的常量计算非常有用(比如,不随循环变化的计算)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//不好的代码(在for()中包含不变的if()):
for( i 。。。 )
{
  if( CONSTANT0 )
  {
    DoWork0( i ); // 假设这里不改变CONSTANT0的值
  }
  else
  {
    DoWork1( i ); // 假设这里不改变CONSTANT0的值
  }
}

//推荐的代码:
if( CONSTANT0 )
{
  for( i 。。。 )
  {
    DoWork0( i );
  }
}
else
{
  for( i 。。。 )
  {
    DoWork1( i );
  }
}

如果已经知道if()的值,这样可以避免重复计算。虽然不好的代码中的分支可以简单地预测,
但是由于推荐的代码在进入循环前分支已经确定,就可以减少对分支预测的依赖。

  1. 选择好的无限循环:在编程中,我们常常需要用到无限循环,常用的两种方法是while (1)for (;;)。这两种方法效果完全一样,但那一种更好呢?然我们看看它们编译后的代码:
1
2
3
4
5
6
7
8
9
10
11
12
//编译前:
while (1);
//编译后:
mov eax,1
test eax,eax
je foo+23h
jmp foo+18h

//编译前:
for (;;);
//编译后:
jmp foo+23h

显然,for (;;)指令少,不占用寄存器,而且没有判断、跳转,比while (1)好。

提高CPU的并行性

  1. 使用并行代码:尽可能把长的有依赖的代码链分解成几个可以在流水线执行单元中并行执行的没有依赖的代码链。很多高级语言,包括C++,并不对产生的浮点表达式重新排序,因为那是一个相当复杂的过程。需要注意的是,重排序的代码和原来的代码在代码上一致并不等价于计算结果一致,因为浮点操作缺乏精确度。在一些情况下,这些优化可能导致意料之外的结果。幸运的是,在大部分情况下,最后结果可能只有最不重要的位(即最低位)是错误的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//不好的代码:
double a[100], sum;
int i;
sum = 0.0f
for (i=0; i<100; i++)
sum += a[i];

//推荐的代码:
double a[100], sum1, sum2, sum3, sum4, sum;
int i;
sum1 = sum2 = sum3 = sum4 = 0.0
for (i = 0; i < 100; i += 4)
{
  sum1 += a[i];
  sum2 += a[i+1];
  sum3 += a[i+2];
  sum4 += a[i+3];
}
sum = (sum4+sum3)+(sum1+sum2);

要注意的是:使用4 路分解是因为这样使用了4段流水线浮点加法,浮点加法的每一个段占用一个时钟周期,保证了最大的资源利用率。

  1. 避免没有必要的读写依赖:当数据保存到内存时存在读写依赖,即数据必须在正确写入后才能再次读取。虽然AMD Athlon等CPU有加速读写依赖延迟的硬件,允许在要保存的数据被写入内存前读取出来,但是,如果避免了读写依赖并把数据保存在内部寄存器中,速度会更快。在一段很长的又互相依赖的代码链中,避免读写依赖显得尤其重要。如果读写依赖发生在操作数组时,许多编译器不能自动优化代码以避免读写依赖。所以推荐程序员手动去消除读写依赖,举例来说,引进一个可以保存在寄存器中的临时变量。这样可以有很大的性能提升。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//下面一段代码是一个例子:
//不好的代码:
float x[VECLEN], y[VECLEN], z[VECLEN];
for (unsigned int k = 1; k < VECLEN; k ++)
{
  x[k] = x[k-1] + y[k];
}

for (k = 1; k <VECLEN; k++)
{
  x[k] = z[k] * (y[k] - x[k-1]);
}

//推荐的代码:
float x[VECLEN], y[VECLEN], z[VECLEN];
float t=x[0];
for (unsigned int k = 1; k < VECLEN; k ++)
{
  t = t + y[k];
  x[k] = t;
}

t = x[0];
for (k = 1; k <; VECLEN; k ++)
{
  t = z[k] * (y[k] - t);
  x[k] = t;
}

循环不变计算

对于一些不需要循环变量参加运算的计算任务可以把它们放到循环外面,
现在许多编译器还是能自己干这件事,不过对于中间使用了变量的算式它们就不敢动了,
所以很多情况下你还得自己干。对于那些在循环中调用的函数,凡是没必要执行多次的操作通通提出来,
放到一个init函数里,循环前调用。另外尽量减少喂食次数,没必要的话尽量不给它传参,
需要循环变量的话让它自己建立一个静态循环变量自己累加,速度会快一点。
还有就是结构体访问,东楼的经验,凡是在循环里对一个结构体的两个以上的元素执行了访问,
就有必要建立中间变量了(结构这样,那C++的对象呢?想想看),看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
旧代码:
total =
a->b->c[4]->aardvark +
a->b->c[4]->baboon +
a->b->c[4]->cheetah +
a->b->c[4]->dog;
新代码:
struct animals * temp = a->b->c[4];
total =
temp->aardvark +
temp->baboon +
temp->cheetah +
temp->dog;

一些老的C语言编译器不做聚合优化,而符合ANSI规范的新的编译器可以自动完成这个优化,看例子:

1
2
3
4
5
6
7
float a, b, c, d, f, g;
a = b / c * d;
f = b * g / c;
优化后代码:
float a, b, c, d, f, g;
a = b / c * d;
f = b / c * g;

如果这么写的话,一个符合ANSI规范的新的编译器可以只计算b/c一次,然后将结果代入第二个式子,
节约了一次除法运算。

函数优化

  1. Inline函数:在C++中,关键字Inline可以被加入到任何函数的声明中。这个关键字请求编译器用函数内部的代码替换所有对于指出的函数的调用。这样做在两个方面快于函数调用:第一,省去了调用指令需要的执行时间;第二,省去了传递变元和传递过程需要的时间。但是使用这种方法在优化程序速度的同时,程序长度变大了,因此需要更多的ROM。使用这种优化在Inline函数频繁调用并且只包含几行代码的时候是最有效的。避免小函数调用开销(提炼宏函数 或 inline内联化)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    int afunc(char *buf, bool enable){
    if(check_null(buff)==true){
    return -1;
    }
    }

    //优化为:
    #define check_null(a) if (a==null){return -1;}
    int afunc(char *buf, bool enable){
    check_null(buf);
    }
  2. 不定义不使用的返回值:函数定义并不知道函数返回值是否被使用,假如返回值从来不会被用到,应该使用void来明确声明函数不返回任何值。函数入参低于一定数量(如4个),则形参由R0,R1,R2,R3四个寄存器进行传递;若形参个数大于的部分必须通过堆栈进行传递,性能降低;用指针传递的效率高于结构赋值;直接用全局变量省去了传递时间,但是影响了模块化和可重入,要慎重使用;
    如果函数不需要返回值就明确void;

  3. 减少函数调用参数:使用全局变量比函数传递参数更加有效率。这样做去除了函数调用参数入栈和函数完成后参数出栈所需要的时间。然而决定使用全局变量会影响程序的模块化和重入,故要慎重使用。

  4. 所有函数都应该有原型定义:一般来说,所有函数都应该有原型定义。原型定义可以传达给编译器更多的可能用于优化的信息。

    1
    2
    3
    int max(int *a, int m, int n);//这行就是函数原型,函数定义在主函数后面。
    //函数原型的就是实现函数先(main中调用),
    //后(定义在后面)
  5. 尽可能使用常量(const):C++ 标准规定,如果一个const声明的对象的地址不被获取,
    允许编译器不对它分配储存空间。这样可以使代码更有效率,而且可以生成更好的代码。

  6. 把本地函数声明为静态的(static):如果一个函数只在实现它的文件中被使用,把它声明为静态的(static)以强制使用内部连接。否则,默认的情况下会把函数定义为外部连接。这样可能会影响某些编译器的优化——比如,自动内联。

变量

减少不必要的赋值或者变量初始化,减少不必要的临时变量;

1
2
3
4
int i = 0;
i = input_value;
//优化:
int i = input_value
1
2
3
4
int ret = do_next_level_func();
return ret;
//优化:
return do_next_level_func();
  1. register变量:在声明局部变量的时候可以使用register关键字。这就使得编译器把变量放入一个多用途的寄存器中,而不是在堆栈中,合理使用这种方法可以提高执行速度。函数调用越是频繁,越是可能提高代码的速度。
  2. 同时声明多个变量优于单独声明变量。
  3. 短变量名优于长变量名,应尽量使变量名短一点。
  4. 在循环开始前声明变量。
  5. 如果确定整数非负,应直接使用unsigned int,处理器处理无符号unsigned 整形数的效率远远高于有符号signed整形数。
  6. 局部变量尽可能的不使用char和short类型。对于char和short类型,编译器需要在每次赋值的时候将局部变量减少到8或者16位,是通过寄存器左移24或者16位,然后根据有无符号标志右移相同的位数实现,这会消耗两次计算机指令操作。
  7. 使用尽量小的数据类型:能够使用字符型(char)定义的变量,就不要使用整型(int)变量来定义;
    能够使用整型变量定义的变量就不要用长整型(long int),能不使用浮点型(float)变量就不要使用浮点型变量。

条件判断

  1. 使用switch替代if else:switch…case会生成一份大小(表项数)为最大case常量+1的跳表,程序首先判断switch变量是否大于最大case 常量,若大于,则跳到default分支处理;否则取得索引号为switch变量大小的跳表项的地址(即跳表的起始地址+表项大小*索引号),程序接着跳到此地址执行。
  2. if(xxx1>XXX1 && xxx2=XXX2)多个条件判断中,确保AND表达式的第一部分最快或最早得到结果,这样第二部分便有可能不需要执行。
  3. 在必须使用if…else…语句,将最可能执行的放在最前面。
  4. 使用嵌套的if结构:在if结构中如果要判断的并列条件较多,最好将它们拆分成多个if结构,然后嵌套在一起,这样可以避免无谓的判断。

单处理器计算(一)

翻自:《Introduction to High Performance Scientific Computing》-Victor Eijkhout

了解计算机体系结构对于编写高效、科学的代码具有十分重要的作用。两段基于不同处理器架构编写的代码,其计算结果可能相同,但速度差异可能从几个百分点到几个数量级之间不等。显然,仅仅把算法放到计算机上是不够的,计算机架构也是至关重要的内容。

有些问题可以在单个中央处理单元(CPU)上解决,而有些问题则需要由多个处理器组成的并行计算机解决。我们将在下一章详细介绍并行计算机,但即便是使用并行计算机处理,也需要首先了解单个CPU的情况。

在该部分,我们将重点关注CPU及其内存系统内部发生的事情。首先讨论指令如何执行,研究处理器核心中的运算;最后,由于内存访问通常比处理器执行指令要慢得多,因此我们将重点关注内存、处理器以及处理器内部的数据移动情况;“flops(每秒浮点操作数)计数”作为预测代码性能的时代已经一去不复返了。这种差异实际上是一个不断增长的趋势,所以随着时间的推移,处理内存流量的问题变得越来越重要,而非逐渐销声匿迹。

这一章中,我们将对CPU设计是如何影响性能的,以及如何编写优化性能的代码等问题有一个清晰的认识。想学习更多细节,请参阅关于PC架构的在线书籍[114],以及关于计算机架构的标准工作,Hennesey和Patterson[97]。

冯·诺依曼架构

虽然各类计算机在处理器细节上存在很多不同,但也有许多相似之处。总的看来,它们都采用了「冯·诺伊曼架构」(von Neumann architectures)。该架构主要包含:存储程序和数据的内存,以及一个在“获取、执行、存储周期”中对数据进行操作的指令处理单元。

注释 1: 具有指定指令序列的模型也称为「控制流」(control flow),与「数据流」(data flow)相对应。

由于指令和数据共同存储在一个处理器中,这使得冯·诺依曼架构区别于早期或一些其他特殊用途的硬接线当代处理器,能够允许修改或生成其他程序。这给我们提供了编辑器和编译器:计算机可以将程序视为数据进行处理。

注释 2: 存储程序的概念允许一个正在运行的程序修改其源代码。然而,人们很快就意识到这将导致代码变得难以维护,因此在实际中很少见到。

本书将不会讨论编译器将高级语言翻译成机器指令的过程,而是讨论如何编写高质量的程序以确保底层运行的效率。

在科学计算中,我们通常只关注数据在程序执行期间如何移动,而非程序代码具体如何。大多数应用中,程序与数据似乎是分开存储的。与高级语言不同,处理器执行的机器指令通常会指定操作的名称,操作数和结果的位置。这些位置不是表示为内存位置,而是表示为「寄存器」(registers)位置:即在CPU中被称作内存的一小部分。

注释 3: 我们很少分析到内存的架构,尽管它们已经存在。20世纪80年代的Cyber 205超级计算机可以同时有三个数据流,两个从内存到处理器,一个从处理器到内存。这样的架构只有在内存能够跟上处理器速度的情况下才可行,而现在已经不是这样了。

下面是一个简单的C语言例子:

1
2
3
void store(double *a, double *b, double *c) {
*c = *a + *b;
}

及其X86汇编输出,由gcc -O2 -S -o - store.c得到:

1
2
3
4
5
6
7
8
9
      .text
.p2align 4,,15
.globl store
.type store, @function
store:
movsd. (%rdi), %xmm0 # Load *a to %xmm0
addsd (%rsi), %xmm0 # Load *b and to %xmm0
movsd %xmm0, (%rdx) # Store to *c
ret

(程序演示的为64位系统输出;32位系统可以添加-m64指令输出)

这段程序的指令有:

  • 从内存加载到寄存器;
  • 执行加法操作;
  • 将结果写回内存。

每条指令的处理如下:

  • 指令获取:根据「程序计数器」(program counter)的指示将下一条指令装入进程。此处我们不考虑这是如何发生以及从哪里发生的问题。
  • 指令解码:处理器检查指令以确定操作和操作数。
  • 内存获取:必要时,数据将从内存取到寄存器中。
  • 执行:执行操作,从寄存器读取数据并将数据写回寄存器。

数组的情况稍微复杂一些:加载(或存储)的元素被确定为数组的基址后会加上一个偏移量。

在某种程度上,现代CPU在程序员看来就像冯·诺伊曼机器,但也有各种例外。首先,虽然内存看起来为随机寻址,但在实际中存在着「局部性」(locality)的概念:一旦一个数据项被加载,相邻的项将更有效地加载,而重新加载初始项也会更快。

简单数据加载的另一个复杂之处是,当前的CPU同时操作多条指令,这些指令被称为“「正在执行」(in flight)”,这意味着它们处于不同的完成阶段。当然,与这些同步指令一起,它们的输入和输出也以重叠的方式在内存和处理器之间移动。这是超标量CPU体系结构的基本思想,也被称为「指令级并行」(Instruction Level Parallelism,ILP)。因此,虽然每个指令可能需要几个时钟周期才能完成,但处理器可以在合适的情况下每个周期完成一条指令;在某些情况下,每个周期可以完成多条指令。

CPU的处理速度处在千兆赫级(G),意味着处理器的速度是决定计算机性能的主要因素。速度虽然与性能紧密联系,但实际情况却更为复杂:一些算法被CPU所限制,此时进程的速度是最重要的制约;另外一些算法受到内存的限制,总线速度和缓存大小等方面是影响该问题的关键。

在科学计算中,第二种情况相当显著,因此在本章中,我们将大量关注将数据从内存转移到处理器的过程,而对实际处理器的关注相对较少。

当代处理器

当代处理器极为复杂,在这一节中,我们将简短地介绍一下其组成部分。下图是Intel Sandy Bridge处理器的芯片图。这种芯片大约一英寸大小,包含近十亿个晶体管。

处理核心

冯·诺依曼模型中只有一个执行指令的实体。自21世纪初以来,这种情况并没有显著的增长。上图所示的Sandy Bridge有8个核,每个核都是执行指令流的独立单元。在本章中,我们将主要讨论单个核心的各个方面;第1.4节将讨论多核的集成方面。

指令处理

冯·诺伊曼模型也是不现实的,因为它假设所有的指令都严格按照顺序执行。在过去的二十年中,处理器越来越多地使用了无序指令处理,即指令可以按照不同于用户程序指定的顺序进行处理。当然,处理器只有在不影响执行结果的情况下才允许对指令重新排序!

图1.2中,你可以看到与指令处理有关的各种单元:这种聪明的做法实际上要花费相当多的能源及大量的晶体管。正因如此,包括第一代英特尔Xeon Phi协处理器,Knights Corner在内的处理器都采取了使用顺序指令处理的策略。然而,在下一代Knights Landing中,这种做法却由于性能不佳而被淘汰。

浮点单元

在科学计算中,我们最感兴趣的是处理器如何处理浮点数据。而并非整数或布尔值的运算。因此,核心在处理数值数据方面具有相当的复杂性。

例如,过去的处理器只有一个「浮点单元」(FPU),而现在的它们有多个且能够同时执行的浮点单元。

例如,加法和乘法通常是分开的;如果编译器可以找到独立的加法和乘法操作,就可以同时调度它们从而使处理器的性能翻倍。在某些情况下,一个处理器会有多个加法或乘法单元。

另一种提高性能的方法是使用「乘加混合运算」(Fused Multiply-Add,FMA)单元,它可以在与单独的加法或乘法相同的时间内执行指令$x \leftarrow ax +b$。配合流水线操作,这意味着处理器在每个时钟周期内有几个浮点运算的渐近速度。

顺便说一句,很少有用除法当制约因素的算法。在现代CPU中,除法操作的优化程度远不及加法和乘法。除法操作可能需要10或20个时钟周期,而CPU可以有多个加法和/或乘法单元(渐进地)使之每个周期都可以产生一个结果。下表为多个处理器架构的浮点能力(每个核心),以及8个操作数的DAXPY周期数

处理器 年份 加/乘/乘加混合 单元(个数$\times$宽度) daxpy cycles(arith vs load/store)
MIPS R10000 1996 $1\times 1+1\times 1+0$ 8/24
Alpha EV5 1996 $1\times 1+1\times 1+0$ 8/12
IBM Power5 2004 $0+0+2\times 1$ 4/12
AMD Bulldozer 2011 $2\times 2+2\times 2+0$ 2/4
Intel Sandy Bridge 2012 $1\times 4+1\times 4+0$ 2/4
Intel Haswell 2014 $0+0+2\times 4$ 1/2

流水线

处理器的浮点加乘单元是流水线式的,其效果是独立的操作流可以以每个时钟周期一个结果的渐近速度执行。流水线背后的思想如下:假设一个操作由若干个简单操作组成,并且每个子操作在处理器中都有独立的硬件。如果我们现在有多个操作要执行,我们可以通过让所有的子操作同步执行来获得加速:每个操作将其结果交给下一个操作,并接受前一个操作的输入。

注释4 这与排队救火的过程非常类似,是一种收缩算法。

例如,加法指令可以包含以下组件:

  • 指令解码,包括查找操作数的位置。
  • 将操作数复制到寄存器中(数据获取)。
  • 调整指数;加法$.35 \times10^{-1} + .6 \times 10^{−2}$变成$.35 \times 10^{-1} + .06 \times 10^{−1}$。
  • 执行尾数的加法,在这个例子中是$.41$。
  • 将结果归一化,在本例中为$.41 \times 10^{−1}$。(本例中归一化并未执行任何操作,$.3 \times 100 + .8 \times 100$和$.35 \times 10^{−3} +(−.34)\times 10^{−3}$做了很大的调整)

这些部分通常被称为流水线的“「深度」(stages)”或“「阶段」(segments)”。

如果按照顺序执行,上文中每个组件设计为1个时钟周期,则整个过程需要6个时钟周期。然而,如果每个操作都有自己对应的硬件,我们就可以在少于12个周期内执行两个操作:

  • 第一个操作执行解码;
  • 第一个操作获取数据,同时第二个操作执行解码;
  • 同时执行第一操作的第三阶段和第二操作的第二阶段;
  • 以此类推。

可以看到,第一个操作仍然需要6个时钟周期,但第二个操作只需再延后一个周期就能同时完成。

对流水线获得的加速做一个正式的分析:在传统的浮点单元上,产生$n$个结果需要花费时间为 $t(n)=n\ell\tau$,其中$\ell$是状态个数,而$\tau$是时钟周期。结果产生的速率是$t(n)/n$的倒数:$r_{serial}\equiv(\ell \tau)^{-1}$

另一方面,对于流水线的浮点单元,时间是$t(n)=[s+l+n-1]t$其中$s$是设置成本;第一次执行时必须要经历一个完整的串行阶段,但在此后,处理器将在每个周期获得更多的收益。可以记作公式

表示线性时间,加上偏移量。流水线示意图描述如下:

练习1.1 请对比传统FPU和流水线FPU的速度差异。证明结果速率依赖于$n$:给出$r(n)$和$r_\infty = \lim\limits_{n\rightarrow \infty}r(n)$的公式。在非流水线情况下$r$的加速极限是什么样?它需要多长时间才能接近极限情况?注意到$n=n_{1/2}$,可以得到$r(n)=r_\infty/2$,这通常被用做$n_{1/2}$的定义。

由于向量处理器同时处理多个指令,因此这些指令必须是独立且相互之间没有依赖关系的。$∀_i:a_i\leftarrow b_i + c_i$有独立的加法运算;$\forall_i:a_{i+1}\leftarrow a_ib_i+c_i$将一次迭代$(a_i)$的结果输入到下一次迭代的输入$(a_{i+1}=…)$,所以这些操作并非独立。

与传统的CPU相比,流水线处理器可以将操作速度提高4、5甚至6倍。在上世纪80年代,当第一台向量计算机成功上市时,这样的加速效率十分常见。现在,CPU可以有20个阶段的流水线,是否意味着它们的速度非常快?这个问题有点复杂。芯片设计者不断提高主频,流水线部分不再能够在一个周期内完成他们的工作,所以他们进一步分裂。有时甚至有一些时间片段什么也没有发生:但这段时间是又是必须的,以确保数据可以及时传输到芯片的不同部分。

人们能从流水线CPU得到的改进是有限的,为了追求更高的性能,计算机科学家们尝试了几种不同的流水线设计。例如,Cyber 205有单独的加法和乘法流水线,可以将一个流水线输入另一个流水线,而无需先将数据返回内存。像 $∀_i: a_i\leftarrow b_i + c·d_i$这样的操作被称为“「链接三元组」(linked triads)”(因为到内存的路径数量,一个输入操作数必须是标量)。

练习1.2 分析链接三元组的加速和$n_{1/2}$。

另一种提高性能的方法是使用多个相同的流水线。NEC SX系列完善了这种设计。例如,有4条流水线时,$∀_i:a_i\leftarrow b_i +c_i$操作将对模块4进行拆分,以便第一条流水线对索引$i = 4·j$操作,第二条流水线对索引$i = 4·j + 1$操作,以此类推。

练习1.3 分析具有多个并行操作流水线处理器的速度提升情况和$n_{1/2}$。也就是说,假设有$p$个执行相同指令的独立流水线,每条流水线都可以处理的操作数流。

(你可能想知道我们为什么在这里提到一些相当老的计算机:真正的流水线超级计算机已经不存在了。在美国,Cray X1是该系列的最后一款,而在日本,只有NEC还在生产。然而,现在CPU的功能单元是流水线的,所以这个概念仍然很重要。)

练习1.4 如下操作

1
2
3
for (i) {
x[i+1] = a[i]*x[i] + b[i];
}

不能由流水线处理,因为在操作的一次迭代的输入和前一次迭代的输出之间存在依赖关系。但是,我们可以将循环转换为数学上等价的循环,并且可能更有效地计算。导出一个表达式,该表达式从$x[i]$中计算$x[i+2]$而不涉及$x[i+1]$。这就是所谓的「递归加倍」(recursive doubling)。假设有足够的临时存储空间。参考如下:

  • 做初步计算;
  • 计算$x[i],x[i+2],x[i+4],…$,并从这些中
  • 计算缺失项$x[i+1],x[i+3],…$

通过给出$T_0(n)$和$T_s(n)$的计算公式,分析了该格式的有效性。你能想到为什么初步计算在某些情况下可能不那么重要吗?

收缩计算

上面描述的流水线操作是「收缩算法」(systolic algorithm)的一种情况。在20世纪80年代和90年代,有研究使用流水线算法并构建特殊硬件——「脉动阵列」 (systolic arrays)来实现它们[125]。这也与「现场可编程门阵列」(Field-Programmable Gate Arrays,FPGA)的计算连接,其中脉动阵列是由软件定义的。

峰值性能

现代CPU由于流水线的存在,时钟速度和峰值性能之间存在着较为简单的关系。由于每个FPU可以在一个周期内产生一个结果,所以峰值性能是时钟速度乘以独立FPU的数量。浮点运算性能的衡量标准是“「每秒浮点运算」(floating point operations per second)”,缩写为flops。考虑到现在计算机的速度,你会经常听到浮点运算被表示为“gigaflops”:$10^9$次浮点运算的倍数。

8位,16位,32位,64位

处理器的特征通常是可以处理多大的数据块。这可以联系到

  • 处理器和内存之间路径的宽度:一个64位的浮点数是否可以在一个周期内加载,还是分块到达处理器。
  • 内存的寻址方式:如果地址被限制为16位,只有64,000字节可以被识别。早期的PC有一个复杂的方案,用段来解决这个限制:用段号和段内的偏移量来指定一个地址。
  • 单个寄存器中的数值位数,特别是用于操作数据地址的整数寄存器的大小;参见前一点。(浮点寄存器通常更大,例如在x86体系结构中是80位。)这也对应于处理器可以同时操作的数据块的大小。
  • 浮点数的大小:如果CPU的算术单元被设计成有效地乘8字节数(“双精度”;见3.2.2节),那么一半大小的数字(“单精度”)有时可以以更高的效率处理,而对于更大的数字(“四倍精度”),则需要一些复杂的方案。例如,一个四精度的数字可以由两个双精度的数字来模拟,指数之间有一个固定的差异。

这些测量值不一定相同。例如,原来的奔腾处理器有64位数据总线,但有一个32位处理器。另一方面,摩托罗拉68000处理器(最初的苹果Macintosh)有一个32位CPU,但16位的数据总线。

第一个英特尔微处理器4004是一个4位处理器,它可以处理4位的数据块。如今,64位处理器正在成为标准。

缓存(Caches):芯片上的内存

计算机内存的大部分是在与处理器分离的芯片中。然而,通常有少量的片上内存(通常是几兆字节),这些被称为「高速缓存」(cache)。后面我们将会详细解释。

图形、控制器、专用硬件

“消费型”和“服务器型”处理器之间的一个区别是,消费型芯片在处理器芯片上花了相当大的空间用于图形处理。手机和平板电脑的处理器甚至可以有专门的安全电路或mp3播放电路。处理器的其他部分专门用于与内存或I/O子系统通信。我们将不在本书中讨论这些方面。

超标量处理和指令级并行性

在冯·诺伊曼模型中,处理器通过控制流进行操作:指令之间线性地或通过分支相互跟踪,而不考虑它们涉及哪些数据。随着处理器变得越来越强大,一次可以执行多条指令,就有必要切换到数据流模型。这种超标量处理器分析多个指令以找到数据相关性,并行执行彼此不依赖的指令。

这个概念也被称为「指令级并行」(Instruction Level Parallelism,ILP),它被各种机制推动:

  • 多发射(multiple-issue):独立指令可同时启动;
  • 流水线(pipelining):上文提到,算术单元可以在不同的完成阶段处理多个操作;
  • 分支预测和推测执行(branch prediction and speculative execution):编译器可以“预测”条件指令的值是否为真,然后相应地执行这些指令;
  • 无序执行(out-of-order execution):如果指令之间不相互依赖,并且执行效率更高,则指令可以重新排列;
  • 预取(prefetching):数据可以在实际遇到任何需要它的指令之前被推测地请求(这将在后面进一步讨论)。

在上面我们看到了浮点操作上下文中的流水线操作。事实上,不仅是浮点运算,当代处理器的整个CPU都是流水线的,任何类型的指令都将尽快被放入指令流水线中。注意,这个流水线不再局限于相同的指令:现在,流水线的概念被概括为同时 “在执行 “的任何指令流。

随着主频的增加,处理器流水线的长度也在增加,以使分段在更短的时间内可被执行。可以看到,更长的流水线有着更大的$n_{1/2}$,因此需要更多的独立指令来使流水线以充分的效率运行。当达到指令级并行性的极限时,使流水线变长(或者称为“更深”)将不再有好处。因此,芯片设计者们通常转向多核架构以更高效地利用芯片上的晶体管。

这些较长的流水线的第二个问题是:如果代码到达一个分支点(一个条件或循环中的测试),就不清楚要执行的下一条指令是什么。在该点上,流水线就会会停止。例如,CPU总是假设测试结果是正确的,因此采取了「分支预测执行」(speculative execution)。如果代码随后接受了另一个分支(这称为分支错误预测),则必须刷新流水线并重新启动。执行流中产生的延迟称为「分支惩罚」(branch penalty)。

内存层次结构

冯·诺伊曼体系结构中,数据立即从内存加载到处理器,并在处理器中进行操作。然而这是不现实的,因为这一过程中会有「访存墙」(memory wall)[204]的存在:内存速度过慢,无法和处理器速度相匹配。具体来说,单次加载可能需要1000个周期,而处理器每个周期可以执行若干个操作。(在长时间等待加载之后,下一个加载可能会更快,但对处理器来说仍然太慢)

实际上,在FPU和内存之间会有不同的存储器级别:寄存器和缓存,一起称为「存储器层级结构」(memory hierarchy)。它们可以更快速地从内存中读取一些最近使用的数据以缓解访存墙的问题。当然,这是在数据被多次使用的前提下。这类「数据复用」(data reuse)问题将在后面进行更详细的讨论。

寄存器和缓存都在不同程度上比内存快;寄存器的速度越快,其存储量就越小。寄存器的大小和速度之间的矛盾产生了一个有趣的博弈,我们将随后讨论这些问题。

接下来,我们将讨论内存层次结构的各部分并分析其在执行过程所需的理论基础。

总线

在计算机中,将数据从处理器移动到CPU、磁盘控制器或屏幕的线路被称为「总线」(busses)。对我们来说最重要的是连接CPU和内存的「前端总线」(Front-Side Bus,FSB)。在当前较为流行的架构中,这被称为“「北桥」(north bridge)”,与连接外部设备(除了图形控制器)的“「南桥」(south bridge)”相对。

总线通常比处理器的速度慢,其时钟频率作为CPU时钟频率的一部分,数值上略高于1GHz,这也正是引入缓存的原因之一;事实上,一个处理器可以在每个时钟周期中消耗大量数据项。除了频率之外,总线的带宽也由每个时钟周期可移动的比特数决定。在目前的体系结构中,这通常是64或128。我们现在将更详细地讨论这个问题。

延迟和带宽

寄存器中访问数据几乎是瞬时的,而将数据从内存加载到寄存器是进行任何操作之前的一个必要步骤,加载的过程会导致大量的延迟,下面我们将细化这一过程。

有两个重要的概念来描述数据的移动:「延迟带宽」(latency and bandwidth)。这里的假设是,请求一组数据会引起初始延迟;如果这一项是该组数据的第一个项,则通常是连续的内存地址范围,而该组数据的其余部分将以一个固定的时间周期不再延迟到达。

延迟」:为处理器发出内存项请求到内存项实际到达之间的延迟。我们可以区分不同的延迟,比如从内存到缓存的传输,缓存到寄存器的传输,或者将它们总结为内存和处理器之间的延迟。延迟是以(纳米)秒或时钟周期来衡量的。如果处理器按照在汇编代码中的顺序去执行指令,则在从内存中提取数据时经常会停止;这也被称为「内存延迟」(memory stall。低延迟非常重要。在实际中,许多处理器都有指令的“无序执行”情况,即允许它们在等待所请求的数据时执行其他操作。程序员可以考虑这一点,并以一种实现延迟隐藏的方式编写代码;「图形处理单元」(GPU)可以在线程之间快速切换,以实现延迟隐藏。

带宽」:为克服初始延迟后,数据到达目的地的速率。带宽以字节(千字节k、兆字节m、千兆字节g)/秒或每个时钟周期来衡量。两个存储层之间的带宽通常是通道的周期速度(总线速度)和总线宽度的乘积:总线时钟的每个周期中可以同时发送的比特数。

延迟和带宽的概念通常结合在一个公式中,表示消息从开始到结束所花费的时间: $T(n)=\alpha+\beta n$

其中$\alpha$是延迟,$\beta$是带宽的倒数:即每字节所用的时间。

通常,距离处理器越远,延迟就越长,带宽就越低。因此我们希望处理器尽量使用缓存中的数据而非内存,例如考虑向量加法

1
2
for(i)
a[i] = b[i] + c[i]

每次迭代执行一个浮点操作,现代CPU可以通过流水线技术在一个时钟周期内完成这一操作。但是,每次迭代都需要加载两个数字并写入一个数字,总共需要24字节的内存流量(实际上,$a[i]$在写入之前就被加载了,所以每次迭代有4次内存访问,总共32字节)。典型的内存带宽数字(参见图1.5)远远没有接近24(或32)字节每周期。这意味着,在没有缓存的情况下,算法性能可能受到内存性能的限制。当然,缓存不会加速每一个操作,但这对我们的例子并没有影响。我们将会在1.7节中讨论高效利用缓存的编程策略。

当我们讨论从一个处理器向另一个处理器发送数据时,延迟和带宽的概念也会出现在并行计算机中。

寄存器

每个处理器内部都有少量类似内存的结构:「寄存器」(registers)或「寄存器堆」(registers file)。寄存器是处理器实际操作的对象:例如

1
a := b + c

实际过程为

  • 将b的值从内存中装入寄存器,
  • 将c的值从内存中装入另一个寄存器,
  • 计算和并将其写入另一个寄存器,然后
  • 将计算后的总和写回a的内存位置。

查看汇编代码(例如编译器的输出)就可以看到显式的加载、计算和存储指令。

像加或乘这样的计算指令只能在寄存器上操作。例如,在汇编语言中我们会看到如下指令

1
addl	%eax	%edx

它将一个寄存器的内容添加到另一个寄存器。正如在这个示例指令中看到的,与内存地址相反,寄存器没有编号,而是具有在汇编指令中引用的不同名称。通常,一个处理器有16或32个浮点寄存器;英特尔安腾(Intel Itanium)的128个浮点寄存器是例外。

寄存器具有高带宽和低延迟,因为它们是处理器的一部分。可以将进出寄存器的数据移动看作是瞬时的。

在本章中,我们会发现从内存中读取数据的时间开销较大。因此,尽可能将数据留在寄存器中是一种简单的优化策略。例如,如果上面的计算后面跟着一个语句

1
2
a := b + c
d := a + e

a的计算值就可以留在寄存器中。编译器通常会帮助我们完成这种优化:编译器不会生成存储和重新加载a的指令。我们就称a停留在寄存器中。

将值保存在寄存器中通常是为了避免重新计算新的变量。例如,在

1
2
t1 = sin(alpha) * x + cos(alpha) * y;
t2 = -cos(alpha) * x + sin(alpha) * y;

正弦和余弦值可能会保留在寄存器中。我们可以通过显式地引入临时数量来帮助编译器:

1
2
3
s = sin(alpha); c = cos(alpha);
t1 = s * x + c * y;
t2 = -c * x + s * y

当然,寄存器的数量是有限制的;试图在寄存器中保留太多的变量被称为「寄存器漫溢」(register spill),这会降低代码的性能。

如果变量出现在内部循环中,那么将该变量保存在寄存器中尤为重要。在计算

1
2
for i = 1, length
a[i] = b[i] * c

中,变量c可能会被编译器保存在寄存器中,而在

1
2
3
for k =1, nevctors
for i =1, length
a[i,k] = b[i,k] * c[k]

最好是显式地引入一个临时变量来保存$c[k]$。在C语言中,你可以通过将变量声明为寄存器变量来提示编译器将变量保存在寄存器中:

1
register double t;

译者注:声明寄存器格式并不能完全使变量存储在寄存器中,这仅仅起到提示寄存器的作用,如果想完全控制寄存器的执行,则需要编写汇编代码。

缓存

在包含指令即时输入和输出数据的寄存器和大量数据可以长期存放的内存之间,有各种级别的高速缓冲存储器,它们比内存具有更低的延迟和更高的带宽,并将数据保存一段时间。

数据从内存通过高速缓存到达寄存器的好处是,如果一组数据在第一次使用后不久就要被复用,由于它仍然在缓存中,因此访问的速度比从内存中引入数据要快得多。

从历史的角度来看,存储器层次结构的概念早在1946年就已经被讨论过了[25],当时提出的原因是内存技术发展较为缓慢。

示例

例如,假设一个变量$x$被使用了两次,它的两次使用间隔较大,以至于会留在寄存器中:

1
2
3
... = ... x ..... //执行x的指令
......... //不涉及到x的一些指令
... = ... x ..... //执行x的指令

汇编代码为:

  • 将$x$从内存加载到寄存器中,并对其进行操作;
  • 执行中间的指令;
  • 将$x$从内存加载到寄存器中,并对其进行操作;

使用缓存,汇编代码保持不变,但内存系统的实际行为现在变成:

  • 将$x$从内存加载到缓存中,并从缓存加载到寄存器中,执行操作;
  • 执行中间其他的指令;
  • 从内存中请求$x$,但由于它仍然在缓存中,因此直接从缓存中加载,继续操作。

由于从缓存加载比从主存加载要快,因此计算速度将会更快。缓存的容量较小,所以数值不能无限期地保存在那里。我们将在下面的讨论中看到它的含义。

缓存和寄存器之间有一个重要的区别:虽然数据是通过显式的汇编指令移入寄存器的,但从内存到缓存的移动完全是由硬件完成的。因此,缓存的使用和重用不在程序员的直接控制范围之内。稍后,特别是在1.6.2和1.7节中,我们将看到如何间接影响缓存的使用。

缓存标签

上面没有提及在缓存中找到某个项的机制,但每个缓存位置都有一个标记,以便我们有足够多的信息来找到某个缓存项与内存的映射。

缓存级别、速度和大小

缓存通常被称为“level 1”和“level 2”(简称L1和L2)缓存;有些处理器可能有L3缓存。L1和L2缓存位于处理器的芯片上;L3缓存则在芯片外部。L1缓存的容量很小,通常在16Kbyte左右。相比之下,第2级(如果有,则是第3级)缓存容量则较大,最多可达几兆字节,但速度也随之下降。与可扩展的内存不同,缓存的大小是固定的。如果某个版本的处理器芯片上附带了一个较大的缓存,那么它的价格通常相当昂贵。

某些操作所需的数据在传送到处理器的过程中被复制到不同的缓存中。如果在一些指令之后,又需要一个数据项,计算机会首先在L1缓存中搜索它;如果没有找到,就在L2缓存中搜索;如果没有找到,就从内存中加载。在缓存中找到数据称为「缓存命中」(cache hit),没有找到数据则称为「缓存失效」(cache miss)。

图1.5展示了缓存层次结构的基本情况,本例是针对Intel Sandy Bridge芯片:缓存越接近FPU,其速度越快,容量越小。

hierarchysb

  • 从寄存器加载数据是如此之快,以致于它不会成为阻碍算法执行速度的限制。另一方面,寄存器的数量很少。每个核心有16个通用寄存器和16个SIMD寄存器。
  • L1缓存很小,但是每个周期保持了32字节的带宽,即4个双精度数。这足以为两个操作分别加载两个操作数,但请注意,内核实际上每个周期可以执行4个操作。因此,为了达到峰值速度,某些操作数需要留在寄存器中:通常,L1带宽足以满足大约一半的峰值性能。
  • L2和L3缓存的带宽名义上与L1相同。然而,部分带宽将在缓存一致性问题上浪费。
  • 主内存访问带宽大于100个周期,带宽为4.5字节/周期,约为L1带宽的1/7。然而,这个带宽是由一个处理器芯片的多个核心共享的,因此有效的带宽是这个数字的一个小数。大多数集群每个节点也有多个「插槽」(socket,即处理器芯片),通常是2或4个,因此一些带宽花费在维持缓存一致性上(参见1.4节),再次减少了每个芯片的可用带宽。

在L1上,指令和数据有单独的缓存;L2和L3的缓存则同时包含数据和指令。

可以看到,越来越大的缓存无法足够快地向处理器提供数据。因此,有必要以这样一种方式编码,即数据尽可能地保存在最高缓存级别。我们将在本章的其余部分详细讨论这个问题。

练习 1.5 L1缓存比L2缓存小,如果有L3缓存,则L2要比L3小。给出一个实际的和理论上的原因。

缓存失效的类型

缓存失效有三种类型。

正如在上面的例子中看到的,第一次引用数据时,总是会导致缓存丢失,这被称为「强制失效」(compulsory miss),因为这些是不可避免的。这是否意味着在第一次需要数据项时,我们要一直等待它?不一定:第1.3.5节解释了硬件如何通过预测下一步需要什么数据来帮助你。

下一种类型的缓存丢失是由于工作集的大小造成的:「容量失效」(capacity miss)是由于数据被覆盖,因为缓存不能包含所有问题数据(第1.3.4.6节讨论了处理器如何决定要覆盖哪些数据)。如果你想要避免这种类型的失误,需要将问题划分为足够小的块,以便数据可以在缓存中停留相当长的时间。当然,这是在假设数据项被多次操作的前提下,所以把数据项保存在缓存中是有意义的;这将在第1.6.1节中讨论。

最后,由于一个数据项被映射到与另一个相同的缓存位置而导致的「冲突失效」(conflict miss),而这两个数据项仍然是计算所需要的,并且可能有更好的候选者需要被驱逐。这将在1.3.4.10节中讨论。

在多核上下文中还有另外一种类型的缓存丢失:「无效失效」(invalidation miss)。如果缓存中的某个项因为另一个内核改变了相应内存地址的值而失效,就会发生这种情况。内核将不得不重新加载这个地址。

复用是关键

一个或多个缓存的存在并不能立即保证高性能:这在很大程度上取决于代码的内存访问模式,以及如何充分利用缓存。第一次引用一个项时,它被从内存复制到缓存,并通过处理器的寄存器。缓存的存在并没有以任何方式减少延迟和带宽。当同一项第二次被引用时,它可能在缓存中被找到,因此在延迟和带宽方面的成本大大降低:缓存比主存有更短的延迟和更高的带宽。

我们的结论是,首先,算法必须有数据复用的机会。如果每个数据项只被使用一次(就像除了两个向量之外),不存在复用,则缓存的存在在很大程度上是无关紧要的。只有当缓存中的项被多次引用时,代码才会从缓存增加的带宽和减少的延迟中受益;详细的讨论请参见1.6.1节。例如,矩阵向量乘法$𝑦=𝐴𝑥$,其中$𝑥$的每个元素都在$𝑛$操作中使用,其中$𝑛$是矩阵维数。

其次,算法理论上可能有复用的机会,但需要以较为明显的复用方式进行编码。我们将在1.6.2节中解决这些问题。后者尤其重要。

有些问题很小,可以完全放在缓存中,至少在L3缓存中是这样。这是在进行「基准测试」(benchmarking)时需要注意的一点,因为它对处理器性能的描述过于乐观。

替换策略

高速缓存和寄存器中的数据仅由硬件决定,而非由程序员控制。同样地,当缓存或寄存器中的数据在一段时间内没有被引用,并且其他数据需要放在那里时,系统就会决定什么时候覆盖这些数据。下面,我们将详细介绍缓存如何做到这一点,但在整合一个总体原则,一个「最近最少使用」(Least Recently Used,LRU)替换:如果缓存已满,需要放入新数据,最近最少使用的数据从缓存中刷新,这意味着它是覆盖在新项目,因此不再访问。LRU是目前最常见的替换策略;其他的策略还有:「先进先出」(First In First Out,FIFO)或「随机替换」。

练习 1.6 LRU替换策略与直接映射缓存和关联缓存有什么关系?

练习 1.7 描绘一个简单的场景,并给出一些(伪)代码,以论证LRU比FIFO更适合作为替代策略。

缓存线

在内存和高速缓存之间或多个高速缓存之间的数据移动不是用单个字节,甚至字来完成的。相反,移动数据的最小单位称为「高速缓存线」(cache line),有时也称为「高速缓存块」(cache block)。一个典型的缓存行是64或128字节长,在科学计算中意味着8或16个双精度浮点数。移动到L2缓存的数据的缓存线大小可能比移动到L1缓存的数据大。

高速缓存线的第一个设计初衷是便于实际应用:它简化了所涉及的电路。由于许多代码显示出空间局部性,缓存线的存在有着非同寻常的意义。1.6.2章。

反之,我们现在需要利用数据的局部性编写高质量代码,因为任何内存访问都需要传输几个字符(参见1.7.4节中的一些例子)。在加载缓存线后,我们希望尽可能地使用一同加载进来的其他数据以实现高效利用资源,因为同一缓存线上的内容访实际上是自由且方便的。这种现象在通过「跨步访问」(stride access)数组的代码中是十分常见的:以规则的时间间隔读取或写入元素。

Stride 1 连续访问数组中的元素:

1
2
for (i=0; i<N; i++)
... = ... x[i] ...

让我们用一个例子来说明:每个缓存线上有4个字。请求第一个元素将包含它的整个缓存线加载到缓存中。然后,对第2、3和4个元素的请求可以从缓存中得到满足,这意味着实现了高带宽和低延迟。

Stride 2 跨步访问数组中的元素:

1
2
for (i=1; i<N; i+=stride)
... = ... x[i] ...

意味着在每个缓存线中只有某些元素被使用。我们用Stride = 3来说明这一点:第一个元素加载一个缓存线,该也包含第二个元素。然而,第三个元素在下一个缓存上,因此加载它会引起主内存的延迟和带宽。第四个元素也是如此。加载四个元素现在需要加载三条缓存线而不是一条,这意味着三分之二的可用带宽被浪费了。(如果没有注意到常规访问模式的硬件机制,并先发制人地加载进一步的缓存线,第二种情况也会导致三倍于第一种情况的延迟;见1.3.5)

有些应用程序自然会导致大于1的进步,例如,只访问一个复数数组的实数部分(关于复数实际实现的一些注释,请参阅3.7.6节)。另外,使用递归加倍的方法通常具有非单位步长的代码结构

1
2
for (i=0; i<N/2; i++)
x[i] = y[2*i];

在这个关于缓存线的讨论中,我们隐式地假设缓存线的开头也是一个单词的开头,不管是整数还是浮点数。实际情况中往往并非如此:一个8字节的浮点数可以放置在两个缓存线之间的边界上。可以想象,这将极大程度上影响程序性能。第37.1.3节讨论了实际中处理缓存线边界对齐的方法。

缓存映射

越接近FPU的缓存速度越快,其容量也更小。但即便最大的缓存也比内存容量小的多。在第1.3.4.6节中,我们已经讨论了如何做出保留哪些元素和替换哪些元素的决定。

现在我们将讨论缓存映射的问题,也就是“如果一个条目被放在缓存中,它会被放在哪里”的问题。这个问题通常是通过将项目的(主存)地址映射到缓存中的地址来解决的,这就导致了“如果两个项目映射到同一个地址会怎么样”的问题。

直接映射缓存

最简单的缓存映射策略是「直接映射」(direct mapping)。假设内存地址是32位长,因此它们可以寻址4G字节;进一步假设缓存有8K个字,也就是64K字节,需要16位来寻址。直接映射从每个内存地址取最后(“最低有效”)16位,并使用这些作为缓存中的数据项的地址;参见图1.8。

直接映射的效率非常高,因为它的地址计算速度非常快,导致了较低的延迟,但在实际应用中存在一个问题。如果两个被8K字分隔的条目被寻址,它们将被映射到相同的缓存位置,这将使某些计算效率低下。例子:

1
2
3
double A[3][8192];
for (i=0; i<512; i++)
a[2][i] = (a[0][i]+a[1][i])/2.;

directmap

directmapconflict

或在Fortran中:

1
2
3
4
real*8 A(8192,3);
do i=1,512
a(i,3) = ( a(i,1)+a(i,2) )/2
end do

此处,$[0] [i]$,$[1] [i]$和$[2] [i]$ (或者$a(i,1)$,$a(i,2)$,$a(i,3)$)的位置对于每个$i$来说是8K的,所以它们地址的最后16位是相同的,因此它们将被映射到缓存中的相同位置;参见图1.9。

现在,循环的执行情况将如下:

  • $a [0] [0]$处的数据被带入高速缓存和寄存器中,这产生了一定的延时。和这个元素一起,整个高速缓存行被转移。
  • 在$[1] [0]$处的数据被带入缓存(和寄存器中),连同其整个缓存行,以一些延迟为代价。由于这个缓存行和第一个缓存行被映射到相同的位置,所以第一个缓存行被覆盖。
  • 为了写入输出,包含$a[2] [0]$的缓存行被带入内存。这又被映射到同一位置,导致刚刚加载的$a[1] [0]$的缓存行被刷新。
  • 在下一次迭代中,需要$a[0] [1]$,它和$a[0] [0]$在同一个缓存行。然而,这个缓存行已经被刷新了,所以它需要从主内存或更深的缓存层中被重新带入。在这样做的时候,它覆盖了保存$a[2] [0]$的缓存行。
  • $a[1] [1]$的情况类似:它在$a[1] [0]$的缓存行上,不幸的是,它已经被上一步覆盖了。

如果一个缓存行有四个字,我们可以看到,循环的每四次迭代都涉及到八个$a$元素的传输的元素,而如果不是因为缓存冲突,两个元素就足够了。

练习 1.8 在直接映射高速缓存的例子中,从内存到高速缓存的映射是通过使用32位内存地址的最后16位作为高速缓存地址完成的。如果使用前16位(”最有意义的”)作为缓冲区地址,那么这个例子中的问题就会消失。为什么在一般情况下这不是一个好的解决方案?

注释5:到目前为止,我们一直假装缓存是基于虚拟内存地址的。实际上,缓存是基于内存中数据的物理地址,这取决于将虚拟地址映射到内存页的算法。

关联式缓存

如果任何数据项目都可以进入任何缓存位置,那么上一节中概述的缓存冲突问题就会得到解决。在这种情况下,除了缓存被填满之外,不会有任何冲突,在这种情况下,缓存替换策略(第1.3.4.6节)会刷新数据,为新来的项目腾出空间。这样的缓存被称为「全关联映射」(fully associative mapping)的,虽然它看起来是最好的,但它的构建成本很高,而且在使用中比直接映射的缓存慢得多。

正因如此,最常见的解决方案是建立一个「k-路关联缓存」($𝑘$-way associative mapping),其中$𝑘$至少是两个。在这种情况下,一个数据项可以进入任何一个$𝑘$缓存位置。代码必须要有$𝑘+1$路冲突,才会像上面的例子那样过早地刷新数据。在这个例子中,$𝑘=2$的值就足够了,但在实践中经常会遇到更高的值。图1.10展示了一个直接映射的和一个三向关联的缓冲区的内存地址与缓冲区位置的映射情况。两个缓冲区都有12个元素,但是它们的使用方式不同。直接映射的高速缓存(左边)在内存地址0和12之间会有冲突,但是在三向关联高速缓存中,这两个地址可以被映射到三个元素中的任何一个。

assoc-mapping

作为一个实际的例子,英特尔Woodcrest处理器有一个32K字节的L1缓存,它是8路设置的关联性,缓存行大小为64字节,L2缓存为4M字节,是8路设置的关联性,缓存行大小为64字节。另一方面,AMD Barcelona芯片的L1缓存是2路关联,L2缓存是8路关联。更高路关联性显然是可取的,但是会使处理器变得更慢,因为确定一个地址是否已经在缓存中变得更加复杂。由于这个原因,在速度最重要的地方,L1高速缓存的关联性通常比L2低。

练习 1.9 用你喜欢的语言写一个小型的高速缓存模拟器。假设一个有32个条目的$k$方式的同构缓存和一个16位地址的架构。对$𝑘=1, 2, 4, …$进行以下实验。

  1. 让$k$模拟高速缓存的关联性。

  2. 写下从16位内存地址到32/𝑘缓存地址的转换。

  3. 生成32个随机机器地址,并模拟将其存储在缓存中。

    由于高速缓存有32个条目,最佳情况下这32个地址都可以存储在高速缓存中。这种情况实际发生的几率很小,往往一个地址的数据会在另一个地址与之冲突时被驱逐出缓存(意味着它被覆盖)。记录在模拟结束时,32个地址中,有多少地址被实际存储在缓存中。将步骤3做100次,并绘制结果;给出中位数和平均值,以及标准偏差。观察一下,增加关联性可以提高存储地址的数量。其极限行为是什么?(为了获得奖励,请做一个正式的统计分析)

缓存内存与普通内存的对比

那么,缓冲存储器有什么特别之处;为什么我们不把它的技术用于所有的存储器?

缓存通常由静态随机存取存储器(SRAM)组成,它比用于主存储器的动态随机存取存储器(DRAM)更快,但也更昂贵,每一位需要5-6个晶体管,而不是一个,而且耗电量更大。

加载与存储

在上述描述中,在程序中访问的所有数据都需要在使用这些数据的指令执行之前被移入高速缓存。这对读取的数据和写入的数据都适用。然而,已经写入的数据,如果不再需要(在一定的合理时间内),就没有理由留在缓存中,可能会产生冲突或驱逐仍然可以重复使用的数据。出于这个原因,编译器通常支持流式存储:一个纯粹输出的连续数据流将被直接写入内存,而不被缓存。

预取流

在传统的冯·诺依曼模型中(第1.1节),每条指令都包含其操作数的位置,所以实现这种模型的CPU会对每个新的操作数进行单独请求。在实践中,往往后续的数据项在内存中是相邻的或有规律的间隔。内存系统可以通过查看高速缓存的数据模式来检测这种数据模式,并请求「预取数据流」(prefetch data stream);

prefetch

在最简单的形式下,CPU会检测到来自两个连续的高速缓存行的连续加载,并自动发出对接下来的高速缓存行的请求。如果代码对第三条高速缓存线发出实际请求,这个过程可以重复或扩展。由于这些高速缓存行现在是在需要提前从内存中取出,所以预取有可能消除除前几个数据项之外的所有延迟。

现在我们需要重新审视一下缓存失效的概念。从性能的角度来看,我们只对缓存失效的停顿感兴趣,也就是说,在这种情况下,计算必须等待数据被带入。不在缓存中的数据,但在其他指令还在处理的时候可以被带入,这不是一个问题。如果 “L1缺失 “被理解为只是 “缺失时的停顿”,那么术语 “L1缓存重新填充 “被用来描述所有的缓存线负载,无论处理器是否在它们上面停顿。

由于预取是由硬件控制的,所以它也被描述为「硬件预取」(hardware prefetch)。预取流有时可以从软件中控制,例如通过「源语」(intrinsic)。

由程序员引入预取是对一些因素的谨慎平衡[94]。其中最重要的是预取距离:从预取开始到需要数据时的周期数。在实践中,这通常是一个循环的迭代次数:预取指令请求未来迭代的数据。

并发和内存传输

在关于内存层次的讨论中,我们提出了一个观点:内存比处理器慢。如果这还不够糟糕的话,利用内存提供的所有带宽甚至不是小事。换句话说,如果你不仔细编程,你会得到比你根据可用带宽所期望的更少的性能。让我们来分析一下。

内存系统的带宽通常为每周期一个以上的浮点数,所以你需要每周期发出那么多请求来利用可用的带宽。即使在零延迟的情况下也是如此;由于存在延迟,数据从内存中出来并被处理需要一段时间。因此,任何基于第一个数据的计算而请求的数据都必须在延迟至少等于内存延迟的情况下请求。

为了充分利用带宽,在任何时候都必须有相当于带宽乘以延迟的数据量在运行。由于这些数据必须是独立的,我们得到了 「Little 定律」[147]。

little

这在图1.12中得到了说明。维护这种并发性的问题并不是程序没有这种并发性,而是程序要让编译器和运行时系统识别它。例如,如果一个循环遍历了一个长的数组,编译器就不会发出大量的内存请求。预取机制(1.3.5节)会提前发出一些内存请求,但通常不够。因此,为了使用可用的带宽,多个数据流需要同时进行。因此,我们也可以将 「Little 定律」表述为

内存bank

上面,我们讨论了与带宽有关的问题。你看到内存,以及在较小程度上的缓存,其带宽低于处理器可以最大限度地吸收的带宽。这种情况实际上比上面的讨论看起来还要糟糕。由于这个原因,内存通常被分为交错的内存组:在四个内存组中,字0、4、8…在0组,字1、5、9…在1组,依此类推。

假设我们现在按顺序访问内存,那么这样的4路交错式内存可以维持4倍于单个内存组的带宽。不幸的是,按跨度2访问将使带宽减半,而更大的跨度则更糟糕。如果两个连续的操作访问同一个内存bank,我们就会说到内存bank冲突[7]。在实践中,内存库的数量会更多,因此,小跨度的内存访问仍然会有完整的广告带宽。例如,Cray-1有16个banks,而Cray-2有1024个。

练习 1.10 证明在有质数的bank时,任何达到该质数的跨步都是无冲突的。你认为为什么这个解决方案没有在实际的内存架构中被采用?

在现代处理器中,DRAM仍然有bank,但由于有缓存的存在,其影响较小。然而,GPU有内存组,没有缓存,所以它们遭受了与老式超级计算机相同的一些问题。

练习 1.11 对一个数组的元素进行求和的递归加倍算法是

1
2
3
for (s=2; s<2*n; s*=2)
for(i=0; i<n-s/2; i+=s)
x[i] += x[i+s/2]

分析该算法的bank冲突。假设$𝑛=2^p$,bank有$2^k$元素,其中$𝑘 < 𝑝$。同时考虑到这是一个并行算法,内循环的所有迭代都是独立的,因此可以同时进行。另外,我们可以使用递归减半法

1
2
3
for (s=(n+1)/2; s>1; s/=2)
for(i=0; i<n; i+=1)
x[i] += x[i+s]

再次分析,bank的混乱情况。这种算法更好吗?在并行情况下呢?

缓存存储器也可以使用bank。例如,AMD巴塞罗那芯片的L1缓存中的缓存线是16个字,分为两个8个字的交错库。这意味着对高速缓存线的元素进行顺序访问是有效的,但串联访问的性能就会下降。

TLB、页和虚拟内存

一个程序的所有数据可能不会同时出现在内存中。这种情况可能由于一些原因而发生:

  • 计算机为多个用户服务,所以内存并不专门用于任何一个用户。
  • 计算机正在运行多个程序,这些程序加起来需要的内存超过了物理上可用的内存。
  • 一个单一的程序所使用的数据可能超过可用的内存。

基于这些原因,计算机使用虚拟内存:如果需要的内存比可用的多,某些内存块会被写入磁盘。实际上,磁盘充当了真实内存的延伸。这意味着一个数据块可以出现在内存的任何地方,事实上,如果它被换入和换出,它可以在不同时间出现在不同位置。交换不是作用于单个内存位置,而是作用于内存页:连续的内存块,大小从几千字节到几兆字节。(在早期的操作系统中,将内存移至磁盘是程序员的责任。互相替换的内存页被称为覆盖层)

由于这个原因,我们需要一个从程序使用的内存地址到内存中实际地址的翻译机制,而且这种翻译必须是动态的。一个程序有一个 “逻辑数据空间”(通常从地址0开始),是编译后的代码中使用的地址,在程序执行过程中需要将其翻译成实际的内存地址。出于这个原因,有一个页表,指定哪些内存页包含哪些逻辑页。

大页

在非常不规则的应用中,例如数据库,页表会变得非常大,因为更多或更少的随机数据被带入内存。然而,有时这些页面显示出某种程度的集群,这意味着如果页面大小更大,需要的页面数量将大大减少。由于这个原因,操作系统可以支持大的页面,通常大小为2Mb左右。(有时会使用’巨大的页面’;例如,英特尔Knights Landing有Gigabyte页面)

大页面的好处取决于应用:如果小页面没有足够的集群,使用大页面可能会使内存过早地被大页面的未使用部分填满。

TLB

然而,通过查找该表进行地址转换是很慢的,所以CPU有一个「转译后备缓冲器」(Translation Look-aside Buffer,TLB)。TLB是一个经常使用的页表项的缓存:它为一些页提供快速的地址转换。如果一个程序需要一个内存位置,就会查询TLB,看这个位置是否真的在TLB所记忆的页面上。如果是这样,逻辑地址就被翻译成物理地址;这是一个非常快速的过程。在TLB中没有记住该页的情况被称为TLB缺失,然后查询页面查找表,如果有必要,将需要的页面带入内存。TLB是(有时是完全)关联的(1.3.4.10节),使用LRU策略(1.3.4.6节)。

一个典型的TLB有64到512个条目。如果一个程序按顺序访问数据,它通常只在几个页面之间交替进行,而且不会出现TLB缺失。另一方面,一个访问许多随机内存位置的程序可能会因为这种错过而出现速度下降。目前正在使用的页面集被称为 “工作集”。

第1.7.5节和附录37.5讨论了一些简单的代码来说明TLB的行为。

[这个故事有一些复杂的情况。例如,通常有一个以上的TLB。第一个与L2缓存相关,第二个与L1相关。在AMD Opteron中,L1 TLB有48个条目,并且是完全(48路)关联的,而L2 TLB有512个条目,但只是4路关联的。这意味着实际上可能存在TLB冲突。在上面的讨论中,我们只谈到了L2 TLB。之所以能与L2缓存而不是主内存相关联,是因为从内存到L2缓存的转换是确定性的]。

使用大页也可以减少潜在的TLB缺失次数,因为可以减少工作页的集合。

单处理器计算(二)

多核架构

近年来,传统的处理器芯片设计已经达到了性能的极限。主要由于

  • 主频已经不能再增加,因为这会增大功耗,使芯片发热过大;见1.8.1节。
  • 从代码中提取更多的指令级并行(ILP)变得困难,要么是由于编译器的限制,要么是由于内在可用的并行量有限,要么是由于分支预测使之无法实现(见1.2.5节)。

从单个处理器芯片中获得更高的利用率的方法之一是,从进一步完善单个处理器的策略,转向将芯片划分为多个处理 “核心”。这些独立的内核可以在不相关的任务上工作,或者通过引入数据的并行性(第2.3.1节),以更高的整体效率协作完成一个共同的任务[163]。

注释6 另一个解决方案是英特尔的超线程,它可以让一个处理器混合几个指令流的指令。这方面的好处在很大程度上取决于具体情况。然而,这种机制在GPU中也得到了很好的利用,见2.9.3节。讨论见2.6.1.9节。

这就解决了上述两个问题:

  • 两个主频较低的内核可以拥有与主频较高的单个处理器相同的吞吐量;因此,多个内核的能效更高。
  • 指令级并行现在被明确的任务并行化所取代,由程序员管理。

虽然第一代多核CPU只是在同一个芯片上的两个处理器,但后来的几代CPU都加入了L3或L2缓存,在两个处理器核心之间共享;见图1.13。

cache-hierarchy

L3或L2缓存,在两个处理器内核之间共享;见图1.13,这种设计使内核能够有效地联合处理同一问题。内核仍然会有自己的L1高速缓存。

而这些独立的高速缓存造成了高速缓存的一致性问题;见下面1.4.1节。

我们注意到,”处理器 “这个词现在是模糊的:它可以指芯片,也可以指芯片上的处理器核心。由于这个原因,我们大多谈论的是整个芯片的「插槽」(socket)和包含一个算术和逻辑单元并有自己的寄存器的部分的核心(core)。目前,具有4或6个内核的CPU很常见,甚至在笔记本电脑中也是如此,英特尔和AMD正在销售12个内核的芯片。核心数量在未来可能会上升。英特尔已经展示了一个80核的原型,它被开发成48核的 “单芯片云计算机”,如图1.14所示。这个芯片的结构有24个双核 “瓦片”,通过一个二维网状网络连接。只有某些瓦片与内存控制器相连,其他的瓦片除了通过片上网络外,无法到达内存。


通过这种共享和私有缓存的混合,多核处理器的编程模型正在成为共享和分布式内存的混合体。

  • 核心(Core):各个内核都有自己的私有L1缓存,这是一种分布式内存。上面提到的英特尔80核原型,其核心以分布式内存的方式进行通信。

  • 插槽(Socket):在一个插座上,通常有一个共享的二级缓存,这是内核的共享内存。

  • 节点(Node):在一个 “节点 “或主板上可以有多个插座,访问同一个共享内存。

  • 网络(Network):需要分布式内存编程(见下一章)来让节点进行通信。

从历史上看,多核结构在多处理器共享内存设计中已有先例(第2.4.1节),如Sequent Symmetry和Alliant FX/8。从概念上讲,程序模型是相同的,但现在的技术允许将多处理器板缩小到多核芯片上。

缓存一致性

在并行处理中,如果一个以上的处理器拥有同一个数据项的副本,就有可能发生冲突失效。确保所有的缓存数据都是主内存的准确副本,这个问题被称为「缓存一致性」(cache coherence):如果一个处理器改变了它的副本,则另一个副本也需要更新。

在分布式内存架构中,数据集通常在处理器上被不连续地分割,所以只有在用户知道的情况下才会出现数据的冲突副本,而处理这个问题则是由用户决定的。共享内存的情况更微妙:由于进程访问相同的主内存,似乎冲突实际上是不可能的。然而,处理器通常有一些私有缓存,包含来自内存的数据副本,所以冲突失效的副本可能发生。这种情况特别出现在多核设计中。

假设两个核在它们的(私有)L1高速缓存中有一个相同数据的副本,其中一个修改了它的副本。现在另一个核心的缓存数据不再是其对应的准确副本:处理器将使该项目副本失效,事实上也是其整个缓存线失效。当该进程需要再次访问该项目时,它需要重新加载该缓存线。另一种方法是,任何改变数据的内核都要将该缓存线发送给其他内核。这个策略的开销可能更大,因为其他内核不可能有一个缓存线的副本。

这个更新或废止缓存线的过程被称为「维护缓存一致性」(maintaining cache coherence),它是在处理器的一个非常低的层次上完成的,不需要程序员参与。(这使得更新内存位置成为一个原子操作;关于这一点,请看2.6.1.5节)。然而,这将减慢计算速度,并且浪费了核心的带宽,而这些带宽本来是可以用来加载或存储操作数的。

缓存行相对于主存中的数据项的状态通常被描述为以下几种情况之一。

  • Scratch:缓存行不包含该项目副本。

  • Valid:缓存行是主内存中数据的正确拷贝。

  • Reserved:缓存行是该数据的唯一副本。

  • Dirty:缓存行已被修改,但尚未写回主内存。
  • Invalid:缓存线上的数据在其他处理器上也存在(它没有被保留),并且另一个进程修改了它的数据副本。

一个更简单的变体是修改后的共享无效(MSI)一致性协议,在一个给定的核心上,一个缓存线可以处于以下状态。

  • Modified:缓存线已经被修改,需要写到备份仓库。这个写法可以在行被驱逐时进行,也可以立即进行,取决于回写策略。
  • Shared:该行至少存在于一个缓存中且未被修改。
  • Invalid:该行在当前缓存中不存在,或者它存在但在另一个缓存中的副本已被修改。

这些状态控制着高速缓存线在内存和高速缓存之间的移动。例如,假设一个核对一个缓存线进行读取,而这个缓存线在该核上是无效的。然后,它可以从内存中加载它,或者从另一个高速缓存中获取它,这可能更快。(找出一个行是否存在于另一个缓冲区(状态为M或S)被称为「监听」(snooping);另一种方法是维护「标签目录」(tag directory;见下文)。如果该行是共享的,现在可以简单地复制;如果它在另一个高速缓存中处于M状态,该核心首先需要把它写回内存。

练习 1.12 考虑两个处理器,内存中的一个数据项 $x$,以及这两个处理器的私有缓存中的缓存线$x_1,x_2$,这两个缓存线被映射到这两个处理器。描述在两个处理器上读写 $x_1$ 和 $x_2$ 的状态之间的转换。同时指出哪些行为会导致内存带宽被使用。(这个过渡列表是一个有限状态自动机(FSA),见第19节)。

MSI协议的变种增加了一个 “独占 “或 “拥有 “状态,以提高工作效率。

缓存一致性的解决方案

有两种实现缓存一致性的基本机制:「监听」(snooping)和基于「标签目录」(tag directory)的方案。

在监听机制中,任何对数据的请求都会被发送到所有的缓冲区,如果数据存在于任何地方,就会被返回;否则就会从内存中检索。这个方案的一个变形为,一个核心 “监听 “所有的总线流量,这样当另一个核心修改它的拷贝时,它就可以使自己的缓存线拷贝失效或更新。缓存失效比更新的代价要小,因为它是一个位操作,而更新涉及到复制整个高速缓存线。

练习 1.13 什么条件下更新才是更优方案?写一个简单的缓存模拟器来评估这个问题。

由于监听通常涉及到向所有核心广播信息,所以它的规模不能超过最少的核心数量。一个可以更好地扩展的解决方案是使用一个标签目录:一个中央目录,它包含了一些缓存中存在的数据的信息,以及具体在哪个缓存中。对于拥有大量内核的处理器(如英特尔Xeon Phi),该目录可以分布在各个内核上。

伪共享

如果内核访问不同的项目,就可能会出现缓存一致性问题。例如,

1
double x,y;

可能会在内存中紧挨着分配 $x$ 和 $y$,所以它们很有可能落在同一个缓存线上。现在,如果一个核心更新 $x$,另一个更新 $y$,这个缓存线就会在核心之间不断移动。这就是所谓的「伪共享」(false sharing)。

最常见的错误共享情况发生在线程更新一个数组的连续位置时。例如,在下面的OpenMP片段中,所有线程都在更新自己在部分结果数组中的位置。

1
2
3
4
5
6
7
 local_results = new double[num_threads];
#pragma omp parallel{
int thread_num = omp_get_thread_num();
for (int i=my_lo; i<my_hi; i++)
local_results[thread_num] = ... f(i) ...
}
global_result = g(local_results)

虽然没有实际的竞争条件(如果线程都更新global_result变量就会有),但这段代码的性能会很低,因为带有local_result数组的缓存线会不断被废止。

标签目录

在具有分布式但连贯的缓存的多核处理器中(如英特尔Xeon Phi),标签目录本身也可以是分布式的。这增加了缓存查找的延迟。

在多核芯片上进行计算

多核处理器可以通过各种方式提高性能。首先,在桌面情况下,多个内核实际上可以运行多个程序。更重要的是,我们可以利用并行性来加快单个代码的执行速度。这可以通过两种不同的方式实现。

MPI库(2.6.3.3节)通常用于通过网络连接的处理器之间的通信。然而,它也可以在单个多核处理器中使用:然后通过共享内存拷贝实现MPI调用。

另外,我们可以使用共享内存和共享缓存,并使用线程系统,如OpenMP(第2.6.2节)进行编程。这种模式的优点是并行性可以更加动态,因为运行时系统可以在程序运行过程中设置和改变线程和内核之间的对应关系。

我们将比较详细地讨论多核芯片上线性代数操作的调度;第6.12节。

TLB shootdown

第1.3.8.2节解释了TLB是如何用于缓存从逻辑地址,也就是逻辑页到物理页的转换。TLB是插槽内存单元的一部分,所以在多插槽设计中,一个插槽上的进程有可能改变页面映射,这使得其他插槽的映射不正确。
解决这个问题的一个办法叫做TLB shootdown:改变映射的进程会产生一个处理器间中断,从而导致其他处理器重建他们的TLB。

节点架构和插槽

在前面的章节中,我们已经通过内存层次结构,访问了寄存器和各种缓存级别,以及它们可以被私有或共享的程度。在内存层次结构的最底层是所有内核共享的内存。它的范围从低级别的笔记本电脑的几千兆字节到一些超级计算机中心的几兆字节。

ranger-node-small

zeus_phi

虽然这个内存是在所有核心之间共享的,但它有一些结构。这源于一个事实,即集群节点可以有一个以上的插槽,即处理器芯片。节点上的共享内存通常分布在直接连接到一个特定插槽的库中。例如,图1.15显示了TACC Ranger集群超级计算机(已停产)的四插槽节点和TACC Stampede集群超级计算机的两插槽节点,后者包含一个英特尔至强Phi协处理器。在这两个设计中,你可以清楚地看到直接连接到插槽上的内存芯片。

这是一个「非一致内存访问」(Non-Uniform Memory Access,NUMA)架构的例子:对于在某个核心上运行的进程,连接到其插槽上的内存比连接到另一个插座上的内存访问速度略快。

这方面的一个结果就是First-touch现象。动态分配的内存在第一次被写入之前实际上并没有被分配。现在考虑下面的OpenMP(2.6.2节)代码。

1
2
3
4
5
6
double *array = (double*)malloc(N*sizeof(double));
for (int i=0; i<N; i++)
array[i] = 1;
#pragma omp parallel for
for (int i=0; i<N; i++)
.... lots of work on array[i] ...

由于First-touch,数组被完全分配到插槽的主线程内存上。在随后的并行循环中,其他插槽的核心将对它们操作的内存有较慢的访问。

这里的解决方案是将初始化循环也做成并行的,即使其中的工作量几乎可以忽略不计。

局部性和数据复用

算法的执行不仅包含计算操作,也包含数据传输部分,事实上,数据传输可能是影响算法效率的主要因素。由于缓存和寄存器的存在,数据传输量可以通过编程的方式最小化,使数据尽可能地留在处理器附近。这部分是一个巧妙编程的问题,但我们也可以看看理论上的问题:算法是否一开始就允许这样做。

事实证明,在科学计算中,数据往往主要与在某种意义上靠近的数据互动,这将导致数据的局部性;1.6.2节。通常这种局部性来自于应用的性质,就像第四章看到的PDEs的情况。在其他情况下,如分子动力学(第7章),没有这种内在的局部性,因为所有的粒子都与其他粒子相互作用,为了获得高性能,需要相当的编程技巧。

数据复用和计算密度

在前面的章节中,我们了解到处理器的设计有些不平衡:加载数据比执行实际操作要慢。这种不平衡对于主存储器来说是很大的,而对于各种高速缓存级别来说则较小。因此,我们有动力将数据保存在高速缓存中,并尽可能地保持数据的复用量。

当然,我们首先需要确定计算是否允许数据被重复使用。为此,我们定义了一个算法的计算密度如下。

  • 如果$n$是一个算法所操作的数据项的数量,而$f(n)$是它所需要的操作的数量,那么算术强度就是$f(n)/n$。

(我们可以用浮点数或字节来衡量数据项。后者使我们更容易强度与处理器的硬件规格相关联)

计算密度也与延迟隐藏有关:即你可以减轻计算活动背后的数据加载对性能的负面影响的概念。要做到这一点,你需要比数据加载更多的计算来使这种隐藏有效。而这正是计算强度的定义:每一个字节/字/数字加载的高比率操作。

示例:向量操作

考虑到向量加法

这涉及到三次内存访问(两次加载和一次存储)和每次迭代的一次操作,给出的算术强度为1/3。axpy(表示 “$a$乘以 $x$ 加 $y$ “)操作

有两个操作,但内存访问的数量相同,因为 $a$ 的一次性负载被摊销了。因此,它比简单的加法更有效率,重用率为2/3。因此,它比简单的加法更有效,重用率为2/3。

内积计算

在结构上类似于axpy操作,每次迭代涉及一个乘法和加法,涉及两个向量和一个标量。然而,现在只有两个加载操作,因为 $s$ 可以保存在寄存器中,只在循环结束时写回内存。这里的重用是1。

示例:矩阵操作

考虑矩阵乘法

这涉及 $3𝑛^2$ 个数据项和 $2𝑛^3$ 个运算,属于高阶运算。算术强度为 $O(n)$,每个数据项将被使用𝑂(𝑛)次。这意味着,通过适当的编程,这种操作有可能通过将数据保存在快速缓存中来克服带宽/时钟速度的差距。

练习 1.14 根据上述定义,矩阵-矩阵乘积作为一种操作,显然具有数据重用性。矩阵-矩阵乘积显然具有数据复用。请你论证一下,这种复用并不是自然形成的,是什么决定了初始算法中国呢缓存是否对数据进行复用?

[在这次讨论中,我们只关心某个特定实现的操作数,而不是数学操作。例如,有一些方法可以在少于$O(n^3)$的操作中执行矩阵-矩阵乘法和高斯消除算法[189, 167]。然而,这需要不同的实现方式,在内存访问和重用方面有自己的分析]。

矩阵与矩阵乘积是LINPACK基准[51]的核心;见2.11.4节。如果将其作为评价计算机从能的标准则结果可能较为乐观:矩阵与矩阵的乘积是一个具有大量数据复用的操作,因此这对内存带宽并不敏感,对于并行计算及而言,这对网络通信也并不敏感。通常情况下,计算机在Linpack基准测试中会达到其峰值性能的60-90%,而其他测试标准得到的数值则可能较低。

Roofline模型

有一种平价计算及性能的理想模型,就是所谓的「屋脊线」(roofline model)模型[202],该模型指出:性能受两个因素的制约,如图1.16的第一个图所示。

  1. 图中顶部的横线所表示的峰值性能是对性能的绝对约束3,只有在CPU的各个方面(流水线、多个浮点单元)都完美使用的情况下才能达到。这个数字的计算纯粹是基于CPU的特性和时钟周期;假定内存带宽不是一个限制因素。

  2. 每秒的操作数受限于带宽、绝对数和计算密度的乘积。

    这是由图中的线性增长线所描述的。

Roofline模型优雅地指出了影响性能的因素。例如,如果一个算法没有使用全部的SIMD宽度,这种不平衡会降低可达到的峰值。图1.16中的第二张图显示了降低上限的各种因素。还有各种降低可用带宽的因素,比如不完善的数据隐藏。这在第三张图中由倾斜的屋顶线的降低表示。

对于一个给定的计算密度,其性能是由其垂直线与Roofline相交的位置决定的。如果这是在水平部分,那么该计算被称为受「计算约束」(compute-bound):性能由处理器的特性决定,而带宽不是问题。另一方面,如果这条垂直线与屋顶的倾斜部分相交,那么计算被称为受「带宽约束」(bandwidth-bound):性能由内存子系统决定,处理器的全部能力没有被使用。

练习 1.15 如何确定一个给定的程序内核是受到带宽约束还是计算约束的?



局部性

由于从缓存中读取数据的时间开销要小于从内存中读取,我们当然希望以这种方式进行编码,进而使缓存中的数据最大程度上得到复用。虽然缓存中的数据不受程序员的控制,甚至编写汇编语言也无法控制(在Cell处理器和一些GPU中,低级别的内存访问可以由程序员控制),但在大多数CPU中,知道缓存的行为,明确什么数据在缓存中,并在一定程度上控制它,还是有可能的。

这里的两个关键概念是「时间局部性」(temporal locality)和「空间局部性」(spatial locality)。时间局部性是最容易解释的:即数据使用一次后短时间内再次被使用。由于大多数缓存使用LRU替换策略,如果在两次引用之间被引用的数据少于缓存的大小,那么该元素仍然会存在缓存之中,进而实现快速访问。而对于其他的替换策略,例如随机替换,则不能保证同样结果。

时间局部性

下面为时间局部性的例子,考虑重复使用一个长向量:

1
2
3
4
5
for (loop=0; loop<10; loop++) {
for (i=0; i<N; i++) {
... = ... x[i] ...
}
}

$x$ 的每个元素将被使用10次,但是如果向量(加上其他被访问的数据)超过了缓存的大小,每个元素将在下一次使用前被刷新。因此,$x[i]$ 的使用并没有表现出时间局部性:再次使用时的时间间隔太远,使得数据无法停留在缓存中。

如果计算的结构允许我们交换循环。

1
2
3
4
5
for (i=0; i<N; i++) {
for (loop=0; loop<10; loop++) {
... = ... x[i] ...
}
}

$x$ 的元素现在被反复使用,因此更有可能留在缓存中。这个重新排列的代码在使用 $x[i]$ 时显示了更好的时间局部性。

空间局部性

空间局部性的概念要稍微复杂一些。如果一个程序引用的内存与它已经引用过的内存 “接近”,那么这个程序就被认为具有空间局部性。经典的冯·诺依曼架构中只有一个处理器和内存,此时空间局部性并不突出,因为内存中的一个地址可以像其他地址一样被快速检索。然而,在一个有缓存的现代CPU中,情况就不同了。上面我们已经看到了两个空间局部性的例子。

  • 由于数据是以缓存线而不是单独的字或字节为单位移动的,因此以这样的方式进行编码,因此使缓存线所有的元素都得到应用是有所裨益的,在下列循环中

    1
    2
    3
    for (i=0; i<N*s; i+=s){
    ... x[i] ...
    }

    空间局部性体现为函数所进行的跨步递减$s$。

    设 $S$ 为缓存线的大小,那么当 $s$ 的范围从$1 …. S$,每个缓存线使用的元素数就会从S下降到1。相对来说,这增加了循环中的内存流量花销:如果$s=1$,我们为每个元素加载$1/S$的缓存线;如果$s=S$,我们为每个元素加载一个缓存线。这个效果在1.7.4节中得到了证明。

  • 第二个值得注意的空间局部性的例子是TLB(1.3.8.2节)。如果一个程序引用的元素距离很近,它们很可能在同一个内存页上,通过TLB的地址转换会很迅速。另一方面,如果一个程序引用了许多不同的元素,它也将引用许多不同的页。由此产生的TLB缺失是时间花销十分庞大;另见1.7.5节。

练习 1.16 请考虑以下对 $n$ 数字 $x[i]$ 进行求和的算法的伪码,其中 $n$ 是2的倍数。

1
2
3
4
for s=2,4,8,...,n/2,n:
for i=0 to n-1 with steps s:
x[i] = x[i] + x[i+s/2]
sum = x[0]

分析该算法的空间和时间局部性,并将其与标准算法进行对比

1
2
3
sum = 0
for i=0,1,2, ... ,n-1
sum = sum+ x[i]

练习 1.17 考虑以下代码,并假设nvectors相比于缓存很小,而长度很大。

1
2
3
for (k=0; k<nvectors; k++)
for (i=0; i<length; i++)
a[k,i] = b[i] * c[k]

以下概念与该代码的性能有什么关系。

  • 复用(Reuse)
  • 缓存尺寸(Cache size)
  • 关联性(Associativity)

下面这段交换了循环的代码的性能是更好还是更差,为什么?

1
2
3
for (i=0; i<length; i++)
for (k=0; k<nvectors; k++)
a[k,i] = b[i] * c[k]

局部性示例

让我们看一个实际的例子。矩阵与矩阵的乘法 $C \leftarrow A ·B $可以用几种方法计算。我们比较两种实现方式,假设所有的矩阵都是按行存储的,且缓存大小不足以存储整个行或列。

1
2
3
4
for i=1..n
for j=1..n
for k=1..n
c[i,j] += a[i,k]*b[k,j]
1
2
3
4
for i=1..n
for k=1..n
for j=1..n
c[i,j] += a[i,k]*b[k,j]

这些实现如图1所示。 第一个实现构建了 $(i, j)$ 元素

ijk-mult

$A$ 的一行与 $B$ 的一列的内积来更新 $C$,在第二行中,$B$ 的一行是通过对 $A$ 的元素进行缩放来更新。$A$ 的元素来更新 $B$ 的行数。

我们的第一个观察结果是,这两种实现都确实计算了 $C \leftarrow C + A ·B$,并且它们都花费了大约 $2n^3$ 的操作。然而,它们的内存行为,包括空间和时间的局部性是非常不同的。

  • $c[i,j]$ :在第一个实现中,$c[i,j]$在内部迭代中是不变的,这构成了时间局部性,所以它可以被保存在寄存器中。因此,$C$ 的每个元素将只被加载和存储一次。

    在第二个实现中,$c[i,j]$ 将在每个内部迭代中被加载和存储。特别是,这意味着现在有 $𝑛^3$ 次存储操作,比第一次实现多了$n$。

  • $a[i,k]$:在这两种实现中,$a[i,k]$元素都是按行访问的,所以有很好的空间局部性,因为每个加载的缓存线都会被完全使用。在第二个实现中,$a[i,k]$在内循环中是不变的,这构成了时间局部性;它可以被保存在寄存器中。因此,在第二种情况下,$A$只被加载一次,而在第一种情况下则是$n$次。

  • $b[k,j]$:这两种实现方式在访问矩阵 $B$ 的方式上有很大不同。首先,$b[k,j]$ 从来都是不变的,所以它不会被保存在寄存器中,而且 $B$ 在两种情况下都会产生 $𝑛^3$ 的内存负载。但是,访问模式不同。

    在第二种情况下,$b[k,j]$ 是按行访问的,所以有很好的空间局部性性:缓存线在被加载后将被完全利用。

    在第一种实现中,$b[k,j]$ 是通过列访问的。由于矩阵的行存储,一个缓存线包含了一个行的一部分,所以每加载一个缓存线,只有一个元素被用于列的遍历。这意味着第一个实现对 $B$ 的加载量要比缓存线长度的系数大。也有可能是TLB的影响。

请注意,我们并没有对这些实现的代码性能做任何绝对的预测,甚至也没有对它们的运行时间做相对比较。这种预测是很难做到的。然而,上面的讨论指出了与广泛的经典CPU相关的问题。

练习 1.18 乘积 $C \leftarrow A ⋅B$ 的实现算法较多。请考虑以下情况。

1
2
3
4
for k=1..n:
for i=1..n:
for j=1..n:
c[i,j] += a[i,k]*b[k,j]

分析矩阵 $C$ 的内存流量,并表明它比上面给出的两种算法更糟糕。

核心局部性

上述空间和时间局部性的概念主要是程序的属性,尽管诸如高速缓存线长度和高速缓存大小这样的硬件属性在分析局部性的数量方面发挥了作用。还有第三种类型的局部性与硬件有更密切的联系:「核心局部性」(core locality)。

如果空间上或时间上接近的写访问是在同一个核心或处理单元上进行的,那么代码的执行就会表现出核心局部性。这里的问题是缓存一致性的问题,两个核心在他们的本地存储中都有某个缓存线的副本。如果它们都从该缓存中读取,那就没有问题了。但是,如果他们中的一个对它进行了写操作,一致性协议就会把这个缓存线复制到另一个核心的本地存储中。这需要占用宝贵的内存带宽,所以要避免这种情况。

核心局部性不仅仅是一个程序的属性,而且在很大程度上也是程序的并行执行方式。

单处理器计算(三)

高性能编程策略

在本节中,我们将简要介绍不同的编程方式如何影响代码的性能。想要了解更多,见Goedeker和Hoisie的书[78]。

本章中所有的性能结果都是在TACC Ranger集群的AMD Opteron处理器上获得的[173]。

峰值性能

厂家出于营销的目的,定义了CPU的“峰值速度”。由于一个流水线上的浮点单元可以渐进地在每个周期产生一个结果,我们可以把理论上的「峰值性能」(peak performance)定义为时钟速度(以每秒ticks为单位)、浮点单元数量和核心数量的乘积。该峰值速度在实际中是无法实现的,很少有代码能接近它。Linpack基准是衡量接近峰值的评判标准之一;该基准的并行版本作为”top 500”的评分标准。

流水线

在前文中,我们了解到现代CPU中的浮点运算单元是以流水线形式进行的,流水线需要一些独立的操作才能有效地运行。典型的可流水线操作是向量加法;不能流水线操作的一个例子是计算内积和:

1
2
for (i=0; i<N; i++)
s += a[i]*b[i]

$s$ 既被读又被写,使得加法流水线中止。启用流水线的一个方法是循环展开

1
2
3
4
for (i = 0; i < N/2-1; i ++) {
sum1 += a[2*i] * b[2*i];
sum2 += a[2*i+1] * b[2*i+1];
}

现在,在累积之间有两个独立的乘法。通过一点索引优化,这就变成了

1
2
3
4
5
for (i = 0; i < N/2-1; i ++) {
sum1 += *(a + 0) * *(b + 0);
sum2 += *(a + 1) * *(b + 1);
a += 2; b += 2;
}

关于这段代码的第一个观察点是,我们隐式地使用了加法的结合律和交换律:虽然同样的量被加起来,但它们现在实际上是以不同的顺序加起来的。正如你将在后面的内容看到的,在计算机运算中,这并不能保证得到完全相同的结果。

在进一步的优化中,我们将每条指令的加法和乘法部分分离开来。希望在积累等待乘法结果的时候,中间的指令能让处理器忙起来,实际上是增加了每秒的操作数。

1
2
3
4
5
6
for (i = 0; i < N/2-1; i ++) {
temp1 = *(a + 0) * *(b + 0);
temp2 = *(a + 1) * *(b + 1);
sum1 += temp1; sum2 += temp2;
a += 2; b += 2;
}

最后,我们意识到,我们可以将加法从乘法中移开的最远距离是将它放在下一次迭代的乘法前面

1
2
3
4
5
6
7
8
for (i = 0; i < N/2-1; i ++) {
sum1 += temp1;
temp1 = *(a + 0) * *(b + 0);
sum2 += temp2;
temp2 = *(a + 1) * *(b + 1);
a += 2; b += 2;
}
s = temp1 + temp2;

当然,我们可以将循环展开超过2层。虽然我们期望因为更长的流水线操作序列而提高性能,但大量的循环展开需要大量的寄存器。对寄存器的要求超过了CPU所拥有的数量,这就是所谓的「寄存器漫溢」(register spill),将降低程序性能。

另一个需要注意的问题是,操作的总数不太可能被展开因子所除。这就需要在循环之后进行清理代码,以考虑到最后的迭代。因此,展开的代码比直接的代码更难写,人们已经写了一些工具来自动执行这种源到源的转换。

表1.2中给出了内积循环展开操作的周期时间,最多为六次。请注意,在展开层数达到4时,时间并没有显示展示出单调的性质。这种变化是由于各种与内存有关的因素造成的。

1 2 3 4 5 6
6794 507 340 359 334 528

内积操作的周期时间,最多展开六次。

缓存尺寸

上面我们了解到,从L1移动数据可以比从L2移动数据有更低的延迟和更高的带宽,而L2又比L3或内存快。这很容易用重复访问相同数据的代码来证明

1
2
3
for (i=0; i<NRUNS; i++)
for (j=0; j<size; j++)
array[j] = 2.3*array[j]+1.2;

如果尺寸参数允许数组填入缓存,那么操作会相对较快。随着数据集大小的增长,它的一部分将从L1缓存中转移至其他部分,所以操作的速度将由L2缓存的延迟和带宽决定。这可以从图1.18中看出。每个操作的平均周期数与数据集大小的关系图如下:

练习 1.19 试论证:如果有一个足够大规模的问题和LRU替换策略(第1.3.4.6节),基本上L1中的所有数据都会在外循环的每次迭代中被替换。你能不能写一个例子,让一些L1的数据保持不变?

通常情况下,可以通过安排操作来将数据保留在L1缓存中。例如,在我们的例子中,我们可以编写

1
2
3
4
5
6
7
8
for (b=0; b<size/l1size; b++) {
blockstart = 0;
for (i=0; i<NRUNS; i++) {
for (j=0; j<l1size; j++)
array[blockstart+j] = 2.3*array[blockstart+j]+1.2;
}
blockstart += l1size;
}

假设L1大小与数据集大小平均分配。这种策略被称为「缓存模块化」(cache blocking)或「缓存复用模块化」(blocking for cache reuse)。

在下面的循环中,针对不同的缓存大小值,测量每个周期的内存访问次数。如果观察到时间与缓冲区大小无关,请让编译器生成一个优化报告。对于英特尔的编译器使用-qopt-report

1
2
3
4
for (int irepeat=0; irepeat<how_many_repeats; irepeat++) { 
for (int iword=0; iword<cachesize_in_words; iword++)
memory[iword] += 1.1;
}

论证发生了什么。你能找到防止循环交换的方法吗?

练习 1.21 为了得到模块化的代码,$j$的循环被分割成一个块的循环和一个块元素的内循环;然后i的外循环被替换成块的循环。在这个特殊的例子中,你也可以简单地交换i和j的循环。为什么这不是最佳性能?

注释 7 模块化的代码可能会改变表达式的评估顺序。由于浮点运算不是关联性的,所以模块化不是编译器允许进行的转换。

缓存线与跨步访问

由于数据是以连续的块状形式从内存转移到缓存中的,称为「缓存线」(cache line),没有利用缓存线中所有数据的代码要付出带宽的代价。这可以从一个简单的代码中看出来

1
2
for (i=0,n=0; i<L1WORDS; i++,n+=stride)
array[n] = 2.3*array[n]+1.2;

此处执行的是固定操作数。但随着跨度的增加,我们预计运行时间也会增加,这在图1.19中得到了证实。

图中还显示了高速缓存线的复用率在下降,定义为向量元素的数量除以L1失误的数量。

下表为:在Frontera的56个核心上,每个核心的数据量为3.2M,每次操作的时间(纳秒)是跨度的函数。

stride nsec/word
56 cores, 3M
56 cores, .3M 28 cores, 3M
1 7.268 1.368 1.841
2 13.716 1.313 2.051
3 20.597 1.319 2.852
4 27.524 1.316 3.259
5 34.004 1.329 3.895
6 40.582 1.333 4.479
7 47.366 1.331 5.233
8 53.863 1.346 5.773

滞后的影响可以通过处理器的带宽和缓存行为来缓解。考虑在TACC的Frontera集群的英特尔Cascadelake处理器上的一些运行情况(每个插槽28个核,双插槽,每个节点总共56个核)。我们测量了一个简单的流媒体内核的每操作时间,使用递增的步长。上表在第二栏中报告了每个操作时间确实随着步长的增加而线性上升。

然而,这是对一个溢出二级缓存的数据集而言的。如果我们让这个运行包含在二级缓存中,就像第三列中报告的那样,这种增加就会消失,因为有足够的带宽可以从二级缓存中全速流式传输数据。

TLB

正如前面文章所解释的,转译后备缓冲器(TLB)维护着一个经常使用的内存页及其位置的小列表;寻址位于这些页上的数据比不在其中的数据快得多。因此,人们希望以这样的方式编写代码,使访问的页数保持在低水平。 考虑以两种不同的方式遍历一个二维数组的元素的代码。

1
2
3
4
5
6
7
8
9
10
#define INDEX(i,j,m,n) i+j*m
array = (double*) malloc(m*n*sizeof(double));
/* traversal #1 */
for (j=0; j<n; j++)
for (i=0; i<m; i++)
array[INDEX(i,j,m,n)] = array[INDEX(i,j,m,n)]+1;
/* traversal #2 */
for (i=0; i<m; i++)
for (j=0; j<n; j++)
array[INDEX(i,j,m,n)] = array[INDEX(i,j,m,n)]+1;

结果(源代码见附录37.5)绘制在图1.21和1.20中。

每列的TLB缺失次数与列数的函数关系;数组的逐列遍历

使用 $m=1000$ 意味着,在AMD Opteron上有512个双倍的页面,我们每列大约需要两个页面。我们运行这个例子,绘制 “TLB缺失 “的数量,也就是说,一个页面被引用的次数没有被记录在TLB中。

  1. 在最初的遍历中,这确实是发生的情况。在我们接触到一个元素,并且TLB记录了它所在的页面后,该页面上的所有其他元素随后被使用,所以没有进一步的TLB缺失发生。图1.20显示,随着𝑛的增加,每列的TLB缺失次数大约为2次。
  2. 在第二次遍历中,我们为第一行的每一个元素接触一个新的页面。第二行的元素将在这些页面上,因此,只要列的数量少于TLB条目的数量,这些页面仍将被记录在TLB中。随着列数的增加,TLB的数量也在增加,最终每个元素的访问都会有一个TLB缺失。图1.21显示,在列数足够多的情况下,每列的TLB缺失次数等于每列的元素数。

缓存关联性

有许多算法是通过对一个问题的递归划分来工作的,例如快速傅里叶变换(FFT)算法。因此,这类算法的代码经常在长度为2的幂的向量上操作。不幸的是,这可能会与CPU的某些架构特征产生冲突,其中许多涉及到2的幂。

每列的TLB缺失次数与列数的函数关系;数组的按列遍历

在前面,我们看到了将少量向量相加的操作是如何进行的

对于直接映射的缓冲区或具有关联性的集合关联缓冲区是一个问题。

我们以AMD Opteron为例,它有一个64K字节的L1高速缓存,而且是双向设置的关联性。由于设置了关联性,该缓存可以处理两个地址被映射到同一个缓存位置,但不能处理三个或更多。因此,我们让向量的大小 $n=4096$ 个双倍数,我们测量了让 $m=1, 2, ….$ 的缓存缺失和周期的影响。

首先,我们注意到我们是按顺序使用向量的,因此,在一个有8个双倍数的缓存线中,我们最好能看到1/8倍于向量数量𝑚的缓存丢失率。相反,在图1.22中,我们看到了一个与𝑚成正比的速率,这意味着确实有缓存行被立即驱逐。这里的例外是𝑚=1的情况,双向关联性允许两个向量的缓存线留在缓存中。

对比图1.23,我们使用了一个稍长的向量长度,所以具有相同$j$的位置不再被映射到同一个缓存位置。因此,我们看到的缓存缺失率约为1/8,而周期数较少,相当于完全重复使用了缓存线。

有两点需要注意的是:由于处理器会使用预取流,所以缓存缺失数实际上比理论预测的要低。其次,在图1.23中,我们看到时间随着 $m$ 的增加而减少;这可能是由于负载和存储操作之间逐渐形成了有利的平衡。由于各种原因,存储操作比负载的开销更大。

L1高速缓存的缺失次数和每个$j$列累积的周期数,向量长度为4096

L1缓存丢失的次数和每个𝑗列累积的周期数,向量长度4096+8

循环嵌套

如果代码中有两层独立的循环嵌套,我们可以自由选择将哪个循环设置为外循环。

练习 1.22 给出一个可以交换与不能交换的双层循环例子,可以的话请使用本书中的实际案例。

「C与Fortran的比较」:如果我们的循环使用了一个二维数组的 $(i, j)$ 索引,对于Fortran来说,通常最好让 $i$ 索引在内,而对于C语言, $j$索引最好在内部。

练习 1.23 试给出两种理由说明这对性能更加。

上述内容并不是一条硬性规定,决定循环的因素有许多,例如:循环的大小和其他等。在矩阵与向量乘积中,改变循环的顺序会改变输入和输出向量的使用方式。

并行模式:如果我们使用OpenMP优化循环,我们通常希望令外部循环次数比内部要多,因为短的内循环有助于编译器向量化操作;如果使用的是GPU优化,则尽量将大循环放在内部,并行工作的单元不应该有分支或循环。

另一方面,如果你的目标是GPU,你希望大循环是内循环。并行工作的单元不应该有分支或循环。

循环分块

在某些情况下,可以通过将一个循环分解成两个嵌套的循环来提高性能:一个是用于迭代空间中的块的外循环,一个是穿过块的内循环。这就是所谓的「循环分块」(loop tiling):(短的)内循环为块,其许多连续的实例构成了迭代空间。

例如

1
2
for (i=0; i<n; i++)
...

变为

1
2
3
4
5
bs = ...       /* the blocksize */
nblocks = n/bs /* assume that n is a multiple of bs */
for (b=0; b<nblocks; b++)
for (i=b*bs,j=0; j<bs; i++,j++)
...

对于单一循环而言,这可能不会产生任何影响,但在某些情况下就可能会产生影响。例如,如果一个数组被重复使用,但它太大,无法装入缓存。

1
2
3
for (n=0; n<10; n++)
for (i=0; i<100000; i++)
... = ...x[i] ...

那么循环分块可能会导致一种情况,即数组被划分为适合缓存的块

1
2
3
4
5
bs = ... /* the blocksize */
for (b=0; b<100000/bs; b++)
for (n=0; n<10; n++)
for (i=b*bs; i<(b+1)*bs; i++)
... = ...x[i] ...

由于这个原因,循环叠加也被称为「缓存模块化」(cache blocking)。块的大小取决于在循环体中访问多少数据;理想情况下,我们会尽量使数据在L1缓存中得到重用,但也有可能为L2重用进行模块化。当然,L2重用的性能不会像L1重用那样高。

分析一下这个例子。 $x$什么时候被带入缓存,什么时候被重新使用,什么时候被刷新?在这个例子中,所需的缓冲区大小是多少?重写这个例子,用一个常数

1
#define L1SIZE 65536

下面观察矩阵转置 $A \leftarrow Bt$ 。通常情况下,我们会遍历输入和输出矩阵。

1
2
3
4
// regular.c
for (int i=0; i<N; i++)
for (int j=0; j<N; j++)
A[i][j] += B[j][i];

使用模块化,这就变成了

1
2
3
4
5
6
// blocked.c
for (int ii=0; ii<N; ii+=blocksize)
for (int jj=0; jj<N; jj+=blocksize)
for (int i=ii*blocksize; i<MIN(N,(ii+1)*blocksize); i++)
for (int j=jj*blocksize; j<MIN(N,(jj+1)*blocksize); j++)
A[i][j] += B[j][i];

与上面的例子不同,输入和输出的每个元素只被触及一次,所以没有直接的重复使用。然而,缓存线是可以重复使用的。

图1.24显示了其中一个矩阵是如何以与它的存储顺序不同的顺序被遍历的,比如说按列存储,而按行存储。这样做的结果是,每个元素的加载都会传输一个缓存线,其中只有一个元素会被立即使用。在常规的遍历中,这种缓存线流很快就溢出了缓存,而且没有重复使用。然而,在模块化遍历中,在需要这些行的下一个元素之前,只有少量的缓存行被遍历了。因此,缓存线是可以重复使用的,也就是空间局部性。

blockedtranspose

通过模块化获得性能的最重要的例子是矩阵,矩阵积,循环展开。前面,我们研究了矩阵与矩阵的乘法,得出的结论是在高速缓存中可以保留的数据很少。通过循环展开可以改善这种情况。例如,这个乘法的标准写法是

1
2
3
4
for i=1..n
for j=1..n
for k=1..n
c[i,j] += a[i,k]*b[k,j]

只能通过优化使 $c[i,j]$ 保持在寄存器中。

1
2
3
4
5
6
for i=1..n
for j=1..n
s=0
for k=1..n
s += a[i,k]*b[k,j]
c[i,j] += s

假设 $a$ 是按行存储的,使用循环平铺法,我们可以将 $a[i,:]$ 的部分内容保留在缓存中。

1
2
3
4
5
6
7
for kk=1..n/bs
for i=1..n
for j=1..n
s=0
for k=(kk-1)*bs+1..kk*bs
s += a[i,k]*b[k,j]
c[i,j] += s

优化策略

离散傅里叶变换的初始和优化后的性能对比

矩阵与矩阵乘积的初始实现和优化后的性能对比

图1.25和1.26显示,一个操作的原始实现(有时称为 “参考实现”)和优化实现的性能之间可能存在很大的差异。然而,优化并没有套路可循。由于使用模块化,循环展开后的操作是正常深度的2倍,矩阵与矩阵乘积变成了深度为6的循环;最佳的模块大小也取决于具体的目标架构等因素。

我们提出以下参考

  • 编译器无法提取接近最佳性能的东西。
  • 有一些自动调整项目,用于自动生成根据架构进行调整的实现。这种方法可以是适度的,也可以是非常成功的。这些项目中最著名的是用于Blas内核的Atlas[199]和用于变换的Spiral[172]。

缓存感知和缓存无关编程

区别于寄存器和内存可以在(汇编)代码中寻址,缓存的使用是隐式的。即便在汇编语言中程序员也不能显式地将某个数据加载到某个缓冲区。

然而,仍然存在“「缓存感知」(cache aware)”的方式进行编程。若一段代码的复用操作数小于缓冲区大小,这些数据经过第一次访问后将在第二次访问时暂时停留在缓存中;另一方面,若数据量超过了缓存的大小,那么在访问的过程中,它将部分或全部被冲出缓存。

我们可以通过实验来证明这个现象。用一个非常精确的计数器:

1
2
3
for (x=0; x<NX; x++)
for (i=0; i<N; i++)
a[i] = sqrt(a[i]);

将花费$N$的线性时间,直到$a$填满缓存的时候。一个更容易理解的方法是计算归一化的时间,基本上是每次执行内循环的时间。

1
2
3
4
5
6
t = time();
for (x=0; x<NX; x++)
for (i=0; i<N; i++)
a[i] = sqrt(a[i]);
t = time()-t;
t_normalized = t/(N*NX);

归一化的时间将是恒定的,直到阵列$a$填满缓存,然后增加,最终再次持平。(见1.7.3节的详细讨论)解释是,只要 $a[0]…a[N-1]$ 适合在L1缓存中,内循环就会使用L1缓存中的数据。访问的速度由L1缓存的延迟和带宽决定。当数据量的增长超过L1缓存的大小时,部分或全部的数据将从L1缓存中刷出,而性能将由L2缓存的特性决定。让数据量进一步增长,性能将再次下降到由主内存的带宽决定的线性行为。

如果知道高速缓存的大小,在如上的情况下,就有可能安排算法来最佳地使用高速缓存。但是,每个处理器的缓存大小是不同的,所以这使得我们的代码不能移植,或者至少其高性能不能移植。另外,对多级缓存模块化也很复杂。由于这些原因,有些人主张采用「缓存无关编程」(cache oblivious programming)[70]。

缓存无关编程可以被描述为一种自动使用所有级别的缓存层次的编程方式。这通常是通过使用「分治」(divide-and-conquer)的策略来实现的,也就是对问题进行递归细分。

缓存无关编程的一个简单例子是矩阵转置操作 $B \leftarrow At$。首先我们观察到,两个矩阵的每个元素都被访问一次,所以唯一的重用是在缓存线的利用上。如果两个矩阵都是按行存储的,我们按行遍历 $B$,那么 $A$ 是按列遍历的,每访问一个元素就加载一条缓存线。如果行数乘以每条缓存线的元素数超过了缓存容量,那么在重新使用之前,行将被更新。

oblivious1

矩阵转置操作,源矩阵的简单和递归遍历

在缓存无关的实现中,我们将$A$和$B$划分为2$\times$2的块矩阵,并递归计算$B_{11} \leftarrow A_{11}^t, B_{12} \leftarrow A_{21}^t$ 等等,见上图。在递归的某一点上,块$A_{ij}$现在将小到足以容纳在缓存中,并且$A$的缓存线将被完全使用。因此,这个算法比简单的算法提高了一个系数,等于缓存线的大小。

缓存遗忘的策略通常可以产生改进,但它不一定是最佳的。在矩阵与矩阵乘积中,它比朴素的算法有所改进,但是它还不如一个明确设计为优化使用缓存的算法[85]。

参见第6.8.4节关于模版计算中的此类技术的讨论。

矩阵与向量乘积的案例研究

考虑如下的矩阵向量乘积:

这涉及到对$n^2+2n$数据项的 $2n^2$操作,所以重用率为𝑂(1):内存访问和操作的顺序相同。然而,我们注意到,这里涉及到一个双循环,而且$x,y$向量只有一个索引,所以其中的每个元素都被多次使用。

利用这种理论上的再利用并非易事。在

1
2
3
4
/* variant 1*/
for (i)
for (j)
y[i] = y[i] + a[i][j] * x[j];

元素$y[i]$ 似乎被重复使用。然而,这里给出的语句会在每次内存迭代中把$y[i]$写入内存,我们必须把循环写成:

1
2
3
4
5
6
7
/* variant 2 */
for (i){
s = 0;
for (j)
s =s +a[i][j] * x[j];
y[i] = s;
}

以保证重复使用。这个变体使用了$2n^2$的负载和$n$的存储。

这个代码片段只是明确地利用了$y$的重复使用。如果缓冲区太小,不能容纳整个向量$x$和$a$的一列,$x$的每个元素仍然在每个外层迭代中被重复加载。将循环反转为

1
2
3
4
/* variant 3 */
for (j)
for (i)
y [i] = y[i] + a[i][j] * x[j];

暴露了$x$的重复使用,特别是如果我们把它写成:

1
2
3
4
5
6
/* variant 3 */
for (j){
t = x[j];
for (i)
y[i] = y[i] + a[i][j] * t;
}

此外,我们现在有$2n^2+n$的负载,与variant 2相当,但有$n^2$的存储,这是一个更高的顺序。

我们有可能重复使用$𝑥$和$𝑦$,但这需要更复杂的编程。这里的关键是将循环分成若干块。比如说

1
2
3
4
5
6
7
8
for (i=0; i<M; i+=2){
s1 =s2 =0;
for (j){
s1 = s1 +a[i][j] * x[j];
s2 = s2 + a[i+1][j] * x[j];
}
y[i] = s1; y[i+1] = s2;
}

这也被称为「循环展开」(loop unrolling),或「Strip mining」。循环展开的层数由可用寄存器的数量决定。

拓展探究

功率消耗

高性能计算机的另一个重要话题是其功耗。在这里,我们需要区分单个处理器芯片的功耗和一个完整的集群的功耗。

随着芯片上组件数量的增加,其功耗也会增加。幸运的是,在一个反作用的趋势下,芯片特征的小型化同时也在减少必要的功率。假设特征尺寸$\lambda$(想想看:导线的直径)按比例缩小到$s\lambda$,其中$s<1$。为了保持晶体管中的电场不变,通道的长度和宽度、氧化物厚度、基质浓度密度和工作电压都按相同的因素进行缩放。

缩放属性的推导

恒电场按比例缩小」(constant field scaling)或Dennard缩放的特性[18, 44]是对电路微型化时的特性的理想情况描述。一个重要的结果是,当芯片特征变小时,功率密度保持不变,而频率同时增加。从电路理论中得出的基本属性是,如果我们将特征尺寸缩小$s$。

属性 情况
Feature size ~s
Voltage ~s
Current ~s
Frequency ~s

则可以推导:

而由于电路的总尺寸也随着 $ s^2$ 的减少而减少,功率密度不变。因此,在一个点路上放置更多的晶体管也可能从根本上不改变冷却问题。

这一结果可以被认为是「摩尔定律」(Moore’s law)背后的驱动力,摩尔定律指出,处理其中的晶体管数量每18个月翻一番。一个程序所需的与频率有关的部分功率来自于对电路电容的充电和放电,因此

状态 公式
充电 q = CV
工作 W=qV=$CV^2$
功率 W/time = WF = CV^2F

这一分析可以用来证明引入多核处理器的合理性。

多核

在2010年左右,元件的微型化几乎已经停滞不前了,因为降低电压已经达到了峰值。频率也不能扩大,因为这将提高芯片的发热量,导致芯片的发热量过大,下图给出了一种戏剧化的例子。说明了一个芯片所产生的热量,如果采用单核结构:

chipheat

如果趋势继续下去,CPU的预计散热量情况 - (由Pat Helsinger提供)

处理器的趋势仍在继续。

一个结论是:计算机设计正在面临一道「功率墙」(power wall),单核的复杂性不能再增加了(所以我们不能再增加ILP和流水线深度),提高性能的唯一途径是增加明确可见的并行性。这一发展导致了当前一带多核处理器的出现。这也是GPU以其简化的处理设计并因此降低能耗而具有吸引力的原因。回顾上述共识,讲一个处理器与两个频率为一般的处理器进行比较,这应该具有相同的计算能力,对吗?由于我们降低了频率,如果我们保持相同的工业技术,我们可以降低电压。

理想情况下,两个处理器核心的总电功率为:

在实际中,电容会上升到2以上,而电压则不可能完全下降2,所以更可能是$P_{multi}\approx 0.4 \times P$ 当然,集成方面的问题在实践中要复杂一些[19];重要的结论是,现在为了降低功率(或者反过来说,为了在保持功率不变的情况下进一步提高性能),我们现在必须开始并行编程。

计算机总功率

并行计算机的总功率由每个处理器的功率和全部的处理机器数量决定。目前,这通常是几兆瓦。根据上述推理,增加处理器数量所需的功率增加已经不能被更多的高能效处理器所消耗,所以当并行计算机从petascale(2008年IBM的 Roadrunner达到)到预计的exascale时,功率正在成为压倒一切的考虑因素。

在最近几代的处理器中,功率正在成为压倒一切的考虑因素,并且在不可能的地方产生影响。例如:「单指令多数据」(Single Instruction Multiple Data,SIMD)设计是由解码的功率成本决定的。

操作系统影响

HPC从业人员通常不怎么担心操作系统(OS)。然而,有时可以感觉到操作系统的存在进而影响到性能。其原因是「周期性中断」(periodic interrupt),即操作系统每秒中断当前进程100次以上,以让另一个进程或系统守护进程拥有一个时间片。

如果只运行一个程序,我们不希望出现开销和抖动,以及进程运行时间的不可预测性,这就引入了一个问题。因此,已经存在的计算机基本上不需要有操作系统来提高性能。

周期性中断有进一步的负面影响。例如,它污染了高速缓存和TLB。作为抖动的细微影响,它降低了依赖线程间障碍的代码的性能,比如经常发生在OpenMP中。

特别是在金融应用中,非常严格的同步是很重要的,我们采用了一种Linux内核模式,周期性定时器每秒只跳动一次,而不是数百次。这就是所谓的tickless内核。

复习题

判断真假,若为假则请你给出解释

练习 1.25 判断

1
2
for (i=0; i<N; i++)
a[i] = b[i] + 1;

对a和b中的每个元素都遍历一次,所以每个元素都会有一次缓存丢失。

练习 1.26 请举例说明3路关联缓存会有冲突,但4路缓存不会有冲突的代码片段。

练习 1.27 考虑用一个$N\times N$的矩阵向量积。在执行这个操作时,需要多大的缓存容量才会出现强制性的缓存丢失?你的答案取决于操作的实现方式:分别回答矩阵的行和列的遍历,你可以假设矩阵总是按行存储。

并行计算

规模最大且运算能力最强的计算机通常被称为 “超级计算机”。在过去的二十年里,超级计算机的概念无一例外地指向拥有多个CPU且可同时处理一个问题的机器——并行计算机。

我们很难精确定义并行的概念,因为它在不同的层面上有着不同的含义。在上一章中,同一个CPU内部可以有若干条指令同时“执行”,这是所谓的「指令级并行」(instruction-level parallelism,ILP)。指令级并行并不在用户的控制范围内,而是由编译器和CPU共同决定。另一种并行的概念为多个处理器同时处理一条以上的指令,每个处理器都在自己所在的电路板上,这种并行可以由用户显式地调度。

这一章中,我们将分析这种更明确的并行类型,支持它的硬件,使其成为可能的编程,以及分析它的概念。

引言

在科学计算中,我们常常需要处理大量且有规律的操作。有没有一种并行计算机可以加快这项工作?假设我们需要执行$n$步操作,且每一步操作在单处理器上需要花费的时间为$t$,那么使用$p$个处理器,我们能否在$t/p$的时间内完成这些工作?

让我们先从一个简单的例子开始。假设需要将两个长度为$n$的向量相加:

1
2
for (i=0; i<n; i++)
a[i] = b[i] + c[i];

最多可以用$n$个处理器来完成。下图所示中,每个处理器都存储一个a,b,c,且各自执行单一指令:a=b+c。

parallel-add

一般情况下,每个处理器执行的指令类似于

1
2
for (i=my_low; i<my_high; i++)
a[i] = b[i] + c[i];

程序执行的时间随着处理器数量的增加而线性减少。若定义每步操作为单位时间,原始算法耗时$n$,而在$p$个处理器上的并行执行需要的时间为$n/p$,并行后的速度是原来的$p$倍。

输入一个向量而得到一个标量的操作通常称为规约(reduction)

下面我们考虑对向量内各元素求和,假设每个处理器只包含一个数组中的元素,顺序执行:

1
2
3
s = 0;
for (i=0; i<n; i++)
s += x[i]

这段代码的并行情况并不明显,但如果我们将循环改写成:

1
2
3
for (s=2; s<2*n; s*=2)
for (i=0; i<n-s/2; i+=s)
x[i] += x[i+s/2]

则可以找到相应的方法将其并行化:外循环的每一次迭代现在都是一个可以由$n/s$处理器并行完成的循环。由于外循环将经过 $log_2n$次迭代,我们可以看到新算法的运行时间缩短为$n/p⋅log_2n$。并行算法的速度比原来快了 $p/log_2n$倍。

parallel-sum

从这两个简单的例子中可以看到并行计算的一些特点:

  • 算法被稍加改写后可以成为并行算法。

  • 并行算法并不一定能达到理想加速效果。

此外,第一种情况下每个处理器的$x_i, y_i$都在本地存储,这不会造成额外的时间开销;但在第二种情况下,处理器之间需要进行数据通信,这又会造成大量的开销。

下面我们讨论通信。我们可以把上图右半部分的并行算法变成一个树状图,把输入定义为树的节点,将所有的加和操作视为内部节点,总和作为根节点。如果一个节点是另一个节点的(部分)和的输入,则有一条从一个节点到另一个节点的边。在这个树状图中,可同时计算的元素放置在同一层级;每一级有时被称为「超步计算」(superstep)。垂直排列的节点意味着计算是在同一处理器上完成的,从一个处理器到另一个处理器的箭头对应着一次通信。树状图中的排列顺序并非唯一。如果节点在一个超步或水平层级上重新排列,就会出现不同的通信模式。

练习 2.1 考虑将超步内的节点放在随机处理器上。表明如果没有两个节点出现在同一个处理器上,那么最多只能进行两倍于图中的通信数量。

练习 2.2 你能画出在每个处理器上留下总和结果的计算图吗?有一种解决方案需要两倍的超步,也有一种解决方案需要相同的数量。在这两种情况下,图不再是一棵树,而是一个更普遍的「有向无环图」(Directed Acyclic Graph,DAG)。

处理器之间通常通过网络连接,由于网络之间传输数据需要时间,我们引入处理器之间距离的概念。在上文树状图中,处理器的排列是线性的,这与它们在排序中的等级有关。如果网络只连接一个处理器和它的邻节点,外循环的每一次迭代都会增加通信的距离。

练习 2.3 假设一个加法操作需要一个单位时间,而把一个数字从一个处理器移到另一个处理器也需要同样的单位时间。证明通信时间等于计算时间。

现在假设从处理器$p$发送一个数字到$p \pm k$需要时间$k$。说明现在并行算法的执行时间与顺序时间是一样的。求和的例子做了一个不现实的假设,即每个处理器最初只存储一个向量元素:实际上我们会有$p < n$,每个处理器都会存储一些向量元素。明显的策略是给每个处理器一个连续的元素序列,但有时明显的策略并不是最好的。

练习 2.4 考虑用4个处理器对8个元素求和的情况。表明图2.3中的一些边不再对应于实际通信。现在考虑用4个处理器对16个元素进行求和。这次通信边的数量是多少?

这些关于算法适应性、效率和通信的问题,对所有的并行计算都是至关重要的。在本章中,我们将以各种形式回到这些问题上。

功能性并行与数据级并行

从上面的介绍中,我们可以将并行概念定义为:在程序的执行过程中寻找独立的操作。这些独立的操作往往其执行逻辑相同,只是用于不同的数据项。我们把这种情况称为「数据级并行」(data parallelism):同一操作被并行地应用于许多数据元素,这在科学计算中是十分常见的。并行性往往源于这样一个事实:一个数据集(向量、矩阵、图……)被分散到许多处理器上,每个处理器都在处理其数据的一部分。

如果是单指令操作,传统上多采用数据级并行;如果是在子程序下处理,则通常称为「任务并行」(task parallelism)。

我们一定可以找到这样一种场景,使得指令之间相互独立且无依赖关系。一般情况下,编译器根据指令级并行来分析、运行代码:一条独立的指令可以被赋予给一个独立的浮点单元,也可以在优化寄存器时被重新排列。(同样参考2.5.2节)。

指令级并行是功能性并行的一种情况;功能并行可以通过连接相互独立的子程序来获得。在更高层次上,功能并行可以通过包含独立的子程序来获得,通常称为任务并行;见2.5.3节。

功能性并行的一些例子是蒙特卡洛模拟,以及其他穿越参数化搜索空间的算法。一个参数化的搜索空间,如布尔可满足性问题。

算法中的并行性与代码中的并行性

有时程序可以直接并行化处理,例如上文讨论的向量加法;有时我们很难找到简易的并行策略,例如在6.10.2节中将要讨论线性递归;而有些情况下,代码看起来可能没办法并行,但我们可以从理论上进行并行化处理。

练习 2.5 回答下列有关双循环$i,j$的问题。

1
2
3
for i in [1:N]:
x[0,i] = some_function_of(i)
x[i,0] = some_function_of(i)
1
2
3
for i in [1:N]:
for j in [1:N]:
x[i,j] = x[i-1,j]+x[i,j-1]
  1. 内循环的迭代是否独立,也就是说,它们是否可以同时执行?
  2. 外循环的迭代是否独立?
  3. 如果$x[1,1]$是已知的,说明$x[2,1]$和$x[1,2]$可以独立计算。
  4. 这是否让你对并行化策略有了一个想法?

我们将在第6.10.1节讨论这个难题的解决方案。总的来说,第6章的全部内容都将是关于科学计算算法中内在的并行性数量。

理论概念

使用并行计算机有两个重要原因:获得更多的内存或者获得更高的性能。用更多的内存的原因很容易解释,因为总内存是各个内存的总和;而并行计算机的速度则较难描述。本节将对采用并行架构后的措施和理论速度进行拓展讨论。

定义

加速比和效率

对比同一个程序在单处理器上运行的时间与$p$个处理器上的运行时间可以得到加速比,设$T_1$是在单个处理器上的执行时间,$T_p$是在$p$个处理器上的运行时间,则加速比为$S_p=T_1/T_p$(有时$T_1$被定义为 “在单个处理器上解决问题的最短时间”,这允许在单个处理器上使用不同于并行的算法)。 在理想情况下,$T_p=T_1/p$,但在实际中往往难以达到,因此$S_p\leqslant p$。为了衡量我们离理想的加速有多远,我们引入了效率$E_p = S_p/p$。显然,$0<E_p≤1$。

上面的定义会产生一个问题:某个需要并行解决的问题由于规模太大,无法在任何一个单独的处理器上运行;反之,将单处理器上的问题拆解在多处理器上,由于每个处理器上的数据非常少,因此可能会得到一个十分扭曲的结果。下面我们将讨论更现实的速度提升措施。

有各种原因导致实际速度低于𝑝。首先,使用多个处理器意味着额外的通信开销;其次,如果处理器并未分配到完全相同的工作量,则会产生一部分的闲置,就会造成「负载不均衡」(load unbalance),再次降低实际速度;最后,代码运行可能依赖其原有顺序。

处理器之间的通信是效率损失的一个重要来源。显然,一个不用通信就能解决的问题是非常有效的。这类问题实际上由许多完全独立的计算组成被称为「高度并行」(embarrassingly parallel),它们拥有接近完美的加速比和效率。

练习 2.6 加速比大于处理器数量被称为「超线性加速」(superlinear speedup)。请给出一个理论上的论据,为什么这种情况不会发生。

在实践中,超线性加速可能发生。例如,假设一个问题太大,无法装入内存,一个处理器只能通过交换数据到磁盘来解决。如果同一个问题适合在两个处理器的内存中解决,那么速度的提升很可能大于2,因为磁盘交换不再发生。拥有更少或更局部的数据也可以改善代码的缓存行为。

代价最优

在达不到理想加速比的情况下,我们可以将理想加速比与实际加速之间的差异定义为「额外开销」(overhead):

我们也可以把它解释为在单个处理器上模拟并行算法,与实际的最佳串行算法之间的差异。

我们以后会看到两种不同类型的开销。

  1. 并行算法可以与串行算法有本质上的不同。例如,排序算法的复杂度为$O(nlogn)$,但并行双调排序(8.6 节)的复杂度为$O(nlog^2n)$。

  2. 并行算法可以有来自于过程或并行化的开销,比如发送消息的成本。我们在6.2.2节中分析了矩阵与向量乘积中的通信开销。

如果一个并行算法与串行算法达到了数量级差距,那么该算法就被称为「代价最优」(cost-optimal)。

练习 2.7 上面的额外开销定义隐含地假定开销是不可并行的。在上述两个例子的背景下讨论这个假设。

渐近论

如果我们忽略一些限制,比如处理器的数量,或者它们之间的互连的物理特性,我们可以就推导出关于并行计算效率极限的理论结果。本节将简要介绍这些结果,并讨论它们与现实中高性能计算的联系。

例如,考虑矩阵与矩阵乘法$𝐶=𝐴𝐵$,它需要$2𝑁$步操作,其中$𝑁$是矩阵规模的大小。由于对$𝐶$元素的操作之间没有依赖性,我们可以并行地执行。如果我们有$𝑁^2$处理器,我们可以将每个处理器分配给$𝐶$中的$(𝑖,𝑗)$坐标,并让它在$2𝑁$时间内计算$c_{ij}$。因此,这个并行操作的效率为1,是最优的。

练习 2.8 证明这个算法忽略了关于内存的严重问题。

  • 如果矩阵被保存在共享内存中,那么从每个内存位置同时读多少次?内存位置进行多少次读取?

  • 如果处理器将输入和输出都保存在本地存储器中,那么有多少重复?

将𝑁数字$\{𝑥\}_{i=1…N}$相加,可以在对数𝑁时间内由𝑁/2个处理器完成。作为一个简单的例子,考虑$n$数之和:$𝑠 = \sum_{i=1}^n a_i$。如果我们有$n/2$个处理器,我们可以计算:

  1. 定义$s_i^{(0)} = a_i$
  2. 迭代$j=1,…,log_2n$:
  3. 计算$n/2^j$ 部分和$s_{i}^{(j)}=s_{2 i}^{(j-1)}+s_{2 i+1}^{(j-1)}$

我们看到,$n/2$ 个处理器在 $log_2n$的时间内总共完成了$n$的操作(应该如此)。这个并行方案的效率是$𝑂(1/ log_2 n)$,是一个缓慢下降的$n$的函数。

练习 2.9 请指出,使用刚才的并行加法方案,用$𝑁^3/2$个处理器在对数$log_2N$时间内完成两个矩阵的相乘所得的效率是多少?

现在,我们可以提出一个合理的理论问题

  • 如果我们有无限多的处理器,矩阵与矩阵乘法的最低时间复杂度是多少?

  • 是否有更快的算法仍然具有𝑂(1)的效率?

这类问题已经被前人研究过了(例如,见[100]),但它们对高性能计算没有什么影响。

对这些理论界线的第一个反对意见是,它们隐含地假定了某种形式的共享内存。事实上,这种算法模型被称为「PRAM模型」(Parallel Random Access Machine),是「RAM模型」(Random Access Machine)在共享内存系统上的扩展。该模型假设所有处理器共享一个连续的内存空间。此外,模型还允许同一位置上同时进行多个访问。这在实际应用中,特别是在扩大问题规模和处理器数量的情况下是不可能的。对PRAM模型的另一个反对意见是,即使在单个处理器上,它也忽略了内存的层次结构;1.3节。

但是,即使我们把分布式内存考虑在内,这个结果仍然是不现实的。上述求和算法确实可以在分布式内存中不变地工作,只是我们必须考虑随着进一步迭代,活跃处理器之间的距离会增加。如果处理器是由一个线性数组连接起来的,那么活跃处理器之间的 “跳跃 “次数就会增加一倍,渐进地,迭代的计算时间也会随之增加。总的执行时间变为𝑛/2,考虑到我们在问题上投入了这么多处理器,这个结果显然令人感到失望。

如果处理器是以超立方体拓扑结构连接的呢(2.7.5节)?不难看出,求和算法确实可以在$log_2n$的时间内完成。然而,当$𝑛\rightarrow \infty$时,我们能否建立一个由$n$节点组成的超立方体序列,并保持两个连接的通信时间不变?由于通信时间取决于延迟,而延迟部分取决于总线的长度,所以我们必须担心最近的邻居之间的物理距离。

这里的关键问题是,超立方体(𝑛维的物体)是否可以被嵌入到三维空间中,同时保持连接的邻居之间的距离(以米计算)不变。很容易看出,3维网格可以任意放大,同时保持总线的单位长度,但对于超立方体来说,这个问题并不明确。在这里,导线的长度可能会随着$n$的增加而增加,这就与电子的有限速度相抵触。

我们草拟了一个证明(详见[65]),即在我们的三维世界和有限的光速下,对于$𝑛$处理器上的问题,无论互连方式如何,速度都被限制在$\sqrt[4]{n}$。该论点如下。考虑一个涉及在一个处理器上收集最终结果的操作。假设每个处理器占用一个单位体积的空间,在单位时间内产生一个结果,并且在单位时间内可以发送一个数据项。那么,在一定的时间内,最多只有半径为$t$的球中的处理器,即$𝑂(𝑡^3)$处理器可以对最终结果做出贡献;所有其他处理器都离得太远。那么,在时间$T$内,能够对最终结果做出贡献的操作数$\int_{0}^{T} t^{3} d t=O\left(T^{4}\right)$.在时间$T$内,这意味着,最大的可实现的速度提升是串行时间的四次方根。

最后,”如果我们有无限多的处理器怎么办 “这个问题本身并不现实,但请先不要急着将它抛弃,我们在后面提出弱可扩展性问题时还会讨论它(第2.2.5节)。”如果我们让问题的大小和处理器的数量成比例增长怎么办”。这个问题是合理的,因为它与购买更多的处理器是否可以运行更大的问题,以及如果可以的话,有什么 “好处 “等非常实际的考虑相一致。

阿姆达尔定律

无法达到理想加速比的一个原因是,部分代码依赖固有顺序执行。假设有5%的代码必须串行执行,那么这部分的时间将不会随着处理器的数量增加而减少。因此,对该代码的提速被限制在20的系数。这种现象被称为「阿姆达尔定律」(Amdahl’s Law)[4],下面我们将对其进行表述。

令$F_s$分别为代码的串行部分,$F_p$为代码的并行部分(更严格地说法应该为:”可并行 “部分)。那么$F_p + F_s = 1$。在$p$个处理器上的并行执行时间$T_p$是串行执行的部分$T_1F_s$和可并行化的部分$T_1F_p/P$之和。

随着处理器数量的增加,当$𝑃 \rightarrow \infty $时,现有的并行执行时间已经接近代码的串行部分的时间。$T_P\downarrow T_1F_s$。我们的结论是,加速受限于$S_P \leqslant 1/F_s$,效率是一个递减函数$E \sim 1/P$。

代码的串行部分可以由I/O操作等内容组成。然而,并行的代码中也有一些部分实际上是串行的。考虑一个执行单个循环的程序,其中的所有迭代都可以独立计算。显然,这段代码没有提供任何的并行化障碍。然而,通过将循环分割成许多部分(每个处理器一个),每个处理器现在必须处理循环开销:计算边界和完成测试。只要有处理器,这种开销就会被复制很多次。实际上,循环开销是代码的一个串行部分。

练习 2.10 我们来做一个具体的例子。假设一段代码的执行需要1秒,可并行部分在单个处理器上需要1000秒。如果该代码在100个处理器上执行,其速度和效率是多少?对于500个处理器来说,速度和效率又是多少?请保留最多两位有效数字。

练习 2.11 调查阿姆达尔定律的含义:如果处理器的数量$P$增加,代码的并行部分必须如何增加才能保持固定的效率?

有通信开销的阿姆达尔定律

尽管阿姆达尔定律十分准确的指出了并行后速度的提升情况,但由于通信开销的存在,实际的性能相比于理论性能仍有所降低。让我们细化一下方程(2.1)的模型(见[137, p. 367])。

其中$T_c$是一个固定的通信时间。为了评估这种通信开销的影响,我们假设代码是完全可并行的,即$F_p=1$。可以发现:

为了使之接近$p$,我们需要$T_c<<T_1/p$或$p<<T_1/T_c$。换句话说,处理器的数量增长不应超过标量执行时间和通信开销的比例。

古斯塔法森定律

阿姆达尔定律认为只增加处理器数量并不会对并行加速结果有明显的提升。其隐含假设是:越来越多的处理器上执行同一个固定计算。然而在实际中情况并非如此:通常有一种方法可以扩大问题的规模(在第四章中我们将学习 “离散化 “的概念),人们根据可用处理器的数量来调整问题规模的大小。

一个更现实的假设是,有一个独立于问题大小的顺序部分,以及可以任意复制的并行部分。为了正式说明这一点,让我们从并行程序的执行时开始:

现在我们有两种可能的$T_1$的定义。首先是在$T$中设置$p=1$得到的$T_1$(说服自己这实际上与$T_p$相同)。然而,我们需要的是$T_1$,它描述的是完成并行程序所有操作的时间。(见图2.4)。

这给我们提供了一个加速比

从这个公式我们可以看出。

  • 加速仍以𝑝为界。

  • …它仍是一个正数。

  • 对于一个给定的𝑝,它又是顺序分数的一个递减函数。

练习 2.12 重写方程(2.3),用$p$和$F_p$表示速度的提高。效率$E_p$的渐近行为是什么?

与阿姆达尔定律一样,如果我们把通信开销包括在内,我们可以研究古斯塔法森定律的行为。让我们回到一个完全可并行问题的方程(2.2),并将其近似为

现在,在问题逐渐放大的假设下,$T_c,T_1$成为$p$的函数。我们看到,如果$T_1(p)\sim pT_c(p)$,我们得到的线性加速是远离1的恒定分数。一般来说,我们不能进一步进行这种分析;在6.2.2节,你会看到一个例子的详细分析。

阿姆达尔定律和混合结构

上文我们已经认识了分布式和共享内存式的混合结构,这导致阿姆达尔定律的一种新型变式:

假设我们有$p$节点,每个节点有$c$核,$F_p$描述了使用$𝑐$路线程并行的代码的比例。我们假设整个代码在$p$节点上是完全并行的。理想的速度是$p_c$,理想的并行运行时间是$T_1/(pc)$,但实际运行时间是

练习 2.13 证明加速$T_1/T_p$,$c$可以用$p/F_s$近似。

在最初的阿姆达尔定律中,提速被顺序部分限制在一个固定的数字$1/F_s$,在混合结构中,它被任务并行部分限制在$p/F_s$。

关键路径和布伦特定理

上面关于加速和效率的定义,以及对阿姆达尔定律和古斯塔法森定律的讨论都隐含了一个假设,即并行工作可以被任意细分。正如你在第2.1节的求和例子中所看到的,然而事实情况并不总是这样:操作之间可能存在依赖关系,这限制了可以采用的并行量。

我们将「关键路径」(critical path)定义为最长度的依赖关系链(可能是非唯一的),这个长度有时被称为「跨度」(span)。由于关键路径上的任务需要一个接一个地执行,关键路径的长度是并行执行时间的一个下限。

为了使这些概念准确,我们定义了以下概念。

定义 1

  • $T_1$:计算在单个处理器上花费的时间
  • $T_p$:计算在$p$处理器上花费的时间
  • $T_\infty$:如果有无限的处理器,计算所需的时间。
  • $P_\infty$:$T_p=T_\infty$的$p$值。

有了这些概念,我们可以将算法的「平均并行度」(average parallelism)定义为$T_1/T_\infty$,而关键路径的长度为$T_\infty$。

现在我们将通过展示一个任务及其依赖关系的图来进行一些说明。为了简单起见,我们假设每个节点是一个单位时间的任务。

可以使用的最大处理器数量为3,平均并行度为9/5;效率最大的是$p=2$。

可以使用的最大处理器数量是4,这也是平均并行度;图中说明了一个效率为$\equiv 1$的$P=3$的并行化。

根据这些例子,你可能会发现有两种极端情况:如果每个任务都恰好依赖于其他任务,你会得到一个依赖链。

  • 如果每个任务都精确地依赖于其他任务,你会得到一个依赖链,并且对于任何$T_p= T_1$。
  • 另一方面,如果所有的任务都是独立的(并且$p$除以它们的数量),你会得到$T_p = T_1/p$,对于任何$p$。
  • 在一个比上一个稍微不那么琐碎的情况下,考虑关键路径的长度为$m$,在这些$m$的每一步中,有$p-1$个独立的任务,或者至少:只依赖于前一步的任务。这样,每一个$m$步骤中都会有完美的并行性,我们可以表达为$T_p=T_1/T_p=m +(T_1-m)/p$。

最后这句话实际上在一般情况下是成立的。这被称为 “「布伦特定理」(Brent’s Theorem)”。

命题 1:设$m$为任务总数,$p$为处理器数量,$t$为关键路径的长度。那么计算可以在

证明:将计算分成几步,使$i+1$各步中的任务相互独立,而只依赖于步骤$i$。设步骤中的任务数为$s_i$ ,则该步骤的时间为$\lceil \frac{s_i}{p}\rceil$ 。将其相加得出

练习 2.14 考虑一棵深度为$d$的树,即有$2^d - 1$的节点,以及一个搜索$\max_\limits{n \in \text { nodes }} f(n)$。

假设所有的节点都需要被访问:我们对它们的值没有任何了解或排序。分析在$p$处理器上的并行运行时间,你可以假设$p=2q$,其中$q<d$。这与你从布伦特定理和阿姆达尔定律得到的数字有什么关系?

可扩展性

上文说过,使用越来越多数量的处理器处理一个给定的问题是没有意义的:每个节点上的处理器都没有足够有效地运行。在实践中,并行计算的用户要么选择与问题规模相匹配的处理器数量,要么在相应增加的处理器数量上解决一系列越来越大的问题。在这两种情况下,都很难谈及速度提升。因此我们使用了「可扩展性」(scalability)的概念。

我们区分了两种类型的可扩展性。所谓的「强可扩展性」(strong scalability)实际上与上面讨论的加速相同。我们说,如果一个程序在越来越多的处理器上进行分割,它显示出完美或接近完美的速度,也就是说,执行时间随着处理器数量的增加而线性下降,那么这个程序就显示出强大的可伸缩性。在效率方面,我们可以将其描述为。

通常情况下,人们会遇到类似 “这个问题可以扩展到500个处理器 “的说法,这意味着在500个处理器以下,速度不会明显低于最佳状态。这个问题不一定要在一个处理器上解决:通常使用一个较小的数字,如64个处理器,作为判断可扩展性的基线。

更有趣的是,「弱可扩展性」(weak scalability)是一个定义更模糊的术语。它描述了执行的行为,当问题的大小和处理器的数量都在增长时,但每个处理器的数据量却保持不变。由于操作数和数据量之间的关系可能很复杂,所以诸如加速等措施很难报告。如果这种关系是线性的,可以说每个处理器的数据量保持不变,并报告说随着处理器数量的增加,并行执行时间是不变的。(你能想到工作和数据之间的关系是线性的应用吗?哪里不是呢?)

练习 2.15 我们可以将强扩展性表述为运行时间与处理器数量成反比。

证明在对数图上,也就是将运行时间的对数与处理器数量的对数相比较,你会得到一条斜率为-1的直线。你能提出一种处理不可并行部分的方法吗,也就是说,运行时间$t=c_1+c_2/p$?

练习 2.16 假设你正在研究一个代码的弱可扩展性。在运行了几种规模和相应数量的进程之后,你发现在每一种情况下,翻转率都是大致相同的。论证该代码确实是弱可扩展的。

练习 2.17 在上面的讨论中,我们总是隐含地比较一个串行算法和该算法的并行形式。然而,在第2.2.1节中,我们注意到,有时提速被定义为一个并行算法与同一问题的最佳顺序算法的比较。考虑到这一点,请将运行时间为$(\log𝑛)^2$的并行排序算法(例如双调排序;第8节)与运行时间为$n\log n$的最佳串行算法进行比较。

证明在$n=p$的弱可扩展情况下,速度提升为$p/ \log p$。证明在强可扩展情况下,加速是$n$的一个递减函数。

注释 8 一则历史轶事.

Message: 1023110, 88 lines

Posted: 5:34pm EST, Mon Nov 25/85, imported: ….

Subject: Challenge from Alan Karp

To: Numerical-Analysis, … From GOLUB@SU-SCORE.ARPA

I have just returned from the Second SIAM Conference on Parallel Processing for Scientific Computing in Norfolk, Virginia. There I heard about 1,000 processor systems, 4,000 processor systems, and even a proposed 1,000,000 processor system. Since I wonder if such systems are the best way to do general purpose, scientific computing, I am making the following offer. I will pay $100 to the first person to demonstrate a speedup of at least 200 on a general purpose, MIMD computer used for scientific computing.

This offer will be withdrawn at 11:59
PM on 31 December 1995.

这一点通过扩大问题的规模得到了满足。

等效性

在上述弱可扩展性的定义中,我们指出,在问题规模$N$和处理器数量$P$之间的某种关系下,效率将保持不变。我们可以使之精确化,并将等效率曲线定义为$𝑁$,$𝑃$之间的关系,使效率恒定[86]。

可扩展性到底是什么意思?

在工业界的说法中,”可扩展性 “一词有时被应用于架构或整个计算机系统。

可扩展的计算机是由少量的基本部件设计而成的,没有单一的瓶颈部件,因此计算机可以在其设计的扩展范围内逐步扩展,为一组明确定义的可扩展的应用提供线性递增的性能。通用可扩展计算机提供广泛的处理、内存大小和I/O资源。可扩展性是指可扩展计算机的性能增量是线性的程度”[11]。

在科学计算中,可扩展性是一个算法的属性,以及它在一个架构上的并行化方式,特别是注意到数据的分布方式。在第6.2.2节中,你会发现对矩阵与向量乘积操作的分析:按块行分布的矩阵原来是不可扩展的,但按子矩阵分布的二维是可以的。

缩放模拟

在大多数关于弱扩展的讨论中,我们都假设工作量和存储量是线性关系,这并不总是如此。例如,对于$N^2$的数据,矩阵-矩阵乘积的操作复杂度为$N^3$。如果线性地增加处理器的数量,并保持每个进程的数据不变,工作可能会随着功率的增加而上升。

如果模拟随时间变化的PDEs,也会有类似的效果。(这里,总功是每个时间步骤的功和时间步骤数的乘积。这两个数字是相关的;在第4.1.2节中,你看到时间步长有一定的最小尺寸,是空间离散化的一个函数。因此,时间步数将随着每个时间步数的工作上升而上升。

在本节中,我们不是从算法运行的角度来研究可扩展性,而是研究模拟时间$S$和运行时间$T$是恒定的情况,我们看看这对我们需要的内存量有何影响。这相当于我们有一个模拟,在一定的运行时间内模拟了一定量的现实世界的时间;现在你买了一台更大的计算机,你想知道在相同的运行时间和保持相同的模拟时间内,你能解决多大的问题。换句话说,如果你能在一天内计算出两天的天气预报,你不希望当你买了更大的电脑后,它开始花费三天时间。

让 $m$为每个处理器的内存$P$为处理器的数量,得出

如果𝑑是问题的空间维数,通常是2或3,我们可以得到

为了稳定起见,这将时间步长$\Delta t$限制为

(注意到第四章没有讨论双曲的情况)在模拟时间$S$的情况下,我们发现

如果我们假设各个时间步骤是完全可并行的,也就是说,我们使用显式方法,或带有最优求解器的隐式方法,我们发现运行时间为

令$T/S=C$,则

也就是说,每个处理器的内存量随着处理器数量的增加而减少。(最后一句话中缺少的步骤是什么?)

进一步分析这个结果,我们发现

代入𝑀=𝑃𝑚,我们最终发现

也就是说,我们可以使用的每个处理器的内存会随着处理器数量的高次方而减少。

其他的缩放措施

上面的阿姆达尔定律是以在一个处理器上的执行时间来表述的。在许多实际情况下,这是不现实的,因为并行执行的问题对任何一个处理器来说都太大了。一些公式的处理给了我们在某种程度上等同的数量,但不依赖于这个单处理器的数量[159]。

首先,将定义$S_p(n)=\frac{T_1(n)}{T_p(n)}$应用于强可扩展,我们发现$T_1(n)/n$是每次操作的串行时间。它的倒数$n/T_1(n)$可以称为串行计算率,表示为$R_1(n)$。同样可以定义 “并行计算率”

我们发现

在强可扩展中,$R_1(n)$将是一个常数,所以我们做一个加速的对数图,纯粹是基于测量$T_p(n)$。

并发;异步和分布式计算

即使在非并行的计算机上,也有一个同时执行多个进程的问题。操作系统通常有一个时间切片的概念,在这个概念中,所有活动的进程都被赋予CPU的指令,在一小段时间内轮流执行。通过这种方式,串行可以模拟一个并行机器;当然,这种做法效率较低。

然而,即使不运行并行程序,时间切片也是有用的。操作系统会有一些独立的进程(例如编辑器,收到的邮件,等等),它们都需要保持活跃,或多或少地运行。这种独立进程的困难在于,它们有时需要访问相同的资源。两个进程都需要相同的两个资源,而每个进程都得到一个,这被称为「死锁」(deadlock)。资源争夺的一个著名的形式化被称为「哲学家进餐」(dining philosophers)问题。

研究这种独立进程的领域有多种说法,如「并发性」(concurrency)、「异步计算」(asynchronous computing)或「分布式计算」(distributed computing)。并发这一术语描述了我们正在处理同时活动的任务,它们的行动之间没有时间串行。分布式计算这一术语来源于数据库系统等应用,在这些应用中,多个独立的客户需要访问一个共享数据库。

本书将不多讨论这个话题。第2.6.1节讨论了支持时间切片的线程机制;在现代多核处理器上,线程可以用来实现共享内存并行计算。

《Communicating Sequential Processes》一书对并发进程之间的交互进行了分析[109]。其他作者使用拓扑结构来分析异步计算[103]。

Flynn

并行计算机架构

相当一段时间以来,超级计算机都是某种并行计算机,即允许同时执行多个指令或指令序列的架构。福林(Flynn)[66]提出了一种描述这种架构的各种形式的方法。Flynn的分类法通过数据流和控制流是共享的还是独立的来描述结构。结果有以下四种类型(也见图2.5)。

  • 单指令单数据」(Single Instruction Single Data,SISD):这是传统的CPU结构:在任何时候只有一条指令被执行,对一个数据项进行操作。
  • 单指令多数据」(Single Instruction Multiple Data,SIMD):在这种计算机类型中,可以有多个处理器,每个处理器对自己的数据项进行操作,但它们都在对该数据项执行相同的指令。向量计算机(2.3.1.1节)通常也被定性为SIMD。
  • 多指令单数据」(Multiple Instruction Single Data,MISD):目前还没有符合这种描述的架构;人们可以说,安全关键应用的冗余计算就是MISD的一个例子。
  • 多指令多数据」(Multiple Instruction Multiple Data,MIMD):这里有多个CPU对多个数据项进行操作,每个都执行独立的指令。目前大多数并行计算机都属于这种类型。

现在我们将更详细地讨论SIMD和MIMD架构。

SIMD

SIMD类型的并行计算机同时对一些数据项进行相同的操作。这种计算机的CPU的设计可以相当简单,因为算术单元不需要单独的逻辑和指令解码单元:所有的CPU都是锁步执行相同的操作。这使得SIMD计算机在对数组的操作上表现出色,如

1
for (i=0; i<N; i++) a[i] = b[i]+c[i];

而且,由于这个原因,它们也经常被称为「阵列处理器」(array processors)。科学代码通常可以写得很好,使很大一部分时间花在阵列操作上。

另一方面,有些操作不能在阵列处理器上有效执行。例如,评估递归的若干项$x_{i+1}=ax_i+b_i$涉及许多加法和乘法,但它们是交替进行的,因此每次只能处理一种类型的操作。这里没有同时作为加法或乘法输入的数字阵列。

为了允许对数据的不同部分进行不同的指令流,处理器会有一个 “屏蔽位”,可以被设置来阻止指令的执行。在代码中,这通常看起来像

1
2
while(x>0) {
x[i] = sqrt(x[i])

将相同的操作同时应用于一些数据项的编程模型,被称为「数据并行」(data parallelism)。

这种数组操作可以在物理模拟中出现,但另一个重要来源是图形应用。对于这种应用,阵列处理器中的处理器可能比PC中的处理器弱得多:通常它们实际上是位处理器,一次只能对一个位进行操作。按照这种思路,ICL在20世纪80年代有4096个处理器的DAP[115],固特异在20世纪70年代制造了一个16K处理器的MPP[10]。

后来,连接机(CM-1、CM-2、CM-5)相当流行。虽然第一台连接机有位处理器(16个到一个芯片),但后来的型号有能够进行浮点运算的传统处理器,并不是真正的SIMD架构。所有这些都是基于超立方体互连网络;见2.7.5节。另一家拥有商业上成功的阵列处理器的制造商是MasPar;图2.6说明了该架构。你可以清楚地看到一个方形阵列处理器的单一控制单元,加上一个做全局操作的网络。

基于阵列处理的超级计算机已经不存在了,但是SIMD的概念以各种形式存在着。例如,GPU是基于SIMD的,通过其CUDA编程语言强制执行。另外,英特尔Xeon Phi有一个强大的SIMD组件。早期的设计SIMD架构的初衷是尽量减少必要的晶体管数量,而这些现代协处理器则是考虑到电源功率。与浮点运算相比,处理指令(称为指令问题)在时间、能源和所需的芯片地产方面实际上是昂贵的。因此,使用 SIMD 是在后两项措施上节约成本的一种方式。

流水线

许多计算机都是基于向量处理器或流水线处理器的设计。第一批商业上成功的超级计算机,Cray-1和Cyber205都属于这种类型。近来,Cray-X1和NEC SX系列都采用了向量流水线。在TOP 500 中领先3年的 “地球模拟器 “计算机[178],就是基于NEC SX处理器的。

虽然基于流水线处理器的超级计算机明显是少数,但流水线现在在作为集群基础的超标量CPU中是主流。一个典型的CPU有流水线的浮点单元,通常有独立的加法和乘法单元。

然而,现代超标量CPU的流水线与更老式的向量单元的流水线有一些重要区别。这些向量计算机中的流水线单元并不是CPU中的集成浮点单元,而是可以更好地看作是附属于本身具有浮点单元的CPU的向量单元。向量单元有矢量寄存器,其长度一般为64个浮点数;通常没有 “向量缓存”。向量单元的逻辑也比较简单,通常可以通过明确的向量指令来寻址。另一方面,超标量CPU完全集成在CPU中,面向利用非结构化代码中的数据流。

CPU和GPU中的真SIMD

真正的SIMD阵列处理可以在现代CPU和GPU中找到,在这两种情况下,都是受到图形应用中需要的并行性的启发。

英特尔和AMD的现代CPU,以及PowerPC芯片,都有向量指令,可以同时执行一个操作的多个实例。在英特尔处理器上,这被称为「SIMD流扩展」(Streaming Extensions,SSE)或「高级矢量扩展」(Advanced Vector Extensions,AVX)。这些扩展最初是用于图形处理的,在这种情况下,往往需要对大量的像素进行相同的操作。通常情况下,数据必须是总共128位,这可以分为两个64位实数,四个32位实数,或更多更小的块,如4位。

AVX指令是基于高达512位宽的SIMD,也就是说,可以同时处理8个浮点数。就像单次浮点运算对寄存器中的数据进行操作一样(第1.3.3节),向量运算使用「向量寄存器」(vector registers)。向量寄存器中的位置有时被称为「SIMD流水线」(SIMD lanes)。

SIMD的使用主要是出于功耗的考虑。解码指令实际上比执行指令更耗电,所以SIMD并行是一种节省功耗的方法。

目前的编译器可以自动生成SSE或AVX指令;有时用户也可以插入pragmas,例如英特尔的编译器。

1
2
3
4
5
6
void func(float *restrict c, float *restrict a, float *restrict b, int n)
{
#pragma vector always
for (int i=0; i<n; i++)
c[i] = a[i] * b[i];
}

这些扩展的使用通常要求数据与缓存行边界对齐(第1.3.4.7节),所以有一些特殊的allocate和free调用可以返回对齐的内存。

OpenMP的第4版还有指示SIMD并行性的指令。

更大规模的阵列处理可以在GPU中找到。一个GPU包含大量的简单处理器,通常以32个一组的形式排列。每个处理器组只限于执行相同的指令。因此,这是SIMD处理的真正例子。进一步的讨论,见2.9.3节。

MIMD/SPMD计算机

到目前为止,现在最常见的并行计算机结构被称为多指令多数据(MIMD):处理器执行多条可能不同的指令,每条指令都在自己的数据上。说指令不同并不意味着处理器实际上运行不同的程序:这些机器大多以「单程序多数据」(Single Program Multiple Data,SPMD)模式运行,即程序员在并行处理器上启动同一个可执行文件。由于可执行程序的不同实例可以通过条件语句采取不同的路径,或执行不同数量的循环迭代,它们一般不会像SIMD机器上那样完全同步。如果这种不同步是由于处理器处理不同数量的数据造成的,那就叫做「负载不均衡」(load unbalance,),它是导致速度不完美的一个主要原因;见2.10节。

MIMD计算机有很大的多样性。其中一些方面涉及到内存的组织方式,以及连接处理器的网络。除了这些硬件方面,这些机器还有不同的编程方式。我们将在下面看到所有这些方面。现在的许多机器被称为「集群」(clusters)。它们可以由定制的或商品的处理器组成(如果它们由PC组成,运行Linux,并通过以太网连接,它们被称为Beowulf集群[93]);由于处理器是独立的,它们是MIMD或SPMD模型的例子。

不同类型的内存访问

在介绍中,我们将并行计算机定义为多个处理器共同处理同一问题的设置。除了最简单的情况,这意味着这些处理器需要访问一个联合的

数据池。在上一章中,你看到了即使是在单个处理器上,内存也很难跟上处理器的需求。对于并行机器来说,可能有几个处理器想要访问同一个内存位置,这个问题变得更加糟糕。我们可以通过它们在协调多个进程对联合数据池的多次访问问题上所采取的方法来描述并行机器。

这里的主要区别在于「分布式内存」(distributed memory)和「共享内存」(shared memory)之间的区别。在分布式内存中,每个处理器有自己的物理内存,更重要的是有自己的地址空间。因此,如果两个处理器引用一个变量$x$,他们会访问自己本地内存中的一个变量。另一方面,在共享内存中,所有处理器都访问相同的内存;我们也说它们有一个「共享地址空间」(shared address space)。因此,如果两个处理器都引用一个变量$x$,它们就会访问同一个内存位置。

对称多核处理器:统一内存访问

如果任何处理器都可以访问任何内存位置,并行编程就相当简单。由于这个原因,制造商有很大的动力来制造架构,使处理器看不到一个内存位置和另一个内存位置之间的区别:每个处理器都可以访问任何内存位置,而且访问时间没有区别。这被称为「统一内存访问」(Uniform Memory Access,UMA),基于这一原则的架构的编程模型通常被称为「对称多处理」(Symmetric Multi Processing,SMP)。

有几种方法可以实现SMP架构。目前的台式电脑可以有几个处理器通过一条内存总线访问一个共享的内存;例如,苹果公司在市场上销售一种带有2个六核处理器的机型。处理器之间共享的内存总线只适用于少量的处理器;对于更多的处理器,可以使用连接多个处理器和多个内存bank的横梁。

在多核处理器上,有一种不同类型的统一内存访问:各核通常有一个共享的高速缓存,通常是L3或L2高速缓存。

非统一内存访问

基于共享内存的UMA方法显然只限于少量的处理器。十字架网络是可以扩展的,所以它们似乎是最好的选择。然而,在实践中,人们将具有本地内存的处理器放在一个具有交换网络的配置中。这导致了一种情况,即一个处理器可以快速访问自己的内存,而其他处理器的内存则较慢。这就是所谓的NUMA的一种情况:一种使用物理分布式内存的策略,放弃统一的访问时间,但保持逻辑上的共享地址空间:每个处理器仍然可以访问任何内存位置。

ranger-numa

上图说明了TACC Ranger集群的四插槽主板的NUMA情况。每个芯片都有自己的内存(8Gb),但是主板的行为就像处理器可以访问一个32Gb的共享池。很明显,访问另一个处理器的内存比访问本地内存要慢。此外,请注意,每个处理器有三个连接,可以用来访问其他内存,但最右边的两个芯片使用一个连接来连接网络。这意味着访问对方的内存只能通过一个中间处理器进行,减缓了传输速度,并占用了该处理器的连接。

虽然NUMA方法对程序员来说很方便,但它为系统提供了一些挑战。想象一下,两个不同的处理器在其本地(缓存)内存中都有一个内存位置的副本。如果一个处理器改变了这个位置的内容,这个变化必须被传播到其他处理器上。如果两个处理器都试图改变一个内存位置的内容,程序的行为会变得不确定。

保持一个内存位置的副本同步被称为「缓存一致性」(cache coherence)(详见1.4.1节);使用这种方法的多处理器系统有时被称为 “缓存一致性的NUMA “或ccNUMA架构。

将NUMA发挥到极致,有可能有一个软件层,使网络连接的处理器看起来在共享内存上运行。这被称为「分布式共享内存」(distributed shared memory)或「虚拟共享内存」(virtual shared memory)。在这种方法中,管理程序提供了一个共享内存API,通过翻译系统调用到分布式内存管理。这种共享内存API可以被Linux内核所利用,它可以支持4096个线程。

在目前的供应商中,只有SGI(UV系列)和Cray(XE6)的市场产品具有大规模的NUMA。两者都对分区全局地址空间(PGAS)语言提供了强有力的支持;见2.6.5节。有一些厂商,如ScaleMP,为普通集群上的分布式共享内存提供了软件解决方案。

逻辑上和物理上的分布式内存

对内存访问问题最极端的解决方案是提供不仅在物理上,而且在逻辑上也是分布式的内存:处理器有自己的地址空间,不能直接看到其他处理器的内存。这种方法通常被称为 “分布式内存”,但这个术语是不明显的,因为我们必须分别考虑内存是否是分布式的和是否是分布式的问题。请注意,NUMA也有物理上的分布式内存;它的分布式性质对于程序员来说并不明显。

在逻辑和物理的分布式内存中,一个处理器与另一个处理器交换信息的唯一方式是通过网络明确传递信息。你将在2.6.3.3节中看到更多关于这方面的内容。

这种类型的架构有一个显著的优势,即它可以扩展到大量的处理器:IBM蓝色基因已经建立了超过20万个处理器。另一方面,这也是最难编程的一种并行系统。

存在上述类型之间的各种混合体。事实上,大多数现代集群会有NUMA节点,但节点之间是分布式内存网络。

并行计算(二)

并行计算中的粒度

一个程序有多少并行度?事实上,大部分指令都可以并行执行,但我们要考虑并行后的代价:并行后程序是否变得简单?以及并行后加速效率是否明显等问题。

本节的讨论主要是在概念层面上进行的;后面将详细介绍如何对并行进行实际编程。

数据并行化

对于有简单主体循环的程序来说,遍历大数据集的操作相当常见。

1
2
for (i=0; i<1000000; i++)
a[i] = 2*b[i];

这样的代码被认为是「数据并行」(data parallelism)或「细粒度并行」(fine-grained parallelism)的一个实例。如果我们有和数组元素一样多的处理器,那么并行后的代码将非常简单:每个处理器将在其本地数据上执行

1
a = 2*b

如果代码主要由数组的循环组成,它可以在所有处理器锁步的情况下有效执行。基于这种思想设计的并行架构早已存在,事实上处理器只能以锁步方式工作。这种数组上的完全并行操作出现在计算机图形学中,图像的每个像素都被独立处理。因此,GPU的并行就是基于数据并行的。

继续上面的例子,考虑以下操作

在数据并行机器上,可以实现为

其中shiftleft/right指令导致一个数据项被发送到数字较低或较高为1的处理器。 为了使第二个例子有效,有必要使每个处理器能够与其近邻快速通信,并使第一个和最后一个处理器彼此通信。

在各种情况下,如图形中的 “模糊 “操作,对二维数据的操作是有意义的。

因此,处理器必须能够将数据移动到二维网格中的相邻处。

指令级并行

在ILP中,并行性仍然是在单个指令的层面上,但这些指令不一定是相似的。例如,在

这两个赋值是独立的,因此可以同时执行。编译器可以帮助我们处理这种并行。事实上,识别ILP对于从现代超标量CPU中获得良好的性能至关重要。

任务并行

数据和指令级并行的另一种应用为「任务并行」(task parallelism),是指可以并行执行的整个子程序。例如,在树形数据结构中的搜索可以按以下方式实现。

1
2
3
4
5
if optimal (root) then 
exit
else
parallel: SearchInTree (leftchild),SearchInTree (rightchild)
Procedure SearchInTree(root)

这个例子中的搜索任务是不同步的,而且任务数量也不固定。在实际应用中,任务过多并不是一个很好的策略,因为处理器只在一个任务上工作时其效率才最高。上面的例子可以略加改写为:

1
2
3
while there are tasks left do
wait until a processor becomes inactive;
spawn a new task on it

(之前的两个伪代码之间有一个微妙的区别。在第一个代码中,任务是自我调度的:每个任务都会衍生出两个新的任务。第二个代码是一个Manager-Worker Paradigm的例子:一个贯穿整个程序执行过程的中心任务负责派生和分配节点任务。)

与数据并行不同,该方案中数据对处理器的分配不是事先确定的。因此,这种并行模式最适合于线程编程,例如通过OpenMP库的并行。下面考虑另一个高度任务并行的例子:

在最简单的情况下,一个有限元网格是覆盖二维物体的三角形的集合。由于应该避免过于尖锐的角度,「Delauney网格细化」(Delauney mesh refinement)过程可以选择某些三角形,用形状更好的三角形取代它们。图2.9说明了这一点:黑色的三角形违反了一些角度条件,所以要么它们自己被细分,要么它们与一些相邻的三角形(呈现为灰色)连接,然后共同被重新细分。

伪代码参考如下。

1
2
3
4
5
6
7
8
9
10
11
Mesh m = /* read in initial mesh */ 
WorkList wl;
wl.add(mesh.badTriangles());
while (wl.size() != 0) do
Element e = wl.get(); //get bad triangle
if (e no longer in mesh) continue;
Cavity c = new Cavity(e);
c.expand();
c.retriangulate();
mesh.update(c);
wl.add(c.badTriangles());

很明显,该算法是由一个必须在所有进程之间共享的工作列表(或任务队列)数据结构驱动的。再加上动态分配数据给进程,这意味着这种不规则的并行性适合于共享内存编程,而在分布式内存中则较难做到。

高度并行

单处理器的计算通常需要在众多不同的输入上进行。如果计算的数据不存在相关依赖,且不需要任何特定情况,则被称为「高度并行」(embarrassingly parallel)或「便捷并行」(conveniently parallel)计算。这种并行可以发生在几个层面。在诸如计算Mandelbrot set或评估国际象棋游戏中的棋子的例子中,一个子程序级别的计算被调用了许多参数值。在一个更粗略的层面上,可能是一个简单的程序需要对许多输入进行运行。在这种情况下,整体计算被称为「参数扫描」(parameter sweep)。

中粒度的数据并行化

上述数据并行假定了有与数据元素同样多的处理器。在实际中,处理器内存通常会很大,且处理的数据数量要远远大于处理器数量。因此,数组被分组到子数组的处理器上。伪代码如下

1
2
3
4
my_lower_bound = // some processor-dependent number
my_upper_bound = // some processor-dependent number
for (i=my_lower_bound; i<my_upper_bound; i++)
// the loop body goes here

这种模式有数据并行的特点,因为在大量的数据项上执行的操作是相同的。它也可以被看作是任务并行,因为每个处理器执行的代码部分较大,而且不一定对同等大小的数据块进行操作。

任务粒度

在前面的小节中,我们考虑了寻找并行工作的不同层次,或者说划分工作的不同方式,以便找到并行性。还有另一种方法:我们将并行方案的「粒度」(granularity)定义为一个处理元素在不得不与其他处理元素进行通信或同步之前可以执行的工作量(或任务大小)。

在ILP中,我们处理的是非常细粒度的并行,就像一条指令或几条指令一样。在真正的任务并行中,颗粒度要粗得多。

有趣的是,我们可以自行选择数据并行中的任务大小。SIMD机器上,我们选择的是单指令粒度,但操作可以被分为中等大小的任务。因此,在处理器数量和总问题规模之间的适当平衡下,数据并行的操作可以在分布式内存集群上执行。

练习 2.18 讨论为一个数据并行操作选择合适的粒度,如在二维网格上进行平均化。表明存在一个表面到体积的效应:通信量比计算量低一阶。这意味着,即使通信比计算慢得多,增加任务量仍然会得到一个平衡的执行。

如果试图加大任务规模以减小通信开销,则会导致另一个问题:集合操作时可能会有不同运行时间的任务,导致负载不均衡。一种解决办法是使用过度分解:创建比处理元素更多的任务,并将多个任务分配给一个处理器(或动态分配任务)以平衡不规则的运行时间。这就是所谓的「动态调度」(dynamic scheduling)。

并行编程

并行编程比串行编程更复杂。虽然对于后者来说,大多数编程语言的操作原理是相似的(除了一些例外,如函数式语言或逻辑语言),但有多种方法来处理并行问题。让我们来探讨一下其中的一些概念和实际问题。

并行编程的策略有多种。我们很难做出一个能自动将串行程序转变为并行程序的编译器。除了弄清楚哪些操作是独立的问题之外,最主要的问题是,在并行环境中定位数据的问题是非常困难的。编译器需要考虑整个代码,而不是一次一个子程序。

较为有效的方法是:用户编写串行程序,同时给出哪些计算可以并行化或数据改如何分配的指示。明确指出操作的并行性是在OpenMP中进行的;指出数据分布并将并行性留给编译器和运行时是PGAS语言的基础。这种方法在共享内存中效果最好。

到目前为止最难的并行编程方式,同时也是实际中效果最好的并行方式,就是把一切留给程序员,让程序员管理一切。这种方法在分布式内存编程的情况下是必要的。

线程并行

我们将简要介绍一下 “线程”。为了解释什么是「线程」(thread),我们首先需要从技术上了解什么是「进程」(process)。一个unix进程对应于对应于单个程序的执行。因此,它在内存中拥有

  • 程序代码,以机器语言指令的形式存在。

  • 」(heap),包含malloc创建的数组。

  • 」(stack),包含快速变化的信息,如「程序计数器」(program counter,PC),它显示了当前正在执行的结构。堆栈中包含快速变化的信息,如表明当前正在执行的程序计数器,以及具有本地范围的数据项,以及计算的中间结果。

这个过程可以有多个线程;这些线程的相似之处在于它们看到相同的程序代码和堆,但它们有自己的栈。因此,一个线程是通过进程执行的一个独立 “股”。

进程可以属于不同的用户,或者是一个用户并发运行的不同程序,因此它们有自己的数据空间。另一方面,线程是一个进程的一部分,因此它们共享进程堆。线程可以有一些私有数据,例如通过拥有自己的数据栈,但它们的主要特征是它们可以在相同的数据上进行协作。

叉形连接机制

线程是动态的,它们可以在程序执行过程中被创建。(这与MPI模型不同,在MPI模型中,每个处理器运行一个进程,它们都是在同一时间创建和销毁的)。当程序启动后,处于活跃状态的线程称为「主线程」(main thread),其他线程通过主线程「生成」(thread spawning)创建,主线程需等待其完成,称为「生成-汇合模型」(fork-join)。从同一个线程生成出来并同时活动的一组线程被称为「线程组」(thread team)。

fork-join

线程的硬件支持

上面所描述的线程是一种软件结构。在并行计算机出现之前,线程是可能的;例如,它们被用来处理操作系统中的独立活动。在没有并行硬件的情况下,操作系统将通过多任务或时间切片来处理线程:每个线程将定期使用CPU的一小部分时间。(从技术上讲,Linux内核通过任务的概念来处理进程和线程;任务被保存在一个列表中,并定期被激活或取消)

这可以导致更高的处理器利用率,因为一个线程的指令可以在另一个线程等待数据时被处理。(在传统的CPU上,线程之间的切换是有些耗费精力的(超线程机制是个例外),但在GPU上则不然,事实上,它们需要许多线程才能达到高性能)。

在现代多核处理器上,有一种明显的支持线程的方法:每个核有一个线程,可以有效地使用硬件的并行执行。共享内存允许线程看到相同的数据,但这也会导致问题。

线程实例

下面的例子,严格来说是在Unix上运行,在Windows上是行不通的,它清楚地说明了fork-join的模型。它使用pthreads库来生成一些任务,这些任务都会更新一个全局计数器。由于线程共享相同的内存空间,它们确实看到并更新相同的内存位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdlib.h>
#include <stdio.h>
#include "pthread.h"

int sum=0;

void adder() {
sum = sum+1;
return;
}

#define NTHREADS 50
int main() {
int i;
pthread_t threads[NTHREADS];
printf("forking\n");
for (i=0; i<NTHREADS; i++)
if (pthread_create(threads+i,NULL,&adder,NULL)!=0) return i+1;
printf("joining\n");
for (i=0; i<NTHREADS; i++)
if (pthread_join(threads[i],NULL)!=0) return NTHREADS+i+1;
printf("Sum computed: %d\n",sum);
return 0; }

事实上,这段代码给出了正确的结果,但这是一个巧合:它之所以发生,只是因为更新变量比创建线程要快得多。(在多核处理器上,出错的机会将大大增加)。如果人为地增加更新的时间,我们将不再得到正确的结果。

1
2
3
4
void adder() {
int t = sum; sleep(1); sum = t+1;
return;
}

现在所有的线程都读出了sum的值,等待一段时间(估计是在计算什么),然后再更新。
这可以通过在应该是 “互斥 “的代码区域上设置一个锁来解决。

1
2
3
4
5
6
7
8
9
10
11
pthread_mutex_t lock;
void adder() {
int t;
pthread_mutex_lock(&lock);
t = sum; sleep(1); sum = t+1;
pthread_mutex_unlock(&lock);
return;
}
int main() {
....
pthread_mutex_init(&lock,NULL);

锁定和解锁命令保证了没有两个线程可以干扰对方的更新。关于pthreads的更多信息,请参见例如https://computing.llnl.gov/tutorials/pthreads。

上下文

在上面的例子和它的sleep命令版本中,我们忽略了一个事实,即有两种类型的数据参与其中。首先,变量s是在线程生成部分之外创建的。因此,这个变量是「共享」(shared)的。另一方面,变量t是在每个生成的线程中创建一次的。我们称其为「私有」(private)数据。

一个线程可以访问的所有数据的总和被称为其「上下文」(context)。它包含了私有和共享数据,以及线程正在进行的计算的临时结果。(还包含程序计数器和堆栈指针。如果现在不知道这些是什么,请不用担心)

创建的线程比处理器的内核多是很有可能的,所以处理器可能需要在不同线程的执行之间进行切换。这就是所谓的「上下文切换」(context switch)。

普通的CPU进行上下文切换会造成时间开销,所以只有在线程工作的粒度足够高时,我们才会这样执行。下面几种情况则较为常见

  • 有硬件支持多线程的CPU,通过「超线程」(hyperthreading)或Intel Xeon Phi来实现
  • GPU,它实际上依赖于快速上下文切换。
  • 某些其他 “奇特 “的架构,如Cray XMT。

竞争条件、线程安全和原子操作

共享内存使程序员的工作变得简单,因为每个处理器都可以访问所有的数据:处理器之间不需要明确的数据通信。另一方面,多个进程/处理器也可以写到同一个变量,这是潜在问题的来源。

假设两个进程都试图递增一个整数变量I。

进程1:I=I+2

进程2:I=I+3

如果该变量是一个由独立进程计算的累加,这是一个合法的活动。这两个更新的结果取决于处理器读取和写入变量的顺序。

Three executions of a data race scenario

图2.12说明了三种情况。这种情况下,最终结果取决于哪个线程先执行,被称为「竞争条件」(race condition)或「数据竞争」(data race)。一个正式的定义是:如果有两个语句$S_1$,$S_2$,数据竞争为

  • 两个语句之间不存在因果关系
  • 都是访问一个位置$L$;并且
  • 至少有一个访问是写操作。

这种冲突性更新的一个非常实际的例子是内积计算。

1
2
for (i=0; i<1000; i++)
sum = sum+a[i]*b[i];

这里的乘积是真正独立的,所以我们可以选择让循环迭代并行进行,例如由它们自己的线程进行。然而,所有的线程都需要更新同一个变量的总和。

无论是串行执行还是线程执行,代码的行为都是一样的,这叫做「线程安全」(thread safe)。从上面的例子可以看出,缺乏线程安全通常是由于对共享数据的处理。这意味着程序越是使用本地数据,它是线程安全的机会就越大。不幸的是,有时线程需要写到共享/全局数据,例如,当程序进行「规约」(reduction)时。

解决这个问题的方法基本上有两种。一种是,我们将共享变量的这种更新宣布为代码的「临界区」(critical section)。这意味着临界区的指令(在内积的例子中,”从内存中读取和,更新,写回内存”)一次只能由一个线程来执行。特别是,它们需要完全由一个线程执行,然后其他线程才能启动它们,所以上面的模糊问题不会出现。当然,上述代码片段非常常见,以至于像OpenMP这样的系统有专门的机制来处理它,把它声明为一个减少操作。

例如,临界区可以通过信号机制[47]来实现。在每个临界区的周围,会有两个原子操作控制着一个信号灯,即一个信号柱。第一个遇到信号灯的进程将降低信号灯,并开始执行临界区。其他进程看到已经降低的信号灯,并等待。当第一个进程完成临界区时,它执行第二条指令,提高信号灯,允许其中一个等待的进程进入临界区。

解决共享数据的共同访问的另一种方法是在某些内存区域设置一个临时「」(lock)。如果对临界区的共同执行是可能的,例如,如果它实现了对数据库或哈希表的写入,那么这种解决方案可能是比较好的。在这种情况下,一个进程进入临界区将阻止任何其他进程写入数据,即使他们可能是写入不同的位置;那么锁定被访问的特定数据项是一个更好的解决方案。

锁的问题是,它们通常存在于操作系统层面。这意味着它们的速度相对较慢。由于我们希望上述内积循环的迭代能以浮点单元的速度执行,或者至少以内存总线的速度执行,所以这是不可接受的。

这方面的一个实现是「事务内存」(transactional memory),硬件本身支持原子操作;这个术语来自于数据库事务,它有一个类似的完整性问题。在交易型内存中,一个进程将执行正常的内存更新,除非处理器检测到与另一个进程的更新有冲突。在这种情况下,更新(”事务”)被取消并重新尝试,一个处理器锁定内存,另一个处理器等待锁定。这是一个优雅的解决方案;然而,取消事务可能会带来一定的「流水线冲洗」(pipeline flushing)和缓存线失效的代价。

内存模型和串行一致性

上面提到的竞争条件现象意味着一些程序的结果可能是非确定性的,这取决于指令的执行顺序。还有一个因素在起作用,它被称为处理器和/或语言使用的「内存模型」(memory model)[2]。内存模型控制一个线程或内核的活动如何被其他线程或内核看到。

例如,考虑

初始:A=B=0;,然后

进程1:A=1;x=B。

进程2:B=1;y=A。

如上所述,我们有三种情况,我们通过给出一个全局性的语句序列来描述这些情况。

场景 1. 场景 2. 场景 3.
$A\leftarrow 1$ $A\leftarrow 1$ $B\leftarrow 1$
$x\leftarrow B$ $B\leftarrow 1$ $y\leftarrow A$
$B\leftarrow 1$ $x\leftarrow B$ $A\leftarrow 1$
$y\leftarrow A$ $y\leftarrow A$ $x\leftarrow B$
$x=0, y=1$ $x=1, y=1$ $x=1, y=0$

(在第二种情况下,语句1,2和3,4都可以颠倒过来,但结果不会改变。)

这三种不同的结果可以被描述为是由尊重局部排序的状态要素的全局排序来计算的。这被称为「串行一致性」(sequential consistency):并行的结果与顺序执行是一致的,该顺序执行将并行计算交错进行,尊重它们的本地语句排序。

保持串行一致性的代价是很昂贵的:它意味着对一个变量的任何改变都需要立即在所有其他线程上可见,或者对一个线程上的变量的任何访问都需要咨询所有其他线程。

在一个「松弛内存模型」(relaxed memory model)中,有可能会得到一个不符合顺序的结果。假设在上面的例子中,编译器决定对两个进程的语句重新排序,因为读写是独立的。实际上,我们得到了第四种情况。

场景 4.
$x\leftarrow B$
$y\leftarrow A$
$A\leftarrow 1$
$B\leftarrow 1$
$x=0, y=0$

导致结果$𝑥=0$,$𝑦=0$,这在上面的串行一致模型下是不可能的。(有寻找这种依赖关系的算法[127])。串行一致意味着

1
2
3
4
integet n
n=0
!$omp parallel shared(n) n=n+1
!$omp end parallel

效果应该与下述相同

1
2
3
4
n=0
n = n+1 ! for processor 0
n = n+1 ! for processor 1
! et cetera

有了串行一致性,就不再需要声明原子操作或临界区;然而,这对模型的实现提出了强烈的要求,所以可能导致代码的低效。

亲和性

线程编程非常灵活,可以根据需要有效地创建并行性。然而,本书的很大一部分内容是关于科学计算中数据移动的重要性,在线程编程中不能忽视这一方面。

在多核处理器的背景下,任何线程都可以被安排到任何核上,这没有什么直接的问题。然而,如果你关心的是高性能,这种灵活性会带来意想不到的代价。你想让某些线程只在某些核心上运行,有各种原因。由于操作系统允许迁移线程,可能你只是想让线程留在原地。

  • 如果一个线程迁移到不同的核心,而该核心有自己的缓存,你就会失去原来的缓存内容,不必要的内存转移就会发生。
  • 如果一个线程迁移了,没有什么可以阻止操作系统把两个线程放在一个核心上,而让另一个核心完全不使用。这显然导致了不太完美的速度提升,即使线程的数量等于核心的数量。

我们称亲和性为「线程亲和性」(thread affinity)或「进程亲和性」(process affinity)与核心之间的映射。亲和性通常表示为一个掩码:对允许一个线程运行的位置的描述。例如,考虑一个双插槽的节点,每个插槽有四个核心。有了两个线程和插槽的亲和力,我们就有了以下的「关联掩码」(affinity mask)。

thread socket 0 socket 1
0 0-1-2-3
1 4-5-6-7

对于核心亲和性,面具取决于亲和力类型。典型的策略是 “接近 “和 “扩散”。在亲和关系密切的情况下,掩码可以是

thread socket 0 socket 1
0 0
1 1

在同一个插槽上有两个线程意味着它们可能共享一个二级缓存,所以如果它们共享数据,这种策略是合适的。

另一方面,随着「亲和性扩散」(spread affinity),线程被进一步分开。

thread socket 0 socket 1
0 0
1 4

这种策略对于带宽受限的应用来说更好,因为现在每个线程都拥有一个插槽的带宽,而不是在 “关闭 “的情况下不得不分享它。

如果分配了所有的内核,关闭和分散策略会导致不同的安排。

socket 0 socket 1
0-1-2-3
4-5-6-7

相对于

socket 0 socket 1
0-2-4-6
1-3-5-7

亲和性也可以被认为是一种将执行与数据绑定的策略。

考虑一下这段代码:

1
2
3
4
for (i=0; i<ndata; i++) // this loop will be done by threads
x[i] = ....
for (i=0; i<ndata; i++) // as will this one
... = .... x[i] ...

第一个循环,通过访问𝑥的元素,将内存带入高速缓存或页表。第二个循环以同样的顺序访问元素,所以为了性能,固定的亲和性是正确的决定。

在其他情况下,固定的映射不是正确的解决方案。

1
2
3
4
5
6
for (i=0; i<ndata; i++) // produces loop
x[i] = ....
for (i=0; i<ndata; i+=2) // use even indices
... = ... x[i] ...
for (i=1; i<ndata; i+=2) // use odd indices
... = ... x[i] ...

在这第二个例子中,要么程序必须被改造,要么程序员必须实际维护一个任务队列。

  • 第一次接触:从 “把执行放在数据所在的地方 “的角度来考虑亲和性是很自然的。然而,在实践中,相反的观点有时是有意义的。例如,图2.8显示了一个集群节点的共享内存实际上是如何分布的。因此,一个线程可以连接到一个插槽,但数据可以由操作系统分配到任何一个插槽上。操作系统经常使用的机制被称为first-touch策略。
  • 当程序分配数据时,操作系统实际上并不创建数据。
  • 相反,数据的内存区域是在线程第一次访问它时创建的。
  • 因此,第一个接触该区域的线程实际上导致数据被分配到其插槽的内存中。

练习 2.19 用下面的代码解释一下这个问题。

1
2
3
4
5
6
// serial initialization
for (i=0; i<N; i++)
a[i] = 0.;
#pragma omp parallel for
for (i=0; i<N; i++)
a[i] = b[i] + c[i];

关于内存策略的深入讨论,见[134]。

Cilk Plus

还有其他基于线程的编程模型存在。例如,英特尔Cilk Plus(http://www.cilkplus.org/)是一套C/C++的扩展,程序员可以用它创建线程。

1
2
3
4
5
6
7
8
9
10
//串行代码
int fib(int n){
if (n<2) return 1;
else {
int rst=0;
rst += fib(n-1);
rst += fib(n-2);
return rst;
}
}
1
2
3
4
5
6
7
8
9
10
11
//Clik 代码
cilk int fib(int n){
if(n<2) return 1;
else{
int rst = 0;
rst += cilk_spawn fib (n-1);
rst += cilk+spawn fib(n-2);
cilk_sync;
return rst;
}
}

在这个例子中,变量rst被两个可能独立的线程更新。这种更新的语义,也就是如何解决同时写入等冲突的精确定义,是由串行一致性定义的;见2.6.1.6节。

超线程与多线程的比较

在上面的例子中,你看到在一个程序运行过程中产生的线程基本上都是执行相同的代码,并且可以访问相同的数据。因此,在硬件层面上,一个线程是由少量的局部变量唯一决定的,比如它在代码中的位置(程序计数器)和它所参与的当前计算的中间结果。

超线程是英特尔的一项技术,让多个线程真正同时使用处理器,这样处理器的一部分将得到最佳利用。

如果一个处理器在执行一个线程和另一个线程之间切换,它将保存一个线程的本地信息,并加载另一个线程的信息。与运行整个程序相比这样做的成本并不高,但与单条指令的成本相比可能很昂贵。因此,超线程不一定能带来性能的提高。

某些架构有对多线程的支持。这意味着硬件实际上对多个线程的本地信息有明确的存储,而且线程之间的切换可以非常快。GPU和英特尔Xeon Phi架构就是这种情况,每个内核可以支持多达四个线程。

OpenMP

OpenMP是对编程语言C和Fortran的一个扩展。它的主要并行方法是循环的并行执行:基于「编译器指令」(compiler directives),预处理器可以安排循环迭代的并行执行。

由于OpenMP是基于线程的,它的特点是「动态并行」(dynamic parallelism):在代码的一个部分和另一个部分之间,并行运行的执行流的数量可以变化。并行性是通过创建并行区域来声明的,例如表明一个循环嵌套的所有迭代都是独立的,然后运行时系统将使用任何可用的资源。

OpenMP不是一种语言,而是对现有的C和Fortran语言的一种扩展。它主要通过在源代码中插入指令来操作,由编译器进行解释。与MPI不同,它也有少量的库调用,但这些不是重点。最后,还有一个运行时系统来管理并行的执行。

与MPI相比,OpenMP的一个重要优势在于它的可编程性:可以从一个串行代码开始,通过「增量并行化」(incremental parallelization)来改造它。相比之下,将串行代码转化为分布式内存MPI程序是一个全有或全无的事情。

许多编译器,如gcc或Intel编译器,支持OpenMP扩展。在Fortran中,OpenMP指令被放在注释语句中;在C中,它们被放在#pragma CPP指令中,用来表示编译器特定的扩展。因此,对于不支持OpenMP的编译器来说,OpenMP代码看起来仍然像合法的C或Fortran语句。程序需要链接到OpenMP运行库,其行为可以通过环境变量来控制。
关于OpenMP的更多信息,见[31]和http://openmp.org/wp/。

OpenMP示例

OpenMP使用的最简单的例子是并行循环。

1
2
3
4
#pragma omp parallel for
for (i=0; i<ProblemSize; i++) {
a[i] = b[i];
}

很明显,所有的迭代都可以独立执行,并且以任何顺序执行。然后,pragma CPP指令将这个事实传达给编译器。

有些循环在概念上是完全并行的,但在实现上不是。

1
2
3
4
for (i=0; i<ProblemSize; i++) { 
t = b[i]*b[i];
a[i] = sin(t) + cos(t);
}

这里看起来好像每个迭代都在向一个共享变量t写和读。然而,t实际上是一个临时变量,是每个迭代的局部。应该是可并行的代码,但由于这样的结构而不能并行,这被称为非线程安全。

OpenMP指出,临时变量对每个迭代都是私有的,如下所示。

1
2
3
4
5
#pragma omp parallel for shared(a,b), private(t) 
for (i=0; i<ProblemSize; i++) {
t = b[i]*b[i];
a[i] = sin(t) + cos(t);
}

如果一个标量确实是共享的,OpenMP有各种机制来处理这个问题。例如,共享变量通常出现在规约操作中。

1
2
3
4
5
s = 0;
#pragma omp parallel for reduction(+:sum)
for (i=0; i<ProblemSize; i++) {
s = s + a[i]*b[i];
}

正如上面所看到的,串行代码可以较为轻易地并行化。

迭代到线程的分配是由运行时系统完成的,但用户可以指导这种分配。我们主要关注迭代次数多于线程的情况:如果有$P$个线程和$N$个迭代,并且$N > P$,如何将迭代$i$分配给线程?

最简单的分配是使用「Round-robin任务调度」(round-robin task scheduling, a static scheduling),这是一种静态的调度策略,线程$p$获得迭代$p\times (N/P), …, (p + 1) \times (N/P) - 1$。这样做的好处是,如果一些数据在迭代之间被重复使用,它将留在执行该线程的处理器的数据缓存中。另一方面,如果迭代涉及的工作量不同,进程可能会遭受静态调度的负载不均衡。在这种情况下,动态调度策略的效果会更好,每个线程在完成当前迭代后就开始对下一个未处理的迭代进行工作。

我们可以用schedule关键字来控制OpenMP对循环迭代的调度,它的值包括静态和动态。也可以指出一个chunksize,它可以控制一起分配给线程的迭代块的大小。如果省略了chunksize,OpenMP将把迭代分成和线程数量一样多的块。

练习2.20 假设有$t$个线程,代码为

1
2
3
for (i=0; i<N; i++) {
a[i] = // 执行部分计算
}

如果指定chunksize为1,那么迭代0、𝑡、2𝑡……进入第一个线程,1、1+𝑡、1+2𝑡……进入第二个线程,依此类推。讨论一下为什么从性能的角度看这是一个糟糕的策略。提示:查一下「伪共享」(false sharing)的定义。什么是一个好的chunksize?

通过消息传递的分布式内存编程

虽然OpenMP程序和使用其他共享内存范式编写的程序看起来仍然非常像串行程序,但对于消息传递代码来说,情况并非如此。在我们详细讨论消息传递接口(MPI)库之前,我们先来看看并行代码编写方式的这种转变。

分布式编程中的全局视野与局部视野

在观察者看来,一个并行算法与它的实际编程方式之间可能存在明显的差异。考虑这样的情况:我们有一个处理器$\{P_i\}_{i=0…p-1}$的数组,每个处理器包含数组𝑥和𝑦中的一个元素,并且$P_i$计算

这方面的全局描述可以是

  • 每个处理器$𝑃_𝑖$(最后一个除外)都将其$𝑃_𝑖$元素发送给$𝑃_{𝑖+1}$。

  • 除了第一个之外,每个$𝑃_𝑖$处理器都从他们的邻居$𝑃_{𝑖-1}$那里收到一个$𝑥$元素,并且

  • 他们将其添加到自己的$𝑦$元素中。

然而,在一般情况下,我们不能用这些全局术语来编码。在SPMD模型中,每个处理器执行相同的代码,而整体算法是这些单独行为的结果。本地程序只能访问本地数据—其他一切都需要用发送和接收操作来沟通—而且处理器知道自己的编号。

一种可能的写法是

  • 如果是第0个处理器,什么都不做;否则从左边接收一个元素,增加一个𝑥元素。
  • 如果是最后一个处理器,什么都不做。否则,将我的𝑦元素发送到右边。

首先,我们看一下发送和接收是所谓的「阻塞通信」(blocking communication)的情况:发送指令在实际收到发送的项目之前不会结束,而接收指令则等待相应的发送。这意味着处理器之间的发送和接收必须被仔细配对。现在我们将看到,这可能导致在通往高效代码的路上出现各种问题。

图2.13展示了上述解决方案,我们展示了描述本地处理器代码的局部时间线,以及由此产生的全局行为。你可以看到,处理器不是在同一时间工作的:我们得到的是序列化的执行。

如果我们把发送和接收操作倒过来呢?

  • 如果不是最后一个处理器,就把我的𝑥元素发送到右边。
    • 如果不是第一个处理器,从左边接收一个𝑥元素,并将其添加到𝑦元素中。

向右边发送数据的算法的局部和结果的全局视野:

wave_right_1

向右边发送数据的算法的局部和结果的全局视野:

wave_right_2

向右边发送数据的算法的局部和结果的全局视图:

wave_right_3

图2.14说明了这一点,你可以看到我们再次得到一个序列化的执行,只不过现在处理器是从右到左激活的。

如果方程2.5中的算法是循环的:

问题会更加严重。现在,最后一个处理器无法开始接收,因为它被阻止向0号处理器发送𝑥𝑛-1。这种情况下,程序无法进展,因为每个处理器都在等待另一个处理器,这被称为「死锁」(deadlock)。

获得高效代码的解决方案是使尽可能多的通信同时发生。毕竟,在算法中没有串行的依赖性。因此,我们对算法的编程如下

  • 奇数处理器,先发后收。
  • 偶数处理器,先收后发。

图2.15说明了这一点,我们看到现在的执行是并行的。

练习 2.21 再看一下图2.3中的并行规约。其基本动作是 - 接收来自邻居的数据

  • 将其添加到自己的数据中
  • 将结果发送出去。

正如在图中看到的,至少有一个处理器不发送数据,其他的处理器在发送结果之前可能会做不同次数的接收。编写节点代码,使SPMD程序实现分布式规约。提示:用二进制写每个处理器的编号。该算法使用的步骤数等于该位串的长度。

  • 假设一个处理器收到一条消息,用步数表示到该消息的原点的距离。
  • 每个处理器最多发送一条消息。用二进制处理器编号来表示发生这种情况的步骤。

阻塞和非阻塞通信

阻断指令的原因是为了防止网络中的数据积累。如果一条发送指令在相应的接收指令开始之前完成,网络将不得不在这段时间内将数据储存在某个地方。考虑一个简单的例子:

1
2
3
4
buffer = ... ;  // 生成一些数据
send(buffer,0); // 发送给 0 处理器
buffer = ... ; // 生成更多数据
send(buffer,1); // 发送给 1 处理器

在第一次发送后,我们开始覆盖缓冲区。如果其中的数据还没有被收到,那么第一组数值就必须在网络的某个地方被缓冲,这是不现实的。通过发送操作的阻断,数据会一直留在发送方的缓冲区中,直到它被保证复制到接收方的缓冲区。

解决由阻塞指令引起的顺序化或死锁问题的一个方法是使用「非阻塞通信」(non-blocking communication)指令,其中包括明确的数据缓冲区。使用非阻塞式发送指令,用户需要为每次发送分配一个缓冲区,并检查何时可以安全地覆盖缓冲区。

1
2
3
4
5
6
buffer0 = ... ;   // data for processor 0
send(buffer0,0); // send to processor 0
buffer1 = ... ; // data for processor 1
send(buffer1,1); // send to processor 1
...
// wait for completion of all send operations.

MPI库

如果说OpenMP是对共享内存进行编程的方式,那么消息传递接口(MPI)[184]则是对分布式内存进行编程的标准解决方案。MPI(’Message Passing Interface’)是一个库接口的规范,用于在不共享数据的进程之间移动数据。MPI例程可以大致分为以下几类。

  • 进程管理。这包括查询并行环境和构建处理器的子集。
  • 点对点通信。这是一组调用,其中两个进程进行交互。这些大多是发送和接收调用的变种。
  • 集体调用。在这些程序中,所有的处理器(或整个指定的子集)都参与其中。例如,「广播」(broadcast)调用,一个处理器与其他所有处理器分享它的数据,或者收集调用,一个处理器从所有参与的处理器收集数据。

让我们考虑如何在MPI4中对OpenMP的例子进行编码。首先,我们不再分配

1
double a[ProblemSize];

而是分配

1
double a[LocalProblemSize];

其中,局部尺寸大约是全局尺寸的$1/P$部分。(实际的考虑决定了是让这个分布尽可能的均匀,还是在某种程度上有偏向)

并行循环是琐碎的并行,唯一的区别是它现在只对一部分数组进行操作。

1
2
3
for (i=0; i<LocalProblemSize; i++) {
a[i] = b[i];
}

然而,如果循环涉及基于迭代数的计算,我们需要将其映射到全局值。

1
2
3
for (i=0; i<LocalProblemSize; i++) {
a[i] = b[i]+f(i+MyFirstVariable);
}

(我们将假设每个进程都以某种方式计算了LocalProblemSize和MyFirstVariable的值)。 本地变量现在自动成为本地变量,因为每个进程都有自己的实例。

1
2
3
4
for (i=0; i<LocalProblemSize; i++) {
t = b[i]*b[i];
a[i] = sin(t) + cos(t);
}

然而,共享变量更难实现。由于每个进程都有自己的数据,因此必须明确地组装本地计算。

1
2
3
4
for (i=0; i<LocalProblemSize; i++) {
s = s + a[i]*b[i];
}
MPI_Allreduce(s,globals,1,MPI_DOUBLE,MPI_SUM);

“规约”操作将所有的本地值s汇总到一个变量globals中,该变量在每个处理器上都收到一个相同的值。这就是所谓的「集合操作」(collective operation)。

让我们把这个例子变得稍微复杂些

1
2
3
4
5
6
7
8
for (i=0; i<ProblemSize; i++) {
if (i==0)
a[i] = (b[i]+b[i+1])/2
else if (i==ProblemSize-1)
a[i] = (b[i]+b[i-1])/2
else
a[i] = (b[i]+b[i-1]+b[i+1])/3
}

如果有共享内存,我们可以写出以下的并行代码。

1
2
3
4
for (i=0; i<LocalProblemSize; i++) {
bleft = b[i-1]; bright = b[i+1];
a[i] = (b[i]+bleft+bright)/3
}

为了将其转化为有效的分布式内存代码,首先我们要说明,对于i==0 (bleft)i==LocalProblemSize-1 (bright),bleft和bright需要从不同的处理器获得。我们通过与我们的左邻右舍处理器进行交换操作来做到这一点。

1
2
3
4
5
6
7
8
// get bfromleft and bfromright from neighbor processors, then
for (i=0; i<LocalProblemSize; i++) {
if (i==0) bleft=bfromleft;
else bleft = b[i-1]
if (i==LocalProblemSize-1) bright=bfromright;
else bright = b[i+1];
a[i] = (b[i]+bleft+bright)/3
}

获得邻居值的方法如下。首先,我们需要询问我们的处理器编号,这样我们就可以与编号高一低一的处理器开始通信。

1
2
3
4
5
6
7
8
9
10
MPI_Comm_rank(MPI_COMM_WORLD,&myTaskID);
MPI_Sendrecv
(/* to be sent: */ &b[LocalProblemSize-1],
/* destination */ myTaskID+1,
/* to be recvd: */ &bfromleft,
/* source: */ myTaskID-1,
/* some parameters omitted */
);
MPI_Sendrecv(&b[0],myTaskID-1,
&bfromright, /* ... */ );

这段代码仍有两个问题。首先,sendrecv操作需要对第一个和最后一个处理器进行异常处理。这可以通过以下方式优雅地完成。

1
2
3
4
5
6
7
8
MPI_Comm_rank(MPI_COMM_WORLD,&myTaskID);
MPI_Comm_size(MPI_COMM_WORLD,&nTasks);
if (myTaskID==0) leftproc = MPI_PROC_NULL;
else leftproc = myTaskID-1;
if (myTaskID==nTasks-1) rightproc = MPI_PROC_NULL;
else rightproc = myTaskID+1;
MPI_Sendrecv( &b[LocalProblemSize-1], &bfromleft, rightproc );
MPI_Sendrecv( &b[0], &bfromright, leftproc);

练习 2.22 这段代码还存在一个问题:没有考虑到原版、全局的边界条件。请给出解决这个问题的代码。

如果不同的进程需要采取不同的行动,例如,如果一个进程需要向另一个进程发送数据,MPI就会变得复杂。这里的问题是每个进程执行的是同一个可执行文件,所以它需要包含发送和接收指令,根据进程的等级来执行。

1
2
3
4
5
6
7
if (myTaskID==0) {
MPI_Send(myInfo,1,MPI_INT,/* to: */ 1,/* labeled: */,0,
MPI_COMM_WORLD);
} else {
MPI_Recv(myInfo,1,MPI_INT,/* from: */ 0,/* labeled: */,0,
/* not explained here: */&status,MPI_COMM_WORLD);
}

阻塞

尽管MPI有时被称为 “并行编程的汇编语言”,因为它被认为是困难的和明确的,但它并不是那么难学,大量使用它的科学代码就证明了这一点。使MPI使用起来有些复杂的主要问题是缓冲区管理和阻塞语义。

这些问题是相关的,源于这样一个事实:理想情况下,数据不应该同时出现在两个地方。让我们简单考虑一下如果处理器1向处理器2发送数据会发生什么。最安全的策略是处理器1执行发送指令,然后等待处理器2确认数据被成功接收。这意味着处理器1被暂时阻断,直到处理器2实际执行其接收指令,并且数据已经通过网络。这是MPI_Send和MPI_Recv调用的标准行为,据说是使用「阻塞通信」(blocking communication)。

另外,处理器1可以把它的数据放在一个缓冲区里,告诉系统确保它在某个时间点被发送出去,然后再检查缓冲区是否可以重新使用。这第二种策略被称为「非阻塞通信」(non-blocking communication),它需要使用一个临时缓冲区。

集合操作

在上面的例子中,你看到了MPI_Allreduce调用,它计算了一个全局和,并将结果留在每个处理器上。还有一个本地版本MPI_Reduce,它只在一个处理器上计算结果。这些调用是集体操作或集合体的例子。集合运算有

  • 规约」(reduction) : 每个处理器都有一个数据项,这些数据项需要用加法、乘法、最大或最小操作进行算术组合。其结果可以留在一个处理器上,也可以留在所有处理器上,在这种情况下,我们称之为allreduce操作。
  • 广播」(broadcast):一个处理器有一个数据项,所有处理器都需要接收。
  • 收集」(gather):每个处理器都有一个数据项,这些数据项需要被收集到一个数组中,而不需要通过加法等操作将其合并。其结果可以留在一个处理器上,也可以留在所有处理器上,在这种情况下,我们称其为allgather。
  • 散发」(scatter):一个处理器有一个数据项的数组,每个处理器接收该数组的一个片段。
  • 全局」(all-to-all):每个处理器都有一个项目数组,将被分散到所有其他处理器。

集合操作是阻塞的,尽管MPI 3.0(目前只是一个草案)将有非阻塞的集合操作。我们将在第6.1节详细分析集体操作的成本。

非阻塞通信

传统的计算机程序中,指令执行的方式取决于处理器中正在进行的操作,而在并行程序中,情况则较为复杂。一个简单发送操作,例如发送某个缓冲区的数据会导致程序执行停止,直至该缓冲区被另一个处理器安全发送和接收时结束。这种操作被称为「非本地操作」(non-local operation ),因为它依赖于其他进程的行动;这也被称为「阻塞通信」(blocking communication)操作,因为执行将停止以等待某个事件的发生。

阻塞操作的缺点是它们可能导致死锁。在消息传递的上下文中表现为:一个进程正在等待一个从未发生的事件;例如,它可能正在等待接收一个消息,而该消息的发送者正在等待其他事情。如果两个进程互相等待,或者更普遍的情况是,如果你有一个进程的循环,每个进程都在等待循环中的下一个进程,就会发生死锁。例如

1
2
3
4
if ( /* 为处理器 0 */ )
// 等待来自处理器 1 的消息
else if ( /* 为处理器 1 */ )
// 等待来自处理器 0 的消息

这里的块接收会导致死锁。即使没有死锁,处理器在等待时并没有执行任何操作,也会使其产生大量闲置时间。其优点是可以明确缓冲区何时可以被重用:在操作完成后,可以保证数据在另一端被安全地接收。

可以通过使用非阻塞通信操作来避免阻塞行为,但代价是使缓冲区语义复杂化。一个非阻塞的发送(MPI_Isend)声明需要发送一个数据缓冲区,但随后并不等待相应的接收完成。有第二个操作MPI_Wait,它实际上会阻塞,直到接收完成。这种发送和阻塞的解耦的好处是,现在有可能进行写入。

1
2
3
4
5
MPI_ISend(somebuffer,&handle); // 开始发送,且
// 掌握这个特殊的通信
{ ... } // 做一些对本地数据做有用的工作
MPI_Wait(handle); // 锁住直至通信完成
{ ... } // 做一些对输入的数据进行有用的工作

运气好的话,本地操作所花的时间比通信的时间多,这样就完全消除了通信时间。

除了非阻塞的发送,还有非阻塞的接收。一个典型的例子如下:

1
2
3
4
5
MPI_ISend(sendbuffer,&sendhandle);
MPI_IReceive(recvbuffer,&recvhandle);
{ ... } // 做一些对本地数据有用的工作
MPI_Wait(sendhandle); Wait(recvhandle);
{ ... } // 做一些对输入的数据进行有用的工作

练习 2.23 再看一下方程(2.6),给出使用非阻塞发送和接收解决问题的伪代码。与阻塞式解决方案相比,这个代码的缺点是什么?

三种版本的MPI对比

第一个MPI标准[164]有一些明显的遗漏,这些遗漏包括在MPI 2标准[91]中。其中之一是关于并行输入/输出:没有为多个进程访问同一个文件提供设施,即使底层硬件允许这样做。一个单独的项目MPI-I/O现在已经被纳入MPI-2标准。我们将在本书中讨论并行I/O。

MPI中缺少的第二个设施是进程管理,尽管它在MPI之前的PVM[50, 73]中就已经存在了:没有办法创建新的进程并让它们成为并行运行的一部分。最后,MPI-2支持单边通信:一个进程将数据放入另一个进程的内存中,而接收进程不做实际接收指令。我们将在下面的2.6.3.8节进行简短的讨论。

在MPI-3中,该标准获得了一些新的特性,如非阻塞集合体、邻接集合体和剖析接口。单边机制也得到了更新。

单边通信

MPI编写匹配发送和接收指令的方式并不理想。首先,它要求程序员两次给出相同的数据描述,一次发送,一次接收调用。其次,如果要避免死锁,它需要对通信进行相当精确的协调;如果使用异步调用的替代方法,程序将会十分繁琐,且需要程序管理大量的缓冲区。最后,它要求接收处理器知道要等待多少个传入的消息,这在不规则的应用中可能很棘手。如果有可能从另一个处理器中提取数据,或者反过来把数据放在另一个处理器上,而不需要另一个处理器明确参与,过程就会轻松很多。

一些硬件上存在的远程直接内存访问(RDMA)支持进一步鼓励了这种编程风格。一个早期的例子是Cray T3E。如今,通过在MPI-2库中的整合,单边通信被广泛使用;2.6.3.7节。

让我们简单看一下MPI-2中的单边通信,以数组值的平均化为例:

MPI并行代码为

1
2
// 做一些转换
a_local = (a_local+left+right)/3

转换要完成的任务很清楚:a_local变量需要在等级较高的处理器上成为左边的变量,而在等级较低的处理器上成为右边的变量。

首先,处理器需要明确声明哪些内存区域可用于单边传输,即所谓的 “窗口”。在这个例子中,这包括处理器上的a_local、左边和右边变量。

1
2
3
MPI_Win_create(&a_local,...,&data_window);
MPI_Win_create(&left,....,&left_window);
MPI_Win_create(&right,....,&right_window);

该代码现在有两个选择:可以将数据推送出去

1
2
3
4
target = my_tid-1;
MPI_Put(&a_local,...,target,right_window);
target = my_tid+1;
MPI_Put(&a_local,...,target,left_window);

或将其拉入

1
2
3
4
5
data_window = a_local;
source = my_tid-1;
MPI_Get(&right,...,data_window);
source = my_tid+1;
MPI_Get(&left,...,data_window);

如果Put和Get调用是阻塞的,上述代码将具有正确的语义;见2.6.3.4节。然而,单边通信的部分吸引力在于它使通信的表达更加容易,为此,我们假设了一个非阻塞语义。

非阻塞的单边调用的问题是,有必要明确地确保通信成功完成。例如,如果一个处理器在另一个处理器上做了一个单边的put操作,另一个处理器就没有办法检查数据是否已经到达,或者是否已经开始传输。因此,有必要在程序中插入一个全局屏障,每个包都有自己的实现。在MPI-2中,相关调用是MPI_Win_fence例程。这些屏障实际上是将程序的执行分为超骤;见2.6.8节。

另一种形式的单边通信在Charm++包中使用;见2.6.7节。

混合共享/分布式内存计算

现代架构通常是共享和分布式内存的混合体。例如,一个集群在节点层面上是分布式的,但节点上的插槽和内核为共享内存。再往上一层,每个插槽可以有一个共享的L3缓存,但有独立的L2和L1缓存。直观地说,共享和分布式编程技术的混合似乎很清楚,可以提供与架构最匹配的代码。在这一节中,我们将讨论这种混合编程模型,并讨论其功效。

一个常见的集群设置使用分布式内存节点,每个节点包含几个彼此之间共享内存的插槽。这建议使用MPI在节点之间进行通信(节点间通信),使用OpenMP在节点上进行并行化(节点内通信)。在实践中,这实现了以下几点

  • 在每个节点上启动一个MPI进程(而不是每个核心一个)。
  • 这一个MPI进程然后使用OpenMP(或其他线程协议)来产生尽可能多的线程,这些线程在节点上有独立的套接字或核心。
  • 然后,OpenMP线程可以访问节点的共享内存。

另一种方法是在每个核或插槽上有一个MPI进程,通过消息传递进行通信,甚至可以看到进程之相同的共享内存。

注释 9:由于亲和性的原因,我们希望每个插槽启动一个MPI进程,而不是每个节点。这并没有实质性地改变上述论点。

这种混合策略听起来是个好主意,但事实上却很复杂。

尽管MPI进程之间消息传递看起来比共享内存的通信开销更大,但当MPI的优化版本检测进程在同一个节点时,就会采取时间开销更小的数据拷贝以代替通信。不使用MPI的唯一理由是:每个进程都有自己的数据空间,这会因为每个进程都要为缓冲区和被复制的数据分配空间而造成内存开销。

线程更加灵活:如果代码的某一部分需要每个进程有更多的内存,那么OpenMP方法可以限制这一部分的线程数量。另一方面,对线程的灵活处理会产生一定的操作系统开销,而MPI的固定进程是没有这种开销的。

共享内存编程在概念上很简单,但也会有意想不到的性能隐患。例如,现在两个进程的性能可能会因为需要维持缓存一致性和虚假共享而受到阻碍。

另一方面,混合方法提供了一些优势,因为它捆绑了消息。例如,如果一个节点上的两个MPI进程分别向另一个节点上的两个进程发送消息,就会有四条消息;在混合模型中,这些消息将被捆绑成一条消息。

练习 2.24 分析上面最后一项的讨论。假设两个节点之间的带宽只够一次维持一条消息。与纯分布式模型相比,混合模型的成本节约是多少?提示:分别考虑频带宽度和延时。

这种MPI进程的捆绑可能有一个更深层次的技术原因的优势。为了支持握手协议,每个MPI进程需要为每个其他进程提供少量的缓冲空间。在进程数量较多的情况下,这可能是一个限制,因此在英特尔Xeon Phi等高核数处理器上,捆绑是有吸引力的。

MPI库中明确指出了其支持的线程类型:是否完全支持多线程、是否所有的MPI调用都必须来自一个线程或一次一个线程,或者在从线程进行MPI调用时是否有完全的自由。

并行语言

缓解并行编程困难的一个方法是设计出对并行性提供明确支持的语言。下面列举了一些方法:

  • 一些语言反映了科学计算中的许多操作是数据并行的(第2.5.1节)。诸如高性能Fortran(HPF)(第2.6.5.3节)等语言有一个数组语法,其中数组的加法等操作可以表示为$A=B+C$。这种语法简化了编程,但更重要的是,它在一个抽象的层次上指定了操作,这样下层就可以对如何处理并行作出具体决定。然而,HPF中表达的数据并行只是最简单的一种,即数据包含在常规数组中。不规则的数据并行比较困难;Chapel语言(第2.6.5.5节)试图解决这个问题。

  • 并行语言中的另一个概念,不一定与前者正交,是分区全局地址空间(PGAS)模型:只有一个地址空间(与MPI模型不同),但这个地址空间是分区的,每个分区与线程或进程有亲和力。因此,这个模型包含了SMP和分布式共享内存。一种典型的PGAS语言,统一并行C(UPC),允许你编写程序,在大多数情况下看起来像普通的C代码。然而,通过指出主要阵列在处理器上的分布方式,程序可以被并行执行。

讨论

并行语言有希望使并行编程变得更容易,因为它们使通信操作看起来像简单的复制或算术操作。然而,通过这样做,它们邀请用户编写可能并不高效的代码,例如,通过诱导许多小信息。

作为一个例子,考虑将数组a,b在处理器上进行水平分割,并进行移位(见图2.16)。

1
2
3
for (i=0; i<N; i++)
for (j=0; j<N/np; j++)
a[i][j+joffset] = b[i][j+1+joffset]

abshift

如果这段代码在共享内存机器上执行,它将是高效的,但在分布式情况下的天真翻译将在$i$循环的每个迭代中传达一个数字。显然,这些都可以结合在一个缓冲区的发送/接收操作中,但编译器通常无法进行这种转换。因此,用户被迫,实际上,重新实现了需要在MPI实现中完成的阻塞。

1
2
3
4
5
6
7
for (i=0; i<N; i++)
t[i] = b[i][N/np+joffset]
for (i=0; i<N; i++)
for (j=0; j<N/np-1; j++) {
a[i][j] = b[i][j+1]
a[i][N/np] = t[i]
}

另一方面,某些机器通过全局内存硬件支持直接内存拷贝。在这种情况下,PGAS语言可以比显式消息传递更有效率,即使是物理分布式内存。

Unified Parallel C

统一并行C(UPC)[191]是C语言的一个扩展。它的主要并行来源是数据并行,编译器发现了数组上操作的独立性,并将其分配给不同的处理器。该语言有一个扩展的数组声明,允许用户指定数组是按块划分,还是以轮流方式划分。

下面的UPC程序执行了一个向量与向量加法。

1
2
3
4
5
6
7
8
9
//vect_add.c
#include <upc_relaxed.h>
#define N 100*THREADS
shared int v1[N], v2[N], v1plusv2[N];
void main() {
int i;
for(i=MYTHREAD; i<N; i+=THREADS)
v1plusv2[i]=v1[i]+v2[i];
}

同样的程序有一个明确的并行循环结构

1
2
3
4
5
6
7
8
9
10
//vect_add.c
#include <upc_relaxed.h>
#define N 100*THREADS
shared int v1[N], v2[N], v1plusv2[N];
void main()
{
int i;
upc_forall(i=0; i<N; i++; i)
v1plusv2[i]=v1[i]+v2[i];
}

在含义上与UPC相当,但基于Java而不是C。

High Performance Fortran

高性能Fortran5(HPF)是Fortran90的一个扩展,具有支持并行计算的结构,由高性能Fortran论坛(HPFF)发布。HPFF由莱斯大学的Ken Kennedy召集并担任主席。HPF报告的第一个版本发表于1993年。

在Fortran 90引入的数组语法的基础上,HPF使用数据并行计算模型来支持将单个数组计算的工作分散到多个处理器上。这使得在SIMD和MIMD风格的架构上都能有效地实现。HPF的特点包括。

  • 新的Fortran语句,如FORALL,以及创建PURE(无副作用)程序的能力。

  • 使用编译器指令来推荐阵列数据的分布。

  • 用于与非HPF并行程序接口的外在程序接口,如那些使用消息传递。

  • 额外的库例程,包括环境查询、并行前缀/后缀(例如,’扫描’)、数据散射和排序操作。

Fortran 95整合了几个HPF功能。虽然一些供应商在20世纪90年代确实将HPF纳入了他们的编译器中,但有些方面被证明是难以实现的,而且用途值得怀疑。从那时起,大多数供应商和用户都转向了基于OpenMP的并行处理。然而,HPF仍然有影响。例如,为即将到来的Fortran-2008标准提出的BIT数据类型包含了许多直接来自HPF的新的内在函数。

Co-array Fortran

Co-array Fortran(CAF)是Fortran 95/2003语言的一个扩展。支持并行的主要机制是对数组声明语法的扩展,其中一个额外的维度表示并行分布。例如,在

1
2
3
Real,dimension(100),codimension[*] :: X
Real :: Y(100)[*]
Real :: Z(100,200)[10,0:9,*]

数组X,Y在每个处理器上有100个元素。数组Z的行为就像可用的处理器在一个三维网格上,其中两边是指定的,第三边可以调整以适应可用的处理器。

现在处理器之间的通信是通过沿着描述处理器网格的(共)维度的拷贝来完成的。Fortran 2008的标准包括共同数组。

Chapel

Chapel[30]是一种新的并行编程语言6,由Cray公司开发,是DARPA领导的高生产率计算系统计划(HPCS)的一部分。Chapel旨在提高高端计算机用户的生产效率,同时也是一个可移植的并行编程模型,可用于商品集群或桌面多核系统。Chapel致力于极大地提高大规模并行计算机的亲和力,同时匹配或击败当前编程模型(如MPI)的性能和可移植性。

Chapel通过对数据并行、任务并行、并发和嵌套并行的高级抽象支持多线程执行模型。Chapel的locale类型使用户能够指定并重新确定数据和任务在目标架构上的位置,以便对位置进行调整。Chapel支持具有用户定义实现的全局视图数据聚合,允许以自然方式表达对分布式数据结构的操作。与许多以前的高级并行语言相比,Chapel是围绕多分辨率哲学设计的,允许用户最初编写非常抽象的代码,然后逐步增加细节,直到他们接近机器的需要。Chapel通过面向对象的设计、类型推理和通用编程的功能,支持代码重用和快速原型设计。

Chapel是根据第一原则设计的,而不是通过扩展现有的语言。它是一种im-perative块状结构的语言,旨在使C、C++、Fortran、Java、Perl、Matlab和其他流行语言的用户易于学习。虽然Chapel建立在许多以前的语言的概念和语法上,但它的并行功能最直接地受到ZPL、高性能Fortran(HPF)和Cray MTA对C和Fortran的扩展的影响。
下面是Chapel中的向量与向量加法:

1
2
3
4
5
const BlockDist= newBlock1D(bbox=[1..m], tasksPerLocale=...);
const ProblemSpace: domain(1, 64)) distributed BlockDist = [1..m];
var A, B, C: [ProblemSpace] real;
forall(a, b, c) in(A, B, C) do
a = b + alpha * c;

Fortress

Fortress[67]是由Sun Microsystems开发的一种编程语言。Fortress7的目的是通过几种方式使平行主义更容易操作。首先,并行性是默认的。这是为了推动工具设计、库设计和程序员技能向并行化方向发展。第二,语言被设计成对并行更友好。不鼓励副作用,因为副作用需要同步化以避免错误。Fortress提供了事务,这样程序员就不会面临确定锁定顺序的任务,或者调整他们的锁定代码,以便有足够的正确性,但又不至于妨碍性能。Fortress的循环结构,连同库,把 “迭代 “变成了内部;而不是循环指定如何访问数据,数据结构指定如何运行循环,聚合数据结构被设计成可以有效地安排并行执行的大型部分。Fortress还包括来自其他语言的功能,旨在普遍地帮助提高生产力—测试代码和方法,与被测试的代码相联系;合同,可以在代码运行时选择检查;以及属性,可能运行成本太高,但可以反馈给定理验证器或模型检查器。此外,Fortress还包括安全的语言特性,如检查数组边界、类型检查和垃圾收集,这些在Java中已经被证明是有用的。Fortress的语法被设计为尽可能地类似于数学语法,因此任何人在解决其规范中的数学问题时,都可以写出一个与原始规范明显相关的程序。

X10

X10是一种实验性的新语言,目前正在IBM与学术伙伴合作开发。X10工作是DARPA高生产率计算机系统计划中的IBM PERCS项目(生产性易使用的可靠计算机系统)的一部分。PERCS项目专注于硬件-软件联合设计方法,以整合芯片技术、架构、操作系统、编译器、编程语言和编程工具方面的进展,提供新的可适应、可扩展的系统,在2010年之前将并行应用的开发效率提高一个数量级。

X10旨在通过开发新的编程模型,结合集成到Eclipse中的一套新的工具和新的实现技术,在可管理的运行环境中提供优化的可扩展的并行性,为提高生产率作出贡献。X10是一种类型安全的、现代的、并行的、面向对象的分布式语言,旨在让Java(TM)程序员能够使用。它的目标是未来的低端和高端系统,其节点由多核SMP芯片构成,具有非统一的内存层次,并以可扩展的集群配置互连。作为分区全局地址空间(PGAS)语言家族中的一员,X10强调以地方的形式明确地重新定义位置;体现在async、future、foreach和attach con-结构中的轻量级活动;用于终止检测(finish)和分阶段计算(clocks)的结构;使用无锁同步(原子块);以及对全局数组和数据结构进行操作。

Linda

现在应该很清楚了,数据的处理是迄今为止并行编程最重要的方面,远比算法方面的考虑更重要。编程系统Linda[74, 75],也被称为协调语言,旨在明确地解决数据处理问题。琳达不是一种语言,但是可以,而且已经被纳入其他语言。

琳达的基本概念是元组空间:通过给数据添加一个标签,将其添加到一个全局可访问的信息池中。然后,进程通过标签值来检索数据,而不需要知道是哪个进程将数据添加到元组空间中的。

Linda主要针对的是与高性能计算(HPC)不同的计算模型:它解决的是异步通信进程的需求。然而,它已经被用于科学计算[45]。例如,在热方程的并行模拟中(第4.3节),处理器可以将他们的数据写入元组空间,而相邻的进程可以检索他们的鬼魂重区,而不必知道它的出处。因此,Linda成为实现单边通信的一种方式。

The Global Arrays library

The Global Arrays library(http://www.emsl.pnl.gov/docs/global/)是另一个单边通信的例子,事实上它早于MPI。这个库的主要数据结构是笛卡尔积数组8,分布在相同或更低维度的处理器网格上。通过库的调用,任何处理器都可以通过放或取的操作访问阵列中的任何子砖。这些操作是非集体的。与任何单边协议一样,屏障同步是必要的,以确保发送/接收的完成。

基于操作系统的方法

可以设计一个具有共享地址空间的架构,并让数据移动由操作系统处理。Kendall Square计算机[124]有一个名为 “全缓存 “的架构,其中没有数据与任何处理器直接相关。相反,所有的数据都被认为是缓存在一个处理器上,并根据需要通过网络移动,就像数据从主内存移动到普通CPU的缓存中一样。这个想法类似于目前SGI架构中的NUMA支持。

活跃通信

MPI范式(第2.6.3.3节)传统上是基于双侧操作的:每个数据传输都需要一个明确的发送和接收操作。这种方法对于相对简单的代码来说效果很好,但是对于复杂的问题来说,就很难协调所有的数据移动。简化的方法之一是使用「活跃通信」(active message)。这在Charm++[119]包中被使用。

通过主动消息,一个处理器可以向另一个处理器发送数据,而不需要第二个处理器做明确的接收操作。相反,接收者声明处理传入数据的代码,用客观方向的说法是 “方法”,而发送处理器则用它想发送的数据调用这个方法。由于发送处理器实际上是激活了另一个处理器上的代码,这也被称为「远程调用」(remote method invocation)。这种方法的一个很大的优点是,通信和编译的重叠变得更容易实现。

作为一个例子,考虑用一个三对角矩阵进行矩阵与向量乘法

关于这个问题在PDEs中的起源,见4.2.2节的解释。假设每个处理器正好有一个索引$i$,MPI代码可以是这样的。

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

if ( /* I am the first or last processor */ )
n_neighbors = 1;
else
n_neighbors = 2;
/* do the MPI_Isend operations on my local data */

sum = 2*local_x_data;
received = 0;
for (neighbor=0; neighbor<n_neighbors; neighbor++) {
MPI_WaitAny( /* wait for any incoming data */ )
sum = sum - /* the element just received */
received++
if (received==n_neighbors)
local_y_data = sum
}

有了活跃通信,这看起来就像

1
2
3
4
5
6
7
8
9
void incorporate_neighbor_data(x) { 
sum = sum-x;
if (received==n_neighbors)
local_y_data = sum
}
sum = 2*local_xdata;
received = 0;
all_processors[myid+1].incorporate_neighbor_data(local_x_data);
all_processors[myid-1].incorporate_neighbor_data(local_x_data);

批量同步并行

MPI库(2.6.3.3节)可以带来非常高效的代码。这样做的代价是,程序员需要非常详细地说明通信的内容。在光谱的另一端,PGAS语言(第2.6.5节)对程序员的要求很低,但却没有带来多少性能回报。一种试图找到中间地带的方法是「批量同步并行」(Bulk Synchronous Parallel,BSP)模型[192, 183]。在这里,程序员需要写出通信,但不是它们的顺序。

BSP模型将程序排列成一个超步的序列,每个步骤都以一个障碍物同步结束。在一个超步中开始的通信都是异步的,并依靠屏障来完成。这使得编程更容易,并消除了死锁的可能性。

此外,所有通信都是单边通信类型。

练习 2.25 考虑2.1节中的并行求和例子。论证BSP的实现需要$\log_2n$超步。

由于其通过障碍物完成超级步骤的处理器的同步,BSP模型可以对并行算法做一个简单的成本分析。

BSP模型的另一个方面是它对问题的「过度分解」(overdecomposition),即把多个进程分配给每个处理器,以及「随机放置」(random placement)数据和任务。这是以统计学的论点为依据的,表明它可以补救负载的不均衡。如果有$𝑝$个处理器,如果在一个超步中进行了$𝑝$次远程访问,那么很可能有些处理器会收到$\log𝑝/\log \log 𝑝$次访问,而其他处理器则没有收到。因此,负载不均衡的问题会随着处理器数量的增加而变得更加严重。另一方面,如果有$𝑝\log p$的访问,例如因为每个处理器上有$\log 𝑝$的进程,最大的访问次数是$3\log 𝑝$,而且概率很大。这意味着负载平衡是在一个完美的恒定系数内。

BSP模型是在BSPlib[107]中实现的。其他系统可以说是类似BSP的,因为它们使用了超步的概念;例如,谷歌的Pregel[150]。

数据依赖

如果两个语句引用了相同的数据项,我们就说这些状态之间存在着「数据依赖」(data dependency)关系。这种依赖关系限制了语句的执行可以被重新安排的程度。对这一主题的研究可追溯到20世纪60年代,当时处理器可以不按串行执行语句以提高吞吐量。语句的重新排序受到了限制,因为执行必须遵守程序的「串行语义」(program order):结果必须像语句严格按照它们在程序中出现的串行执行一样。

语句排序以及因此而产生的数据依赖性的问题,以几种方式出现:

  • 并行化编译器必须对资源进行分析,以确定允许哪些转换。
  • 如果你用OpenMP指令并行化一个顺序代码,你必须自己进行这样的分析。

这里有两种需要进行这种分析的活动:

  • 当一个循环被并行化时,迭代不再按其程序顺序执行,所以我们必须检查依赖关系。

  • 引入任务是指程序的某些部分可以按照与顺序执行不同的顺序执行。

依赖性分析的最简单的情况是检测循环迭代是否可以独立执行。如果一个数据项在两个不同的迭代中被读取,迭代当然是独立的,但是如果同一个项目在一个迭代中被读取,在另一个迭代中被写入,或者在两个不同的迭代中被写入,我们需要做进一步分析。

数据依赖性的分析可以由编译器来执行,但是编译器必须采取一种保守的方法。这意味着迭代可能是独立的,但不能被编译器所识别。因此,OpenMP把这个责任转移给了程序员。

现在,我们将详细讨论数据依赖的细节。

数据依赖类型

这三种类型的依赖关系是

  • 流依赖」(flow dependencies),或 “读后写”。
  • 反依赖」(anti dependencies),即 “读后写”;以及

  • 输出依赖」(output dependencies),即 “写完再写”。

这些依赖关系可以在标量代码中进行研究,事实上编译器也是这样做的,以确定语句是否可以重新排列,但是我们将主要关注它们在循环中的出现,因为在科学计算中很多工作都出现在这里。

  • 流依赖:如果读和写发生在同一个循环迭代中,那么流量依赖,或者说读-写,就不是一个问题。
1
2
3
4
for (i=0; i<N; i++) { 
x[i] = .... ;
.... = ... x[i] ... ;
}

另一方面,如果读取发生在后来的迭代中,就没有简单的方法来并行化或向量化循环。

1
2
3
4
for (i=0; i<N; i++) { 
.... = ... x[i] ... ;
x[i+1] = .... ;
}

这通常需要重写代码。

练习 2.26 考虑如下代码

1
2
3
4
for (i=0; i<N; i++) { 
a[i] = f(x[i]);
x[i+1] = g(b[i]);
}

其中f()和g()表示没有进一步依赖x或i的算术表达式。

  • 反依赖性:反依赖性或读后写的最简单情况是减少。
1
2
3
for(i=0; i<N; i++){
t =t+ ...
}

这可以通过明确声明循环是一个减法来处理,或者使用6.1.2节中的任何其他策略。

如果读和写是在一个数组上,情况就更复杂了。这个片段中的迭代

1
2
3
for (i=0; i<N; i++) { 
x[i] = ... x[i+1] ... ;
}

不能像这样以任意顺序执行。然而,从概念上讲,这并不存在依赖性。我们可以通过引入一个临时数组来解决这个问题。

1
2
3
4
5
for (i=0; i<N; i++) 
xtmp[i] = x[i];
for (i=0; i<N; i++) {
x[i] = ... xtmp[i+1] ... ;
}

这是一个编译器不太可能执行的转换的例子,因为它可能会大大影响程序的内存需求。因此,这就留给了程序员。

  • 输出依赖:输出依赖或写后依赖的情况本身不会发生:如果一个变量被依次写了两次,中间没有读,那么第一次写可以被删除而不改变程序的意义。因此,这种情况会减少为流动依赖。

其他的输出依赖也可以被移除。在下面的代码中,t可以被声明为私有,从而消除了依赖性。

1
2
3
4
for (i=0; i<N; i++) { 
t = f(i)
s += t*t;
}

如果想要t的最终值,可以在OpenMP中使用lastprivate。

嵌套循环的并行化

在上述例子中,如果在一个循环的迭代$𝑖$中出现了不同的指数,如$𝑖$和$𝑖+1$,那么数据的依赖性就是非实质性的。反之,循环如

1
2
for (int i=0; i<N; i++) 
x[i] = x[i]+f(i);

是简单的并行化。然而,嵌套的循环则需要更多的思考。OpenMP有一个 “折叠 “指令,用于诸如以下的循环

1
2
3
for (int i=0; i<M; i++) 
for (int j=0; j<N; j++)
x[i][j] = x[i][j] + y[i] + z[j];

这里,整个$i$,$j$迭代空间是并行的。这是怎么回事?

1
2
3
4
for (n = 0; n < NN; n++) 
for (i = 0; i < N; i++)
for (j = 0; j < N; j++)
a[i] += B[i][j]*c[j] + d[n];

练习 2.27 对这个循环做一个重用分析。假设a,b,c不能一起放进缓存。现在假设c和b的一行可以放入缓存,并且还有一点空间。你能找到一个能使性能大大提高的循环交换吗?写一个测试来证实这一点。

分析这个循环嵌套的并行性,你会发现j循环是一个减法,而n循环有流量依赖:每个a[i]在每个n次迭代中被更新。结论是,你只能合理地并行化$i$环路。

练习 2.28 这个并行性分析与练习2.27中的循环交换有什么关系?交换后的循环是否仍然是可并行的?

如果你会说OpenMP,请通过编写将a的元素相加的代码来确认你的答案,无论交换和引入OpenMP并行性,你都应该得到同样的答案。

并行程序设计

很久以前,人们认为编译器和运行时系统的某种神奇组合可以将现有的顺序程序转化为并行程序。这种希望早已破灭,所以现在的并行程序从一开始就被写成了并行程序。当然,有不同类型的并行性,它们对你如何设计你的并行程序都有各自的影响。在这一节中,我们将简要地探讨其中的一些问题。

并行数据结构

并行程序设计中的一个问题是使用数组结构(Array-Of-Structures,AOS)与结构化数组(Structure-Of-Arrays,SOA)。在正常的程序设计中,我们经常定义一个结构

1
2
3
4
struct { int number; double xcoord,ycoord; } _Node; 
struct { double xtrans,ytrans} _Vector;
typedef struct _Node* Node;
typedef struct _Vector* Vector;

而如果需要许多这样的结构,我们就创建一个这样的结构数组。

1
Node *nodes = (Node*) malloc( n_nodes*sizeof(struct _Node) );

这就是AOS的设计。

现在,假设我们想将一个操作并行化

1
2
3
4
void shift(Node the_point,Vector by) { 
the_point->xcoord += by->xtrans;
the_point->ycoord += by->ytrans;
}

这是在一个循环中完成的

1
2
3
for (i=0; i<n_nodes; i++) { 
shift(nodes[i],shift_vector);
}

这段代码具有MPI编程的正确结构(2.6.3.3节),每个处理器都有自己的本地节点数组。这个循环也很容易用OpenMP并行化(第2.6.2节)。

然而,在20世纪80年代,人们意识到AOS的设计并不适合向量计算机,因此不得不对代码进行大幅重写。在这种情况下,我们的操作数需要是连续的,所以代码必须采用SOA设计。

1
2
3
node_numbers = (int*) malloc( n_nodes*sizeof(int) ); 
node_xcoords = // et cetera
node_ycoords = // et cetera

而将迭代

1
2
3
4
for (i=0; i<n_nodes; i++) { 
node_xoords[i] += shift_vector->xtrans;
node_yoords[i] += shift_vector->ytrans;
}

最初的SOA设计最适合于分布式内存编程吗,这意味着在向量计算机时代的10年后,每个人都必须为集群重新编写他们的代码。当然,如今随着SIMD宽度的增加,我们也需要部分地回到AOS的设计。(在英特尔的ispc项目中,有一些实验性的软件支持这种转变,http: //ispc.github.io/,它将SPMD代码翻译成SIMD)。

延迟隐蔽性

处理器之间的通信通常很慢,比单个处理器上的内存数据传输要慢,而且比对数据的操作要慢得多。因此在设计一个并行程序时,最好考虑到网络流量与 “有用 “操作的相对数量。每个处理器必须有足够的工作来抵消通信。

应对通信相对缓慢的另一种方法是安排程序,使通信实际发生在一些计算正在进行的时候。这被称为通信的「重叠计算」(overlapping computation with communication)或「延迟隐藏」(latency hiding)。

例如,考虑矩阵与向量乘积$𝑦=𝐴$的并行执行。假设向量是分布式的,那么每个处理器$𝑝$都会执行

由于$𝑥$也是分布式的,我们可以将其写为

这个方案如图2.17所示。我们现在可以按以下方式进行。

  • 开始转移$𝑥$的非本地元素。
  • 在数据传输过程中,对$𝑥$的本地元素进行操作。
  • 确保传输完成。
  • 对$𝑥$的非本地元素进行操作。

distmvp

练习 2.29 你能从计算和通信的重叠中获得多少好处?提示:考虑计算耗时为零且只有通信的边界情况,以及相反的情况。现在考虑一般情况。

当然,这种情况的前提是有软件和硬件对这种重叠的支持。MPI允许这样做(见2.6.3.6节),通过所谓的异步通信或非阻塞通信例程。这并不立即意味着重叠将实际发生,因为硬件支持是一个完全独立的问题。

拓扑

如果一些处理器一起工作在一个任务上,他们很可能需要交流数据。由于这个原因,需要有一种方法使数据从任何一个处理器到其他处理器。在本节中,我们将讨论一些可能的方案来连接并行机器中的处理器。这种方案被称为(处理器)「拓扑」(topology)。

为了明确这里的问题,请考虑两个不能 “扩展 “的简单方案。

  • 以太网是一种连接方案,网络上的所有机器都在一条电缆上(见下文注释)。如果一台机器在电线上放了一个信号来发送信息,而另一台机器也想发送信息,那么后者将检测到唯一可用的通信通道被占用,它将等待一段时间后再重新进行发送操作。在以太网上接收数据是很简单的:信息包含了目标接收者的地址,所以一个处理器只需要检查电线上的信号是否是为它准备的。

    这个方案的问题应该很清楚。通信通道的容量是有限的,所以当更多的处理器连接到它时,每个处理器可用的容量将下降。由于解决冲突的方案,信息开始前的平均延迟也会增加。

  • 在完全连接的配置中,每个处理器都有一条与其他处理器通信的线路。

    其他处理器。这种方案是完美的,因为消息可以在最短的时间内发送,而且两个消息永远不会互相干扰。一个处理器可以发送的数据量不再是处理器数量的递减函数;事实上,它是一个递增函数,如果网络控制器可以处理,一个处理器甚至可以同时进行多次通信。

    当然,这个方案的问题是,一个处理器的网络接口的设计不再是固定的:随着更多的处理器被添加到并行机器上,网络接口得到更多的连接线。网络控制器也同样变得更加复杂,机器的成本增加速度超过了处理器数量的线性增长。

注释 10 以上对以太网的描述是对原始设计的描述。随着交换机的使用,特别是在HPC的背景下,这种描述已经不再真正适用。

最初人们认为,信息碰撞意味着以太网将不如其他解决方案,如IBM的令牌环网,它明确地防止碰撞。需要相当复杂的统计分析来证明,以太网的工作原理比朴素预期好得多。

在本节中,我们将看到一些可以增加到大量处理器的方案。

图论

互联并行计算机中的处理器的网络可以方便地用一些基本的图论概念来描述。我们用一个图来描述并行机器,每个处理器都是一个节点,如果两个节点之间有直接的联系,那么这两个节点就是相连的。(我们假设连接是对称的,所以网络是一个无向图)

下面分析图的两个重要概念。

首先,图中一个节点的程度是它所连接的其他节点的数量。节点代表处理器,边代表导线,很明显,高度不仅是计算效率所希望的,而且从工程的角度来看也是昂贵的。我们假设所有处理器都有相同的度。

其次,从一个处理器到另一个处理器的信息,通过一个或多个中间节点,很可能在节点之间路径的每个阶段产生一些延迟。由于这个原因,图的直径很重要。直径被定义为任何两个节点之间的最大最短距离,包括链接的数量。

如果$𝑑$是直径,如果在一条线上发送一个信息需要单位时间,这意味着一个信息总是在最多$𝑑$时间内到达。

练习 2.30 找出处理器的数量、它们的程度和连接图的直径之间的关系。

除了 “一个消息从处理器A到处理器B需要多长时间 “的问题外,我们还经常担心两个同时进行的消息之间的冲突:是否存在两个同时进行的消息需要使用同一网络链接的可能性?在图 2.18 中,我们说明了如果每个处理器$𝑝_𝑖$在$i < n/2$ 的情况下向$𝑝_{i+n/2}$ 发送消息会发生什么:会有$n/2$ 的消息试图通过$p_{n/2-1}$ 和$p_n$之间的线路。这种冲突被称为「拥堵」(congestion)或「争夺」(contention)。显然,一台并行计算机的链接越多,发生拥堵的机会就越小。

描述拥堵可能性的一个精确方法是看「二分宽度」(bisection width)。这被定义为将处理器图分割成两个非连接图所必须移除的最小链接数。例如,考虑处理器连接成一个线性阵列,即处理器$P_𝑖$与$P_{i-1}$和$P_{i+1}$连接。在这种情况下,分界线宽度为1。

二分宽度𝑤描述了在一台并行计算机中可以保证有多少信息同时进行。证明:采取𝑤发送和𝑤接收处理器。这样定义的𝑤路径是不相交的:如果不相交,我们只需去除𝑤-1个链接就可以把处理器分成两组。

当然,在实践中,超过$w$条信息可以同时进行。例如,在一个线性阵列中,$w=1$,如果所有的通信都是在邻居之间,如果一个处理器在任何时候都只能发送或接收,而不能同时发送和接收,则可以同时发送和接收𝑃/2条信息。如果处理器可以同时发送和接收,那么网络中可以有𝑃个信息正在进行。

二分宽度也描述了网络中的「冗余度」(redundancy):如果一个或多个连接出现故障,信息是否仍能从发送方找到接收方?

虽然二分宽度是一种表示导线数量的措施,但实际上我们关心的是通过导线的容量。这里的相关概念是「二分带宽」(bisection bandwidth):横跨分节宽度的带宽,是分节宽度与导线容量(以每秒比特为单位)的乘积。二分带宽可以被认为是衡量任意一半处理器与另一半处理器进行通信所能达到的带宽。二分带宽是一个比有时引用的总带宽更现实的衡量标准,它被定义为每个处理器都在发送时的总数据率:处理器的数量乘以连接的带宽乘以一个处理器可以执行的同时发送的数量。这可能是一个相当高的数字,而且它通常不能代表实际应用中实现的通信速率。

总线

我们考虑的第一个互连设计是让所有的处理器位于同一内存总线上。这种设计将所有处理器直接连接到同一个内存池,因此它提供了一个UMA或SMP模型。

使用总线的主要缺点是可扩展性有限,因为每次只有一个处理器可以进行内存访问。为了克服这个问题,我们需要假设处理器的速度比内存慢,或者处理器有缓存或其他本地内存来操作。在后一种情况下,通过让处理器监听总线上的所有内存流量,维持缓存一致性是很容易的,这个过程被称为「监听」(snooping)。

线性阵列和环状网络

连接多个处理器的一个简单方法是将它们连接成一个「线性阵列」(linear array):每个处理器都有一个编号$i$,处理器$P_i$与$P_{i-1}$和$P_{i+1}$相连。第一个和最后一个处理器是可能的例外情况:如果它们相互连接,我们称该架构为环状网络(ring network)。

这个方案要求每个处理器有两个网络连接,所以设计相当简单。

练习2.31 线性阵列的二分宽度是什么?环状网络的二分宽度是什么?

练习2.32 由于线性数组的连接有限,你可能必须对并行算法进行巧妙的编程。例如,考虑一个 “广播 “操作:处理器0有一个数据项需要发送给其他每个处理器。

我们做了以下简化的假设。

  • 一个处理器可以同时发送任意数量的信息。

  • 但一条线一次只能携带一条信息;然而。

  • 任何两个处理器之间的通信都需要单位时间,不管它们之间有多少个处理器。

在一个「全连接」(fully connected)的网络或一个「星型」(star)网络中,你可以很容易地写出:

1
2
for 𝑖 = 1 ... 𝑁 − 1:
send the message to processor 𝑖

假设一个处理器可以发送多个消息,即操作是一步到位的。现在考虑一个线性阵列。说明即使有这种无限的发送能力,上述算法也会因为拥堵而遇到麻烦。

请你尝试找到一个更好的方法来组织发送操作。提示:假装你的处理器是以二叉树的形式连接的。假设有$𝑁=2^n-1$个处理器。证明广播可以在对数$N$个阶段内完成,并且处理器只需要能够同时发送一条信息即可。

这个练习是一个将 “逻辑 “通信模式嵌入物理模式的例子。

二维和三维阵列、环面

一种流行的并行计算机设计是将处理器组织在一个二维或三维的「笛卡尔网状」(Cartesian mesh)网络中。这意味着每个处理器都有一个坐标$(i, j)$或$(i, j, k)$,并且它在所有坐标方向上都与邻居相连。处理器的设计还是相当简单的:网络连接的数量(连接图的度数)是网络的空间维数(2或3)的两倍。

拥有二维或三维网络是一个相当自然的想法,因为我们周围的世界是三维的,而且计算机经常被用来模拟现实生活的现象。如果我们暂时接受物理模型需要近邻型通信(我们将在第4.2.3节看到这种情况),那么网状计算机是运行物理模拟的自然候选者。

练习2.33 $n \times n \times n$处理器的三维立方体的直径是多少?二分宽度是多少?如果增加环绕环状的连接,会有什么变化?

练习 2.34 你的并行计算机的处理器被组织成一个二维网格。芯片制造商推出了一种具有相同时钟速度的新芯片,它是双核的,而不是单核的,而且可以装在现有的插槽上。批评以下论点:”每秒钟可以完成的工作量(不涉及通信)增加了一倍;由于网络保持不变,二分带宽也保持不变,所以我可以合理地期望我的新机器变得两倍快”。

基于网格的设计通常有所谓的环绕或环形连接,它连接二维网格的左右两边,以及顶部和底部。这在图2.19中有所说明。

一些计算机设计声称是高维度的网格,例如5D,但这里并不是所有的维度都是平等的。例如,一个3D网格,其中每个节点是一个四插槽四核,可以被认为是一个5D网格。然而,最后两个维度是完全相连的。

超立方体

上面我们根据近邻通信的普遍性,对网状组织处理器的适用性做了一个挥手的论证。然而,有时会发生在随机处理器之间的发送和接收。这方面的一个例子就是上面提到的广播。由于这个原因,它希望有一个比网状网络直径小的网络。另一方面,我们希望避免全连接网络的复杂设计。

一种不错的解决方案是「超立方体」(hypercube)设计。一个$n$维的超立方体计算机有$2^n$个处理器,每个处理器在每个维度上都与另一个处理器相连;见图2.21。

一个简单的描述方法是给每个处理器一个由$𝑑$位组成的地址:我们给超立方体的每个节点一个数字,这个数字是描述它在立方体中的位置的比特模式;见图 2.20。

有了这个编号方案,一个处理器就会与其他所有地址正好相差一位的处理器连接起来。这意味着,与网格不同的是,一个处理器的邻居的号码并不是相差1或$\sqrt P$,而是相差1,2,4,8,….。

超立方体设计的最大优点是直径小,通过网络的流量容量大。

练习2.35 超立方体的直径是多少?二分宽度是多少?

该方案的一个缺点是,处理器的设计取决于机器的总尺寸。在实践中,处理器会被设计成可能的最大连接数,而购买较小机器的人就会为未使用的容量买单。另一个缺点是,扩展一台给定的机器只能通过加倍来实现:$2^p$以外的其他尺寸是不可能的。

练习2.36 考虑第2.1节中的并行求和例子,并给出在超立方体上并行实现的执行时间。证明在超立方体上的执行可以达到该例子的理论速度(最多一个系数)。在超立方体中嵌入网格上面我们提出了一个论点,即网格连接的处理器是许多物理现象建模应用的合理选择。超立方体看起来不像网格,但它们有足够的连接,可以通过忽略某些连接来简单地假装成网格。

比方说,我们想要一个一维数组的结构:我们想要有编号的处理器,这样处理器𝑖可以直接向𝑖 - 1和𝑖 + 1发送数据。我们不能像图2.20中那样使用明显的节点编号。例如,节点1与节点0直接相连,但与节点2的距离为2。节点3在一个环中的右邻,节点4,甚至在这个超立方体中的最大距离为3。显然,我们需要以某种方式对节点重新编号。

我们将展示的是,有可能在超立方体中行走,精确地触摸每个角落,这相当于在超立方体中嵌入一个一维网格。

这里的基本概念是一个(二进制反映的)「格雷编码」(Gray code)[87]。这是一种将二进制数$0…2^{𝑑-1}$排序为$𝑔_0,…𝑔_{2𝑑-1}$的方法,即$𝑔_𝑖$和$𝑔_{𝑖+1}$只相差一个比特。显然,普通的二进制数并不满足这一点:1和2的二进制表示已经有两个比特的差异。为什么格雷编码能帮助我们?因为$𝑔_𝑖$和$𝑔_{𝑖+1}$只相差一位,这意味着它们是超立方体中直接相连的节点数。

图2.22说明了如何构建一个格雷编码。这个过程是递归的,可以正式描述为 “将立方体分为两个子立方体,对一个子立方体进行编号,交叉到另一个子立方体,并按照第一个子立方体的相反顺序对其节点进行编号”。二维立方体的结果如图2.23所示。

由于格雷编码为我们提供了一种将一维 “网状 “嵌入超立方体的方法,我们现在可以继续往上做。

练习2.37 显示如何将一个$2^{2d}$节点的正方形网格嵌入到一个超立方体中,方法是将两个2𝑑节点的立方体嵌入的比特模式相加。你如何容纳一个$2^{d_1+d_2}$节点的网格?一个由$2^{d_1+d_2+d_3}$个节点组成的三维网格?

交换机网络

上面我们简要地讨论了完全连接的处理器。然而,通过在所有处理器之间制作大量的总线来进行连接是不切实际的。然而,还有另一种可能性,即通过将所有处理器连接到一个「交换机」(switch)或「交换机网络」(switch network)。一些流行的网络设计是「交叉开关」(Cross bar)、「蝶形交换」(butterfly exchange)和「胖树」(fat tree.)。

交换机网络是由交换元件组成的,每个交换元件都有少量(最多十几个)的入站和出站链接。通过将所有的处理器连接到一些交换元件上,并有多个交换阶段,那么就有可能通过网络的路径连接任何两个处理器。

交叉开关

最简单的开关网络是一个交叉开关,由$𝑛$水平线和垂直线组成,每个交叉点上都有一个开关元件,决定这些线是否连接在一起;见图2.24。如果我们把横线指定为输入,竖线指定为输出,这显然是让$𝑛$输入映射到$𝑛$输出的一种方式。每一个输入和输出的组合(有时称为 “排列组合”)都是允许的。

这种类型的网络的一个优点是,没有任何连接可以阻挡另一个连接。主要的缺点是开关元素的数量是$n^2$,是处理器数量$n$的一个快速增长的函数。

蝶形交换

蝶形交换网络是由小型交换元件构成的,它们有多个阶段:随着处理器数量的增加,阶段的数量也随之增加。图2.25显示了连接2、4和8个处理器的蝶形网络,每个处理器有一个本地存储器。(另外,你可以把所有的处理器放在网络的一边,而把所有的存储器放在另一边)。

正如在图2.26中所示,蝶形交换允许几个处理器模拟访问内存。而且,它们的访问时间是相同的,所以交换网络是实现UMA结构的一种方式;见2.4.1节。有一台基于Butterfly交换网络的计算机是BBN Butterfly(http://en.wikipedia.org/wiki/BBN_Butterfly)。在2.7.7.1节中,我们将看到这些想法是如何在一个实际的集群中实现的。

练习 2.38 对于简单的交叉开关和蝶形交换,随着处理器数量的增加网络需要扩展。给出两种情况下连接$𝑛$处理器和存储器所需的导线数量(某种单位长度)和交换元件的数量。一个数据包从存储器到处理器所需的时间,用穿越单位长度的导线和穿越开关元件的时间表示是多少?

通过蝶形交换网络的数据包路由是基于考虑目的地地址的位数来完成的。在第𝑖层,考虑第𝑖个数字;如果是1,则选择开关的左出口,如果是0,则选择右出口。这在图2.27中有所说明。如果我们把存储器连接到处理器上,如图2.26所示,我们只需要两个比特(到最后一个开关),但还需要三个比特来描述反向路线。

胖树

如果我们像树一样连接交换节点,那么在靠近根部的地方就会出现很大的拥堵问题,因为只有两根线连接到根注。假设我们有一棵$𝑘$级树,所以有$2^𝑘$个叶子节点。如果左边子树上的所有叶子节点都试图与右边子树上的节点通信,我们就有$2{𝑘-1}$条信息通过一条线进入根部,同样也通过一条线出去。胖树是一个树状网络,每一级都有相同的总带宽,这样就不会出现这种拥堵问题:根部实际上会有$2^{𝑘-1}$条进线和出线连接[88]。图2.28在左边显示了这种结构;右边显示了Stampede集群的一个机柜,机柜的上半部和下半部有一个叶子开关。

第一个成功的基于胖树的计算机结构是连接机CM5。

在胖树中,就像在其他交换网络中一样,每个信息都带有自己的路由信息。由于在胖树中,选择仅限于上升一级,或者切换到当前级别的其他子树上,因此一条信息需要携带的路由信息的位数与级别相同,对于$𝑛$处理器来说是$\log_2𝑛$。

练习 2.39 证明胖子树的分叉宽度是$𝑃/2$,其中$𝑃$是亲子叶子节点的数量。提示:说明只有一种方法可以将胖树连接的处理器集合分割成两个连接的子集。

[142]中对胖树的理论阐述表明,胖树在某种意义上是最优的:它可以像任何其他需要相同空间来构建的网络一样快速传递信息(最多对数因素)。这个说法的基本假设是,离根更近的开关必须连接更多的线,因此需要更多的组件,相应地也就更大。这个论点虽然在理论上很有趣,但没有实际意义,因为网络的物理尺寸在目前最大的使用胖树互连的计算机中几乎没有起到作用。例如,在德克萨斯大学奥斯汀分校的TACC Frontera集群中,只有6个核心交换机(即容纳胖树最高层的机柜),连接91个处理器机柜。

如上图所示,胖树的建设成本很高,因为每下一级都必须设计一个新的、更大的交换机。因此,在实践中,一个具有胖子树特征的网络是由简单的开关元件构成的;见图2.29。这个网络的带宽和路由可能性与胖树相当。路由算法会稍微复杂一些:在胖树中,一个数据包只能以一种方式上升,但在这里,一个数据包必须知道要路由到两个较高的交换机中的哪个。

这种类型的交换网络是Clos网络的一种情况[34]。

超额订购和争夺

在实践中,胖树网络不使用2进2出的元件,而是使用20进20出的开关。这使得网络中的层数有可能被限制在3或4个。(顶层交换机被称为脊柱卡)。

在这种情况下,网络分析的一个额外的复杂因素是超额订购的可能性。网卡中的端口可以配置为输入或输出,而只有总数是固定的。因此,一个40端口的交换机可以被配置为20进20出,或者21进19出,等等。当然,如果所有连接到交换机的21个节点同时发送,19个出端口将限制带宽。

还有一个问题。让我们考虑建立一个小型集群,交换机配置为有$𝑝$入端口和$w$出端口,这意味着我们有$𝑝+𝑤$端口交换机。图2.30描述了两个这样的开关,总共连接了$2^𝑝$个节点。如果一个节点通过交换机发送数据,它对$𝑤$条可用导线的选择由目标节点决定。这就是所谓的输出路由。

显然,我们只能期望$𝑤$个节点能够在有信息碰撞的情况下进行发送,因为这就是交换机之间可用导线的数量。然而,对于许多$𝑤$目标的选择,无论如何都会有对导线的争夺。这就是生日悖论的一个例子。

练习 2.40 考虑上述架构,$p$个节点通过切换间的$w$线发送。编一个模拟代码,其中$p$个节点中的$w’\leqslant w$向一个随机选择的目标节点发送一个信息。作为$w’$、$w$、$p$的函数,碰撞的概率是多少?找到一种方法来制表或绘制数据。

作为反馈,请给出简单情况下$w’ = 2$的统计分析。

集群网络

上面的讨论有些抽象,但在现实生活中的集群中,你可以实际看到网络设计的体现。例如,肥大的树形集群网络会有一个中央机柜,对应树形中的最高层。图2.31显示了TACC Ranger(已不再使用)和Stampede集群的交换机。在第二张图片中可以看出,实际上有多个冗余的胖树网络。

另一方面,像IBM BlueGene这样基于环状网络的集群,看起来将是一个相同机柜的集合,因为每个机柜都包含网络的一个相同部分;见图2.32。

案例研究: Stampede

作为实践中联网的一个例子,让我们考虑一下德克萨斯高级计算机中心的Stampede集群,它是一个多根多级的胖树。

  • 每个机架由2个机箱组成,每个机箱有20个节点。

  • 每个机箱都有一个叶子开关,它是一个内部的横杆,使机箱中的节点之间有完美的连接性。

  • 叶子交换机有36个端口,其中20个连接到节点,16个向外。这种超额订阅意味着最多只有16个节点在机箱外通信时可以拥有完美的带宽。

  • 有8个中心交换机,作为8个独立的胖树根发挥作用。每个机箱通过两个连接到每个中央交换机的 “叶卡”,正好占用了16个出站端口。
  • 每个中心交换机有18个针卡,每个针卡有36个端口,每个端口连接到不同的叶卡。
  • 每台中央交换机有36个叶卡,18个端口连接到叶子交换机,18个端口连接到脊柱卡。这意味着我们可以支持648个机箱,其中640个被实际使用。

网络中的一个优化是,与同一叶卡的两个连接进行通信,没有较高树级的延迟。这意味着,一个机箱中的16个节点和另一个机箱中的16个节点可以有完美的连接。

然而,对于静态路由,如Infiniband中使用的路由,有一个与每个目的地相关的固定端口。(目的地到端口的这种映射在每个交换机的路由表中)。 因此,对于20个可能的目的地中的16个节点的某些子集,将有完美的带宽,但其他子集将看到两个目的地的流量通过同一个端口。

案例研究:Cray Dragonfly网络

Cray的蜻蜓网络是一个有趣的实际妥协。上面我们说过,一个完全连接的网络将太过昂贵,无法扩大规模。然而,如果数量保持有限的话,拥有一个完全连接的处理器集合是可能的。蜻蜓设计使用小的完全连接的组,然后将这些组组成一个完全连接的图。

这引入了一个明显的不对称性,因为一个组内的处理器拥有更大的带宽,而组与组之间则没有。然而,由于动态路由,信息可以采取非最小路径,通过其他组进行路由。这可以缓解争夺问题。

带宽和延迟

上面所说的发送信息可以被认为是一个单位时间的操作,当然是不现实的。一个大的信息比一个短的信息需要更长的时间来传输。有两个概念可以对传输过程进行更现实的描述;我们已经在1.3.2节中看到了在处理器的缓存层之间传输数据的情况。

  • 延迟 在两个处理器之间建立通信需要花费大量时间,这与信息大小无关。这所花费的时间被称为信息的延时。造成这种延迟的原因有很多。
    • 两个处理器进行 “握手”,以确保收件人已经准备好,并且有适当的缓冲空间来接收信息。
    • 信息需要由发送方进行编码传输,并由接收方进行解码。
    • 实际传输可能需要时间:并行计算机通常足够大,即使在光速下,信息的第一个字节也需要数百个周期来穿越两个处理器之间的距离。
  • 带宽 在两个处理器之间的传输开始后,主要的数字是每秒可通过通道的字节数。这就是所谓的带宽。带宽通常可由信道速率(物理链路可传送比特的速率)和信道宽度(链路中物理线的数量)决定。信道宽度通常是16的倍数,通常为64或128。这也可以表示为,一个通道可以同时发送一个或两个8字节的字。

带宽和延迟被正式定义为

为一个$n$字节的信息的传输时间。这里,$\alpha$是延迟,$\beta$是每字节的时间,也就是带宽的倒数。有时我们会考虑涉及通信的数据传输,例如在集体操作的情况下;见6.1节。然后我们将传输时间公式扩展为

其中$\gamma$是每次操作的时间,也就是计算率的倒数。

也可以将这个公式细化为

其中$𝑝$是所穿越的网络 “「」(hops)”。然而,在大多数网络中,$\delta$的值远远低于$\alpha$的值,所以我们在这里将忽略它。另外,在胖树网络中,跳数是$\log𝑃$的数量级,其中$𝑃$是处理器的总数,所以它无论如何都不可能很大。

并行计算中的局部性

在第1.6.2节中,你发现了关于单处理器计算中的位置性概念的讨论。并行计算中的位置性概念包括所有这些以及更多的层次。

  • 核心之间:私有缓存 现代处理器上的核心有私有相干缓存。这意味着你似乎不必担心位置性问题,因为无论数据在哪个缓存中都可以访问。然而,维持一致性需要花费带宽,所以最好是保持访问的本地化。
  • 内核之间:共享高速缓存 内核之间共享的高速缓存是一个不需要担心位置性的地方:这是处理核心之间真正对称的内存。
  • 在插槽之间:节点(或主板)上的插槽在程序员看来是共享内存的,但这实际上是NUMA访问(2.4.2节),因为内存与特定的插槽相关。
  • 通过网络结构:有些网络有明显的位置效应。你在第2.7.1节中看到了一个简单的例子,一般来说,很明显,任何网格型网络都会有利于 “附近 “处理器之间的通信。基于胖树的网络似乎不存在这样的争论问题,但是层次引起了不同形式的定位性。比节点上的局域性高一级,小群的节点通常由一个叶子开关连接,它可以防止数据进入中央开关。

并行计算(三)

多线程架构

当代CPU架构模式很大程度上取决于:机器从内存中获取数据要远比处理这些数据要慢得多。因此,由更快更小的存储器组成的层次结构试图通过靠近处理单元以缓解内存的长延迟和低带宽。此外,在处理单元中进行指令级并行也有助于隐藏延迟、充分利用带宽。

然而,寻找指令级并行属于编译器的工作范畴,该处可执行空间有限;另一方面,科学计算中的代码往往更加适合数据并行,这对于编译器来说较为困难但对程序员说却显而易见。能否让程序员明确地指出这种并行性?并让处理器使用它?

前面我们看到SIMD架构可以以明确的数据并行方式进行编程。如果我们有大量的数据并行,但没有那么多的处理单元该怎么办?在这种情况下,我们可以把指令并行变成线程并行,让多个线程在每个处理单元上执行。每当一个线程因为未完成的内存请求而停滞不前时,处理器可以切换到另一个线程,因为所有必要的输入都是可用的。这就是所谓的「多线程」(multi-threading)。虽然这听起来像是一种防止处理器等待内存的方法,但也可以被看作是一种保持内存最大限度被占用的方法。

练习 2.41 把内存的长延迟和有限的带宽看作是两个独立的问题,多线程是否同时解决了这两个问题?

这里的问题是,大多数CPU并不擅长在线程之间快速切换。上下文切换(在一个线程和另一个线程之间切换)需要大量的周期,与等待主内存的数据相当。在所谓的「多线程架构」(Multi-Threaded Architecture,MTA)中,上下文切换是非常有效的,有时只需要一个周期,这使得一个处理器可以同时在许多线程上工作。

多线程的概念在Tera Computer MTA机器中得到了探索,该机器演变成了目前的Cray XMT9。

MTA的另一个例子是GPU,其中处理器作为SIMD单元工作,同时本身也是多线程的。

GPU与协处理器

当前,CPU在处理各种计算中都参与了一定程度的作用,也就是说,如果限制处理器处理的功能,有可能提高其专注效率,或降低其功耗。因此我们试图在主机进程中加入一个辅助处理器如,英特尔的8086芯片,为第一代的IBM PC提供动力;可以添加一个数字协处理器,即80287,这种处理器在超越函数方面非常有效,而且它还采用了SIMD技术;使用独立的图形功能也很流行,导致了X86处理器的SSE指令,以及独立的GPU单元被连接到PCI-X总线。

进一步的例子是使用数字信号处理(DSP)指令的协处理器,以及可以重新配置以适应特定需求的FPGA板。早期的阵列处理器,如ICL DAP也是协处理器。

在本节中,我们将简要介绍这一理念的一些现代化身,特别是GPU。

追溯历史

协处理器可以用两种不同的方式进行编程:有时它是无缝集成的,某些指令在协处理器中自动执行,而不是在 “主 “处理器上执行。另一方面,也有可能需要明确调用协处理器功能,甚至有可能将协处理器功能与主机功能重叠。从效率的角度看,后一种情况可能听起来很有吸引力,但它提出了一个严重的编程问题。程序员现在需要确定两个工作流:一个用于主机处理器,一个用于协处理器。

采用协处理器的并行机器有:

  • 英特尔Paragon(1993)每个节点有两个处理器,一个用于通信,另一个用于计算。这些处理器实际上是相同的,即英特尔i860英特尔i860处理器。在后来的修订中,有可能将数据和函数指针传递给通信处理器。
  • 洛斯阿拉莫斯的IBM Roadrunner是第一台达到PetaFlop的机器。(葡萄计算机更早达到了这一点,但那是一台用于分子动力学计算的特殊用途机器)。它通过使用Cell协处理器达到了这个速度。顺便说一下,Cell处理器实质上是索尼Playstation3的引擎,再次显示了超级计算机的商品化。
  • 中国的 “天河一号 “在2010年登上了Top500榜单,由于使用了NVidia GPU,达到了约2.5PetaFlop。
  • 天河二号和TACC Stampede集群使用英特尔Xeon Phi协处理器。

Roadrunner和Tianhe-1A是协处理器的例子,它们非常强大,需要独立于主机CPU进行明确编程。例如,在天河-1A的GPU上运行的代码是用CUDA编程并单独编译的。

在这两种情况下,由于协处理器不能直接通过网络交流,可编程性问题进一步加剧。要把数据从一个协处理器发送到另一个协处理器,必须先传到一个主处理器,再从那里通过网络传到另一个主处理器,然后才移到目标协处理器。

瓶颈问题

协处理器通常有自己的内存,英特尔Xeon Phi可以独立运行程序,但更多时候存在如何访问主处理器内存的问题。一个流行的解决方案是通过PCI总线连接协处理器。这样访问主机内存的速度比主机处理器的直接连接要慢。例如,Intel Xeon Phi 的带宽为512位宽,每秒5.5GT(我们将在第二部分讨论这个 “GT”),而它与主机内存的连接为5.0GT/s,但只有16位宽。

GT测量 我们习惯于看到以千兆位/秒为单位的带宽。对于PCI总线,人们经常看到GT测量。这代表了千兆传输,它衡量了总线在零和一之间改变状态的速度。通常情况下,每个状态转换都对应一个比特,但总线必须提供自己的时钟信息,如果你发送一个相同的比特流,时钟会被混淆。因此,为了防止这种情况,通常将每8位编码为10位。然而,这意味着有效带宽比理论数字要低,在这种情况下是4/5的系数。

由于制造商喜欢讨论事情的积极一面,因此他们报告的数字会更高。

GPU计算

图形处理单元(Graphics Processing Unit,GPU),有时也称为「通用图形处理单元」(General Purpose Graphics Processing Unit,GPGPU),是一种特殊用途的处理器,是为快速图形处理而设计的。然而,由于为图形所做的操作是一种算术形式,GPU已经逐渐发展出对非图形计算也很有用的设计。GPU的一般设计是由 “图形流水线 “激发的:在数据并行的形式下,对许多数据元素进行相同的操作,并且许多这样的数据并行块可以在同一时间激活。

CPU的基本限制也适用于GPU:对内存的访问会产生很长的延迟。在CPU中解决这个问题的方法是引入各级缓存;在GPU中则采取不同的方法。GPU关注的是吞吐量计算,以高平均速率提供大量数据,而不是尽可能快地提供任何单一结果。这是通过支持许多线程=并在它们之间快速切换而实现的。当一个线程在等待内存中的数据时,另一个已经拥有数据的线程可以继续进行计算。

用内核进行SIMD型编程

当前的GPU的一个架构中结合了SIMD和SPMD并行性。线程并非完全独立,而是在线程块中排列,所有的线程块中执行相同的指令以实现SIMD。也有可能将同一指令流(CUDA术语中的 “内核”)安排在一个以上的线程块上。在这种情况下,线程块可以不同步,这有点类似于SPMD上下文中的进程。然而,由于我们在这里处理的是线程,而不是进程,所以使用了「单指令多线程」(Single Instruction Multiple Thread,SIMT)这一术语。

这种软件设计在硬件中很明显;例如,NVidia GPU有16-30个流式多处理器(SMs),一个SMs由8个流式处理器(SPs)组成,对应于处理器内核;见图2.34。SPs以真正的SIMD方式行动。GPU中的内核数量通常比传统的多核处理器要多,但内核的数量却更加有限。因此,这里使用了多核这个术语。

GPU的SIMD(即数据并行)性质在CUDA启动进程的方式中变得很明显。内核,即一个将在GPU上执行的函数,在𝑚𝑛核上启动。

1
KernelProc<< m,n >>(args)

执行内核的𝑚𝑛内核的集合被称为「网格」(grid),它的结构为𝑚线程块,每个线程块有𝑛线程。一个线程块最多可以有512个线程。

回顾一下,线程共享一个地址空间,所以它们需要一种方法来识别每个线程将对哪一部分数据进行操作。为此,线程中的区块用𝑥 , 𝑦坐标编号,而区块中的线程用𝑥 , 𝑦 , 𝑧坐标编号。每个线程都知道自己在块中的坐标,以及其块在网格中的坐标。

我们用一个向量加法的例子来说明这一点。

1
2
3
4
5
6
7
8
9
10
 // 每个线程执行一次加法
__global__ void vecAdd(float* A, float* B, float* C)
{
int i = threadIdx.x + blockDim.x * blockIdx.x;
C[i] = A[i] + B[i];
}
int main() {
// 运行N/256块的网格,每块256个线程
vecAdd<<< N/256, 256>>>(d_A, d_B, d_C);
}

这显示了GPU的SIMD性质:每个线程都在执行相同的标量程序,只是在不同的数据上执行。

线程块中的线程是真正的数据并行:如果有一个条件,使一些线程走真分支,其他线程走假分支,那么一个分支将首先被执行,另一个分支的所有线程都被停止。随后,而不是同时,另一个分支上的线程将执行他们的代码。这可能会引起严重的性能损失。

GPU依赖于大量的数据并行性和快速上下文切换的能力。这意味着它们将在有大量数据并行的图形和科学应用中茁壮成长。然而,它们不太可能在 “商业应用 “和操作系统中表现良好,因为那里的并行性是指令级并行性(ILP)类型,通常是有限的。

GPU与CPU的对比

这些是GPU和普通CPU之间的一些区别。

  • 截至2010年底,GPU是附加的处理器,例如通过PCI-X总线,所以它们操作的任何数据都必须从CPU传输。由于这种传输的内存带宽很低,至少比GPU的内存带宽低10倍,因此必须在GPU上做足够的工作来克服这种开销。
  • 由于GPU是图形处理器,所以它强调的是单精度浮点运算的metic。为了适应科学计算界,对双精度的支持正在增加,但双精度速度通常是单精度翻转率的一半。这种差异可能会在未来几代中得到解决。
  • CPU被优化为处理单一的指令流,这些指令可能具有很强的异质性;而GPU是明确为数据并行化而制造的,在传统代码上表现很差。
  • CPU是为了处理一个线程,或最多是少量的线程。GPU需要大量的线程,远远大于计算核心的数量,才能有效地执行。

GPU的预期收益

GPU在实现高性能、高成本效益方面已经迅速获得了声誉。关于用最小的努力将代码移植到CUDA上的故事比比皆是,由此带来的速度提升有时达到400倍。GPU真的是如此神奇的机器吗?原有的代码是否编程不当?如果GPU这么厉害,为什么我们不把它用于所有的事情呢?

事实有几个方面。

首先,GPU并不像普通CPU那样具有通用性。GPU非常擅长做数据并行计算,而CUDA则擅长优雅地表达这种细粒度的并行性。换句话说,GPU适用于某种类型的计算,而对许多其他类型的计算则不适合。

相反,普通的CPU不一定擅长数据并行化。除非代码写得非常仔细,否则性能会从最佳状态下降,大约有以下几个因素:

  • 除非使用明确的并行结构指令,否则编译后的代码将永远使用可用内核中的一个,例如4个。
  • 如果指令没有流水线,那么浮点流水线的延迟又会增加4个系数。
  • 如果内核有独立的加法和乘法管线,如果不同时使用,又会增加2个因素。
  • 如果不使用SIMD寄存器,就会在峰值性能方面增加更多的减慢。
  • 编写计算内核的最佳CPU实现往往需要使用汇编程序,而直接的CUDA代码将以相对较少的努力实现高性能,当然,前提是计算有足够的数据并行性。

英特尔Xeon Phi

英特尔Xeon Phi,也被其架构设计称为多集成核心(MIC),是一种专门为数值计算设计的设计。最初的设计,即Knight’s Corner是一个协处理器,而第二次迭代,即Knight’s Landing是自带主机的。

作为一个协处理器,Xeon Phi与GPU既有区别又有相似之处。

  • 两者都是通过PCI-X总线连接,这意味着设备上的操作在启动时有相当的延迟。
  • Xeon Phi有通用的内核,因此它可以运行整个程序;而GPU只在有限的范围内具有这种功能(见2.9.3.1节)。
  • Xeon Phi接受普通的C代码。
  • 两种架构都需要大量的SIMD式并行,在Xeon Phi的情况下,因为有8字宽的AVX指令。
  • 两种设备都是通过加载主机程序来工作,或者可以通过加载主机程序来工作。

负载均衡

在本章的大部分内容中,我们都假设的是一个问题可以被完美分配到各个处理器上,即一个处理器总是在进行有效的工作且只会因为通信延迟而空闲。然而在实际中,处理器的空闲可能因为其正在等待消息,而发送处理器甚至还没有达到其代码中的发送指令。这种情况下,一个处理器在工作,另一个处理器在闲置,被描述为负载不均衡(load unbalance):一个处理器闲置没有内在的原因,如果我们以不同的方式分配工作负载,它本来可以工作。

在处理器有太多的工作和没有足够的工作之间存在着不对称性:有一个处理器提前完成任务,比有一个超负荷的处理器使所有其他处理器都在等待它要好。

练习 2.42 将这个概念精确化。假设一个并行任务在所有处理器上都需要时间1,但只有一个处理器。

  • 假设$0<\alpha<1$,而一个并行任务花费的时间是$1+\alpha$,那么速度提升和效率与处理器数量的关系是什么?在Amdahl和Gustafsson的意义上考虑这个问题(2.2.3节)。

  • 如果一个处理器需要时间$1-\alpha$,请回答同样的问题。

负载均衡的代价往往是昂贵的,因为它需要移动大量的数据。例如,第6.5节有一个分析表明,在稀疏矩阵与向量乘积过程中的数据交换比存储在处理器上的数据要低一阶。然而,我们不会去研究移动的实际成本:我们在这里主要关注的是均衡工作均衡,以及保留原始负载分布中的任何位置性。

负载均衡与数据分配

工作和数据之间存在着双重性:在许多应用中,数据的分布意味着工作的分布,反之亦然。如果一个应用程序更新一个大的数组,数组的每个元素通常 “生活 “在一个唯一确定的处理器上,该处理器负责该元素的所有更新。这种策略被称为拥有者计算(owner computes)。

因此,数据和工作之间存在着直接的关系,相应地,数据分配和负载均衡也是相辅相成的。例如,在第6.2节中,我们将谈论数据分布如何影响效率,但这立即转化为对负载分布的关注。

  • 负载需要被均匀地分配。这通常可以通过均匀地分配数据来实现,但有时这种关系并不是线性的。
  • 任务需要被放置,以尽量减少它们之间的流量。在矩阵-向量乘法的情况下,这意味着二维分布要优于一维分布;关于空间填充曲线的讨论也是类似的动机。

作为数据分布如何影响负载均衡的一个简单例子,考虑一个线性数组,其中每个点经历相同的计算,每个计算需要相同的时间。如果数组的长度$𝑁$,完全可以被处理器的数量$𝑝$所分割,那么工作就完全均匀分布。如果数据不能平均分割,我们首先将$⌊N/p⌋$点分配给每个处理器,剩下的$N- p⌊N/p⌋$点分配给最后几个处理器。

练习 2.43 在最坏的情况下,处理器的工作将变得多么不均衡?将这个方案与将$⌈N/p⌉$点分配给所有处理器的方案进行比较,除了一个处理器得到的点数较少;见上面的练习。

将剩余的$r=N-p⌊N/p⌋$分摊到$𝑟$处理器上比一个更好。这可以通过给第一个或最后一个𝑟处理器提供一个额外的数据点来实现。这可以通过给进程$𝑝$分配范围来实现

虽然这个方案是很均衡的,但例如计算一个给定的点属于哪个处理器是很棘手的。下面的方案使这种计算更容易:让$f(i)=⌊iN/p⌋$,那么处理器𝑖得到的点$f(i)$直到$f(i + 1)$。

练习 2.44 证明$⌊N/p⌋ ≤ f(i + 1) - f(i) \leqslant ⌈N/p⌉$。

根据这个方案,拥有索引𝑖的处理器是$⌊(p(i + 1) - 1)/N ⌋$。

负载调度

有些情况下,负载可以比较自由的进行分配,可以通过负载调度实现负载均衡。例如在共享内存的背景下,所有处理器都可以访问所有的数据。在这种情况下,我们可以考虑使用预先确定的工作分配给处理器的静态调度(static scheduling),或在执行期间确定分配的动态调度(dynamic scheduling)之间的区别。

scheduling

为了说明动态调度的优点,考虑在4个线程上调度8个运行时间递减的任务(图2.36)。在静态调度中,第一个线程得到任务1和4,第二个线程得到2和5,依此类推。在动态调度中,任何完成其任务的线程都会得到下一个任务。在这个特定的例子中,这显然给出了一个更好的运行时间。另一方面,动态调度可能会有更高的开销。

独立任务的负载均衡

在其他情况下,工作负荷不是由数据直接决定的。如果有一个待完成的工作池,而每个工作项目的处理时间不容易从其描述中计算出来,就会发生这种情况。在这种情况下,我们可能希望在给流程分配工作时有一些灵活性。

让我们首先考虑这样一种情况:一项工作可以被划分为不相通的独立任务。一个例子是计算Mandelbrot集图片的像素,其中每个像素都是根据一个不依赖于周围像素的数学函数来设置的。如果我们能够预测绘制图片的任意部分所需的时间,我们就可以对工作进行完美的划分,并将其分配给处理器。这就是所谓的「静态负载均衡」(static load balancing)。

更现实的是,我们无法完美地预测工作的某一部分的运行时间,于是我们采用了「过度分解」(overdecomposition)工作的方法:我们将工作分成比处理器数量更多的任务。然后,这些任务被分配到一个「工作池」(work pool)中,每当处理器完成一项工作,就从工作池中抽取下一项工作。这就是所谓的「动态负载均衡」(dynamic load balancing)。许多图形和组合问题都可以用这种方式来解决。

有结果表明,随机分配任务到处理器在统计学上接近于最优[122],但这忽略了科学计算中的任务通常是频繁交流的方面。

练习2.45 假设你有任务$\{T_i\}_{i=1,…,N}$,运行时间为$𝑡_𝑖$,处理器数量不限。查阅2.2.4节中的Brent定理,并从中推导出任务的最快执行方案可以被描述为:有一个处理器只执行具有最大$𝑡_𝑖$值的任务。(这个练习受到了[170]的启发)。

负载均衡是图论问题

接下来,让我们考虑一个并行的工作,其中各部分都有通信。在这种情况下,我们需要均衡标量工作负载和通信。

一个并行计算可以被表述为一个图(见附录18的图论介绍),其中处理器是顶点,如果两个顶点的处理器需要在某个点上进行通信,那么这两个顶点之间就有一条边。这样的图通常是由被解决的问题的基本图衍生出来的。作为一个例子,考虑矩阵-向量乘积$y=Ax$,其中$A$是一个稀疏的矩阵,详细地看一下正在计算$y_i$的处理器,对于一些$𝑖$。$y_i \leftarrow y_{i} + A_{ij}x_j$意味着这个处理器将需要$x_j$的值,所以,如果这个变量在不同的处理器上,它需要被送过去。

我们可以将其标准化。让向量$x$和$y$不相连地分布在处理器上,并唯一地定义$P(i)$为拥有索引$i$的处理器。如果存在一个非零的元素$a_{ij}$,且$P= P(i)$,$Q = P(j)$,那么就有一条边$(P,Q)$。在结构对称矩阵的情况下,这个图是无定向的,即$a_{ij} \neq 0\Leftrightarrow a_{ij}\neq 0$。

指数在处理器上的分布现在给了我们顶点和边的权重:一个处理器有一个顶点权重,即它所拥有的指数数量;一条边$(P,Q)$有一个权重,即需要从$Q$发送到𝑃的向量成分的数量,如上所述。

现在可以将负载均衡问题表述如下。

找到一个分区$\mathbb{P} = \cup_𝑖\mathbb{P}_i$,这样顶点权重的变化最小,同时边缘权重也尽可能的低。

顶点权重的变化最小化意味着所有处理器的工作量大致相同。保持边缘权重低意味着通信量低。这两个目标不需要同时满足:可能会有一些折衷。

练习 2.46 考虑极限情况,即处理器的速度是无限的,处理器之间的带宽也是无限的。剩下的决定运行时间的唯一因素是什么?你现在需要解决什么图形问题来找到最佳的负载均衡?稀疏矩阵的什么属性给出了最坏情况下的行为?

一个有趣的负载均衡方法来自谱图理论:如果$𝐴𝐺$是无向图的邻接矩阵,$𝐷_𝐺-𝐴_𝐺$是「拉普拉斯矩阵」(graph Laplacian),那么通往最小特征值0的特征向量$u_1$是正的,而通往下一个特征值的特征向量$u_2$是与它正交。因此,$u_2$必须有交替符号的元素;进一步分析表明,有正符号的元素和负符号的元素是相连的。这就导致了图形的自然分割。

负载重分配

在某些应用中,最初的载荷分布是明确的,但后来需要调整。一个典型的例子是在有限元方法(FEM)代码中,载荷可以通过物理域的划分来分配。如果后来领域的离散化发生了变化,负载就必须重新或重新分配。在接下来的小节中,我们将看到旨在保持局部性的负载均衡和再均衡的技术。

扩散负载均衡

在许多实际情况下,我们可以将处理器图与我们的问题联系起来:任何一对进程之间都有一个顶点,通过点对点通信直接互动。因此,在负载均衡中使用这个图似乎是一个很自然的想法,只把负载从一个处理器转移到图中的邻居那里。这就是扩散式负载均衡的想法[37, 112]。虽然该图在本质上不是有向的,但为了负载均衡,我们在边上放上任意的方向。负载均衡的描述如下。

设$l$是进程$i$的负载,$\tau (j)$是边$j\rightarrow i$上负载的转移。那么

虽然我们只是用了一个$i, j$的边数,但在实践中,我们把边数线性化了。然后我们得到一个系统

其中,

  • $A$是一个大小为$|N|\times |E|$的矩阵,描述了连接到方节点的各条边,其元素值等于$\pm1$,取决于

  • $T$是转移的向量,大小为$|E|$;和

    • $\bar{L}$是负荷偏差向量,表明每个节点比平均负荷高出/低出多少。

在线性处理器阵列的情况下,这个矩阵是欠确定的,边比处理器少,但在大多数情况下,系统将是超确定的,边比进程多。因此,我们要解决

由于$A^tA$和$AA^t$是非正定的,我们可以通过放松来解决大约,只需要局部知识。当然, 这种松弛的收敛速度很慢, 全局性的方法, 如Conjugate Gradients (CG), 会更快[112].

用空间填充曲线实现负载均衡

在前面的章节中,我们考虑了负载均衡的两个方面:确保所有的处理器都有大致相等的工作量,以及让分布反映问题的结构,以便将通信控制在合理范围内。我们可以这样表述第二点,当分布在并行机器上时,试图保持问题的局部性:空间中靠近的点很可能会发生交互,所以它们应该在同一个处理器上,或者至少是一个不太远的处理器。

努力保持位置性显然不是正确的策略。在BSP中,有一个统计学上的论点,即随机放置将提供一个良好的负载均衡以及通信均衡。

练习 2.47 考虑将进程分配给处理器,问题的结构是每个进程只与最近的邻居通信,并让处理器在一个二维网格中排序。如果我们对进程网格进行明显的分配,就不会有争执。现在写一个程序,将进程分配给随机的处理器,并评估会有多少争用。

在上一节中,你看到了图划分技术是如何帮助实现第二点,即保持问题的局部性。在本节中,你将看到一种不同的技术,它对初始负载分配和后续的负载再均衡都有吸引力。在后一种情况下,一个处理器的工作可能会增加或减少,需要将一些负载转移到不同的处理器。

例如,有些问题是自适应细化的 。这在图2.37中得到了说明。如果我们跟踪这些细化水平,问题就会得到一个树状结构,其中的叶子包含所有的工作。负载均衡变成了在处理器上划分树叶的问题;图2.38。现在我们观察到,这个问题有一定的局部性:任何非叶子节点的子树在物理上都很接近,所以它们之间可能会有通信。

  • 可能会有更多的子域出现在处理器上;为了尽量减少处理器之间的通信,我们希望每个处理器都包含一个简单连接的子域组。此外,我们希望每个处理器所覆盖的域的一部分是 “紧凑 “的,即它具有低长宽比和低表面体积比。
  • 当一个子域被进一步细分时,其处理器的部分负载可能需要转移到另一个处理器。这个负载重新分配的过程应该保持位置性。

为了满足这些要求,我们使用空间填充曲线(SFC)。负载均衡树的空间填充曲线(SFC)如图2.39所示。我们不会对SFC进行正式的讨论;相反,我们将让图2.40代表一个定义:SFC是一个递归定义的曲线,每个子域都接触一次12

my_octree2

my_octree3

SFC的特性是,在物理上相近的领域元素在曲线上也会相近,所以如果我们将SFC映射到处理器的线性排序上,我们将保留问题的局部性。

更重要的是,如果领域再细化一个层次,我们就可以相应地细化曲线。然后,负载可以被重新分配到曲线上的相邻处理器上,而我们仍然会保留位置性。

(空间填充曲线(SFCs)在N体问题中的使用在[198]和[187]中讨论过)。

其他话题

分布式计算、网格计算、云计算

在本节中,我们将对云计算等术语以及早先的一个术语分布式计算进行简短的了解。这些都是与科学意义上的并行计算有关系的概念,但它们在某些基本方面是不同的。

分布式计算可以追溯到来自大型数据库服务器,如航空公司的预订系统,它必须被许多旅行社同时访问。对于足够大的数据库访问量,单台服务器是不够的,因此发明了「远程过程调用」(remote procedure call)的机制,中央服务器将调用不同(远程)机器上的代码(有关的过程)。远程调用可能涉及数据的传输,数据可能已经在远程机器上,或者有一些机制使两台机器的数据保持同步。这就产生了「存储区域网络」(Storage Area Network,SAN)。比分布式数据库系统晚了一代,网络服务器不得不处理同样的问题,即许多人同时访问必须表现得像一个单一的服务器。

我们已经看到了分布式计算和高性能并行计算之间的一个巨大区别。科学计算需要并行性,因为单一的模拟对一台机器来说变得太大或者太慢;上面描述的商业应用涉及许多用户针对一个大数据集执行小程序(即数据库或网络查询)。对于科学需要,并行机器的处理器(集群中的节点)必须有一个非常快的连接;对于商业需要,只要中央数据集保持一致,就不需要这样的网络。

在高性能计算和商业计算中,服务器都必须保持可用和运行,但在分布式计算中,在如何实现这一点上有相当大的自由度。对于一个连接到数据库等服务的用户来说,由哪个实际的服务器来执行他们的请求并不重要。因此,分布式计算可以利用虚拟化:一个虚拟服务器可以在任何硬件上生成。

可以在远程服务器和电网之间做一个类比,前者在需要的地方提供计算能力,后者在需要的地方提供电力。这导致了网格计算或实用计算的出现,美国国家科学基金会拥有的Teragrid就是一个例子。网格计算最初是作为一种连接计算机的方式,通过「局域网」(Local Area Network,LAN)或「广域网」(Wide Area Network,WAN),通常是互联网连接起来。这些机器本身可以是平行的,而且通常由不同的机构拥有。最近,它被视为一种通过网络共享资源的方式,包括数据集、软件资源和科学仪器。

实用计算作为一种提供服务的方式的概念,你从上述分布式计算的描述中认识到,随着谷歌的搜索引擎成为主流,它为整个互联网建立了索引。另一个例子是安卓手机的GPS功能,它结合了地理信息系统、GPS和混搭数据。Google的收集和处理数据的计算模型已经在MapReduce[40]中正式化。它结合了数据并行方面(”地图 “部分)和中央积累部分(”规约”)。两者都不涉及科学计算中常见的紧密耦合的邻居间通信。一个用于MapReduce计算的开源框架为Hadoop[95]。亚马逊提供了一个商业的Hadoop服务。

即使不涉及大型数据集,由远程计算机为用户需求服务的概念也很有吸引力,因为它免除了用户在其本地机器上维护软件的需要。因此,Google Docs提供了各种 “办公 “应用程序,用户无需实际安装任何软件。这种想法有时被称为软件即服务(SAS),用户连接到一个 “应用服务器”,并通过一个客户端(如网络浏览器)访问它。在谷歌文档的情况下,不再有一个大型的中央数据集,而是每个用户与他们自己的数据互动,这些数据在谷歌的服务器上维护。这当然有一个很大的好处,那就是用户可以从任何可以使用网络浏览器的地方获得数据。

SAS的概念与早期技术有一些联系。例如,在大型机和工作站时代之后,所谓的瘦客户机想法曾短暂流行。在这里,用户将拥有一个工作站而不是一个终端,但却可以在一个中央服务器上存储的数据上工作。沿着这种思路的一个产品是Sun公司的Sun Ray(大约在1999年),用户依靠一张智能卡在一个任意的、无状态的工作站上建立他们的本地环境。

使用场景

按需提供服务的模式对企业很有吸引力,这些企业越来越多地使用云服务。它的优点是不需要最初的货币和时间投资,也不需要对设备的类型和大小做出决定。目前,云服务主要集中在数据库和办公应用上,但具有高性能互连的科学云正在开发中。

以下是对云资源使用场景的大致分类13。

  • 扩展。在这里,云资源被用作一个平台,可以根据用户需求进行扩展。这可以被认为是平台即服务(PAS):云提供软件和开发平台,免除了用户的管理和维护。
    我们可以区分两种情况:如果用户正在运行单个作业并积极等待。如果用户正在运行单个作业并积极等待输出,可以增加资源以减少这些作业的等待时间(能力测试)。另一方面,如果用户正在向一个队列提交作业,并且任何特定作业的完成时间并不重要(能力组合),资源可以随着队列的增长而增加。在HPC应用中,用户可以将云资源视为一个集群;这属于基础设施即服务(IAS):云服务是一个计算平台,允许在操作系统层面进行定制。
  • 多租户。在这里,同一个软件被提供给多个用户,让每个人都有机会进行个性化定制。这属于软件即服务(SAS):软件按需提供;客户不购买软件,只为其使用付费。
  • 批量处理。这是上述扩展方案之一的有限版本:用户有大量的数据需要以批处理模式进行处理。然后,云就成为一个批处理者。这种模式是MapReduce计算的良好候选者;2.11.3节。
  • 存储。大多数云供应商都提供数据库服务,这些模式都是为了让用户不需要维护自己的数据库,就像缩放和批量处理模式让用户不需要担心维护集群硬件一样。
  • 同步化。这种模式在商业用户应用中很受欢迎。Netflix和亚马逊的Kindle允许用户消费在线内容(分别是流媒体电影和电子书);暂停内容后,他们可以从任何其他平台恢复。苹果公司最近的iCloud为办公应用中的数据提供了同步,但与Google Docs不同的是,这些应用不是 “在云中”,而是在用户机器上。

第一个可以公开访问的云是亚马逊的弹性计算云(EC2),于2006年推出。EC2提供各种不同的计算平台和存储设施。现在有一百多家公司提供基于云的服务,远远超出了最初的计算机出租的概念。

从计算机科学的角度来看,云计算的基础设施可能是有趣的,涉及到分布式文件系统、调度、虚拟化和确保高可靠性的机制。

一个有趣的项目,结合了网格和云计算的各个方面,是加拿大天文研究高级网络[179]。在这里,大量的中央数据集被提供给天文学家,就像在一个网格中一样,同时还有计算资源,以类似云的方式对其进行分析。有趣的是,云资源甚至采取了用户可配置的虚拟集群的形式。

角色定位

综上所述,14 我们有三种云计算服务模式。

  • 软件即服务:消费者运行供应商的应用程序,通常通过浏览器等客户端;消费者不安装或管理软件。谷歌文档就是一个很好的例子。

  • 平台即服务:向消费者提供的服务是运行由消费者开发的应用程序的能力,消费者不管理所涉及的处理平台或数据存储。

  • 基础设施即服务:供应商向消费者提供运行软件的能力,并管理存储和网络。消费者可以负责操作系统的选择和网络组件,如防火墙。

这些可以按以下方式部署。

  • 私有云:云基础设施由一个组织管理,供其独家使用。

  • 公共云:云基础设施由广大客户群管理使用。我们还可以定义混合模式,如社区云。

那么,云计算的特点是。

  • 按需和自我服务:消费者可以快速请求服务和改变服务水平,而不需要与提供者进行人工互动。

  • 快速弹性:在消费者看来,存储或计算能力的数量是无限的,只受预算的限制。请求额外的设施是快速的,在某些情况下是自动的。

  • 资源池:虚拟化机制使云看起来像一个单一的实体,而不考虑其底层基础设施。在某些情况下,云会记住用户访问的 “状态”;例如,亚马逊的Kindle书籍允许人们在个人电脑和智能手机上阅读同一本书;云存储的书籍 “记住 “读者离开的地方,而不管平台如何。

  • 网络访问:云可以通过各种网络机制使用,从网络浏览器到专用门户。

  • 测量服务:云服务通常是 “计量 “的,消费者为计算时间、存储和带宽付费。

能力与容量计算

大型并行计算机可以以两种不同的方式使用。在后面的章节中,你将看到科学问题是如何几乎可以任意扩大规模的。这意味着,随着对精度或规模的需求越来越大,需要越来越大的计算机。使用整台机器来解决一个问题,只以解决问题的时间作为衡量成功的标准,这被称为能力计算。

另一方面,许多问题需要比整台超级计算机更少的时间来解决,所以通常一个计算中心会设置一台机器,让它为连续的用户问题服务,每个问题都比整台机器小。在这种模式下,衡量成功的标准是单位成本的持续性能。这就是所谓的容量计算,它需要一个精细调整的作业调度策略。

一个流行的方案是公平分享调度,它试图在用户之间,而不是在进程之间平均分配资源。这意味着,如果一个用户最近有作业,它将降低该用户的优先级,它将给短的或小的作业以更高的优先级。这个原则的调度器的例子是SGE和Slurm。

作业可以有依赖性,这使得调度更加困难。事实上,在许多现实条件下,调度问题是NP-complete的,所以在实践中会使用启发式方法。这个话题虽然有趣,但在本书中没有进一步讨论。

MapReduce

MapReduce[40]是一种用于某些并行操作的编程模型。它的一个显著特点是使用函数式编程来实现。MapReduce模型处理以下形式的编译。

  • 对于所有可用的数据,选择满足某种标准的项目。

  • 并为它们发出一个键值对。这就是映射阶段。

  • 可以选择有一个组合/排序阶段,将所有与相同键值有关的对归为一组。

  • 然后对键进行全局还原,产生一个或多个相应的值。这

    这是还原阶段。

现在我们将举几个使用MapReduce的例子,并介绍支撑MapReduce抽象的函数式编程模型。

MapReduce模型的表达能力

MapReduce模型的减少部分使其成为计算数据集全局统计数据的主要候选者。一个例子是计算一组词在一些文档中出现的次数。被映射的函数知道这组词,并为每个文档输出一对文档名称和一个包含词的出现次数的列表。然后,减法对出现次数进行分量级的求和。

MapReduce的组合阶段使得数据转换成为可能。一个例子是 “反向网络链接图”:map函数为在名为 “源 “的页面中发现的每个目标URL链接输出目标-源对。reduce函数将与一个给定的目标URL相关的所有源URL的列表连接起来,并排放出目标列表(source)对。

一个不太明显的例子是用MapReduce计算PageRank(第9.4节)。在这里,我们利用PageRank的计算依赖于分布式稀疏矩阵-向量乘积的事实。每个网页对应于网页矩阵𝑊的一列;给定一个在网页$p$上的概率$j$,然后该网页可以计算出图元$⟨i, w_{ij}p_j⟩$。然后MapReduce的组合阶段将$(W p)_{i}=\sum_{j} w_{i j} p_{j}$。

数据库操作可以用MapReduce来实现,但由于它的延迟比较大,不太可能与独立的数据库竞争,因为独立的数据库是为快速处理单个查询而优化的,而不是批量统计。

第8.5.1节中考虑了用MapReduce进行排序。

其他应用见http://horicky.blogspot.com/2010/08/designing-algorithmis-for-map-reduce.html。

MapReduce软件

谷歌对MapReduce的实现是以Hadoop的名义发布的。虽然它适合谷歌的单阶段读取和处理数据的模式,但对许多其他用户来说,它有相当大的缺点。

  • Hadoop会在每个MapReduce周期后将所有数据冲回磁盘,所以对于需要超过一个周期的操作来说,文件系统和带宽需求太大。

  • 在计算中心环境中,用户的数据不是连续在线的,将数据加载到Hadoop文件系统(HDFS)所需要的时间很可能会压倒实际分析。

由于这些原因,进一步的项目,如Apache Spark(https://spark.apache.org/)提供了数据的缓存。

执行问题

在分布式系统上实现MapReduce有一个有趣的问题:键-值对中的键集是动态确定的。例如,在上面的 “字数 “类型的应用中,我们并不是先验地知道字数的集合。因此,我们并不清楚应该将键值对发送给哪个还原器进程。

例如,我们可以使用一个哈希函数来确定这一点。由于每个进程都使用相同的函数,所以不存在分歧。这就留下了一个问题,即一个进程不知道要接收多少个带有键值对的消息。这个问题的解决方案在第6.5.6节中描述过。

函数式编程

映射和规约操作很容易在任何类型的并行架构上实现,使用线程和消息传递的组合。然而,在开发这个模型的谷歌公司,传统的并行性没有吸引力,原因有二。首先,处理器在计算过程中可能会出现故障,所以传统的并行模式必须要用容错机制来加强。其次,计算硬件可能已经有了负荷,所以部分计算可能需要迁移,而且一般来说,任务之间的任何类型的同步都会非常困难。

MapReduce是一种从并行计算的这些细节中抽象出来的方法,即通过采用函数式编程模型。在这样的模型中,唯一的操作是对一个函数的评估,应用于一些参数,其中参数本身就是一个函数应用的结果,而计算的结果又被作为另一个函数应用的参数。特别是,在严格的函数模型中,没有变量,所以没有静态数据。

一个函数应用,用Lisp风格写成(f a b)(意思是函数f被应用于参数a和b),然后通过收集输入,从它们所在的地方到评估函数f的处理器,来执行。

1
(map f (some list of arguments))

而结果是一个将f应用于输入列表的函数结果列表。所有并行的细节和保证计算成功完成的所有细节都由map函数处理。现在我们只缺少还原阶段,它也同样简单。

1
(reduce g (map f (the list of inputs)))

reduce函数接收一个输入列表并对其进行还原。

这种函数模型的吸引力在于函数不能有副作用:因为它们只能产生一个输出结果,不能改变环境,因此不存在多个任务访问相同数据的协调问题。

因此,对于处理大量数据的程序员来说,MapReduce是一个有用的抽象。当然,在实现层面上,MapReduce软件使用了熟悉的概念,如分解数据空间、保存工作列表、将任务分配给处理器、重试失败的操作等等。

异构计算

你现在已经看到了几种计算模型:单核、共享内存多核、分布式内存集群、GPU。这些模型的共同点是,如果有一个以上的指令流处于活动状态,所有的指令流都是可以互换的。关于GPU,我们需要细化这一说法:GPU上的所有指令流都是可互换的。然而,GPU并不是一个独立的设备,而是可以被认为是主机处理器的一个协处理器。

如果我们想让主机执行有用的工作,而协处理器处于活动状态,我们现在有两个不同的指令流或指令流类型。这种情况被称为异构计算。在GPU的情况下,这些指令流的编程机制甚至略有不同—为GPU使用CUDA,但情况不必如此:英特尔许多集成核心(MIC)架构是用普通C语言编程的。

计算机中的运算

科学计算等领域中常见的数字类型有:整数(或整型)$\cdots$,-2,-1,0,1,2,$\cdots$ 实数 0,1,-1.5,2/3,$\sqrt 2$,log 10,$\cdots$,以及复数 $1+2i$,$\sqrt3-\sqrt5i, \cdots$, 计算机硬件的组织方式是只给一定的空间来表示每个数字,是「字节」(bytes)的倍数,每个字节包含8「」(bits)。典型的数值是整数为4字节,实数为4或8字节,复数为8或16字节。

由于内存空间有限,计算机并不能存储所有范围的数字。整数中计算机只能存储一个范围(Python等语言有任意大的整数,但这没有硬件支持);而在实数里,甚至不能存储一个范围,因为任意区间[a,b]都包含无限多的数字。任何实数的代表都会导致存储的数字之间存在间隔。计算机中的计算被称为「有限精度的运算」(finite precision arithmetic)。由于许多结果是无法表示的,任何导致这种数字的运算都必须通过发出错误或近似的结果来处理。在本章中,我们将研究这种对数值计算的 “真实 “结果的近似的影响。

关于详细的讨论,请参见Overton的书[165];在网上很容易找到Goldberg的文章[80]。关于算法中舍入误差分析的广泛讨论,见Higham [106] 和Wilkinson [201] 的书。

位运算

计算机的最底层是以「」(bits)来存储和表示的。比特,是 “二进制数字 “的简称,分为0和1。使用比特我们就可以用二进制表达数字。

其中的下标表示数字的进制。

存储器的下一个组织层次是「字节」(bytes):一个字节由8位组成,因此可以代表0-255的数值。

练习 3.1 使用位操作来测试一个数字是奇数还是偶数。你能多想几种方法吗?

整数

科学计算绝大多数情况都是在实数上的运算。除了密码学等应用,对整数的计算很少增加到任意多的位数。也有一些应用,如 “「粒子模型」(particle-in-cell)”,可以用位操作来实现。然而,整数在索引计算中仍有经验。

整数通常以16、32或64位存储,16位越来越少,64位则越来越多。这种增长的主要原因不是计算性质的变化,而是因为整数被用于数组索引。数据集的增长(特别是在并行计算中),需要更大的索引。例如,在32位中可以存储从0到 $2^{32}- 1 \approx 4⋅10^9$的数字。换句话说,一个32位的索引可以解决4GB的内存。直到最近,这对大多数用途来说已经足够了;如今,对更大的数据集的需求使得64位索引成为必要。

我们对数组进行索引时只需要正整数。当然,在一般的整数计算中,我们也需要容纳负整数。现在我们将讨论几种实现负整数的策略。我们的初衷是,正负整数的算术应该和正整数一样简单:我们用于比较和操作比特串的电路应该可以用于(有符号)整数。

有几种实现负整数的方法。其中最简单的是保留一位作为符号位(sign bit),用剩下的31位(或15位或63位;从现在开始,我们将以32位为标准)位来存储绝对大小。通过比较,我们将把比特串的直接解释称为「无符号整数」(unsigned integers)。

比特串 00⋯0 … 01⋯1 10⋯0 … 11⋯1
解释为无符号int 0 … $2^{31}-1$ $2^{31}…2^{32}-1$
解释为有符号整数 0 … $2^{31}-1$ $-0…(2^{31}-1)$

这种方案有一些缺点,其中之一是同时存在正数和负数0。这意味着对平等的测试变得更加复杂,而不是简单地作为一个位串来测试平等。更重要的是,在比特串的后半部分,作为有符号的整数的解释减少了,向右走了。这意味着对大于的测试变得复杂;同时,将一个正数加到一个负数上,现在必须与将其加到一个正数上区别对待。

另一个解决方案是将无符号数$n$解释为$n-B$,其中$B$是某个合理的基数,例如 $2^{31}$。

比特串 00⋯0 … 01⋯1 10⋯0 … 11⋯1
解释为无符号int $0 … 2^{31}-1$ $2^{31}…2^{32}-1$
解释为移位的int $-2^{31}…-1$ $0…2^{31}-1$

这种移位方案不存在$\pm$0的问题,数字的排序也是一致的。然而,如果我们通过对代表$n$的位串进行操作来计算$n-n$,我们并没有得到零的位串。

为了保证这种理想的行为,我们改用正负数旋转数线,将零的模式放回零处。

由此产生的方案,也就是最常用的方案,被称为「二进制补码」(2’s complement)。使用这种方案,整数的表示方法正式定义如下。

定义2:设$n$是一个整数,则其二进制$\beta(n)$是个非负整数定义如下:

  • 如果$0\leqslant n\leqslant 2^{31}-1$,则使用正常的$n$比特模式,即

  • 对于$-2^{31} \leqslant n \leqslant -1$,$n$是由$2^{32} - |n|$的比特模式表示。

我们用$\eta=\beta-1$来表示接受一个比特模式并解释为整数的反函数。

下表显示了比特串与它们作为二进制整数的解释之间的对应关系。

比特串$n$ 00…0 … 01…1 10…0 … 11…1
解释为无符号int $0 …2^{31}-1$ $2^{31} … 2^{32}-1$
解释$\beta(n)$为二进制整数 $0 …2^{31}-1$ $-2^{31} … -1$

值得注意的是:

  • 正整数和负整数的比特模式之间没有重叠,特别是,只有一个零的模式。
  • 正数的前导位是零,而负数的前导位是1。这使得前导位就像一个符号位;但是请注意上面的讨论
  • 如果你有一个正数𝑛,你可以通过翻转所有的位,然后加1来得到-𝑛。

练习 3.2 对于负数的原始方案和二进制补码方案,给出比较测试 $m < n$的伪码,其中 $m$ 和 $n$是整数。请注意区分$m、n$为正数、零数或负数的所有情况。

整数溢出

两个相同符号的数字相加,或两个任意符号的数字相乘,都可能导致结果过大或过小而无法表示。这就是所谓的「溢出」(overflow)。下面以一个例子进行讨论:

练习 3.3 调查一下当你进行这样的计算时会发生什么。如果你试图明确写下一个不可表示的数字,例如在一个赋值语句中,编译器会如何提示?

如果使用C语言,我们可能得到一个有意义的结果,这是因为在有符号数的情况下,C标准下并没有定义溢出行为。

二进制加法

让我们考虑对二进制整数做一些简单的算术。我们首先假设我们拥有能处理无符号整数的硬件。我们的目标是看到我们可以用这个硬件对有符号的整数进行计算,就像用二进制表示一样。

我们考虑$m+n$的计算,其中$m,n$是可表示的数字。

我们区分了不同的情况。

  • 简单的情况是 $0 < m, n$。在这种情况下,我们进行正常的加法运算,只要结果保持在$2^{31}$以下,我们就能得到正确的结果。如果结果是$2^{31}$或更多,我们就会出现整数溢出,对此我们无能为力。

    mminusn1

  • 当$m>0,n<0$,并且$m+n>0$.那么$\beta(m)=m$和$\beta(n)=2^{32}-|n|$,所以无符号加法就变成

    由于$m - |n|>0$,这个结果$>2^{32}$。(见图 3.1)然而,我们观察到这基本上是$m + n$的第 33 位被设置。如果我们忽略这个溢出的位,我们就会得到正确的结果。

  • 当$m>0, n<0$但$m+n<0$,那么

    因为$|n|-m>0$,所以得到

二进制减法

在上面的练习3.2中,我们探索了两个整数的比较。现在让我们来探讨一下如何实现两个补码的减法。考虑$0\leqslant m\leqslant 2^{31}-1$和$1\leqslant n\leqslant 2^{31}$,让我们看看在计算$m-n$时会发生什么。

假设我们有一个无符号32位数加减法的算法。我们能不能用它来减去两个补码的整数?我们先观察一下,整数减法$m-n$变成无符号加法$m+(2^{32}-n)$。

  • 当$m<|n|$时,$m-n$为负数且$1\leqslant |m-n|\leqslant 2^{31}$,那么$m-n$的比特形式为

    现在,$2^{32}-(n-m)=m+(2^{32}-n)$,所以我们可以通过将$m$和$-n$的位型相加作为无符号整数来计算$m-n$的二进制码。

  • 当$m>n$,我们注意到$m+(2^{32}-n)=2^{32}+m-n$。由于$m-n>0$,这个数>232,因此不是一个合法的负数表示。然而,如果我们将这个数字存储在33位,我们会发现它是正确的结果$m-n$,加上33位的一个比特。因此,通过执行无符号加法,并忽略溢出位,我们再次得到正确的结果。

在这两种情况下,我们的结论是,我们可以通过将代表$m$和$-n$的无符号数相加,并忽略溢出的情况,来执行减法$m-n$。

其他操作

有些操作在二进制中非常简单:乘以2相当于将所有位向左移动一个,而除以2相当于向右移动一位。至少,无符号整数是这样的。

练习 3.4 当你使用位移在二进制码中乘以或除以2时,是否有额外的复杂情况?

在C语言中,左移操作是<<,右移是>>,因此

1
i<<3

相当于乘以8。

基于二进制的十进制编码

十进制在科学计算中并不重要,但在金融领域却十分有用,因为设计货币的计算绝对要精确。二进制并不擅长使用十进制转换,因为像1/10这样的数字在二进制中是重复的分数。由于尾数的位数有限,这意味着1/10这个数字不能用二进制精确表示。由于这个原因,二进制的十进制编码方案被用于老式的IBM主机,事实上,在IEEE 754[113]的修订中也被标准化了;另见3.3.7节。

在BCD方案中,一个或多个十进制数字被编码为若干比特。最简单的方案是将数字0 … 9的四个比特。这样做的好处是,一个BCD数字中每个数字都很容易被识别;它的缺点是,大约有1/3的比特被浪费了,因为4个比特可以编码0 … 15. 更有效的编码方法是将0 … 999的10个比特,原则上可以存储数字$0 … 10^{23}$. 虽然这样做的效率很高,因为浪费的位数很少,但是识别这样一个数字中的各个位数需要一些解码。由于这个原因,BCD算术需要处理器的硬件支持,现在很少有这种支持;一个例子是IBM Power架构,从IBM Power6开始。

用于计算机算术的其他数基

已经有一些关于三元运算的实验(见http://en.wikipedia.org/wiki/Ternary_computer和http://www.computer-museum.ru/english/setun.htm),但是,没有实际的硬件存在。

实数

在这一节中,我们将研究实数如何在计算机中表示,以及各种方案的局限性。下一节将探讨这对涉及计算机数字的算术的影响。

它们不是真正的实数

在数学科学中,我们通常用实数工作,所以假装计算机也能这样做是很方便的。然而,由于计算机中的数字只有有限的比特数,大多数实数都不能被准确表示。事实上,甚至许多分数也不能准确表示,因为它们会重复;例如,1/3=0.333…,这在十进制或二进制中都不能表示。附录37.6中给出了这方面的一个说明。

练习 3.5 一些编程语言允许你在写循环时不仅使用整数,还可以使用实数作为 “计数器”。解释一下为什么这是个坏主意。提示:何时达到上界?

一个分数是否重复取决于数字系统。(在二进制计算机中,这意味着像1/10这样的分数是重复的,而在十进制算术中,这些分数的位数是有限的。由于小数运算在金融计算中很重要,所以有些人关心这种算术的准确性;关于对此做了什么,见3.2.4.1节。

练习3.6 显示每个二进制分数,即形式为$1.01010111001_2$的数字,都可以精确地表示为一个终止的十进制分数。不是每个十进制分数都能表示为二进制分数的原因是什么?

实数的表示

实数的存储方式类似于所谓的 “科学符号”,即一个数字用一个显数和一个指数表示,例如$6.022⋅10^{23}$,它的显数是6022,第一个数字后有一个「小数点」(radix point),指数是23。这个数字代表

我们引入一个基数,一个小的整数,在前面的例子中是10,在计算机数字中是2,用它来写数字,作为$t$项的和。

其中的组成部分是

  • 符号位」(sign bit):存储数字是正数还是负数的一个位。

  • $\beta$是数字系统的基数。

  • $0 \leqslant d_i \leqslant \beta - 1$ 尾数或显数的位数 - 小数点的位置(小数的小数点)被隐含地假定为小数。小数点的位置被隐含地假定为紧随第一位的位置。

  • $t$是尾数的长度。

  • $e\in [L,U]$指数;通常$L<0<U$和$L\approx -U$。

注意,整数有一个明确的符号位;指数的符号处理方式不同。出于效率的考虑,$e$不是一个有符号的数字;相反,它被认为是一个超过某个最小值的无符号数字。例如,数字0的比特模式被解释为$e = L$。

案例

让我们看一下浮点表示法的一些具体例子。对于人类来说,基数10是最合理的选择,但计算机是二进制的,所以基数2占据主导地位。老式的IBM大型机将比特分组,使之成为基数16的表示法。

其中,单精度和双精度格式是迄今为止最常见的。我们将在第3.3.7节和进一步讨论这些问题。

限制:溢出和下溢

由于我们只用有限的比特来存储浮点数,所以不是所有的数字都能被表示出来。那些不能被表示的数字分为两类:那些太大或太小(在某种意义上)的数字,以及那些落在空白处的数字。

第二类是计算结果必须经过四舍五入或截断才能表示,这是舍入误差分析领域的基础。我们将在下面的章节中详细研究这个问题。

数字过大或过小有以下几种情况。溢出 我们可以存储的最大的数字,其每个数字都等于$\beta$。

unit fractional exponent
position 0 1 … t-1
digit $\beta -1$ $\beta-1 … \beta -1$
value 1 $\beta^{-1}…\beta^{-(t-1)}$

加起来就是

而最小的数字(即最负数)是$-(\beta - \beta^{-(t-1)})$;任何大于前者或小于后者的情况都会导致溢出。大于前者或小于后者都会导致「溢出」(overflow)的情况发生。

下溢最接近零的数字是$\beta-(t-1)⋅L$。如果计算结果小于该值(绝对值),就会导致一种叫做「下溢」(underflow)的情况。。

只有少数实数可以被精确表示,这一事实是舍入误差分析领域的基础。我们将在下面的章节中详细研究这个问题。

溢出或下溢的发生意味着你的计算将从这一点上 “出错”。溢出将使计算在本应是非零的地方以零进行;溢出被表示为Inf,简称 “无限”。

练习 3.7 对于实数$x,y$,$g=\sqrt{(x^2+y^2/2)}$满足

所以,如果𝑥和𝑦是可表示的。如果你用上述公式计算𝑔,会出现什么问题?你能想到一个更好的方法吗?

用Inf计算在某种程度上是可能的:将这些数量中的两个相加又会得到Inf。然而,减去它们会得到NaN:”不是一个数字”。

在这些情况下,计算都不会结束:处理器会继续,除非你告诉它不这样做。这个 “否则 “是指你告诉编译器产生一个「中断」(interrupt),用一个错误信息来停止计算。见3.6.5节。

归一化和非归一化的数字

浮点数的一般定义,方程式(3.1),给我们留下了一个问题,即数字有不止一种表示方法。例如,$.5\times 10^2=.05\times 10^3$。由于这将使计算机运算变得不必要的复杂,例如在测试数字是否相等时,我们使用规范化的浮点数。如果一个数字的第一个数字是非零的,那么这个数字就是归一化的。这意味着尾数部分是

在二进制数的情况下,一个实际的含义是,第一个数字总是1,所以我们不需要明确地存储它。在IEEE 754标准中,这意味着每个浮点数的形式为

而只有数字$d_1d_2…d_t$被存储。

这个方案的另一个含义是,我们必须修改下溢的定义(见上面3.3.3节):任何小于$1⋅\beta L$的数字现在都会导致下溢。试图计算一个绝对值小于该值的数,有时会通过使用非正常化的浮点数来处理,这个过程被称为「渐进式下溢」(gradual underflow)。在这种情况下,指数的一个特殊值表明该数字不再被规范化。在IEEE标准算术的情况下,这是通过一个零指数域来实现的。

然而,这通常比用普通的浮点数计算要慢几十或几百倍。在写这篇文章的时候,只有IBM Power6有硬件支持渐进式下溢。

表示性误差

让我们考虑一个在计算机的数字系统中无法表示的实数。

一个不可表示的数字可以通过普通四舍五入、向上或向下四舍五入或截断来近似表示。这意味着,一个机器数$x$是它周围的所有$x$的代表。在尾数为$t$的情况下,这是与$x$不同的数字的区间,在$t+1$个数字中。对于尾数部分,我们得到。

如果𝑥是一个数字,$\tilde{x}$它在计算机中的表示,我们称$x-\tilde{x}$为「表示性误差」(representation error)或「绝对表示误差」(absolute representation error),$(x-\tilde{x})/x$为「相对表示误差」(relative representation error)。通常情况下,我们对误差的符号不感兴趣。所以我们可以将误差和相对误差分别应用于$|𝑥-\tilde{x}|$和$|\frac{\tilde{x}-x}{x}|$。

通常,我们只对误差的界限感兴趣。如果$\epsilon$是对误差的约束,我们将写成

对于相对误差,我们注意到

让我们考虑一个十进制算术的例子,即$\beta=10$,并且有一个3位数的尾数:$t=3$。数字$x=1.256$,其表示方法取决于我们是四舍五入还是截断。$\tilde{x}_{round} = 1.26$, $\tilde{x}_{truncate} = 1.25$。误差在第四位:如果$\varepsilon =x-\tilde{x}$那么$|\varepsilon| < \beta^{-(t-1)}$。

练习3.8 本例中的数字没有指数部分。如果有的话,其误差和相对误差是多少?

练习3.9 如上所述,在二进制运算中,单位数总是1。这对表示错误有什么影响?

机器精度

通常我们只对表示误差的数量级感兴趣,我们将写$\tilde{x}=x(1+\varepsilon)$,其中$|\epsilon| \leqslant \beta-t$。这个最大的相对误差被称为「机器精度」(machine precision)(有时也称为machine epsilon),典型的数值是。

机器精度可以用另一种方式定义:$\epsilon$是可以加到1上的最小的数字,这样$1+\epsilon$的表示方法与1不同。一个小例子表明,对齐指数可以转移一个太小的操作数,这样它在加法运算中就被有效地忽略。

另一种方法是,在加法$x+y$中,如果$x$和$y$的比例过大,结果将与$x$相同。

机器精度是计算可达到的最大精度:如果要求单精度超过6位或更多位数的精度,或者双精度超过15位,是没有意义的。

练习3.10 写一个小程序,计算机器的$\epsilon$值。如果你把编译器的优化级别设置得低或高,有什么区别吗?

练习3.11 数字$e\approx 2.72$,自然对数的基数,有多种定义。其中一个是

写一个单精度程序,尝试用这种方式计算$e$。评估上界$n=10^k$的表达式,$k=1, …. , 10$. 解释一下大$n$的输出。对误差的行为进行评论。

IEEE 754的浮点数标准

几十年前,像尾数的长度和操作的四舍五入行为等问题在不同的计算机制造商之间,甚至在同一制造商的不同型号之间可能会有所不同。从代码的可移植性和结果的可重复性来看,这显然是一个坏情况。IEEE 754标准对这一切进行了编纂,例如,规定单精度和双精度算术的尾数为24和53位,使用符号位、指数、尾数的存储序列,见图3.2。图中列出了单精度标准中所有可能的位模式的含义。

注释 11 754标准的全称是’IEEE二进制浮点运算标准(AN- SI/IEEE Std 754-1985)’。它也与IEC 559:’微处理器系统的二进制浮点算术’相同,被ISO/IEC/IEEE 60559:2011所取代。

IEEE 754是二进制算术的标准;还有一个标准,IEEE 854,允许十进制算术。

注释 12 令人瞩目的是,在场的这么多硬件人士在知道p754有多难的情况下,都同意它应该对整个社区有益。如果它能鼓励浮点软件的生产,缓解可靠软件的开发,就能为大家的硬件创造一个更大的市场。这种利他主义的程度是如此惊人,以至于MATLAB的创建者Cleve Moler博士曾经建议外国游客不要错过该国最令人敬畏的两大景观:大峡谷和IEEE p754的会议。W. Kahan,http://www.cs.berkeley.edu/~wkahan/ieee754status/754story.html。

该标准还宣布四舍五入行为是正确的四舍五入:一个操作的结果应该是精确结果的四舍五入版本。关于四舍五入(和截断)对数字计算的影响,下面会有更多的介绍。

Interpretation of single precision

在上面,我们已经看到了溢出和下溢的现象,也就是导致不可表示的数字的操作。还有一种特殊情况需要处理:如果程序要求进行非法运算,如$\sqrt{-4}$,应该返回什么结果?IEEE 754标准对此有两个特殊量。Inf和NaN代表 “无穷大 “和 “不是一个数字”。Inf是指溢出或除以0的结果,not-a-number是指,例如,从infinity中减去infinity的结果。如果NaN出现在一个表达式中,整个表达式将评估为该值。用Inf计算的规则要复杂一些[80]。

图3.3给出了IEEE 754单精度中所有位模式的含义清单。从上面可以看出,对于归一化的数字,第一个非零位是1,它不被存储,所以位模式$d_1d_2 … d_t$被解释为$1.d_1d_2 … d_t$ 。

练习 3.12 每个程序员都会犯这样的错误:将一个实数存储在一个整数中,或者反过来存储。例如,如果你调用一个函数的方式与它的定义不同,就会发生这种情况。

1
2
3
4
5
void a(double x) {....}
int main() {
int i;
.... a(i) ....
}

当在函数中打印x时会发生什么?考虑一个小整数的比特模式,并使用图3.3中的表格将其解释为一个浮点数。解释一下,它将是一个未归一化的数字。

如今,几乎所有的处理器都遵守了IEEE 754标准。早期的NVidia Tesla GPU在单精度方面不符合标准。这样做的理由是,单精度更可能用于图形,在那里,准确的合规性不太重要。对于许多科学计算,双精度是必要的,因为计算的精度会随着问题大小或运行时间的增加而变差。这对于第四章中的那种计算来说是正确的,但对于其他的计算,如格子玻尔兹曼法(LBM),则不是这样。

浮点数异常

各种各样的操作可能会给出一个无法表示为浮点数的结果。这种情况被称为异常(exception),我们说提出了一个异常。结果取决于错误的类型,而计算则正常进行。(可以让程序中断:第3.6.6节)。

Not-a-Number

以下情况处理器将表示为NaN(’不是一个数字’)的结果。

  • 两个无穷大相减,注意两个无穷大相加仍为无穷大
  • 0乘以无穷大
  • 0除以0或无穷大除以无穷大
  • $\sqrt x$当$x<0$时
  • 比较 $x < y$ 或 $x > y$ 时,其中任意一个数为$NaN$

由于处理器可以继续对这样的数字进行计算,所以它被称为「安静的NaN」(quiet NaN)。相比之下,一些NaN数量可以导致处理器产生一个中断或异常。这被称为「信号型NaN」(signalling NaN)。

信号NaN是有用途的。例如,你可以用这样一个值来填充分配的内存,以表明它在计算中是未初始化的。任何使用这样一个值的行为都是一个程序错误,并会引起一个异常。

2008年修订的IEEE 754建议使用NaN的最有效位作为is_quiet位来区分安静和信号NaN。

关于GNU编译器中对Nan的处理,请参见https://www.gnu.org/software/libc/manual/html_node/Infinity-and-NaN.html。

除以零

除以0的结果是Inf。如果一个结果不能作为一个有限的数字来表示,就会引发这个异常。

下溢

如果一个数字太小,不能被表示,就会出现这个异常。

不精确

如果出现不精确的结果,例如平方根,就会引发这个异常,如果没有被困住,就会出现溢出。

舍入误差分析

过大或过小的数字无法表示,导致溢出和下溢,是不正常的:通常可以安排计算,使这种情况不会发生。相比之下,计算机数字之间的计算结果(甚至像一个简单的加法)无法表示的情况是非常普遍的。因此,看一个算法的实现,我们需要分析这种小错误在计算中传播的影响。这就是通常所说的「舍入误差分析」(round-off error analysis)。

正确的舍入

3.3.7节中提到的IEEE 754标准,不仅声明了浮点数的存储方式,还给出了加、减、乘、除等运算的准确性标准。该标准中的算术模型是正确的四舍五入模型:一个操作的结果应该像遵循以下程序一样。

  • 计算出运算的确切结果,无论这是否可以表示。

  • 然后将这个结果四舍五入到最接近的计算机数字。

简而言之:一个操作的结果的表示就是该操作的四舍五入的准确结果。(当然,在两次操作之后,它不再需要坚持计算的结果是精确结果的四舍五入版本)。

如果这句话听起来微不足道或不言而喻,请考虑以减法为例。在尾数为两位的十进制数制中,计算结果为$1.0 - 9.4 ⋅ 10^{-1} = 1.0 - 0.94 = 0.06 = 0.6 ⋅ 10^{-2}$。请注意,在一个中间步骤中,尾数.094出现了,它比我们为我们的数字系统声明的两个数字多了一个数字。这个额外的数字被称为「警戒位」(guard digit)。

如果没有警戒位,这个运算将以$1.0-9.4⋅10^{-1}$的形式进行,其中$9.4⋅10^{-1}$将被四舍五入为0.9,最终结果为0.1,这几乎是正确结果的两倍。

练习 3.13 考虑$1.0-9.5⋅10^{-1}$的计算,并再次假设数字被四舍五入以适应两位数的尾数。为什么这个计算在某种程度上比刚才的例子要差很多?

一个警戒位不足以保证正确的舍入。一项我们在此不做转载的分析表明,需要额外的三个比特[79]。

多重添加操作

2008年,IEEE 754标准进行了修订,以包括融合乘加(FMA)操作的行为,也就是说,操作形式为

这种操作有两方面的动机。

首先,FMA有可能比单独的乘法和加法更精确,因为它可以对中间结果使用更高的精度,例如使用80位的扩展精度格式;3.7.3节。

这里的标准定义了正确的四舍五入,即这种组合计算的结果应该是四舍五入后的正确结果。这种操作的原始实现将涉及两次舍入:一次在乘法之后,一次在加法之后3。

练习3.14 你能想出一个例子,说明对FMA进行正确的舍入比对乘法和加法分别进行舍入更准确吗?提示:让c项的符号与a*b相反,并尝试在减法中强制取消。

其次,FMA指令是一种获得更高性能的方法:通过流水线,我们可以在每个周期内获得两个操作。因此,一个FMA单元比单独的加法和乘法单元更便宜。幸运的是,FMA在实际计算中经常出现。

练习3.15 你能想到一些以FMA运算为特征的线性代数运算吗?参见1.2.1.2节,了解FMA在处理器中的历史应用。

加法

两个浮点数的加法是通过几个步骤完成的。首先,指数被对齐:两个数字中较小的数字被写成与较大的数字具有相同的指数。然后再加上尾数。最后,对结果进行调整,使其再次成为一个标准化的数字。

作为一个例子,考虑$1.00+2.00×10^{-2}$。对准指数,这就变成了1.00+0.02=1.02,这个结果不需要最后调整。我们注意到这个计算是精确的,但是和$1.00+2.55×10^{-2}$有同样的结果,这里的计算显然是不精确的:精确的结果是1.0255,它不能用三位数的尾数来表示。

在$6.15\times 10^1+3.98\times 10^1=10.13\times 101=1.013\times 10^2\rightarrow 1.01\times 10^2$的例子中,我们看到在加上尾数后,需要对指数进行调整。误差又来自于对不适合尾数的结果的第一个数字的截断或四舍五入:如果$x$是真实的和,$\tilde{x}$是计算的和,那么$\tilde{x}=x(1+\varepsilon)$ 其中,3位尾数$|\varepsilon|<10^{-3}$。

形式上,让我们考虑计算$s=x_1+x_2$,我们假设数字$i$表示为$\tilde{x}_i= x_i(1 + \varepsilon_i)$。那么和$s$就表示为

在所有$\epsilon_i$都很小且大小大致相等,并且$𝑥_𝑖>0$的假设下,我们看到相对误差在加法下被加上了。

乘法

浮点乘法,就像加法一样,包括几个步骤。为了使两个数字$m_1\times \beta^{e_1}$和$m_2\times\beta^{e_2}$相乘,需要采取以下步骤。

  • 指数相加:$e \leftarrow e_1 + e_2$。
    • 尾数相乘: $m \leftarrow m_1 \times m_2$。
    • 尾数被归一化,指数也相应调整。

例如:$1.23·10^0 ×5.67⋅10^1 =0.69741⋅10^1→6.9741⋅10^0→6.97⋅10^0$。

练习 3.16 分析乘法的相对误差。

减法

减法的表现与加法非常不同。在加法中,误差是相加的,只是逐步增加整体的舍入误差,而减法则有可能在一次操作中大大增加误差。

例如,考虑尾数为3位的减法:$1.24 - 1.23 = 0.01 → 1.00⋅ 10^{-2}$。虽然结果是准确的,但它只有一个有效数字4 。为了了解这一点,可以考虑这样的情况:第一个操作数1.24实际上是一个四舍五入的计算结果,其结果应该是1.235。在这种情况下,减法的结果应该是$5.00 ⋅ 10^{-3}$,也就是说,存在100%的误差,尽管输入的相对误差是可以预期的小。显然,涉及这一减法结果的后续操作也将是不准确的。我们的结论是,减去几乎相等的数字可能是造成数字四舍五入的原因。

这个例子有一些微妙之处。几乎相等的数字的减法是准确的,而且我们有IEEE算术的正确舍入行为。尽管如此,单一运算的正确性并不意味着包含它的运算序列会是准确的。虽然加法的例子只显示了数字精度的适度下降,但这个例子中的取消会产生灾难性的影响。你会在第3.5.1节看到一个例子。

练习3.17 考虑迭代

这个函数是否有一个固定点,$x_0\equiv f(x_0)$,或者是否有一个循环$x_1=f(x_0),x_0\equiv x_2=f(x_1)$等等?现在对这个函数进行编码。是否有可能重现固定点?不同的起始点$x_0$会发生什么。你能解释一下吗?

关联性

处理浮点数的方式的另一个影响是对运算的「关联性」(associativity),如求和。虽然求和在数学上是关联性的,但在计算机运算中却不再是这样。

让我们考虑一个简单的例子,说明这如何由浮点数的舍入行为引起。让浮点数存储为尾数的一个数字,指数的一个数字,以及一个保护数字;现在考虑4+6+7的计算。从左到右的计算结果是:

另一方面,从右到左的评估给出了。

结论是,对中间结果进行四舍五入和截断的顺序是有区别的。你还可以观察到,从较小的数字开始会得到更准确的结果。在3.5.2节中,你会看到这个原理的一个更详细的例子。

练习 3.18 上面的例子使用了四舍五入。你能在算术系统中想出一个使用截断的类似例子吗?

通常情况下,表达式的求值顺序是由编程语言的定义决定的,或者至少是由编译器决定的。在第3.5.5节中,我们将看到在并行计算中,关联性不是那么唯一地确定。

舍入误差的例子

从上面的介绍中,读者可能会得到这样的印象:舍入误差只在特殊情况下才会导致严重的问题。在这一节中,我们将讨论一些非常实际的例子,在这些例子中,计算机算术的不精确性在计算结果中变得非常明显。这些将是相当简单的例子;更复杂的例子存在于本书的范围之外,例如矩阵反演的不稳定性。有兴趣的读者可以参考[201,106]。

取消:”abc模式”。

作为一个实际的例子,考虑二次方程$ax^2+bx+c=0$,其解$x=\frac{-b \pm \sqrt {b^2-4ac}}{2a}$。假设$b>0$且$b^2>>4ac$,则$\sqrt{b^2-4ac}\approx b$,’+’解将是不准确的。在这种情况下,最好计算$x_-= -b-\sqrt{b^2-4ac}$并使用$𝑥_+ - x_- =c/a$。

练习 3.19 探索计算的根基

通过 “教科书 “的方法,并如上所述。

  • 这些根是什么?
  • 为什么 “教科书 “方法把一个小的根计算为零?
  • 两种方法计算的函数值是多少?相对误差?

练习3.20 写一个程序来计算一元二次方程的根,包括 “教科书 “上的方法和上面描述的方法。

  • 让$b=-1$,$a=-c$,$4ac\downarrow 0$,逐步取较小的$a$和$c$值。
  • 打印出计算出的根,使用稳定计算的根,以及计算出的根中的$f(x)= ax^2 + bx + c$的值。

现在,假设你不太关心根的实际值:你想确保在计算的根中,残差$f(x)$很小。让$x^∗$ 是准确的根,那么

现在分别研究$a \downarrow 0$,$c = -1$ 和 $a = -1$,$ c \downarrow 0$ 的情况,你能解释其中的区别吗?

练习 3.21 考虑函数

  • 证明它们在精确算术中是相同的;但是。

  • 证明$f$可以表现出取消,而$g$则没有这个问题。

  • 编写代码以显示$f$和$g$之间的差异。你可能需要使用较大的$x$的值。

  • 从𝑥和机器精度的角度来分析取消的情况。当$\sqrt{x+1}$和$\sqrt{x}$的距离小于$\varepsilon$?这时会发生什么?(为了更精确的分析,当它们之间相差$\sqrt \varepsilon$,又是如何表现出来的?)

  • 𝑦=𝑓(𝑥)的反函数是

    把这个添加到你的代码中。这是否说明了计算的准确性?

请确保在单精度和双精度下测试你的代码。如果你会用python,可以试试bigfloat包。

总结系列

前面的例子是关于防止一次操作中出现大的舍入误差。这个例子表明,即使是逐渐积累的舍入误差也可以用不同的方法来处理。

考虑总和$\sum_{n=1}^{10000}\frac{1}{n^2} = 1.644834$,假设我们使用的是单精度,这对大多数计算机上意味着机器精度为 $10^{-7}$. 这个例子的问题在于,无论是项之间的比率,还是项与部分和的比率,都在不断增加。在 3.3.6 节中,我们注意到过大的比率会导致加法的一个操作数被忽略。

如果我们按照给出的序列对数列进行求和,我们会发现第一项是 1,所以所有的部分和($\sum^N_{n=1}$,其中$N < 10000$)至少是 1。这意味着任何 $1/n^2 < 10^{-7}$ 的项都会被忽略,因为它小于机器精度。具体来说,最后7000个项被忽略,计算出的总和是1.644725。前4位数字是正确的。

然而,如果我们以相反的顺序评估和,我们会得到单精度的精确结果。我们仍然是把小量加到大量上,但现在的比例永远不会像一比$\epsilon$那样糟糕,所以小的数字永远不会被忽略。要看到这一点,请考虑两个项的比率随后的项。

由于我们只对105项求和,而且机器的精度是10-7,所以在加法1/𝑛2+1/(𝑛-1)2中,第二项不会像我们从大到小求和时那样被完全忽略。

练习 3.22 在我们的推理中还缺少一个步骤。我们已经表明,在加两个后续项时,较小的一项不会被忽略。然而,在计算过程中,我们对序列中的下一个项添加了部分和。说明这不会使情况恶化。

这里的教训是,单调(或接近单调)的数列应该从小到大相加,因为如果要加的量的大小比较接近,误差就最小。请注意,这与减法的情况相反,涉及类似数量的操作会导致较大的误差。这意味着,如果一个应用要求对数列进行加减运算,而我们预先知道哪些项是正数,哪些项是负数,那么相应地重新安排算法可能会有收获。

练习3.23 正弦函数定义为

下面是两个计算这个和的代码片段(假设给定了$x$和$n$个项)。

1
2
3
4
5
6
7
double term = x, sum = term;
for (int i=1; i<=nterms; i+=2) {
term *=
- x*x / (double)((i+1)*(i+2));
sum += term;
}
printf("Sum: %e\n\n",sum);
1
2
3
4
5
6
7
8
9
double term = x, sum = term;
double power = x, factorial = 1., factor = 1.;
for (int i=1; i<=nterms; i+=2) {
power *= -x*x;
factorial *= (factor+1)*(factor+2);
term = power / factorial;
sum += term; factor += 2;
}
printf("Sum: %e\n\n",sum);
  • 解释一下,如果你计算$x>1$的大量项会发生什么。
  • 对于大量的术语,这两种代码是否有意义?
  • 是否有可能从最小的项开始对其进行求和?
  • 你能提出其他方案来改进sin(𝑥)的计算吗?

不稳定的算法

现在我们将考虑一个例子,在这个例子中,我们可以直接论证该算法无法应对因不准确表示的实数而引起的问题。

考虑递归$y_n=\int^1_0\frac{x^n}{x-5}dx=\frac{1}{n}-5y_{n-1}$,它是单调递减的;第一个项可以计算为 $y_0 = ln6 - ln5$。

以小数点后3位数进行计算,我们得到。

我们看到,计算出来的结果很快就不只是不准确,而且实际上是毫无意义的。我们可以分析一下为什么会出现这种情况。

如果我们将$n$在第$n$步中的误差$\varepsilon_n$定义为:

那么

于是$\varepsilon_n \geqslant 5\varepsilon_{n-1}$. 这种计算所产生的误差呈现指数式增长。

线性系统求解

有时我们甚至可以在不指定使用何种算法的情况下对问题的数值精度做出说明。假设我们想解决一个线性系统,也就是说,我们有一个$n\times n$矩阵和一个大小为$n$的向量$b$,我们想计算出使$Ax=b$的向量。(由于向量𝑏将是某种计算或测量的结果,我们实际上是在处理一个向量$\tilde{b}$,它是理想𝑏的某种扰动。

扰动向量$\Delta b$可以是机器精度的数量级,如果它仅仅来自于代表误差。

扰动向量$\Delta b$可以是机器精度的数量级,如果它仅仅来自于代表误差,或者它可以更大,这取决于产生$\tilde{b}$的计算。

我们现在要问的是$x$的精确值与计算值之间的关系,前者是通过对$A$和$b$进行精确计算得到的,而后者是通过对$A$和$\tilde{b}$进行计算得到的。(在讨论中我们将假设𝐴本身是精确的,但这是一种简化)。

写作$\tilde{x}=x+\Delta x$,我们的计算结果现在是

或者

由于$Ax = b$,我们得到$A\Delta x = \Delta b$。由此,我们可以得到(详见附录13)。

$|A||A^{-1}|$的数量被称为矩阵的条件数。边界(3.2)说的是,任何右手边的扰动都会导致解决方案的扰动,该扰动最多只能大于矩阵的条件数$A$。请注意,这并不是说𝑥的扰动必须接近这个大小,但我们不能排除这个可能性,而且在某些情况下,确实可以达到这个界限。

假设$b$是精确到机器精度的,并且$A$的条件数是$10^4$。边界(3.2)通常被解释为:$x$的最后4位数字是不可靠的,或者说,计算 “失去了4位数字的准确性”。

方程(3.2)也可以解释为:当我们解决一个线性系统$ Ax = b$时,我们得到一个近似解$x + \Delta x$,这是一个扰动系统$A(x + \Delta x) = b+ \Delta b$的精确解。解中的扰动可以与系统中的扰动相关,这一事实可以通过说该算法表现出逆向稳定性来表达。

线性代数算法的精度分析本身就是一个研究领域;例如,见Higham的书[106]。

并行计算中的舍入误差

正如我们在第3.4.5节中所讨论的,以及你在上面的数列求和的例子中所看到的,计算机算术中的加法不是关联的。一个类似的事实也适用于乘法。这对并行计算来说有一个有趣的结论:计算在并行处理器上的分布方式会影响结果。

作为一个简单的例子,考虑计算总数 $a+b+c+d$。在单个处理器上,普通执行对应于以下关联性。

另一方面,将这个计算分散到两个处理器上,其中处理器0有$a$,$b$,处理器1有$c$,$d$,相当于

推而广之,我们看到,在不同数量的处理器上,规约操作很可能会得到不同的结果。(MPI标准规定,在同一组处理器上运行的两个程序应该得到相同的结果)。有可能规避这个问题,用对所有处理器的集合操作来代替还原操作,然后再进行局部规约。然而,这增加了处理器的内存需求。

对于并行求和问题,还有一个有趣的解决方案。如果我们用4000比特的尾数来存储浮点数,就不需要指数,这样存储的数字的所有计算都是精确的,因为它们是定点计算的一种形式[129, 128]。虽然用这样的数字做整个应用是非常浪费的,但只为偶尔的内积计算保留这种方案可能是解决可重复性问题的办法。

编程语言中的计算机运算

不同的语言有不同的方法来声明整数和浮点数。这里我们研究一些问题。

Fortran

在Fortran中,变量声明可以采取各种形式。例如,一个类型标识符有可能声明存储一个变量所需的字节数integer2, real8。这种方法的一个优点是容易与其他语言或MPI库互操作。

通常情况下,可以只用INTEGER、REAL来写代码,用编译器标志来表示整数和实数的字节数大小。

更复杂的、现代版本的Fortran可以指出一个浮点数需要有多少位的精度。

1
2
3
integer, parameter :: k9 = selected_real_kind(9)
real(kind=k9) :: r
r = 2._k9; print *, sqrt(r) ! prints 1.4142135623730

kind 值通常为4,8,16,但这取决于编译器。

C99和Fortran2003 最近的C语言和Fortran语言的标准包含了C/Fortran in-teroperability标准,它可以用来声明一种语言的类型,使其与另一种语言的某种类型兼容。

C

在C语言中,常用的类型标识符并不对应于一个标准的长度。对于整数来说,有short int、int、long int,而对于浮点float来说,有double。sizeof()操作符给出了用于存储一个数据类型的字节数。

C整数的数值范围在limit.h中定义,通常给出一个上限或下限。例如,INT_MAX被定义为32767或更大。

浮点类型在float.h中指定。

C语言中存在指定的存储类型:常数如int64_t是由stdint.h中的typedef定义的。

常数NAN是在math.h中声明的。对于检查一个值是否为NaN,可以使用isan()。

Printing bit patterns

1
2
3
4
5
6
7
8
9
10
11
// printbits.c
void printBits(size_t const size, void const * const ptr) {
unsigned char *b = (unsigned char*) ptr;
unsigned char byte;
int i, j;

for (i=size-1;i>=0;i--) for (j=7;j>=0;j--) {
byte = (b[i] >> j) & 1;
printf("%u", byte);
}
}

用作:

1
2
3
4
5
// bits.c
int five = 5;
printf("Five=%d, in bits: ",five);
printBits(sizeof(five),&five);
printf("\n");

C++

C++语言有以下浮点类型。

  • float:这通常是作为IEEE 754 32位浮点数实现的。

  • double:定义为至少和浮点数一样精确,通常实现为IEEE 754的64位浮点数。

  • long double:这也被定义为至少和double一样精确。在一些架构上,它可以是80位的扩展精度,在其他架构上则是全128位的精度。处理器通常通过软件和硬件功能的结合来实现后者,所以性能会比前两种类型低很多。

边界

你仍然可以使用C头的limit.h或 climits,但最好使用std::numeric_limits,它在类型上是模板化的。比如说

1
std::numerical_limits<int>.max();

有以下几种功能。

  • std::numeric_limits::max() for the largest number.
  • std::numeric_limits::min() for the smallest normalized positive number.
  • std::numeric_limits::lowest() for the most negative number.
  • std::numeric_limits::epsilon() for machine epsilon.
  • std::numeric_limits::denorm_min() for smallest subnormal. (See also std::numeric_limits::has_denorm.)
  • std::nextafter(x,y)

例外的情况

定义的例外情况。

  • FE_DIVBYZERO pole error occurred in an earlier floating-point operation.

  • FE_INEXACT inexact result: rounding was necessary to store the result of an earlier floating-point operation.

  • FE_INVALID domain error occurred in an earlier floating-point operation.

  • FE_OVERFLOW the result of the earlier floating-point operation was too large to be representable.

  • FE_UNDERFLOW the result of the earlier floating-point operation was subnormal with a loss of

    precision.

  • FE_ALL_EXCEPT bitwise OR of all supported floating-point exceptions .

用法:

1
2
std::feclearexcept(FE_ALL_EXCEPT); 
if(std::fetestexcept(FE_UNDERFLOW)) { /* ... */ }

在C++中,std::numeric_limits::quiet_NaN()是在limit中声明的,如果std::numeric_limits::has_quiet_NaN为真,这就是有意义的,如果std::numeric_limits::is_iec559为真。(ICE 559本质上是IEEE 754;见3.3.7节)。

同一模块还有 infinity() 和 signaling_NaN()。

对于检查一个值是否为NaN,可以使用C++中cmath的std::isan()。请进一步参阅http://en.cppreference.com/w/cpp/numeric/math/nan。

例外情况

IEEE 754标准和C++语言都定义了一个例外的概念,这两个概念是相互不同的。754例外是指 “没有适合每个合理应用的结果 “的操作的发生。这不一定能转化为语言定义的异常。

打印位元模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// bitprint.cxx
void format(const std::string &s)
{
// sign bit
std::cout << s.substr(0,1) << ' ';
// exponent
std::cout << s.substr(1,8);
// mantissa in groups of 4
for(int walk=9;walk<32;walk+=4)
std::cout << ' ' << s.substr(walk,4);
// newline
std::cout << "\n";
}

uint32_t u;
std::memcpy(&u,&d,sizeof(u));
std::bitset<32> b{u};
std::stringstream s;
s << std::hexfloat << b << '\n';
format(s.str());
//codesnippet cppbitprint
return 0;
}

程序设计中的舍入行为

从上面的讨论中可以看出,一些对数学实数成立的简单说法在浮点数上并不成立。例如,在浮点运算中

这意味着编译器不能在不影响取舍行为的情况下进行某些优化。在一些代码中,这种轻微的差异是可以被容忍的,例如,因为方法有内置的保护措施。例如,第5.5节的静止迭代方法就能抑制任何引入的错误。

另一方面,如果程序员在编写代码时考虑到了舍入行为,那么编译器就没有这样的自由了。这在上面的练习3.10中有所暗示。我们用价值安全的概念来描述编译器被允许如何改变计算的解释。在最严格的情况下,编译器是不允许做任何影响计算结果的改变的。

编译器通常有一个选项,控制是否允许优化,以改变数值行为。对于英特尔的编译器,它是-fp-model=….。另一方面,像-Ofast这样的选项只是为了提高性能,可能会严重影响数值行为。对于Gnu编译器来说,完全符合754标准的选项是-frounding-math,而-ffast-math则允许以性能为导向的编译器转换,这违反了754和/或语言标准。

如果你关心结果的可重复性,这些问题也很重要。如果一个代码被两个不同的编译器编译,用相同的输入运行时,应该有相同的输出?如果一个代码在两个不同的处理器配置上并行运行?这些问题是非常微妙的。在第一种情况下,人们有时会坚持位数的可重复性,而在第二种情况下,只要结果保持 “科学 “上的等价,一些差异是允许的。当然,这个概念是很难做到严格的。

下面是在考虑编译器对代码行为和重现性的影响时的一些相关问题。

重新关联 在编译器对计算所做的改变中,最重要的是重新关联,即把𝑎 + 𝑏 + 𝑐归为𝑎 + (𝑏 + 𝑐)的技术术语。C语言标准和C++语言标准规定了对没有括号的表达式进行严格的从左到右的评估,所以重新关联实际上是标准所不允许的。Fortran语言标准没有这样的规定,但是编译器必须尊重小括号所暗示的评估顺序。

重新关联的一个常见来源是循环解卷;见第1.7.2节。在严格的值安全条件下,编译器在如何展开循环方面受到限制,这对性能有影响。循环解卷的数量,以及是否进行解卷,都取决于编译器的优化水平、编译器的选择和目标平台。

重新关联的一个更微妙的来源是并行执行;见3.5.5节。这意味着代码的输出在不同的并行配置上的两次运行之间不需要严格的重现。

常量表达式 在编译时计算常量表达式是一种常见的编译器优化。例如,在

1
2
3
floaat one = 1, ;
...
x = 2. + y + one;

编译器将赋值改为$x = y+3$。然而,这违反了上面的重新关联规则,而且它忽略了任何动态设置的四舍五入行为。

表达式评估 在评估表达式$a+(b+c)$时,处理器会产生一个中间结果为$b+c$,这个结果没有分配给任何变量。许多处理器能够分配一个更高的中间结果精度。编译器可以有一个标志来决定是否使用这种设施。

浮点单元的行为 四舍五入行为(截断与四舍五入)和渐进下溢的处理可由库函数或编译器选项控制。

库函数 IEEE 754标准只规定了简单的操作;目前还没有处理正弦或对数函数的标准。因此,它们的实现可能是一个变化的来源。

更多的讨论,见[144]。

改变舍入行为

IEEE 754标准还声明,一个处理器应该能够在普通四舍五入、向上或向下四舍五入(有时分别表述为 “向正无穷大 “和 “向负无穷大”)或截断之间切换其四舍五入行为。在C99中,这个API包含在fenv.h中(或者对于C++ cfenv)。

1
2
3
4
5
#include <fenv.h>
int roundings[] =
{FE_TONEAREST, FE_UPWARD, FE_DOWNWARD, FE_TOWARDZERO};
rchoice = ....
int status = fesetround(roundings[rchoice]);

在Fortran2003中,函数IEEE_SET_ROUNDING_MODE在IEEE_ARITHMETIC模块中可用。设置四舍五入行为可以作为一种快速测试算法稳定性的方法:如果结果在两种不同的四舍五入策略之间有明显的变化,那么该算法很可能不稳定。

如果结果在两种不同的四舍五入策略之间有明显的变化,那么该算法可能是不稳定的。

捕捉异常情况

异常这个词有几种含义。

  • 浮点异常是指 “无效数字 “的发生,比如通过溢出或除以零(见3.3.8.1节)。

  • 如果发生任何类型的意外事件,编程语言可以 “抛出异常”,也就是中断正常的程序控制流程。

溢出时的行为也可以被设置为产生一个异常。在C语言中,你可以用一个库调用来指定。

1
2
3
4
#include <fenv.h>
int main() {
...
feenableexcept(FE_DIVBYZERO | FE_INVALID | FE_OVERFLOW);

编译器特定行为

捕获异常有时可以由编译器指定。例如,gcc编译器可以通过标志-ffpe-trap=list来捕获异常;见https://gcc.gnu.org/onlinedocs/gfortran/Debugging-Options.html。

更多关于浮点运算的内容

Kahan的总结

3.4.5节中的例子让我们看到了计算机算术的一些问题:四舍五入会导致结果相当错误,而且非常依赖于评估顺序。有一些算法试图弥补这些问题,特别是在加法的情况下。我们简单讨论一下以William Kahan命名的Kahan求和法[117],它是补偿求和算法的一个例子。

练习3.24 通过3.4.5节中的例子,增加最后一项3;即在该例子的条件下计算4+6+7+3和6+7+4+3。表明当17被四舍五入为20时,修正值正好是3的下限,或者当14被四舍五入为10时,修正值是4的过限;在这两种情况下,都能计算出20的正确结果。

其他计算机运算系统

有人提出了其他系统来处理计算机上的不精确算术问题。一个解决方案是扩展精度算术,即以比平常更多的比特存储数字。这方面的一个常见用途是计算向量的内积:在内部以扩展精度进行累加,但以普通浮点数返回。另外,还有一些库,如GMPlib [77],允许以更高的精度进行任何计算。

另一个解决计算机算术不精确问题的方法是 “区间算术”[114],即对每个计算都保持区间界限。虽然这种方法已经研究了相当长的时间,但除了通过专门的库[21]外,它并没有实际使用。

扩展精度

在制定IEEE 754标准时,人们设想处理器可以有一系列的精度。在实践中,只有单精度和双精度的定义被使用。然而,有一个扩展精度的例子仍然存在。英特尔处理器有80位寄存器用于存储中间结果。(这可以追溯到英特尔80287协处理器。)这种策略在FMA指令和内积的积累中是有意义的。

这些80位寄存器有一个奇怪的结构,有一个显著的整数位,可以产生不是任何定义数字的有效表示的位模式[171]。

降低精度

你可以问 “双精度是否总是比单精度有好处”,答案并不总是 “是”,而是:”这取决于”。

迭代细化中的低精度

在迭代线性系统求解中(第5.5节,精度是由计算残差的精度决定的,而不是由求解步骤的精度决定的。因此,人们可以在降低精度的情况下进行操作,如应用预处理程序(第5.5.6节)[1]。这是一种迭代细化的形式;见5.5.6节。

深度学习中的低精度

IEEE 754-2008有一个二进制16半精度格式的定义,它有一个5位指数和11位尾数。

在深度学习(DL)中,表达值的范围比精确的值更重要。(这与传统的科学应用相反,在传统的科学应用中,接近的数值需要重新解决)。这导致了bfloat16 “大脑浮点 “格式的定义 https://en.wikipedia.org/wiki/Bfloat16_floating-point_format 这是一种16位的浮点格式。它使用8位作为指数,7位作为尾数。这意味着它与IEEE单精度格式共享相同的指数范围;见图3.4。

bfloat16def

  • 由于bfloat16和fp32的前两个字节结构相同,通过截断第三和第四字节,可以从fp32的数字中得出bfloat16的数字。然而,在实践中,四舍五入可能会得到更好的结果。
  • 相反,将一个bloat16转换成fp32只需要在最后两个字节中填充0。

bfloat16的有限精度可能足以代表DL应用中的数量,但为了不失去更多的精度,我们设想FMA硬件在内部使用32位数:两个bfloat16数字的乘积是一个常规的32位数。为了计算内积(作为DL中矩阵-矩阵乘法的一部分),我们需要一个FMA单元,如图3.5所示。

bfloat16fma

  • 基于Intel Knights Landing的Intel Knights Mill支持降低精度。
  • 英特尔Cooper Lake实现了bfloat16格式[35]。

甚至在[46]中讨论了进一步减少到8位。

定点运算

一个定点数(比这里更深入的讨论,见[206])可以表示为⟨𝑁 , 𝐹 ⟩ 其中$𝑁 \geqslant \beta_0$是整数部分,$𝐹 < 1$是小数部分。另一种说法是,定点数字是以$𝑁+𝐹$位数存储的整数,在第一个$𝑁$位数后隐含小数点。

固定点计算可能会溢出,没有可能调整指数。考虑乘法$⟨𝑁_1,𝐹_1⟩\times⟨𝑁_2,F_2⟩$,其中$𝑁_1\geqslant \beta^{𝑛_1}$,$𝑁_2\geqslant \beta^{𝑛_2}$。如果$𝑛_1+𝑛_2$超过了整数部分的可用位置数,则溢出。(非正式地,乘积的位数是操作数的位数之和)。这意味着在使用定点运算的程序中,如果要进行乘法运算,需要有一定数量的前导零位,这就降低了数字的准确性。这也意味着程序员必须更努力地思考计算问题,以保证不会发生溢出,并在合理范围内保持数字的准确性。

那么,人们为什么要使用定点数字呢?其中一个重要的应用是嵌入式低功耗设备,比如电池供电的数字温度计。由于定点计算与整数计算基本相同,因此不需要浮点运算单元,从而降低了芯片尺寸,减少了对功耗的需求。另外,许多早期的视频游戏系统的处理器要么没有浮点单元,要么整数单元比浮点单元快得多。在这两种情况下,使用整数单元将非整数计算变成定点计算,是实现高吞吐量的关键。

另一个仍然使用定点运算的领域是信号处理。在现代CPU中,整数和浮点运算的速度基本相同,但它们之间的转换相对较慢。现在,如果正弦函数是通过查表实现的,这意味着在sin(sin 𝑥 )中,一个函数的输出被用来索引下一个函数的应用。显然,以定点方式输出正弦函数就不需要在实数和整数之间进行转换,这就简化了所需的芯片逻辑,并加快了计算速度。

复数

有些编程语言将复数作为一种内置的数据类型,有些则不是,还有一些则介于两者之间。例如,在Fortran中你可以声明

1
2
COMPLEX z1,z2, z(32)
COMPLEX*16 zz1, zz2, zz(36)

一个复数由一对实数组成,分为实部和虚部,在内存中相邻分配。第一个声明用8个字节来存储REAL *4的数字,第二个声明用REAL*8来存储实部和虚部。(另外,第二行使用DOUBLE COMPLEX或在Fortran90中使用COMPLEX(KIND=2))。

相比之下,C语言没有直接的复数,但是C99和C++都有一个复数.h头文件。

complex.h头文件。这就像Fortran中定义复数一样,定义为两个实数。

像这样存储一个复数是很容易的,但有时在计算上并不是最好的解决方案。当我们研究复数的数组时,这就变得很明显了。如果一个计算经常完全依赖于对复数实部(或虚部)的访问,那么在复数数组中跨步,就会有一个跨步二,这是很不利的(见1.3.4.7节)。在这种情况下,最好为实部分配一个数组,为虚部分配另一个数组。

练习 3.25 假设复数数组是以Fortran方式存储的。分析对数组相乘的内存访问模式,即$\forall_i ∶ c_i \leftarrow a_i ⋅ b_i$,其中a(), b(), c()是复数的数组。

练习 3.26 说明复数上的$n\times n$线性系统$Ax=b$可以写成实数上的$2n\times 2n$系统。提示:将矩阵和向量分成实部和虚部。论证复数数组的实部和虚部分开存储的效率。

结论

在计算机上进行的计算无一例外地存在着数字错误。有些时候错误的原因是计算机运算的不完善:如果我们能用实际的实数进行计算,就不会有问题。(仍然会有数据的测量误差和数值方法中的近似问题;见下一章)。然而,如果我们接受四舍五入作为生活中的一个事实,那么各种观察就会成立。

  • 从稳定性的角度来看,数学上的等价运算不需要表现得完全一样;见 “abc公式 “的例子。

  • 即使是相同计算的重新排列也不会有相同的表现;请看求和的例子。

因此,必须分析计算机算法的舍入行为:舍入是否作为问题参数的一个缓慢增长的函数而增加,比如被演算的项的数量,或者有可能出现更糟糕的行为?我们不会在本书中进一步详细讨论这些问题。

复习题

练习 3.27 判断真假

  • 对于整数类型,”最负 “的整数是 “最正 “的负数。
  • 对于浮点类型,”最负 “的数字是 “最正 “的数字的负数。
  • 对于浮点类型,最小的正数是最大的正数的倒数。

    微分方程的数值处理

在这一章中,我们将研究常微分方程(ODEs)和偏微分方程(PDEs)的数值解。这些方程在物理学中常用来描述一些现象,如飞机周围的空气流动,或桥梁在各种压力下的弯曲。虽然这些方程通常相当简单,但从它们中得到具体的数字(”如果有一百辆汽车在上面,这座桥会下垂多少”)则比较复杂,往往需要大型计算机来产生所需的结果。这里我们将描述将ODEs和PDEs转化为可计算问题的技术。

我们将首先介绍「初值问题」(IVPs),它描述了随着时间变换的过程。此处我们仅考虑常微分方程:只依赖于时间的标量函数。接下来,我们将研究描述空间过程的「边界值问题」(BVPs)。边界值问题通常涉及到多个空间变量,因此我们可以得到偏微分方程。

最后,我们将考虑 “热方程”,这是一个「初始边界值问题」(IBVP),它同时具有IVP和BVP的特点:它描述了热量在一个物理物体(如杆)上的传播。初始值描述了初始温度,而边界值给出了杆两端的规定温度。

我们在这一章的目的是展示一类重要的计算问题的起源。因此,我们不会去讨论解的存在性、唯一性或条件的理论问题。关于这一点,见[99]或任何专门讨论ODE或PDE的书。为了便于分析,我们还将假设所有涉及的函数都存在任意高阶导数,并且处处光滑。

初值问题

许多物理现象随着时间的推移而变化,物理学定律给出了对变化的描述,而非数值本身的描述。例如,牛顿第二定律

是一个关于点质量的位置变化的公式:表示为

它指出,加速度线性地取决于施加在质量上的力。对于质量的位置,可以通过分析得出一个封闭式的描述$𝑥(𝑡)=…$,但在许多情况下需要某种形式的近似或数值计算。这也被称为 “数值积分“。

牛顿第二定律是一个常微分方程,因为它表述了变量关于时间变化的函数。此外,牛顿第二定律也是一个初值问题,因为它给定了初始条件随着时间变化的情况。该方程是二阶的,如果我们引入向量,则可以通过引入双分量向量$u$,结合位置$x$和速度$x’$将上述方程降为一阶微分方程:

以$u$表示的牛顿方程就变成了

为了简单起见,在本课程中我们将只考虑标量方程;那么我们的参考方程是

方程允许过程有明确的时间依赖性,但一般来说,我们只考虑没有这种明确依赖性的方程,即所谓的 “自治(autonomous) “ODE,其形式为

其中右手边并不明确地依赖于$t$。

注释 13 非自治ODE可以转化为自治ODE,所以这并不是什么限制。如果$u=u(t)$是一个标量函数,并且$f=f(t, u)$,我们定义$u_2(t)= t$,并考虑等同的自治系统$\left(\begin{array}{l}
u^{\prime} \\
u_{2}^{\prime}
\end{array}\right)=\left(\begin{array}{c}
f\left(u_{2}, u\right) \\
1
\end{array}\right)$

通常情况下,在某个起点(通常选择$𝑡=0$)的初始值是给定的:$𝑢(0)=𝑢_0$,对于某个值$𝑢_0$,我们对$𝑢$随着$𝑡\rightarrow \infty$的行为感兴趣。举例来说,$f(x)=x$给出的方程是:$𝑢′(𝑡)= 𝑢(𝑡)$。这是一个简单的人口增长模型:该方程指出,增长速度等于人口规模。对于某些$f$的选择,方程(4.2)可以用分析法解决,但我们不会考虑这个问题。相反,我们只考虑数值解和这个过程的准确性。

在数值方法中,我们考虑用离散大小的时间步长来近似解决连续的时间依赖过程。由于这引入了一定量的误差,我们将分析在每个时间步长中引入的误差,以及这些误差是如何叠加成一个整体误差的。在某些情况下,限制全局误差的需要会对数值方案施加限制。

误差和稳定性

由于机器运算会产生不精确性,我们希望尽量避免因为初始值的小扰动而造成的干扰。因此,如果与不同初值$𝑢_0$相关的解在$t\rightarrow\infty$时相互收敛,我们将称微分方程为 「稳定」的。

稳定性的一个充分标准是

证明:设 $u^$为$f$ 零点,即:$f(u^)=0$,则常函数$u(t)\equiv u^*$为 $u’=f(u)$的一个解,即所谓的“平衡解”。平衡状态下,微小的扰动是不会影响系统的稳定性的。例如:设 $u$ 是PDE的一个解,写作 $u(t)= u^∗ + \eta(t)$,那么我们有

忽略二阶项,存在一个解:

这意味着,如果$f^′(x) < 0$,扰动将被阻尼,如果$f^′(x) > 0$,扰动将被放大。

我们经常会提到简单的例子$f(u)=-\lambda u$,其解$u(t)=u_{0} e^{-\lambda t}$。如果$\lambda>0$,这个问题是稳定的。

有限差分近似法:欧拉显式和隐式方法

为了数值解决这个问题,我们通过研究有限时间/空间步长,将连续问题变成离散问题。假设所有的函数都足够平滑,一个简单的泰勒级数展开就可以得到。

这就得到了$u’$

如果所有的导数都是有界的,我们可以用一个$O(\Delta t^2)$来近似高阶导数的无限之和。或者,你可以证明这个和等于$\Delta t^{2} u^{\prime \prime}(t+\alpha \Delta t)$ 当$0<\alpha<1$ 。我们看到,我们可以通过有限差分来近似微分算子,其误差是已知的,其数量级是时间步长的函数。

将其代入 $u′ = f(t, u)$,得到

或者

注释 14 前面的两个方程是数学上的等式,不应该被理解为对一个给定的函数$u′$进行计算的方法。回顾前面的讨论,你可以看到这样的公式对于小$\Delta t$来说很快就会被取消。关于数值微分的进一步讨论超出了本书的范围,请参见任意一本标准的数值分析教科书。

我们现在使用上述方程来推导一个数值方案:在$t_0=0,t_{k+1}=t_k+\Delta t=\cdots=(k+1)\Delta t$的情况下,我们得到一个差分方程

为$u_k$量,我们希望$u_k$将是对$u(t_k)$的良好近似。这就是所谓的 “显式欧拉 “或 “欧拉正向 “方法。

从微分方程到差分方程的过程通常被称为离散化(discretization),因为我们只在离散的点集中计算函数值。计算的数值本身仍然是实值的。另一种说法是:如果我们计算$𝑘$个时间步长,就可以在有限的二维空间$\mathbb{R}^k$中找到数值解。原问题的解是在$\mathbb{R}\rightarrow \mathbb{R}$的函数空间中找到的。

在上面,我们用一个算子逼近另一个算子,这样做的时候,当$\Delta t \downarrow 0$时,截断误差为$O(\Delta t)$(见附录14对这个数量级的符号的更正式介绍)。这并不意味着差分方程计算出的解就接近于真解。为此,还需要进行一些分析。

我们从分析 “局部误差 “开始:如果假设计算出的解在$𝑘$步骤是精确的,即$𝑢_𝑘=𝑢(𝑡_𝑘)$,那么$𝑘+1$步时将会出现怎样的错误? 我们有

于是

这表明,在每一步中,我们的误差为$O(\Delta t^2)$。如果我们假设这些误差可以相加,我们发现全局误差为

由于全局误差在$\Delta t$中是一阶的,我们称之为 “一阶方法”。需要注意的是,这个误差(衡量真实解和计算解之间的距离)与截距误差(即算子的近似误差)是同阶的$O(\Delta t)$。

欧拉显式方法的稳定性

考虑IVP $u′ = f(t,u)$ 对于 $t \geqslant 0$, 其中$f(t,u)= -\lambda u$,给出初始值$u(0) = u_0$。存在一个精确的解,即$u(t)=u_0e^{-\lambda t}$。从上面的讨论中可知这个问题是稳定的,也就是说,如果$\lambda >0$,解的小扰动最终会被抑制。现在我们将研究数值解的表现是否与精确解相同,也就是说,数值解是否也收敛于零。

这个问题的欧拉正向或显式欧拉方案是

为了稳定,我们要求$u_k\rightarrow 0$,因为$k\rightarrow \infty$。这就相当于

我们看到,数值求解方案的稳定性取决于$\Delta t$的值:只有当$\Delta t$足够小时,该方案才是稳定的。为此,我们称显式欧拉方法为条件稳定的。请注意,微分方程的稳定性和数值方案的稳定性是两个不同的问题。如果$\lambda >0$,连续问题是稳定的;数值问题有一个额外的条件,取决于所用的离散化方案。

请注意,我们刚刚进行的稳定性分析是专门针对微分方程$u′=-\lambda u$的。如果你要处理的是不同的IVP,你必须进行单独的分析。一般情况下,显式方法通常会给出条件稳定性。

欧拉隐式方法

刚才的显式方法很容易计算,但条件稳定性是一个潜在的问题。它可能意味着时间步骤的数量将是一个限制性因素。有一个替代显式方法的方法,不会受到同样的限制。

与其扩展$u(t + \Delta t)$,不如考虑以下对$u(t - \Delta t)$的扩展

这意味着

如前所述,我们取方程$u’(t)=f(t, u(t))$,用差分公式近似计算$u′(t)$。

我们再次定义固定点$𝑡_𝑘=𝑘𝑡$,并定义一个数值方案:

其中$u_k$是$u(t_k)$的近似值。

与显式方案的一个重要区别是,$u_{k+1}$现在也出现在方程的右侧。也就是说,$u_{k+1}$的计算现在是隐含的。例如,让$f(t,u)=-u^3$,那么$u_{k+1}=u_k-\Delta tu^3_{k+1}$。换句话说,$u_{k+1}$是方程$\Delta tx=u_k$的解。这是一个非线性方程,通常可以用牛顿法求解。

隐式欧拉方法的稳定性

让我们再看看这个例子$f(t, u(t))=-\lambda u$。用隐式方法计算,可以得到

那么

如果$\lambda>0$,这是一个稳定方程的条件,我们发现,对于所有的$u_k$和$\Delta t$的值,$\lambda \rightarrow 0$。这种方法被称为无条件稳定。与显式方法相比,隐式方法的一个优势显然是稳定性:可以采取较大的时间步长而不用担心非物理行为。当然,大的时间步长会使收敛到稳定状态(见附录15.4)变慢,但至少不会出现发散。

另一方面,隐式方法更加复杂。正如你在上面看到的,它们可能涉及到在每个时间步长中需要解决的非线性系统。在$u$是矢量值的情况下,比如下面讨论的热方程,我们会发现隐式方法需要解决一个方程组。

练习 4.1 分析以下IVP $𝑢′(x) = f(x)$ 方案的准确性和计算性。

相当于把欧拉显式和隐式方案加在一起。我们不需要分析这个方案的稳定性。

练习4.2 考虑初值问题 $y′(t) = y(t)(1-y)$。请注意,$y\equiv 0$和$y\equiv 1$是解决方案。这些被称为 “平衡解”。

  1. 一个解决方案是稳定的,如果扰动 “收敛到解决方案”,意味着对于$\varepsilon$足够小。

    并且

    这就要求,例如

    0是一个稳定的解决方案吗?1是吗?

  2. 考虑显式方法

    用于计算微分方程的数值解。说明

  3. 编写一个小程序来研究在不同的$\Delta t$的选择下数值解的行为。在你提交的作业中包括程序清单和一些运行的情况。

  4. 通过运行你的程序,你发现数值解会出现振荡。请对$\Delta t$提出一个条件,使数值解呈单调性。只要说明$y_k<1\Rightarrow y_{k+1}<1$,以及$y_k>1\Rightarrow y_{k+1}>1$即可。

  5. 现在考虑隐式方法

    并说明$y_{k+1}$可以从$y_k$计算出来。编写一个程序,并研究在不同的$\Delta t$选择下的数值解的行为。

  6. 显示出对所有$\Delta t$的选择,隐式方案的数值解都是单调的。

边界值问题

在上一节中,我们看到了初值问题,它模拟的是随时间变化的现象。现在我们将转向边界值问题,一般来说,边界值问题在时间上是静止的,但它描述的是与位置有关的现象。例如,桥梁在负载下的形状,或窗玻璃中的热量分布,因为外面的温度与里面的不同。

二阶一维BVP的一般形式是

但这里我们只考虑简单的形式

在一个空间维度上,或

在两个空间维度上。这里,$\delta \Omega$是域$\Omega$的边界。由于我们在边界上规定了$u$的值,这样的问题被称为边界值问题(Boundary Value Problem,BVP)。

注释 15 边界条件可以更普遍,涉及区间端点上的导数。这里我们只看Dirichlet边界条件,它规定了域的边界上的函数值。

一般性PDE理论

有几种类型的PDE,每种都有不同的数学特性。最重要的属性是影响区域:如果我们对问题进行修补,使解决方案在一个点上发生变化,那么还有哪些点会受到影响。

双曲方程

PDEs的形式为

$A,B$的符号相反。这样的方程描述的是波,或更一般的对流现象,是保守的,不倾向于稳定状态。

直观地说,在任何一点上改变波浪方程的解,只会改变未来的某些点,因为波有一个传播速度,使得一个点不可能影响到空间上太远的近期的点。这种类型的PDE将不会在本书中讨论。

抛物线方程

PDEs的形式为

并且它们描述了类似于扩散的现象;这些现象往往趋于稳定状态。描述它们的最好方法是考虑在空间和时间的每一点上的解决方案受到空间的每一点上的某个有限区域的影响。

注释 16 这导致了一个限制IBVP时间的条件,即所谓的Courant-Friedrichs-Lewy条件http://en.wikipedia.org/wiki/Courant-Friedrichs-Lewy_condition。它描述了这样一个概念:在精确问题中,$u(x, t)$取决于$ u(x′, t-\Delta t)$的数值范围;数值方法的时间步长必须小到足以让数值解考虑到所有这些点。

热方程是抛物线类型的标准例子。

椭圆方程

PDEs的形式为

其中$A,B>0$;它们通常描述已经达到稳定状态的过程,例如抛物线问题中的$𝑡 \rightarrow \infty$。它们的特点是所有的点都相互影响。这些方程经常描述结构力学中的现象,如梁或膜。直观地讲,压下膜上的任何一点都会改变其他每一点的高度,无论多么微小。泊松方程(4.2.2节)是这种类型的标准例子。

一维空间的泊松方程

算子$\Delta$

是二阶微分算子,方程(4.7)是二阶PDE。具体来说,问题是

被称为泊松方程(Poisson equation),定义在单位平方上。二阶PDEs相当常见,它们描述了流体和热流以及结构力学中的许多现象。

首先,为了简单起见,我们考虑一维泊松方程

下面我们考虑的是二维的情况;然后扩展到三维的情况。

为了找到一个数值方案,我们像以前一样使用泰勒级数,用$𝑢(𝑥+h)$和$u(x-h)$来表示。

$u$及其在$x$的导数。设$h>0$,则

以及

我们现在的目标是对$u’’(x)$进行近似。我们看到,这些方程中的$u’$项在加法下会被抵消,剩下$2u(x)$。

于是

那么,上述数值方案的基础是观察

这表明我们可以用一个差分算子来近似微分算子,在$h\downarrow 0$时有一个$O(h^2)$。截断误差为$h\downarrow 0$。

为了得出一种数值方法,我们将区间[0, 1]划分为等距的点。$x_k=k_h$ 其中$h=1/(n+1),k=0 … n + 1$. 有了这些,有限差分(FD)公式(4.9)导致了一个形成方程组的数值方案。

这种使用FD公式近似解决PDE的过程被称为有限差分法(Finite Difference Method,FDM)。

对于大多数的$k$值,这个方程将$u_k$未知数与$u_{k-1}$和$u_{k+1}$的未知数联系起来。例外情况是$k=1$和$k=n$。在这种情况下,我们记得$u_0$和$u_{n+1}$是已知的边界条件,我们把左边是未知数,右边是已知量的方程写为

我们现在可以将这些方程总结为$u_k ,k = 1 … n- 1$的矩阵方程。

其形式为$Au=f$,$A$为完全已知矩阵,$f$为完全已知向量,$u$为未知向量。请注意,右边的向量在第一个和最后一个位置有问题的边界值。这意味着,如果你想用不同的边界条件解决同一个微分方程,只有向量$f$会发生变化。

练习 4.3 $u(0)= u_0$这种类型的条件被称为狄利克雷边界条件。在物理学上,这相当于,例如,知道一个棒端点的温度。其他边界条件也存在。如果我们对流体流动进行建模,并且已知$x=0$时的流出率,那么为导数指定一个值,$u’(0)=u_0’$,而不是为函数值指定一个值,是合适的。这就是所谓的诺伊曼边界条件。诺伊曼边界条件 $u’(0) = u_0’$ 可以通过以下方式来模拟

证明与狄利克雷边界条件的情况不同,这影响了线性系统的矩阵。

证明在两端都有诺伊曼边界条件会产生一个奇异矩阵,因此线性系统没有唯一的解。(提示:猜测特征值为零的向量)。

在物理学上这是有意义的。例如,在一个弹性问题中,狄利克雷边界条件说明杆子被夹在一定的高度;诺伊曼边界条件只说明它在端点的角度,这使得它的高度无法确定。

让我们列举一些$𝐴$的属性,你会发现这些属性与解决此类方程组有关。

  • 矩阵非常稀疏:非零元素的百分比很低,零元素不是随机分布的,而是位于主对角线周围的一个带状结构中。在一般情况下,我们称之为带状矩阵(banded matrix),而在这个特定情况下称之为三对角矩阵(tridiagonal matrix)。带状结构是典型的PDEs,但不同应用中的稀疏矩阵可能不太规则。

  • 矩阵非对称。这一特性并不涉及由BVP分解而来的所有矩阵,但如果没有奇数阶(指第一、第三、第五……)导数,例如$u_x,u_{xxx},u_{xy}$。

  • 矩阵元素在每个对角线上是恒定的,也就是说,在每一组点$\{(𝑖,𝑗)∶𝑖 - 𝑗 = 𝑐\}$,对于某个𝑐。这只对非常简单的问题是正确的。如果微分方程$\frac{d}{d x}\left(a(x) \frac{d}{d x} u(x)\right)$。如果我们在区间内设置h变量,它也不再成立,例如,因为我们想更详细地模拟左端点周围的行为。

  • 矩阵元素符合以下符号模式:对角线元素是正的,而非对角线元素是非正的。这一属性取决于所使用的数字方案,但它通常是真实的。连同下面的确定性属性,这被称为$M-$矩阵。关于这些矩阵有一整套数学理论[12]。

  • 矩阵是正定的:$x^tAx>0$,适用于所有非零向量$x$。如果仔细选择数值方案,这一属性将从原始连续问题中继承下来。虽然这一点的用途目前看来并不明确,但以后你会看到取决于它的线性系统的求解方法。

严格地说,方程的解很简单:$u=A^{-1}f$。然而,计算$A^{-1}$并不是找到$u$的最好方法。正如刚才所观察到的,矩阵$A$只有$3N$个非零元素可以存储。另一方面,它的逆矩阵则没有一个非零元素。虽然我们不会证明这一点,但这种说法对大多数稀疏矩阵都是成立的。因此,我们希望以一种不需要储存$O(n^2)$的方式来解决$Au=f$。

练习 4.4 你将如何解决这个三对角方程组?证明系数矩阵的LU因子化给出了双对角矩阵形式的因子:它们有一个非零对角线和正好一个非零子对角线或超对角线。解决三对角方程组的总操作数是多少?一个向量与这样的矩阵相乘的运算次数是多少?这种关系并不典型!

二维空间的泊松方程

上面的一维BVP在很多方面是不典型的,特别是与由此产生的线性代数问题有关。在本节中,我们将研究二维泊松问题。你会看到,它构成了一维问题的非微观概括。三维的情况与二维的情况非常相似,所以我们将不讨论它。

上面的一维问题有一个函数$u=u(x)$,现在变成了二维的$u=u(x,y)$。那么我们感兴趣的二维问题就是

其中边界上的数值是给定的。我们通过在$x$和$y$方向上应用方程得到我们的离散方程

或者说,合起来看

令 $h=1/(n+1)$,定义$x_i=ih$,$y_j=jh$;让$u_{ij}$是对$u(x_i,y_j)$的近似,那么我们的离散方程为

我们现在有$n\times n$未知数$u_{ij}$。为了像以前一样将其转化为一个线性系统,我们需要将它们放在一个线性排序中,我们通过定义$I = I_{ij} = j+ i\times n$来实现。这被称为字典序(lexicographic ordering),因为它将坐标$(i, j)$当作字符串来排序。

使用这个排序,我们可以得到$N=n^2$的方程

而线性系统看起来像

矩阵的大小为$N\times N$,其中$N=n^2$。与一维的情况一样,我们看到BVP会产生一个稀疏的矩阵。

以矩阵形式考虑这个线性方程组似乎是很自然的事情。然而,以明确未知数的二维联系的方式呈现这些方程可能更有洞察力。为此,图4.1展示了领域中的变量,以及方程如何通过有限差分模板将它们联系起来。从现在开始,在制作这样的领域图片时,我们将只使用变量的索引,而省略 “$u$ “标识符。

方程矩阵和一维的情况一样是带状的,但是和一维的情况不同,带状的内部有零点。因为该矩阵有五个非零对角线,所以被称为五对角线结构。

你也可以在矩阵上放一个块状结构,把在域的一行中的未知数组合起来。这被称为分块矩阵(block matrix),在块的层面上,它有一个三对角的矩阵结构,所以我们称之为分块三对角矩阵(block tridiagonal matrix)。请注意,对角线块本身是三对角的;非对角线块是减去单位矩阵的。

这个矩阵和上面的一维例子一样,有恒定的对角线,但这又是由于问题的简单性质造成的。在实际问题中,这不会是真的。也就是说,这样的 “恒定系数 “问题是有的,当它们在矩形域上时,有非常有效的方法来解决线性系统,其时间复杂性为$N\log N$。

练习 4.5 矩阵的块状结构,所有的对角线块都有相同的大小,这是因为我们在方形域上定义了我们的BVP。画出方程离散化所产生的矩阵结构,同样是中心差分,但这次是定义在一个三角形域上;见图4.2。说明同样有一个块状三对角矩阵结构,但现在块的大小不一。提示:先画出一个小例子。对于$n=4$,你应该得到一个$10\times 10$的矩阵,其块结构为$4\times 4$。

对于更加不规则的域,矩阵结构也将是不规则的。

有规律的块状结构也是由我们决定将未知数按行和列排序造成的。这被称为自然排序或词法排序;其他各种排序也是可能的。一种常见的未知数排序方式是红黑排序或棋盘排序,这对并行计算有好处。这将在第6.7节讨论。

关于BVP的分析方面还有更多要说的(例如,解有多平滑,它是如何取决于边界条件的),但这些问题超出了本课程的范围。这里我们只关注矩阵的数值方面。在线性代数一章中,特别是第5.4和5.5节,我们将讨论从BVP中求解线性系统。

stencils 差分

离散化通常被表述为应用stencils差分

到函数$u$。给定一个物理域,我们将stencils应用于该域中的每一个点,得出该点的方程。图 4.1 说明了一个由$n\times n$点组成的正方形域的情况。将此图与上述方程联系起来,你会发现同一条线上的连接产生了主对角线和第一条上下对角线;与下一条线和上一条线的连接则成为非对角线块的非零点。

stencils

这种特殊的模版通常被称为 “五点星 “或五点模版。还有其他不同的网板;其中一些网板的结构在图4.3中得到描述。只有水平或垂直方向连接的网板被称为 “星形网板”,而有交叉连接的网板(如图4.3中的第二种)则被称为 “星形网板”。

练习 4.6 考虑图4.3中的第三个模版,用于正方形域上的BVP。如果我们再次将变量按行和列排序,所得到的矩阵的稀疏性结构是什么样子的?

除了五点星形之外,还可以使用其他stencils来达到更高的精度,例如,给出一个$O(h^4)$的截断误差。它们还可以用于上面讨论的微分方程以外的其他微分方程。例如,不难看出,对于方程$u_x+u_{yyy}=f$,我们需要一个同时包含$x$ ,$𝑦\pm h$和$x$ ,$𝑦\pm 2h$连接的钢网,如图中的第三个钢网。相反,使用5点stencils,没有系数值的情况下,四阶问题的离散化小于$O(1)$截断误差。

虽然到目前为止讨论的是二维问题,但对于诸如$-u_x- u_y - u_z= f$这样的方程,它可以被推广到更高维。例如,5点stencils的直接泛化,在三维空间中变成了7点stencils。

其他离散化技术

在上面,我们用有限差分法来寻找微分方程的数值解。还有其他各种技术,事实上,在边界值问题的情况下,它们通常比有限差分更受欢迎。最流行的方法是有限元法和有限体积法。尤其是有限元方法很有吸引力,因为它比有限差分更容易处理不规则的形状,而且它更适合于近似误差分析。然而,在这里讨论的简单问题上,它给出的线性系统与FD方法相似甚至相同,所以我们将讨论限制在有限差分上,因为我们主要关注的是线性系统的计算方面。

初始边界值问题

现在我们将继续讨论初始边界值问题(IBVP),正如你可能从名字中推断的那样,它结合了IVP和BVP的各个方面。在这里,我们将把自己限制在一个空间维度。

我们考虑的问题是棒材中的热传导问题,其中$T(x,t)$描述了在时间上$x$的温度,对于$x\in [a,b],t>0$。所谓的热方程(见附录15对一般的PDE,特别是热方程的快速介绍)是

其中

  • 初始条件$T(x,0) = T_0(x)$ 描述初始温度分布。

  • 边界条件$T(a,t)=T_a(t)$,$T(b,t)=T_b(t)$描述棒的末端,例如可以固定在一个已知温度的物体上。

  • 杆的材料由一个参数$\alpha >0$来模拟,即热扩散率,它描述了热量在材料中扩散的速度。

  • 强迫函数$q(x, t)$描述了外部应用的加热,作为时间和地点的函数。

IBVP和BVP之间有一个简单的联系:如果边界函数$T_a$和$T_b$是常数,并且$q$不依赖于时间,只依赖于位置,那么直观地说,$T$将收敛到一个稳定状态。这方面的方程式是$-\alpha u’’(x)=q$。

离散化

现在我们将空间和时间都离散化,即$x_{j+1}=x_j+\Delta x_{k+1}=t_k+\Delta t$,边界条件$x_0=a$,$x_n=b$,并且$t_0=0$。 我们写出$T_{jk}$的数值解,$x=x_j$,$t=t_k$;运气好的话,这将近似于精确解$T(x_j,t_k)$。

对于空间离散化,我们使用中心差分公式

对于时间离散化,我们可以使用前面中的任何一种方案。我们将再次研究显式和隐式方案,对所产生的稳定性有类似的结论。

显式方案

通过明确的时间步进,我们将时间导数近似为

将此与空间的中心差异结合起来,我们现在有

我们将其改写为

在图4.4中,我们将其呈现为一个差分stencils。这表示每个点的函数值是由前一个时间层次上的点的组合决定的。

将给定的$k$和所有$j$值的方程组用矢量形式概括为

其中

这里的重要观察是,从$T_{k+1}$得出向量$T_k$的主要计算是一个简单的矩阵-向量乘法。

其中$A= I- \frac{\alpha \Delta t}{\Delta x^2}K$。这是第一个迹象,表明稀疏矩阵-向量乘积是一个重要的操作。使用显式方法的实际计算机程序通常不形成矩阵,而是评估方程。然而,为了分析的目的,线性代数公式是更有见地的。

在后面的章节中,我们将考虑操作的并行执行。现在,我们注意到显式方案是琐碎的并行的:每个点都可以只用周围几个点的信息进行更新。

隐式方案

在上述方程中,我们让$T_{k+1}$从$T_k$定义。我们可以通过从$T_{k-1}$定义$T_k$来扭转这一局面,正如我们在第4.1.2.2节中对IVP所做的那样。对于时间离散化,这就得到

整个热力方程的隐式时间步长离散化,在$t_{k+1}$中进行评估,现在变成了。

或者

图4.5将其渲染成一个模版;这表达了当前时间层上的每一个点都会影响到下一层的点的组合。我们再一次用矢量的形式来写这个。

与显式方法相比,在显式方法中,矩阵-向量乘法就足够了,从$𝑇^{𝑘+1}$推导出的向量$𝑇^𝑘$现在涉及一个线性系统解决方案。

其中$A= I+ \frac{\alpha \Delta t}{\Delta x^2}K$ 一个比矩阵-向量乘法更难的操作。在这种情况下,不可能像上面那样,直接评估方程(4.21)。使用隐式方法的代码实际上形成了系数矩阵,并以此来解决线性方程组。解决线性系统将是第5章和第6章的重点。

与显式方案相比,我们现在没有明显的并行化策略。线性系统的并行求解将在第6.6节及以后的章节中占据我们的位置。

练习4.7 证明隐式方法的一个时间步骤的flop数与显式方法的一个时间步骤的flop数是相同的。(这只适用于一个空间维度的问题)。至少给出一个论据,说明为什么我们认为隐式方法在计算上 “更难”。

我们在这里使用的数值方案是时间上的一阶和空间上的二阶:截断误差为$O(\Delta t+ \Delta x^2)$。也可以通过使用时间上的中心差分来使用一个时间上的二阶方案。另外,见练习4.8。

稳定性分析

现在我们在一个简单的情况下分析显式和隐式方案的稳定性。让$q\equiv 0$,并假设$T_j^k=\beta^ke^{i\ell x_j}$,对于某些$\ell$。这一假设在直觉上是站得住脚的:由于微分方程没有 “混合 “$x$和$t$的坐标,我们推测解决方案将是$T(x,t) = v(x) ⋅ \omega (t)$ 的单独解决方案的乘积。

唯一有意义的解发生在$c_1, c_2 < 0$,在这种情况下我们发现。

其中我们用$c=\ell \pi$代替,以考虑到边界条件。

如果对这种形式的解决方案的假设成立,我们需要$|\beta |<1$的稳定性。将推测的$T_{j}^k$形式代入显式方案,可以得到

为了保持稳定,我们需要$|\beta|<1$:

  • $\beta<1 \Leftrightarrow 2 \frac{\alpha \Delta t}{\Delta x^{2}}(\cos (\ell \Delta x)-1)<0$ : this is true for any $\ell$ and any choice of $\Delta x, \Delta t$.
  • $\beta>-1 \Leftrightarrow 2 \frac{\alpha \Delta t}{\Delta x^{2}}(\cos (\ell \Delta x)-1)>-2:$ this is true for all $\ell$ only if $2 \frac{\alpha \Delta t}{\Delta x^{2}}<1$, that is $\Delta t<\frac{\Delta x^{2}}{2 \alpha}$

后一个条件对允许的时间步长提出了很大的限制:时间步长必须足够小,方法才能稳定。这与IVP的显式方法的稳定性分析类似;然而,现在时间步长也与空间离散化有关。这意味着,如果我们决定需要更多的空间精度,并将空间离散化$\Delta x$减半,时间步数将乘以4。

现在让我们考虑隐式方案的稳定性。将解的形式$T_j^k=\beta^ke^{i\ell x_j}$替换到数值方案中,可以得到

除去$e^{i\ell x_j}\beta^{k+1}$,可得

由于$1-\cos l\Delta x\in (0,2)$,分母严格>1。因此,无论l的值如何,也无论$\Delta x$和$\Delta t$的选择如何,条件$|\beta|<1$总是被满足的:该方法总是稳定的。

练习 4.8 我们在这里考虑的方案是时间上的一阶和空间上的二阶:它们的离散化顺序是$O(\Delta t)+ O(\Delta x^2)$。推导出由显式和隐式方案平均化得到的Crank-Nicolson方法,说明它是不稳定的,并且在时间上是二阶的。

数值线性代数

在第四章中,我们了解了偏微分方程的数值解法是如何产生线性代数的问题。在前向欧拉法的情况下是一个矩阵与向量乘法,较为简单;而在后向欧拉方法下是一个线性方程组的解,较为复杂。解决线性系统将是本章的重点;此处我们将不讨论需要解决特征值问题。

你可能已经学过一种解线性方程组的简单算法:消除未知数,也叫「高斯消元法」(Gaussian elimination)。这种方法仍然可以使用,但我们需要对其效率进行一些仔细的讨论。还有其他一些算法即所谓的迭代求解法,它们通过逐步逼近线性系统的解来进行,这也是我们要讨论的内容。

由于PDE的背景,我们只考虑方形和非星形的线性系统。矩形系统,特别是超定系统,在最优化理论的数值分析中也有重要的应用。然而,在本书中我们不会涉及这些。

关于数值线性代数的标准著作是Golub和Van Loan的《矩阵计算》[83]。它包括算法、误差分析和计算细节。Heath的《科学计算》涵盖了科学计算中出现的最常见的计算类型;这本书有许多优秀的练习和实践项目。

消除未知数

下面我们将系统地讲述高斯消元法。

注释 17 我们可以通过高斯-若尔当(Gauss Jordan)方法,乘以逆矩阵$A^{-1}:x \leftarrow A^{-1}x$,来求解方程,但出于数字精度的考虑,本书不讨论这种方法。

本章的讨论主线是各种算法的效率。即便你学会了高斯消元法,也可能从未在大于$4\times 4$的矩阵上使用这种方法。在PDE求解中出现的线性系统可能要大几千倍,计算它们需要操作数以及内存是十分重要的。

正确选择算法对效率而言十分重要。克拉默法则(Cramer’s rule)指出:线性方程组的解可以用一个相当简单的公式表示,即行列式。尽管它在数学上很优雅,但对我们来说却并不实用。

如果给定了一个矩阵$𝐴$和一个向量$𝑏$,想要求解$𝐴𝑥=𝑏$的解,则$|A|$的行列式:

对于任何矩阵$M$,行列式被递归定义为

其中$M[1,i]$表示从$M$中删除第1行和第$i$列而得到的矩阵。计算一个$n$阶行列式要计算$n$次$n-1$阶行列式。每一次都需要$n-1$个大小为$n-2$的行列式,所以行列式的计算所需操作是矩阵大小的阶乘。在本章的后面,我们会用其他合理的方法解决线性方程组。

现在让我们看一下用消除未知数的方法解线性方程的一个简单例子。考虑以下线性方程组

我们从第二和第三个方程中消除$x_1$,方法是

  • 将第二个方程减去第一个方程$\times 2$;
  • 将第三个方程减去第一个方程$\times 1/2$。

这样,线性方程组就变成了

最后,我们通过将第二个方程乘以3,在第三个方程中将其减去,消除三式中的$x_2$。

现在我们可以根据上一个方程求出$x_3=9/4$。将其代入第二个方程,我们得到$-4x_2=-6-2x_2=-21/2$,所以$x_2=21/8$。最后,从第一个方程中,$6x_1=16+2x_2-2x_3=16+21/4-9/2=76/4$,所以$x_1=19/6$。

我们可以通过省略$x_i$系数来写得更紧凑。将

记为

那么消元的过程为

在上面的例子中,矩阵系数可以是任何实数(或复数)系数。我们可以重复上面的过程求解任何线性方程组,但有一个例外。数字6、-4、-4,最后处在矩阵的对角线上,这些非零数字被称为主元(pivots)。

练习 5.1 线性方程组

与我们刚才在公式中研究的相同,除了(2,2)元素。确认你在第二步中得到一个零主元。

第一个主元是原矩阵的一个元素;正如你在前面的练习中所看到的,如果不消元,就无法找到其他主元。没有简单的方法可以预测零主元。

如果一个主元被证明是零,所有的计算都不会丢失:我们总是可以交换两个矩阵行;这就是所谓的主元。不难发现(你可以在任何一本初级线性代数教科书中找到),对于一个非奇异矩阵,总有一个行的交换可以将一个非零元素放在主元的位置。

练习 5.2 假设我们想交换方程中方程组的矩阵第2行和第3行。还需要做哪些调整以确保仍然能计算出正确的解?通过交换第2行和第3行,继续求解上一个练习的解,并检查你得到的答案是否正确。

练习 5.3 再看一下练习5.1。不交换第2和第3行,而要交换第2和第3列。就线性方程组而言,这意味着什么?继续求解该系统;检查你是否得到与之前相同的解。

一般来说,在浮点数和四舍五入的情况下,在计算过程中,一个矩阵元素不太可能完全变成零。另外,在PDE背景下,对角线通常是不为零的。这是否意味着主元运算在实践中没有必要?答案是否定的:从数值稳定性的角度来看,主元是可取的。在下一节,你将看到一个例子来说明这个事实。

线性代数在计算机运算中的应用

本章的大部分内容都可以通过数学运算求解,然而由于计算机的精度有限,我们需要设计将舍入误差降到最小的算法。对数值线性代数的算法需要进行严格而全面的误差分析,然而,这超出了本课程的范围。计算机算术中的计算误差分析是威尔金森的经典《代数过程中的舍入误差》[201]和海姆最近的《数值算法的准确性和稳定性》[106]的重点。

本书将注重计算机运算过程中出现的经典例子:将说明为何在LU分解中主元方法不仅仅是理论手段,此外,我们将给出两个由于计算机算术的有限精度而导致的特征值计算中的问题的例子。

消除过程中的舍弃控制

上面我们看到,如果在消除该行和列的过程中,对角线上出现了一个零元素,那么行间交换(’主元’)是必要的。现在我们讨论如果主元元素不是零,但接近零会发生什么。

考虑线性方程组

其中的解决方案是$𝑥=(1, 1)^𝑡$。使用(1, 1)元素清除第一列的剩余部分,可以得到。

现在我们可以解决$x_2$,并从它得到$x_1$。

如果$\epsilon$较小,如$\epsilon <\epsilon_{mach}$,则右侧的1+𝜖替换为1:线性方程组改写为

但解$(1, 1)^𝑡$仍将满足机器运算。接下来,$1/\epsilon $将非常大,所以消除后的右手边第二部分将是$2-\frac{1}{\epsilon}=-1/\epsilon$,并且(2,2)矩阵中的元素是$-1/\epsilon$而不是$1-1/\epsilon$。

首先求解$x_2$,然后求解$x_1$,我们得到。

所以$x_2$是正确的,但$x_1$是完全错误的。

注释 18 在这个例子中,计算机运算中的数字与精确运算中的数字偏差不大。然而,结果却可能是大错特错。对这种现象的分析见[99]的第一章。

如果我们按照上述方法进行透视,会发生什么?我们交换矩阵的行,得到

交换矩阵的行,得到

现在得到的是,无论$\epsilon$的大小如何。

在这个例子中,我们使用了一个非常小的$\epsilon $值;更精细的分析表明,即使$\epsilon$值大于机器精度,主元交换仍然是有意义的。一般的经验法则是。始终进行换行,使当前列中最大的剩余元素进入主元位置。在第4章中,你看到了在某些实际应用中出现的矩阵;可以证明,对它们来说主元是没有必要的;见练习5.13。

上面讨论的主元也被称为部分主元(partial pivoting),因为它只基于行的交换。另一个选择是完全主元(full pivoting),即结合行和列的交换,找到剩余子块中最大的元素,作为主元。最后,对角线主元将同样的交换应用于行和列。(这相当于对问题的未知数进行重新编号)。这意味着主元只在对角线上被搜索到。从现在开始,我们将只考虑部分主元的问题。

舍入对特征值计算的影响

考虑矩阵

其中$\epsilon_{mach} < |\epsilon| < \sqrt{\epsilon_{mach}}$,其特征值为$1 + \epsilon $和$1 - \epsilon$。如果我们用计算机运算来计算它的特征多项式

我们发现一个双重特征值1。请注意,准确的特征值是可以用工作精度来表达的;是算法导致了错误。显然,使用特征多项式并不是计算特征值的正确方法,即使在行为良好的对称正定矩阵中也是如此。

一个非对称的例子:让$A$为大小为20的矩阵

由于这是一个三角矩阵,其特征值是对角线元素。如果我们通过设置$A_{20,1}=10^{-6}$来扰动这个矩阵,我们发现特征值的扰动要比元素的扰动大得多。

另外,有几个计算出来的特征值有虚数的成分,而准确的特征值没有这个成分。

LU分解

到目前为止,我们已经在解决单一线性方程组的背景下研究了消除未知数的问题。假设我们需要解决一个以上的具有相同矩阵的系统,但有不同的右手边。例如,如果在隐式欧拉方法中采取多个时间步骤,就会发生这种情况。我们能否利用第一个系统中所做的工作来使后面的内容更加容易解决?

答案是肯定的。我们可以将求解过程分为只涉及矩阵的部分和专门针对右手边的部分。如果有一系列的系统需要解决,我们只需要做一次第一部分,幸运的是,这甚至是我们需要做的主要工作。

让我们再看一个例子:

在消除的过程中,我们把第二行减去$2\times $第一行,第三行减去$1/2\times$第一行。说服你自己,这种合并行的做法可以通过从左边的$A$乘以

这是与对角线下第一列中的消除系数相同的。消除变量的第一步相当于将系统$Ax=b$转换为$L_1Ax=L_1b$。

在下一步,你从第三行减去3$\times $第二行。让你自己相信,这相当于将当前的矩阵$L_1A$左乘以

这是与对角线下第一列中的消除系数相同的。消除变量的第一步相当于将系统$Ax=b$转换为$L_2L_1Ax=L_2L_1b$。并且$L_2L_1A$是 “上三角 “形式。如果我们定义$U = L_2L_1A$, 那么$A= L_1^{-1}L_2^{-1}U$。计算诸如$L^{-1}$这样的矩阵有多难?很容易,事实证明是这样。

我们提出以下意见:

同样地

甚至更显著的

即$L_1^{-1}L_2^{-1}$包含$L^{-1}$的非对角线元素,$L^{-1}$不变,它们又包含消除系数。(这是Householder反射器的一个特殊情况,见13.6)。

练习 5.4 证明类似的声明是成立的,即使在对角线之上有元素存在。

如果定义$L=L_1^{-1}L_2^{-1}$,现在有$A=LU$;这被称为$LU$因子化。我们看到,对角线以下的$L$系数是消除过程中所用系数的负数。更好的是,$L$的第一列可以在消除$A$的第一列时写出来,所以$L$和$U$的计算可以在没有额外存储的情况下完成,至少我们可以承受失去$𝐴$。

算法

让我们把$LU$因式分解算法写成正式代码

1
2
3
4
5
6
7
8
9
10
11
12
⟨𝐿𝑈 factorization⟩: 
for 𝑘 = 1, 𝑛 − 1:
⟨eliminate values in column 𝑘⟩
⟨eliminate values in column 𝑘⟩:
for 𝑖 = 𝑘 + 1 to 𝑛:
⟨compute multiplier for row 𝑖⟩
⟨update row 𝑖⟩
⟨compute multiplier for row 𝑖⟩
𝑎𝑖𝑘 ← 𝑎𝑖𝑘/𝑎𝑘𝑘
⟨update row 𝑖⟩:
for 𝑗 = 𝑘 + 1 to 𝑛:
𝑎𝑖𝑗 ← 𝑎𝑖𝑗 − 𝑎𝑖𝑘 ∗ 𝑎𝑘𝑗

或者说,把所有东西放在一起。

1
2
3
4
5
6
⟨𝐿𝑈 factorization⟩: 
for 𝑘 = 1, 𝑛 − 1:
for 𝑖 = 𝑘 + 1 to 𝑛:
𝑎𝑖𝑘 ← 𝑎𝑖𝑘/𝑎𝑘𝑘
for 𝑗 = 𝑘 + 1 to 𝑛:
𝑎𝑖𝑗 ← 𝑎𝑖𝑗 − 𝑎𝑖𝑘 ∗ 𝑎𝑘𝑗

这是呈现$LU$因子化的最常见方式。然而,也存在其他计算相同结果的方法。像$LU$因子化这样的算法可以用几种方式进行编码,这些方式在数学上是等价的,但它们有不同的计算行为。这个问题,在密集矩阵的背景下,是van de Geijn和Quintana的《矩阵计算编程的科学》[193]的重点。

Cholesky因式分解

一个对称矩阵的$LU$因式分解并不能得到相互转置的$L$和$U$:$L$在对角线上有1,而$U$有主元。然而,我们可以对对称矩阵$A$进行因式分解,其形式为$A=LL^t$。这样做的好处是,因式分解所占用的空间与原始矩阵相同,即$n(n+1)/2$个元素。如果运气好的话,我们可以像在$LU$情况下一样,用因子化覆盖矩阵。

我们通过归纳推理来推导出这个算法。让我们把$A=LL^t$写成块状。

则$\ell_{11}^2=a_{11}$,由此得到$\ell_{11}$。我们还发现$\ell_{11}(L^t)_{1j} = \ell_{j1} = a_{1j}$,所以我们可以计算出整个$L$的第一列。最后,$A_{22} = L_{22}L^t_{22} + \ell_{12}\ell^t_{12}$,所以

这表明$L_{22}$是更新的$A_{22}$块的Cholesky因子。递归后,现在定义了该算法。

唯一性

我们时常需要分析不同的计算方式是否会导致相同的结果,这被称为结果的“唯一性”:如果计算结果唯一,那么不论我们使用何种软件库,都不会改变计算的有效性。

下面我们分析LU因式分解的唯一性。$LU$因式分解算法的定义是:给定一个非奇异矩阵$A$,它将给出一个下三角矩阵$L$和上三角矩阵$U$,使得$A= LU$。上述计算$LU$因式分解的算法是确定的(它不包含 “取任何满足…的行 “的指令),所以给定相同的输入,它将总是计算相同的输出。然而,其他的算法也是可能的,所以我们需要担心他们是否会得到相同的结果。

我们假设$A=L_1U_1=L_2U_2$,其中$L1、L2$为下三角,$U_1、U_2$为上三角。那么,$L^{-1}L=UU^{-1}$。 在这个等式中,左手边是下三角矩阵的乘积,而右手边只包含上三角矩阵。

练习 5.5 证明下三角矩阵的乘积是下三角,而上三角矩阵的乘积是上三角。对于非奇异三角形矩阵的倒数,类似的说法是否成立?

积$L^{-1}L$显然既是下三角又是上三角,所以它一定是对角线的。我们称它为$D$,那么$L_1=L_2D$,$U_2=D_1$。结论是,𝐿𝑈因式分解不是唯一的,但它是唯一的 “直到对角线缩放”。

练习 5.6 第5.3.1节中的算法产生了一个下三角因子$L$,其对角线上有1。证明这个额外的条件使得因式分解是唯一的。

练习 5.7 证明另一个条件,即$U$的对角线上有1,也足以实现因式分解的唯一性。

由于我们可以要求$L$或$U$中的单位对角线,你可能想知道是否有可能两者都有。(我们可以这样做:假设$A=LU$,其中$L$和$U$是非星形下三角和上三角,但没有以任何方式归一化。请写出

经过重命名,我们现在有一个因式分解

其中$D$是一个包含主元的对角线矩阵。

练习 5.8 证明你也可以将因式分解的形式定为$A= (D+L)D^{-1}(D+ U )$ 。这个$D$与前面的有什么关系?

练习 5.9 这样考虑一个三对角矩阵的因式分解。$L$和$U$与$A$的三角部分有什么关系?推导出$D$和$D_A$之间的关系,并说明这是产生主元的方程。

主元

在上面的因式分解例子中,我们为了保证非零主元的存在,或者为了数值的稳定性,需要进行主元化,也就是交换行。现在我们将把透视纳入$LU$因式分解。

首先,行交换可以用矩阵乘法来描述。令

那么$P^{(i,j)}A$是交换了行$i$和$j$的矩阵$A$。由于我们可能要在因式分解过程的每次迭代中进行透视,我们引入一个序列$p(·)$,其中$p(i)$是与第𝑖行交换的第𝑗行的值。简写为$P^{(i)}\equiv P^{(i,p(i))}$。

练习 5.10 证明$P^{(i)}$是其自身的逆。

现在可以将部分主元的因式分解过程描述为:

  • 让$A^{(i)}$为矩阵,列$1 … i- 1$被消除,并应用部分主元以获得$(i, i)$位置上的所需元素。
  • 让$\ell^{(i)}$为第$i$个消除步骤中的乘数向量。(也就是说,这一步的消除矩阵$L_i$是身份加$\ell(i)$的第$i$列)。
  • 让$P^{(i+1)}(j\geqslant i+1)$是为下一个消除步骤做部分透视的矩阵,如上所述。
  • 那么$A^{(i+1)} = P^{(i+1)}L_iA^{(i)}$。

这样,我们就得到了一个因式分解的形式

此时我们无法写出$ A= LU$:而是写出

练习 5.11 回顾前面的内容,从性能角度看,分块算法通常是可取的。为什么方程(5.5)中的 “$LU$因子化与交错主元矩阵 “对性能来说是个坏消息?

幸运的是,方程(5.5)可以被简化:$P$和$L$矩阵 “几乎相通”。我们通过一个例子来证明这一点:$P^{(2)}L_1=\tilde{L_1}𝑃^{(2)}$,其中$\tilde{L}_1$非常接近于$L_1$。

matrixdemo2

其中$\tilde{\ell}^{(1)}$与$\ell^{(1)}$相同,只是元素$i$和$p(i)$被调换了。你现在可以说服自己,同样$P^{(2)}$等也可以 “拉过 “$L_1$。

因此,我们得到

这意味着我们可以像以前一样再次形成一个矩阵$L$,只是每次我们进行主元时,我们需要更新已经计算过的$L$的列。

练习 5.12 如果我们把方程写成$PA= LU$,得到$A= P^{-1}LU$。是否能得出一个用$P$ 表示的 $P^{-1}$?提示:每个$P^{(i)}$都是对称的,而且是它自己的逆数;见上面的练习。

练习 5.13 早些时候,我们看到二维BVP产生了某种矩阵。我们在没有证明的情况下指出,对于这些矩阵不需要主元。现在我们可以正式证明这一点,重点放在对角线支配的关键属性上。

假设矩阵$A$满足$\forall j\neq i: a_{ij} \leqslant 0$,表明该矩阵是对角域的,即存在向量$u, v \geqslant 0$(意味着每个分量都是非负的),使得$Au = v$。 证明在消除一个变量后,对于剩余的矩阵$\tilde{A}$又有向量$\tilde{u},\tilde{v} \geqslant 0$,使得$\tilde{A}\tilde{u} =\tilde{v}$。 现在完成论证,如果$A$是对称的、对角线主导的,(部分)主元是不必要的。(实际上可以证明,主元对于任何对称正定(SPD)矩阵都是不必要的,对角线主导是比SPD-性更强的条件)。

解决系统问题

现有一个因式分解$A = LU$,我们可以用它来解决线性系统$Ax= LUx =b$。如果我们引入一个临时向量$y= Ux$,那么我们可以看到这需要两个步骤。

第一部分,$Ly = b$被称为 “下三角解”,因为它涉及下三角矩阵$L$。

在第一行,你看到$y_1=b_1$.然后,在第二行$\ell_{21}y_1+y_2=b_2$,所以$y_2=b_2-\ell_{21}y_1$。你可以想象这如何继续:在每$i$行,你可以从以前的$y$值计算$y_i$。

由于我们以递增的方式计算$y_i$,这也被称为正向替换、正向求解或正向扫瞄。

求解过程的后半部分,即上三角求解、后向替换或后向扫描,从$Ux=y$中计算出$x$。

现在我们看一下最后一行,它立即告诉我们$x_n=u^{-1}_{nn}y_n$。由此可见,最后一行说明了$u_{n-1n-1}x_{n-1}+u_{n-1n}x_n=y_{n-1}$,从而得出$x_{n-1}=u^{-1}_{n-1n-1}(y_{n-1}u{_{n-1n}x_n})$。更一般地,我们可以计算

对$i$的数值递减。

练习 5.14 在回扫中,你必须用数字$𝑢_𝑖$进行除法。如果其中任何一个是零,这是不可能的。把这个问题与上面的讨论联系起来。

复杂度

在本章的开头,我们指出,并不是每一种解决线性系统的方法都需要相同数量的操作。因此,让我们仔细研究一下复杂度1,也就是在解决线性系统时使用LU因子化的操作数与问题大小的函数关系。

首先,我们看一下从$LUx = b$(”解决线性系统”)计算$x$,因为我们已经有了因子化$A= LU$。把下三角和上三角部分放在一起看,会发现与所有对角线外的元素(即元素$\ell_{ij}$或$u_{ij}$与$i\neq j$)进行了乘法。 此外,上三角解涉及到对$u_i$元素的除法。 现在,除法运算通常比乘法运算昂贵得多,所以在这种情况下,我们会计算出$1/u_i$的值,并将其存储起来。

练习 5.15 请看一下因式分解算法,并论证存储主元的倒数并不增加计算的复杂性。

总结一下会发现,在一个大小为$n\times n$的系统中,我们要进行$n^2$的乘法运算和大致相同数量的加法运算。这表明,给定一个因式分解,解决一个线性系统的复杂度与简单的矩阵-向量乘法相同,也就是说,给定$A$和$x$的计算复杂度。

构建$LU$因式分解的复杂度,计算起来就比较麻烦了。你可以看到,在$k$第三步发生了两件事:乘数的计算和行的更新。有$𝑛 - 𝑘$个乘数需要计算,每个乘数都涉及一个除数。除法之后,更新需要$(n-k)^2$次加法和乘法。如果我们暂时不考虑除法的问题。因为它们的数量较少,我们发现$LU$因子化需要$\sum_{k=1}^{n-1}2(n-k)^2$次运算。如果我们顺序给这个和中的项编号,我们会发现

由于可以通过积分来近似求和,我们发现这是$2/3n^3$加上一些低阶项。这比解决线性系统要高一阶:随着系统规模的增长,构建$LU$因子化的成本完全占据了主导地位。

当然,算法分析还有比运算计数更重要的内容。虽然解线性系统的复杂度与矩阵-向量乘法相同,但这两种操作的性质非常不同。一个很大的区别是,因式分解和前向/后向求解都涉及递归,所以它们不容易并行化。关于这一点,我们将在后面详细说明。

分块算法

通常,矩阵有一个自然的块状结构,比如二维BVP的情况。许多线性代数操作可以用这些块来表述。与传统的矩阵标量观点相比,这可能有几个好处。例如,它可以改善缓存阻塞;它也有利于在多核架构上调度线性代数算法。

对于分块算法,我们把矩阵写成

其中$M, N$是块的大小,即用子块表示的大小。通常情况下。

我们选择的块是:$M = N$,对角线块为正方形。作为一个简单的例子,考虑矩阵-向量乘积$y= Ax$,以块的形式表示。

为了说明区块算法的计算结果与旧的标量算法相同,我们看一下一个分支$Y_{i_k}$,也就是第$i$块的$k$个标量分量。首先。

于是

是$a$的第$i$个区块行的第$k$行与整个$X$的积。

一个更有趣的算法是$LU$因子化的分块版。算法就变成了

1
2
3
4
5
6
⟨𝐿𝑈 factorization⟩: 
for 𝑘 = 1, 𝑛 − 1:
for 𝑖 = 𝑘 + 1 to 𝑛:
Aij ← AikA^{−1}
for 𝑗 = 𝑘 + 1 to 𝑛:
𝐴𝑖𝑗 ← 𝐴𝑖𝑗 − 𝐴𝑖𝑘 ⋅ 𝐴𝑘𝑗

该算法与之前的算法有很大的不同,即除以$a_{kk}$的方法被一个乘以$A^{-1}_{kk}$。另外,$U$因子现在在对角线上有支点块,而不是支点元素。所以$U$只是 “块状上三角形”,而不是严格的上三角形。

练习 5.16 我们想表明,这里的块状算法再次计算出与标量算法相同的结果。通过明确查看计算的元素来做到这一点是很麻烦的,所以我们采取另一种方法。首先,回顾一下第5.3.3节,$LU$面化是唯一的:如果$A=L_1U_1=L_2U_2$且$L_1、L_2$有单位斜线,那么$L_1=L_2$。$U_1 = U_2$

接下来,考虑计算$A^{-1}_{kk}$的问题。证明这可以通过以下方式完成:首先计算$A_{kk}$的$LU$因子化。现在用它来证明,块状$LU$因子化可以给出严格意义上的三角形的$L$和$U$因子。$LU$因式分解的唯一性就证明了块算法可以计算出标量结果。

稀疏矩阵

BVP(和IBVP)的离散化可能会引起稀疏矩阵的出现。由于这样的矩阵有$N^2$个元素,但只有$O(N)$个非零元素,将其存储为二维数组将是对空间的极大浪费。此外,我们希望避免对零元素进行操作。

在本节中,我们将探讨稀疏矩阵的有效存储方案,以及使用稀疏存储时熟悉的线性代数操作的形式。

稀疏矩阵的存储

稀疏矩阵没有明确定义,只要当矩阵中的零元素多到需要专门存储时,该矩阵即为稀疏矩阵(sparse matrix)。

我们将在此简要讨论最流行的稀疏矩阵的存储方案。由于矩阵不再是作为一个简单的2维数组来存储,使用这种存储方案的算法也需要重写。

带状存储和对角线存储

带状稀疏矩阵的非零元素正好位于一些子对角线上。对于这样的矩阵,可以采用专门的存储方案。

以一维BVP的矩阵为例。它的元素位于三个子对角线上:主对角线和第一个超对角线和子对角线。在带状存储中,我们只将包含非零点的带状存储在内存中。对于这样一个矩阵,最经济的存储方案是连续存储 $2n-2$ 的元素。然而,由于各种原因,浪费一些存储位置会更方便,如图 5.1 所示。

因此,对于一个大小为$n\times n$,矩阵带宽为$p$的矩阵,我们需要一个大小为$n\times p$的矩形数组来存储矩阵。那么,矩阵将被存储为

其中星星表示不对应矩阵元素的数组元素:它们是图5.1中左上和右下的三角形。

当然,现在我们要想知道阵列元素$A(i,j)$和矩阵元素$𝐴_{𝑖𝑗}$之间的转换。这在Fortran语言中是最容易做到的。如果我们将数组分配为

1
dimension A(n,-1:1)

那么主对角线$A_i$就被储存在$A(*,0)$中。例如,$A(1,0) \sim A_{11}$。在矩阵$A$的同一行的下一个位置,$A(1,1) \sim A_{12}$。很容易看出,我们一起有这样的转换

练习 5.17 什么是反向转换,也就是说,矩阵元素$A_{ij}$对应于哪个数组位置$A(?,?)$?

练习 5.18 如果你是一个C语言程序员,请推导出矩阵元素$A_{ij}$与数组元素$A[i][j]$之间的转换。

如果我们将这种将矩阵存储为$N\times p$数组的方案应用于二维BVP的矩阵,就会变得很浪费,因为我们会存储许多存在于带内的零。因此,在对角线存储或对角线存储中,我们通过只存储非零对角线来完善这一方案:如果矩阵有$p$个非零对角线,我们需要一个$n\times p$数组。对于方程的矩阵,这意味着。

当然,我们需要一个额外的整数阵来告诉我们这些非零对角线的位置。

练习 5.19 对于$d=1, 2, 3$空间维度的中心差分矩阵,作为$N$阶的带宽是多少?离散化参数$h$的阶数是多少?

在前面的例子中,矩阵在主对角线上下有相同数量的非零对角线。在一般情况下,这不一定是真的。为此,我们引入了以下概念

  • 左半带宽度(left halfbandwidth):如果$A$的左半带宽度为$p$,那么$A_{ij} = 0$,因为$i> j + p$,和
  • 右半带宽度(right halfbandwidth):如果$A$的右半带宽度为$p$,那么$A_{ij}=0$,因为$j>i+p$。如果左边和右边的半带宽度相同,我们就直接指半带宽度。

对角线存储的操作

对稀疏矩阵最重要的操作是矩阵-向量乘积。对于一个按对角线存储的矩阵,如上所述,仍然可以使用转换公式(5.9)进行普通的ROWISE或Lumnwise乘积;事实上,这就是Lapack带状程序的工作方式。然而,在带宽较小的情况下,这样做的向量长度较短,循环开销相对较高,所以效率不高。有可能做得比这好得多。

如果我们看一下矩阵元素在矩阵-向量乘积中是如何使用的,我们会发现主对角线被用作

第一个超对角线被用作

第一条对角线为

换句话说,整个矩阵-向量乘积只需执行三个长度为$n$(或$n-1$)的向量运算,而不是长度为3(或2)的$n$内积。

1
2
3
4
5
for diag = -diag_left, diag_right
for loc = max(1,1-diag), min(n,n-diag)
y(loc) = y(loc) + val(loc,diag) * x(loc+diag)
end
end

练习 5.20 写一个程序,通过对角线计算$y \leftarrow A^tx$。用你喜欢的语言来实现它,并在一个随机矩阵上测试它。

练习 5.21 如果矩阵在带内是密集的,上述代码片段是有效的。例如,二维BVP的矩阵就不是这种情况。写出只使用非零对角线的矩阵-向量乘积的代码。

练习 5.22 矩阵相乘比矩阵与向量相乘更难。如果矩阵$A$的左半带宽度为$p_A,q_Q$,而矩阵$B$的左半带宽度为$p_B,q_B$, $C=AB
$的左半带宽度为多少? 假设已经为$C$分配了一个足够大的数组,请写一个程序来计算$C \leftarrow AB$。

压缩行存储

如果我们有一个不具有简单带状结构的稀疏矩阵,或者非零对角线的数量变得不切实际,我们可以使用更通用的压缩行存储(Compressed Row Storage,CRS)方案。顾名思义,这个方案是基于压缩所有的行,消除零值;见图5.2。由于这失去了非零最初来自哪一列的信息,我们必须明确地存储这些信息。考虑一个稀疏矩阵的例子。

在压缩了所有的行之后,我们将所有的非零点存储在一个单一的实数数组中。列的索引也同样存储在一个整数数组中,我们存储指向列开始位置的指针。使用基于0的索引,这就得到了。

crs

CRS的一个简单变体是压缩列存储(CCS),其中列中的元素被连续存储。这也被称为Harwell-Boeing矩阵格式[55]。你可能会遇到的另一种存储方案是坐标存储,其中矩阵被存储为三联体列表$$.流行的矩阵市场网站[161]使用这种方案的一个变体。

关于压缩行存储的算法

在这一节中,我们将看一下一些算法在CRS中的形式。首先我们考虑稀疏矩阵-向量乘积的实现。

1
2
3
4
5
6
7
8
for (row=0; row<nrows; row++) {
s = 0;
for (icol=ptr[row]; icol<ptr[row+1]; icol++) {
int col = ind[icol];
s += a[icol] * x[col];
}
y[row] = s;
}

标准的矩阵-向量乘积算法$y=Ax$,其中每一行$A_{i∗}$与输入向量$x$进行内积。然而,请注意,内循环不再以列号为索引,而是以要找到该列号的位置为索引。这个额外的步骤被称为间接寻址。

练习 5.23 比较密集矩阵-向量乘积(按行执行)和刚才给出的稀疏乘积的数据位置性。说明对于一般的稀疏矩阵来说,在处理输入向量$x$时的空间局部性已经消失了。是否有一些矩阵结构,我们仍然可以期待一些空间局部性?

现在,如果我们想计算乘积$y=A^tx$呢?在这种情况下,我们需要$A^t$的行,或者说,相当于$A$的列。找到$A$的任意列是很难的,需要大量的搜索,所以可能认为这个算法也相应地难以计算。幸运的是,这并不是真的。

如果我们将标准算法中的$i$和$j$循环交换为$y=Ax$,我们可以得到

使用第二种算法来计算$A^tx$的乘积。

练习 5.24 写出转置积$y=A^tx$的代码,其中$A$是以CRS格式存储。写一个简单的测试程序,确认你的代码计算正确。

练习 5.25 如果需要同时访问行和列呢?实现一种算法,测试以CRS格式存储的矩阵是否对称。提示:保留一个指针数组,每行一个,用来记录在该行的进展情况。

练习 5.26 到目前为止所描述的操作是相当简单的,因为它们从未改变过矩阵的稀疏结构。如上所述,CRS格式并不允许在矩阵中添加新的非零点,但做一个允许添加的扩展并不难。

让数字$p_i, i = 1 … n$,描述第$i$行中非零点的数量。设计一个对CRS的扩展,使每一行都有$q$额外元素的空间。实施这个方案并进行测试:构造一个第$i$行有$p_i$个非零点的矩阵,在添加新元素之前和之后检查矩阵-向量乘积的正确性,每行最多有$q$个元素。

现在假设矩阵中的非零点总数不会超过𝑞𝑛。修改你的代码,使其能够处理从空矩阵开始,并逐渐在随机位置添加非零点。再次,检查正确性。

我们将在第6.5.5节在共享内存并行的背景下重新审视转置积算法。

稀疏矩阵和图论

许多关于稀疏矩阵的论点都可以用图论来表述。为了了解为什么可以这样做,考虑一个大小为$n$的矩阵$A$,并观察到我们可以通过$V={1,…,n}$定义一个图$⟨E,V⟩$,$E=\{(i,j)∶a_{ij} \neq 0\}$。 这被称为矩阵的邻接图。 为简单起见,我们假设$A$有一个非零的对角线。 如果有必要,我们可以给这个图附加权重,定义为$w_{ij}= a_{ij}$。 然后,该图被表示为$⟨E,V,W⟩$。 (如果你不熟悉图论的基本知识,请看附录18)。 图的属性现在与矩阵属性相对应;例如,图的度数是每行的最大非零数,不包括对角线元素。 再比如,如果矩阵的图形是一个无向图,这意味着$a_{ij}\neq 0 \Leftrightarrow a_{ij} \neq 0$。 我们称这样的矩阵为结构对称:它不是真正意义上的对称,即$\forall_{ij}∶ a_{ij} = a_{ij}$ ,但上三角的每个非零都对应于下三角的一个,反之亦然。

变换下的图的特性

考虑矩阵图的一个好处是,图的性质并不取决于我们如何对节点进行排序,也就是说,它们在矩阵的变化下是不变的。

练习 5.27 让我们来看看,当矩阵的图形$G=⟨V,E,W⟩$的节点被重新编号后会发生什么。作为一个简单的例子,我们对节点进行反向编号;也就是说,以$n$为节点数,我们将节点$i$映射为$n+1-i$。相应地,我们发现一个新的图$G’=⟨V,E’,W’⟩$ 其中

这种重新编号对与$G′$相对应的矩阵$A’$意味着什么?如果你交换两个节点上的标签$i, j$,对矩阵$A$有什么影响?

注释 19 有些属性在变异中保持不变。说服自己变异不会改变矩阵的特征值。

有些图的特性很难从矩阵的稀疏模式中看出来,但从图中更容易推导出来。

练习 5.28 让$A$是大小为$n$的一维BVP的三对角矩阵,$n$为奇数。$A$的图形是什么样子的?请考虑将节点按以下顺序排列所产生的变化。

矩阵的疏散模式是什么样子的?

现在取这个矩阵,将最接近矩阵 “中间 “的对角线元素归零:让

描述一下这对$A$的图形有什么影响。这样的图被称为可约图(reducible)。现在应用前面练习中的置换法,画出所产生的稀疏模式。请注意,现在图形的可还原性更难从稀疏模式中读出。

稀疏矩阵的LU因子化

一维BVP导致了一个具有三对角系数矩阵的线性系统。如果我们做一步高斯消除,唯一需要消除的元素是在第二行。

有两个重要的观察结果:一个是这个消除步骤并没有将任何零元素变为非零。另一个观察结果是,剩下要消除的那部分矩阵又是三对角的。归纳起来,在消除过程中,没有零元素变为非零:$L+U$的稀疏模式与$A$相同,因此因式分解所需的存储空间与矩阵的相同。

不幸的是,三对角矩阵的情况并不典型,我们很快就会看到二维问题的情况。但首先我们将把第5.4.2节关于图论的讨论扩展到因子化。

稀疏LU因子化的图论

在讨论稀疏矩阵的LU因子化时,图论往往是有用的。让我们研究一下消除第一个未知数(或扫除第一列)在图论方面意味着什么。我们假设是一个结构对称的矩阵。

我们认为消除一个未知数是一个过程,它将一个图$G=⟨V, E⟩$变成一个图$G’=⟨V’, E’⟩$。这些图形之间的关系首先是一个顶点,比如说$k$,已经从顶点中被移除。$k \notin V’,V’\cup{k}=V$ 。

$E$和$E′$之间的关系更为复杂。在高斯消除算法中,消除变量$k$的结果是:声明

对所有$i, j \neq k$执行。 如果$a_{ij}$最初$\neq 0$,即$(i,j)\in E$,那么$a_{ij}$的值只是被改变。 如果原矩阵中的$a_{ij}=0$,即$(i, j)\notin E$,在消除$k$未知数后,会有一个非零元素,称为补入元素。

这在图5.3中有所说明。

综上所述,消除一个未知数可以得到一个少了一个顶点的图,并且对所有$i、j$都有边,这样在$i$或$j$与被消除的变量$k$之间有边。图5.4给出了一个小矩阵上的完整说明。

练习 5.29 回到练习5.28。使用图形参数来确定奇数变量被消除后的稀疏模式。

练习 5.30 证明上述关于消除单一变量的论证的一般化。设$I\subset V$是任意一个顶点集合,设$J$是连接到$I$的顶点。

现在表明,消除$I$中的变量会导致一个图$⟨V′,E′⟩$,其中$J$中的所有节点在剩余的图中是相连的,如果它们之间有一条路径通过$I$。

填充

现在我们回到二维问题的矩阵因式分解上。我们把这种大小为$N\times N$的矩阵写成块维度为$n$的块矩阵,每个块的大小为$n$。(复习问题:这些块从何而来?) 现在,在第一个消除步骤中,我们需要将两个元素归零,即$a_{21}$和$_{n+1,1}$。

fullin

你看,消除$a_{21}$和$a_{n+1,1}$会导致两个填充元素的出现:在原矩阵中,$a_{2,n+1}$和$a_{n+1,2}$为零,但在修改后的矩阵中,这些位置是非零的。我们将填充位置定义为$(i,j)$,其中$a_{ij}=0$,但是$(L+U)_{ij}\neq 0$。 很明显,在因式分解过程中,矩阵被填满了。 只要有一点想象力,你也可以看到,在第一个对角线块之外的带子中的每个元素都会填满。 然而,使用第5.4.3.1节的图形方法,可以很容易地将所产生的填充连接可视化。 在图5.5中,对2d BVP例子的图进行了说明。 (第一行的每个变量被消除后,都会在下一个变量和第二行之间以及第二行的变量之间建立联系。 归纳起来,你会发现在第一行被消除后,第二行是完全连接的。 (将此与练习5.30联系起来。)

练习 5.31 完成这个论证。 第二行的变量是完全相连的,这对矩阵结构意味着什么? 在图中画出第二行的第一个变量被消除后的情况。

练习 5.32 用于密集线性代数的LAPACK软件有一个$LU$因子化程序,可以用因子覆盖输入矩阵。上面你看到了这是可能的,因为$L$的列正是随着$A$的列被消除而产生的。如果矩阵是以稀疏格式存储的,为什么这样的算法是不可能的?

填充估计

在上面的例子中,你看到稀疏矩阵的因式分解所占用的空间可能比矩阵本身要大得多,但仍比存储整个矩阵维度大小的正方形数组要小。现在我们将给出因式分解的空间复杂度的一些界限,也就是执行因式分解算法所需的空间量。

练习 5.33 证明以下陈述。

  1. 假设矩阵$A$有一个半带宽$p$,也就是说,如果$|i - j| > p$,则$a_{ij} = 0$。表明在无主元的因式分解后,$L+U$具有相同的半带宽度。
  2. 证明在经过部分主元的因式分解后,$L$的左半带宽为$p$,而$U$的右半带宽为$2p$。
  3. 假设没有透视,表明填空可以有如下特征:考虑行$i$。让$j_{min}$是第$i$行中最左边的非零点,即在$j<j_{min}$时,$a_{ij}=0$。那么,在$j_{min}$列左边的第$i$行中就不会有填充物。同样地,如果$i_{min}$是第$j$列中最上面的非零值,那么在第$j$列中就不会有高于$i_{min}$行的填充。给定一个稀疏矩阵,现在很容易分配足够的存储来适应一个没有主元的因式分解:这被称为天际线存储。

练习 5.34 考虑矩阵

前面已经证明,执行𝐿𝑈因式分解的任何填充都只限于包含原始矩阵元素的带。在这种情况下,不存在填充。归纳地证明这一点。

看一下邻接图。(这种图有个名字,叫什么?) 你能根据这个图给出一个没有填充的证明吗?

练习5.33表明,我们可以为带状矩阵的因式分解分配足够的存储空间。

  • 对于带宽为𝑝的矩阵的因式分解,有一个大小为$N\times p$的阵列就可以了。

  • 矩阵的一半带宽$p$和一半带宽$q$的部分主元的因素化。可以用$N\times (p+2q+1)$来描述。

  • 可以根据具体的矩阵来构建足以存储因子化的Askylin配置文件矩阵。

我们可以将这个估计应用于二维BVP的矩阵,第4.2.3节。

练习 5.35 证明在方程(4.16)中,原始矩阵有 $O(N) = O(n^2)$非零元素,$O(N^2) = O(n^4)$元素,而因子化有 $O(nN) = O(n^3) = O(N^{3/2})$ 非零。

这些估计表明,一个𝐿𝑈因式分解所需的存储量可能比𝐴所需的更多,而且这个差别不是一个常数,而是与矩阵大小有关。在没有证明的情况下,我们指出,到目前为止你所看到的那种稀疏矩阵的求逆数是完全密集的,所以存储它们需要更多的时间。这是解决线性系统$Ax=y$的一个重要原因,在实践中不是通过计算$A^{-1}$和随后乘以$x=A^{-1}y$来完成。(数值稳定性是不这样做的另一个原因)。事实上,即使是因式分解也会占用很多空间,这是考虑迭代方法的一个原因,我们将在第5.5节中进行。

上面,你看到一个大小为$n\times n$的密集矩阵的因式分解需要$O(n^3)$操作。对于稀疏矩阵来说,这又是怎么回事呢?让我们考虑一个半带宽度为$p$的矩阵的情况,并假设原始矩阵在该带内是密集的。主元元素$a_{11}$用于将第一列中的$p$元素归零,对于每一个第一行的元素都要加到该行,涉及到$p$乘法和加法。总而言之,我们发现操作的数量大致为

加上或减去低阶项。

练习 5.36 对于二维BVP的矩阵来说,初始密集带的假设并不成立。为什么上述估计仍然成立,直到一些低阶项?

在上面的练习5.33中,你得出了一个易于应用的填充量的估计。然而,这可能是一个相当高的估计。我们希望能以比实际因式分解更少的工作量来计算或估计填充的数量。现在我们将勾勒出一种算法,用于寻找$L+U$中非零点的确切数量,其成本与这个数字呈线性关系。我们将在(结构上)对称的情况下这样做。关键的观察是以下几点。假设列𝑖在对角线下有一个以上的非零。

在第$i$步中消除$a_{ki}$会导致更新$a_{kj}$,如果最初$a_{kj}=0$,则会有一个填充元素。然而,我们可以推断出这个非零值的存在:消除$a_{ji}$会导致位置$(j,k)$的填充元素,而且我们知道结构对称性被保留了。换句话说,如果我们只计算非零点,那么只需看看消除$(j, i)$位置的影响,或者一般来说,消除主元下方的第一个非零点的影响。按照这个论点,我们只需要在每一个主元的一行中记录非零点,整个过程的复杂性与因式分解中的非零点数量成线性关系。

减少填充

矩阵的图形属性,如度数和直径,在对变量重新编号时是不变的。其他属性,如因子化过程中的填充,会受到重新编号的影响。事实上,值得研究的是,是否有可能通过对矩阵图的节点重新编号来减少填充量,或者说,通过对线性系统进行置换来减少填充量。

练习 5.37 考虑 “箭头 “矩阵,只在第一行和第二列以及对角线上有非零点。

假设任何加法都不为零,那么矩阵中和因式分解中的非零数是多少?你能找到一个问题的变量的对称排列,使新的矩阵没有填充物吗?

这个例子并不典型,但是通过对矩阵进行巧妙的置换,有时确实可以改善填充估计(例如见第6.8.1节)。即使这样,作为一项规则,声明也是成立的,稀疏矩阵的𝐿𝑈因式分解比矩阵本身需要的空间大得多。这也是下一节中迭代方法的激励因素之一。

Fill-in reducing orderings

一些矩阵的特性在对称排列下是不变的。

练习 5.38 在线性代数课上,你通常会研究矩阵的特性,以及它们在基数变化下是否不变,特别是在单元基的变换下。

证明对称互换是一种特殊的基础变化。说出一些矩阵

属性在单数变换下不会改变。

其他属性则不然:在上一节中,你看到填空量就是其中之一。因此,你可能会想知道什么是最好的排序,以减少给定矩阵的因式分解的填充量。这个问题在实践中是难以解决的,但存在各种启发式方法。其中一些启发式方法也可以从并行的角度进行论证;事实上,嵌套剖分的排序将只在关于并行的6.8.1节中讨论。在这里,我们简要地展示一下其他两个启发式方法,它们比并行性的需求更早。

首先,我们看一下Cuthill-McKee排序,它直接将填充矩阵的带宽降到最低。由于填充量可以用带宽来约束,我们希望这样一个减少带宽的排序也能减少填充量。

其次,我们将考虑最小度数排序,其目的是更直接地减少填充量。

Cuthill-McKee排序 Cuthill-McKee排序[36]是一种减少带宽的排序,它通过对变量进行水平集排序来实现。它考虑了矩阵的邻接图,并按以下步骤进行。

  1. 取一个任意的节点,称其为 “零级”。

  2. 给定一个级别$n$,将所有连接到级别𝑛的节点,以及尚未进入级别的节点,分配到级别$n+1$。

  3. 对于所谓的 “反向Cuthill-McKee排序”,请将各层的编号倒过来。

练习 5.39 证明根据Cuthill-McKee排序对矩阵进行置换有一个块状三对角结构。

我们将在第6.10.1节中考虑并行性时重新审视这个算法。当然,我们可以想知道带宽可以减少到什么程度。

练习 5.40 一个图的直径被定义为两个节点之间的最大最短距离。

  1. 论证在二维椭圆问题的图中,这个直径是$O(N^{1/2})$。
  2. 用带宽来表示节点1和$N$之间的路径长度。
  3. 认为这给出了直径的下限,并使用指针对带宽进行了下限边界。

最小程度排序 另一个排序的动机是观察到填充量与节点的程度有关。

练习 5.41 证明消除一个度数为𝑑的节点会导致最多 2𝑑的填充元素 所谓最小度数排序的过程如下。

  • 找到具有最低度数的节点。

    • 剔除该节点,并更新其余节点的学位信息。
  • 从第一步开始,用更新的矩阵图重复。

练习 5.42 指出上述两种方法的区别。这两种方法都是基于对矩阵图的检查;然而,最小度数方法在使用的数据结构上需要更大的灵活性。解释一下原因并详细讨论两个方面。

迭代法

高斯消除法,即使用𝐿𝑈因式分解法,是一种寻找线性系统解的简单方法,但正如我们在上面看到的,在那种来自离散化PDE的问题中,它可以产生大量的填充。在这一节中,我们将研究一种完全不同的方法,即迭代求解,即通过一连串的逼近来找到系统的解。

这个计算方案看起来,非常粗略,就像。

这里的重要特征是,没有任何系统是用原始系数矩阵解决的;相反,每一次迭代都涉及到矩阵-向量乘法或一个更简单系统的解决。因此,我们用一个重复的更简单、更便宜的操作取代了一个复杂的操作,即构造一个𝐿𝑈因式分解并用它来解决一个系统。这使得迭代方法更容易编码,并有可能更有效率。

让我们考虑一个简单的例子来激励迭代方法的精确定义。假设我们想解决系统

其解为(2,1,1)。假设你知道(例如,从物理学的角度考虑),这么做的分量是大致相同的大小。观察对角线的主导大小,然后,决定

可能是一个很好的近似值。这有一个解决方案(2.1,9/7,8/6)。显然,解决一个只涉及原系统对角线的系统既容易做到,而且,至少在这种情况下,相当准确。

有解(2.1,7.95/7,5.9/6)。解三角形系统比对角线系统更费事一些,但仍比计算𝐿𝑈因式分解容易得多。另外,我们在寻找这个近似解的过程中没有产生任何填充物。

因此我们看到,有一些容易计算的方法可以合理地接近解决方案。我们能否以某种方式重复这个技巧呢?

更抽象地表述一下,我们所做的是不求解$Ax=b$,而是求解$L\tilde{x}=b$。现在将$\Delta x$定义为与真解的距离:$\tilde{x}=x+\Delta x$。这样,$A\Delta x=A\tilde{x}-b \equiv r$,其中$r$是残差。接下来我们再次求解$L\widetilde{\Delta x} = r$,并更新$x = x - \widetilde{\Delta x}$。

在这种情况下,我们每次迭代得到两个小数,这并不典型。

现在很清楚为什么迭代方法会有吸引力。如上图所示,通过高斯消除法解决一个系统需要进行$O(n^3)$运算。如果矩阵是密集的,则上述方案中的单次迭代需要$O(n^2)$操作,对于稀疏矩阵,可能需要低至$O(n)$操作。如果迭代次数少,这就使得迭代方法具有竞争力。

练习 5.43 当比较迭代法和直接法时,运算次数不是唯一相关的指标。概述一些与两种情况下的代码效率有关的问题。同时比较解决一个线性系统和解决多个线性系统的情况。

抽象介绍

现在是时候对上述例子的迭代方案做一个正式介绍了。假设我们想解决$Ax = b$,而直接求解的成本太高,但乘以𝐴是可行的。再假设我们有一个矩阵$K \approx A$,这样就可以廉价地求出 $Ax = b$ 。

我们不求解$Ax=b$,而是求解$Kx=b$,并定义$x_0$为解决方案:$Kx_0=b$。这就给我们留下了一个误差$e_0=x_0-x$,对此我们有一个公式$A(x_0-e_0)=b$或$Ae_0=A_0-b$。我们把$r_0\equiv Ax_0-b$残差;然后误差满足$Ae_0=r_0$。

如果我们能从$Ae_0 = r_0$的方程中解出误差,我们就完成了:然后找到真正的解决方案。然而,由于上次用$A$求解的成本太高,我们这次也不能这样做。

所以我们近似地确定误差修正。我们求解$K\tilde{e}_0 = r_0$,并设定$x_1 ∶= x_0 - \tilde{e}_0$;故事可以继续进行,$e_1=x_1-x$,$r_1=Ax_1-b$,$K\tilde{e}_1=r_1$,$x_2=x_1-\tilde{e}_1$,等等。

那么,迭代方案就是

1
2
3
4
Let 𝑥0 be given 
For 𝑖 ≥ 0:
let 𝑟𝑖 = 𝐴𝑥𝑖 − 𝑏
compute 𝑒𝑖 from 𝐾𝑒𝑖 = 𝑟𝑖 update 𝑥𝑖+1 = 𝑥𝑖 − 𝑒𝑖

我们把基本方案称为

一个固定的迭代。它是静止的,因为每次更新都是以相同的方式进行的,不依赖于迭代数。这种方案的分析很简单,但不幸的是适用性有限。

关于迭代方案,我们有几个问题需要回答。

  • 这个方案是否总能带我们找到解决方案?- 如果这个方案收敛了,有多快?
    • 我们何时停止迭代?
    • 我们如何选择$K$?

现在我们将对这些问题给予一些关注,尽管全面的讨论超出了本书的范围。

收敛性和误差分析

我们从迭代方案是否收敛,以及收敛速度如何的问题开始。考虑一个迭代步骤。

归纳起来,我们发现$r_n=(I-AK^{-1})^nr_0$,所以如果所有的特征值都满足$|\lambda(I-AK^{-1})|<12$,那么$r_n\downarrow 0$。最后这句话给了我们一个收敛的条件,即把$K$与$A$联系起来,以及一个几何收敛率。

如果$K$足够接近的话,我们可以得到一个收敛的条件。

练习 5.44 为$e_n$推导一个类似的归纳关系。

通过计算实际的特征值,很难确定是否满足条件$|\lambda (I- AK^{-1})| < 1$。然而,有时格什哥林定理(附录13.5)给了我们足够的信息。

练习 5.45 考虑我们从二维BVP离散化中得到的方程(4.16)的矩阵$A$。设$K$为包含$A$的对角线的矩阵,即$k_i = a_i$和$k_{ij}= 0$,对于$i\neq j$。 使用格什哥林定理证明$|\lambda (I-AK^{-1})| < 1$。 这个练习中的论证很难推广到更复杂的$K$的选择,例如你将在下面看到。 这里我们只说对于某些矩阵$A$,这些$K$的选择将总是导致收敛,其速度随着矩阵大小的增加而减少。 除了说明对于$M$矩阵(见第4.2.2节),这些迭代方法会收敛外,我们将不做详细说明。 关于静止迭代方法收敛理论的更多细节,见[195]。

计算形式

在上面第5.5.1节中,我们将静止迭代推导为一个涉及到乘以$A$和解$K$的过程。然而,在某些情况下,更简单的实现是可能的。考虑一下这样的情况:$A = K- N$ ,并且我们知道 $K$ 和 $N$ 。然后,我们将$Ax=b$写为

我们观察到,满足(5.13)的𝑥是迭代的一个固定点。

很容易看出,这是一个静止的迭代。

这就是方程(5.11)的基本形式。收敛准则$|\lambda (I-AK^{-1})|<1$(见上文)现在简化为$|\lambda (NK^{-1})|<1$。

让我们考虑一些特殊情况。首先,让$K=DA$,即包含$A$的对角线部分的矩阵:$k_i=a_i$和$k_{ij}=0$,对于所有$i\neq j$。同样地,$n_i =0$和 $n_{ij}=-a_{ij}$对于所有$i\neq j$。

这就是所谓的雅可比方法。迭代方案$Kx^{(n+1)} = Nx^{(n)} + b$ 现在变为

1
2
3
4
5
for 𝑡 = 1, ... until convergence, do: 
for 𝑖 = 1 ... 𝑛:
//𝑎𝑥(𝑡+1)=∑ 𝑎𝑥(𝑡)+𝑏 becomes:
𝑖𝑖 𝑖 𝑗≠𝑖 𝑖𝑗 𝑗 𝑖
𝑥(𝑡+1)=𝑎−1(∑ 𝑎𝑥(𝑡)+𝑏) 𝑖 𝑖𝑖 𝑗≠𝑖𝑖𝑗𝑗 𝑖

(考虑到除法的成本相对较高,第1.2节,我们实际上会明确地存储$a^{-1}$的数量,并以乘法代替除法)。

这就要求我们为当前迭代$x^{(t)}$准备一个向量,为下一个向量$x^{(t+1)}$准备一个临时$u$。最简单的写法可能是。

1
2
3
for 𝑡 = 1, ... until convergence, do: for 𝑖 = 1 ... 𝑛:
𝑢=𝑎−1(−∑ 𝑎𝑥+𝑏) 𝑖 𝑖𝑖 𝑗≠𝑖𝑖𝑗𝑗 𝑖
copy 𝑥 ← 𝑢

对于一个简单的一维问题,如图5.6所示:在每个$x_i$点上,两个相邻点的值与当前值相结合,产生一个新值。由于所有$x_i$点的计算都是独立的,这可以在并行计算机上并行完成。

但是,你可能会想,在总和$\sum_{j\neq i} a_{ij}x_j$为什么不使用已经计算过的$x^{(t+1)}$的值? 就向量$x^(t)$而言,这意味着

1
2
3
for 𝑘 = 1, ... until convergence, do:
for 𝑖 = 1 ... 𝑛:
𝑥(𝑡+1)=𝑎−1(−∑ 𝑎𝑥(𝑡+1)−∑ 𝑎𝑥(𝑡)+𝑏)

令人惊讶的是,该方法的实现比雅可比方法更简单。

1
2
3
for 𝑡 = 1, ... until convergence, do: 
for 𝑖 = 1 ... 𝑛:
𝑥=𝑎−1(−∑ 𝑎𝑥+𝑏)

如果你把这写成一个矩阵方程,你会发现新计算的元素$x^(t+1)$与$D_A+L_A$的元素相乘,而旧的元素$x_j$被$U_A$所取代,得到

这被称为Gauss-Seidel方法。

sor

对于一维的情况,高斯-塞德尔方法如图5.7所示;每一个$x_i$点仍然结合其邻居的值,但现在左边的值实际上是来自下一个外迭代。

最后,我们可以在Gauss-Seidel方案中插入一个阻尼参数,从而得到Successive Over- Relaxation(SOR)方法。

1
2
3
for 𝑡 = 1, ... until convergence, do: 
for 𝑖 = 1 ... 𝑛:
𝑥(𝑡+1)=𝜔𝑎−1(−∑ 𝑎 𝑥(𝑡+1)−∑ 𝑎 𝑥(𝑡)+𝑏)+(1−𝜔)𝑥(𝑡)

令人惊讶的是,对于看起来像插值的东西,该方法实际上在$\omega\in (0,2)$的范围内对$\omega $起作用,最佳值大于1[96]。计算最佳的$\omega$并不简单。

该方法的收敛性

我们对两个问题感兴趣:首先,迭代方法是否完全收敛,如果是的话,速度如何。这些问题背后的理论远远超出了本书的范围。上面我们说过,对于$M$矩阵来说,收敛通常是可以保证的;至于收敛速度,通常只有在模型情况下才能进行全面分析。对于来自BVP的矩阵,如第4.2.3节所述,我们不需要证明,就说系数矩阵的最小特征值是$O(h^2)$。那么,上面得出的几何收敛比$|\lambda(I-AK^{-1})|$可以证明如下。

  • 对于雅可比方法,该比率为$1 - O(h^2)$。
  • 对于高斯-赛德尔迭代,它也是$1-O(h^2)$,但该方法的收敛速度是两倍。
  • 对于SOR方法,最佳$\Omega$可以将收敛系数提高到$1 - O(h)$。

雅可比与高斯-塞德尔和并行性的对比

以上,我们主要是从数学的角度看雅可比、高斯-赛德尔和SOR方法。然而,这种考虑在很大程度上被现代计算机上的并行化问题所取代。

首先,我们观察到雅可比方法的一次迭代中的所有计算都是完全独立的,所以它们可以简单地被矢量化或并行完成。高斯-赛德尔法则不同(从现在开始我们忽略SOR,因为它与高斯-赛德尔法的区别仅在于阻尼参数):由于一次迭代的$x_i$点的计算现在是独立的,这种类型的迭代不是简单的矢量化或在并行计算机上实现。

在许多情况下,这两种方法被认为是被CG或广义最小残差(GMRES)方法所取代的(第5.5.11和5.5.13节)。雅可比方法有时被用作这些方法的预处理。高斯-赛德尔方法仍然很流行的一个地方是作为一个多网格平滑器。在这种情况下,经常通过使用变量的红黑排序来找到并行性。

关于这些问题的进一步讨论可以在第6.7节找到。

𝐾的选择

上面的收敛和误差分析表明,$K$越接近$A$,收敛就越快。在最初的例子中,我们已经看到$K$的对角线和下三角的选择。我们可以通过让$A = D_A + L_A + U_A$来正式描述这些,即把$A$分成对角线、下三角、上三角部分。下面是一些方法及其传统名称。

  • 理查德森迭代法:$K = \alpha I$。
  • 雅可比方法:$K = D_A$(对角线部分)。
  • 高斯-塞德尔方法:$K=D_A+L_A$(下三角,包括对角线)
  • SOR法:$K=\omega^{-1}D_A+L_{A}$
  • 对称SOR(SSOR)法:$K =(D_A+L_A)D_A^{-1}(D_A+U_A)$。
  • 在迭代细化中,我们让$K=LU$是$A$的真正因子化。在精确算术中,求解系统$LUx=y$可以得到精确解,所以在迭代方法中使用$K=LU$会在一步之后得到收敛。在实践中,舍入误差会使解不精确,所以人们有时会迭代几步以获得更高的精度。

练习 5.46 假设是密集系统,几步迭代细化的额外成本是多少?
练习 5.47 线性系统$Ax=b$的雅可比迭代定义为

其中𝐾是𝐴的对角线。证明你可以转换这个线性系统(也就是说,找到一个不同的系数矩阵和右手边的向量,仍然有相同的解),这样你就可以计算相同的𝑥𝑖向量,但是𝐾 = 𝐼,身份矩阵。

这种策略在存储和操作数方面有什么影响?如果𝐴是一个稀疏的矩阵,是否有特殊的影响?

假设𝐴是对称的。请举一个简单的例子,说明𝐾-1𝐴不一定是对称的。你能想出一个不同的系统变换,使系数矩阵的对称性得到保留,并具有与上述变换相同的优点吗?你可以假设该矩阵有正对角线元素。

练习 5.48 证明上一练习的变换也可以用于高斯-赛德尔方法。给出几个理由,为什么这不是一个好主意。

注释 20 静态迭代可以看作是不精确的牛顿方法的一种形式,每次迭代都使用相同的导数逆的近似值。标准的函数分析结果[120]说明了这种近似值可以偏离精确的逆值多远。

一个特殊的情况是迭代细化,牛顿方法应该在一个步骤内收敛,但实际上由于计算机运算中的舍入,需要多个步骤。只要函数(或残差)的计算足够精确,牛顿方法就会收敛,这一事实可以通过以较低的精度进行$LU$解来加以利用,从而获得更高的性能[1]。

选择预处理矩阵$K$有许多不同的方法。其中有一些是按常规定义的,比如下面讨论的不完全因式分解。其他的选择则是受到差分方程的启发。例如,如果算子是

那么矩阵$K$可由算子导出

对于一些选择的$\tilde{a},\tilde{b}$。第二组方程被称为可分离问题,并且有快速解算器,意思是它们具有$O(N\log N)$的时间复杂性;见[200]。

构建$K$的不完全$LU$因子化

我们简单提一下$K$的另一个选择,这是受高斯消除法的启发。和高斯消除法一样,我们让$K=LU$,但现在我们使用不完全$LU$($ILU$)分解法。记住,常规的$LU$因式分解是昂贵的,因为有填充现象。在不完全因式分解中,我们人为地限制了填入的情况。

如果我们把高斯消除法写成

1
2
for k,i,j:
a[i,j] = a[i,j] - a[i,k] * a[k,j] / a[k,k]

我们通过以下方式定义一个不完整的变体

1
2
3
for k,i,j:
if a[i,j] not zero:
a[i,j] = a[i,j] - a[i,k] * a[k,j] / a[k,k]
  • 得到的因式分解不再是精确的:$LU\approx A$,所以它被称为不完全$LU(ILU)$因式分解。
  • $ILU$因式分解比完全因式分解占用的空间要小得多:$L+U$的稀疏程度与$A$相同。

上述算法被称为 “ILU(0)”,其中的 “0 “指的是在不完全因式分解过程中绝对不允许填入。其他允许有限数量的填充的方案也存在。关于这个方法可以说得更多;我们只想说,对于$M$矩阵,这个方案通常会给出一个收敛的方法[154]。

练习 5.49 矩阵-向量乘积的运算次数与用ILU因式分解解系统的运算次数如何比较?

你已经看到,稀疏矩阵的完全因式分解可能需要更高的存储量(因式分解需要3/2,而矩阵需要𝑁),但是不完全因式分解需要𝑂(𝑁),就像矩阵一样。因此,我们可能会惊讶地发现,误差矩阵𝑅 = 𝐴 - 𝐿𝑈不是密集的,而是本身是稀疏的。

练习 5.50 设𝐴是泊松方程的矩阵,𝐿𝑈是不完全因式分解,𝑅 = 𝐴 - 𝐿𝑈 。证明𝑅是一个双对角矩阵。

  • 考虑到𝑅是由那些在分解过程中被忽略的元素组成的。它们在矩阵中的位置是什么?

  • 或者,写出乘积𝐿𝑈的稀疏性模式,并与𝐴的稀疏性模式进行比较。

构建预处理程序的成本

在热方程的例子中(第4.3节),你看到每个时间步骤都涉及到解决一个线性系统。作为一个重要的实际结果,解决线性系统的任何设置成本,如构建预处理程序,将在要解决的系统序列中摊销。类似的论点在非线性方程的背景下也是成立的,我们将不讨论这个问题。非线性方程是通过迭代过程(如牛顿方法)来解决的,它的多维形式导致了一连串的线性系统。尽管这些系统有不同的系数矩阵,但通过重复使用牛顿步骤的预处理程序,又可以摊销设置成本。

并行调节器

构建和使用一个预调节器是许多考虑因素的平衡行为:一个更准确的预调节器会导致在更少的迭代中收敛,但这些迭代可能更昂贵;此外,一个更准确的预调节器的构建成本可能更高。在并行情况下,这种情况甚至更加复杂,因为某些预处理程序一开始就不是非常并行的。因此,我们可能会接受一个并行的预处理程序,但它的迭代次数比串行预处理程序要少。更多的讨论请见第6.7节。

停止测试

我们需要解决的下一个问题是何时停止迭代。上面我们看到,误差以几何级数递减,所以很明显,我们永远不会精确地达到解决方案,即使这在计算机运算中是可能的。既然我们只有这种相对收敛的行为,那么我们怎么知道什么时候已经足够接近了呢?

我们希望误差$e_i = x - x_i$ 很小,但是测量这个误差是不可能的。上面我们观察到,$Ae_i = r_i$,所以

如果我们对$A$的特征值有所了解,这就给了我们一个误差的约束。($A$的准则是只有对称性$A$的最大特征值。一般来说,我们在这里需要奇异值)。另一种可能性是监测计算出的解决方案的变化。如果这些变化很小。

我们也可以得出结论,我们已经接近解决了。

练习 5.51 证明迭代数之间的距离和到真解的距离之间的分析关系。如果你的方程中含有常数,它们可以在理论上或实践中确定吗?

练习 5.52 编写一个简单的程序来试验线性系统的解法。从一维BVP中获取矩阵(使用有效的存储方案),并使用选择$K = D_A$的迭代方法编程。对残差和迭代间的距离进行停止测试的实验。迭代次数如何取决于矩阵的大小?

改变矩阵结构,使某一数量加入对角线,即在原矩阵中加入$\alpha I$。当$\alpha >0$时会发生什么?当$\alpha <0$时会发生什么?你能找到行为改变的数值吗?该值是否取决于矩阵的大小?

一般多项式迭代法的理论

上面,你看到了$x_{i+1}=x_i-K^{-1}r_i$ 的迭代方法,现在我们将看到更一般形式的迭代方法。

也就是说,使用所有以前的残差来更新迭代。有人可能会问,”为什么不引入一个额外的参数而写成$x_{i+1} = \alpha_i x_i + …?$”这里我们给出一个简短的论证,即前一种方案描述了一大类方法。事实上,目前作者并不知道有哪些方法不属于这个方案。

在给定近似解$\tilde{x}$的情况下,我们将残差定义为$ \tilde{r} = A\tilde{x} - b$。在这个一般性讨论中,我们将系统的预设条件定为$K^{-1}Ax=K^{-1}b$。(见第5.5.6节,我们讨论了线性系统的转换。) 初始猜测$x$的相应残差为

我们发现,

现在,Cayley-Hamilton定理指出,对于每一个$A$,都存在一个多项式$\phi (x)$(特征多项式),以便

我们观察到,我们可以把这个多项式$\phi $写为

其中$\pi $是另一个多项式。将此应用于$K^{-1}A$,我们有

所以$x=\tilde{x}+\pi (K^{-1}A)\tilde{r}$。现在,如果我们让$x_0=\tilde{x}$,那么$\tilde{r}=K^{-1}r_0$,得到的公式是

这个方程提出了一个迭代方案:如果我们能找到一系列度数为𝑖的多项式$\pi(i)$来近似$\pi$,它将给我们一串迭代的结果

最终达到真解。基于这种在迭代过程中对多项式的使用,这种方法被称为多项式迭代法。

练习 5.53 静止的迭代方法是多项式方法吗?你能与霍纳法则建立联系吗?

将方程(5.15)乘以$A$,两边减去$b$,得到

其中$\tilde{\pi}^{(i)}(x)=x\pi^{(i)}(x)$。这就立即得到了

其中$\hat{\pi}(i)$是一个度数为𝑖的多项式,$\hat{\pi}(i)(0)=1$。这个声明可以作为迭代方法收敛理论的基础。然而,这已经超出了本书的范围。

让我们看一下方程(5.16)的几个实例。对于$i = 1$,我们有

对于某些值$\alpha_i, \beta_i$。对于$i = 2$

对于不同的值$\alpha_i$。但我们已经确定$AK_{0}^{-1}$是$r_1, r_0$的组合,所以现在我们可以得出

而且很清楚如何归纳证明

将其代入(5.15),最后得到

很容易看出,方案(5.14)的形式是(5.18),反过来的含义也是成立的。综上所述,迭代方法的基础是一个方案,其中迭代数被迄今为止计算的所有残差所更新。

与静止迭代(第5.5.1节)相比,在静止迭代中,迭代数只从最后的残差中更新,而且系数保持不变。

我们可以对$\alpha_{ij}$系数说得更多。如果我们用方程(5.19)乘以$A$,再从两边减去$b$,我们会发现

让我们暂时考虑一下这个方程。如果我们有一个起始残差$r_0$,下一个残差的计算方法是

由此我们得到$AK^{-1}r_0=\alpha_{00}^{-1}(r_1-r_0)$,所以对于下一个残差。

我们看到,我们可以把$AK^{-1}r_1$表示为总和$r_2\beta_2 + r_1\beta_1 + r_0\beta_0$,并且$\sum_i \beta_i = 0$。推而广之,我们发现(与上述不同的$\alpha_{ij}$):

而我们有$\gamma_{i+1,i}=\sum_{j\leqslant i}\gamma_{ji}$。

我们可以把这最后一个方程写成$AK^{-1}R = RH$ 其中

其中,$H$是一个所谓的海森堡矩阵:它是上三角加一个下对角线。我们还注意到,$H$每一列中的元素之和为零。

由于同一性$\gamma_{i+1,i}=\sum_{j\leqslant i}\gamma_{ji}$,我们可以从$b$方程的两边减去$r_{i+1}$并 “除以$A$”,得到

这为我们提供了迭代方法的一般形式。

这种形式适用于许多迭代方法,包括你在上面看到的静止迭代方法。在接下来的章节中,你将看到𝛾𝑖𝑗系数是如何从残差的正交条件中得出的。

通过正交进行迭代

上面描述的静止方法(第5.5.1节)已经以某种形式存在了很长时间了。高斯在给一个学生的信中描述了一些变体。它们在1950年杨的论文[208]中得到完善;最后的参考资料可能是瓦尔加[195]的书。这些方法现在很少被使用,除了在多网格平滑器的专门背景下,这个主题在本课程中没有讨论。

几乎在同一时间,基于正交化的方法领域由两个人[136, 104]拉开了序幕,尽管他们花了几十年时间才找到广泛的适用性。(进一步的历史,见[81])。

其基本思想如下。

如果你能使所有的残差相互正交,并且矩阵的尺寸为$n$,那么经过$n$的迭代,你就已经收敛了:不可能有一个$n+1$的残差与之前所有的残差正交并且不为零。由于零残差意味着相应的迭代就是解,所以我们得出结论,经过$n$的迭代,我们已经掌握了真正的解。

随着当代应用所产生的矩阵的大小,这种推理已经不再适用:在计算上,迭代𝑛次是不现实的。此外,四舍五入可能会破坏解决方案的任何准确性。然而,后来人们意识到[175],在对称正定(SPD)矩阵的情况下,这种方法是一种现实的选择。当时的推理是。

残差序列跨越了一系列维度增加的子空间,通过正交,新的残差被投射到这些空间上。这意味着它们的尺寸会越来越小。

projection

这在图5.8中有所说明。在本节中,你将看到正交迭代的基本思想。这里介绍的方法只具有理论意义;接下来你将看到共轭梯度(CG)和广义最小残差(GMRES)方法,它们是许多现实生活应用的基础。

现在让我们采取基本方案(5.21)并对残差进行正交。我们使用$K^{-1}$内积来代替正常内积。

并且我们将强制残差为$K^{-1}$-正交。

这被称为全正交法(FOM)方案。

1
2
3
4
5
6
7
Let 𝑟0 be given
For 𝑖 ≥ 0:
let 𝑠 ← 𝐾−1𝑟𝑖 let 𝑡 ← 𝐴𝐾−1𝑟𝑖 for 𝑗 ≤ 𝑖:
let 𝛾𝑗 be the coefficient so that 𝑡 − 𝛾𝑗𝑟𝑗 ⟂ 𝑟𝑗 for 𝑗 ≤ 𝑖:
form 𝑠 ← 𝑠 − 𝛾𝑗 𝑥𝑗
and 𝑡←𝑡−𝛾𝑗𝑟𝑗
let 𝑥𝑖+1 = (∑𝑗 𝛾𝑗)−1𝑠, 𝑟𝑖+1 = (∑𝑗 𝛾𝑗)−1𝑡.

你可能认识到其中的Gram-Schmidt正交化(见附录13.2的解释):在每次迭代中,$r_{i+1}$最初被设置为$AK^{-1}r_i$,并对$r_j$进行正交,$j\leqslant i$。

我们可以使用修改后的Gram-Schmidt,将算法改写为。

1
2
3
4
5
Let 𝑟0 be given For 𝑖 ≥ 0:
let 𝑠 ← 𝐾−1𝑟𝑖 let 𝑡 ← 𝐴𝐾−1𝑟𝑖 for 𝑗 ≤ 𝑖:
let 𝛾𝑗 be the coefficient so that 𝑡 − 𝛾𝑗𝑟𝑗 ⟂ 𝑟𝑗 form 𝑠 ← 𝑠 − 𝛾𝑗 𝑥𝑗
and 𝑡←𝑡−𝛾𝑗𝑟𝑗
let 𝑥𝑖+1 = (∑𝑗 𝛾𝑗)−1𝑠, 𝑟𝑖+1 = (∑𝑗 𝛾𝑗)−1𝑡.

这两个版本的FOM算法在精确算术中是等价的,但在实际情况中却有两个不同。

状况下有两个不同之处。

  • 修改后的Gram-Schmidt方法在数值上更加稳定。
    • 未修改的方法允许你同时计算所有内积。我们在下面的6.6节中讨论

我们将在下文第6.6节讨论这个问题。即使FOM算法在实践中没有被使用,这些计算上的考虑也会延续到下面的GMRES方法中。

迭代方法的耦合递归形式

上面,你看到了生成迭代和搜索方向的一般方程(5.21)。这个方程通常被分割为

  • $x_i$迭代的更新,来自单一搜索方向。

  • 从目前已知的残差中构建搜索方向。

    不难看出,我们也可以通过归纳法来定义

    而这最后一种形式是在实践中使用的一种。

迭代依赖系数的选择通常是为了让残差满足各种正交性条件。例如,可以选择让方法通过让残差正交来定义$(r_i^tr_j=0\text{ if } i \neq j)$,或者$A$正交$(r_i^tAr_j=0 \text{ if } i\neq j )$ 这种方法的收敛速度比静止迭代快得多,或者对更多的矩阵和预处理程序类型都能收敛。 下面我们将看到两种这样的方法;然而,它们的分析超出了本课程的范围。

共轭梯度的方法

在本节中,我们将推导出共轭梯度(CG)方法,它是FOM算法的一个具体实现。特别是,在SPD矩阵𝐴的情况下,它具有令人愉快的计算特性。CG方法的基本形式是上述的耦合递归公式,系数的定义是要求残差序列$r_0, r_1, r_2, …$满足

我们首先推导出非对称系统的CG方法,然后说明它在对称情况下是如何简化的。(这里的方法取自[60])。基本方程是

其中第一个和第三个方程在上面已经介绍过了,第二个方程可以通过第一个方程乘以𝐴找到(检查一下!)。

现在我们将通过归纳法推导出这个方法中的系数。实质上,我们假设我们有当前的残差$r_{cur}$,一个待计算的残差$r_{new}$,和一个已知的残差$R_{old}$的集合。我们不使用下标 “old, cur, new”,而使用以下惯例。

  • $x_1, r_1, p_1$是当前迭代数、残差和搜索方向。请注意,这里的下标1并不表示迭代数。

  • $x_2, r_2, p_2$是我们将要计算的迭代数、残差和搜索方向。同样,下标不等于迭代数。

  • $X_0, R_0, P_0$是所有以前的迭代、残差和搜索方向捆绑在一起的一个向量块。

就这些数量而言,更新方程为

其中$\delta_1,v_{12}$是标量,$u_{02}$是一个向量,长度为当前迭代前的次数。现在我们从残差的正交性推导出$\delta_1,v_{12},u_{02}$。具体来说,残差在$K^{-1}$内积下必须是正交的:我们希望有

结合这些关系,我们可以得到,比如说。

或缩写为$RJ=APD$,$P(I-U)=R$其中$J$是具有同一对角线和减去同一对角线的矩阵。然后我们观察到

  • $R^tK^{-1}R$是对角线,表达了残差的正交性。

  • 结合$R{-1}R$是对角线和$P(I-U)=R$,可以得出$R^tP=R^tK^{-1}R(I-U)^{-1}$。我们 现在我们可以推断,$(I-U)^{-1}$是上对角线,所以$R^tP$是上三角形。这告诉我们 诸如$r_2^t p_1$等量是零。

  • 结合$R$和$P$的关系,我们首先得到的是

    这告诉我们,$R^t K^{-t}AP$ 是下对角线。在此方程中展开$R$,可以得到

    这里$D$和$R^tK^{-1}R$是对角线,$(I-U)^{-t}$和$J$是下三角形,所以$P^tAP$是下三角形。

  • 这告诉我们,$P_0^t Ap_2 = 0$,$p_1^tAp_2 = 0$。

  • 将𝑃0𝑡 𝐴, 𝑝1𝑡 𝐴与方程(5.23)中的𝑝2定义相乘,得到

  • 如果$A$是对称的,$P^tAP$是下三角(见上文)和对称的,所以它实际上是对角的。另外,$R^tK^-{t}AP$是下对角线,所以,利用$A=A^t$,$P^{t}AK^{-1}R$是上对角线。由于$P^tAK^{-1}R=P^tAP(I-U)$,我们得出结论:$I-U$是上二角的,所以,只有在对称情况下,$u_{02}=0$。

关于这个推导的一些看法。

  • 严格地说,我们在这里只证明了必要的关系。可以证明,这些也是充分的。
  • 有一些不同的公式可以计算出相同的向量,在精确的算术中。例如,很容易推导出$p_1^t r_1 = r_1^t r_1$,所以可以将其代入刚才的公式中。图5.9给出了CG方法的典型实现方式。

  • 在第3次迭代中,计算$P_0^tAr_2$(需要用于$u_{02}$)需要用到𝑘内积。首先,内积在并行情况下是很不利的。其次,这要求我们无限期地存储所有搜索方向。这第二点意味着工作和存储都随着迭代次数的增加而上升。与此相反,在静止迭代方案中,存储仅限于矩阵和几个向量,而每次迭代的工作量是一样的。

  • 刚才提出的反对意见在对称情况下消失了。由于$u_{02}$为零,对$P_0$的依赖消失了,只剩下对$p_1$的依赖。因此,存储是恒定的,每次迭代的工作量也是恒定的。可以证明每次迭代的内积数只有两个。

练习 5.54 对CG方法的一次迭代中的各种操作做一个翻转计数。假设𝐴是一个五点模版的矩阵,预处理器𝑀是𝐴的不完全因式分解(5.5.6.1节)。让𝑁为矩阵大小。

从最小化推导出

上述CG方法的推导在文献中并不常见。典型的推导是从一个具有对称正定(SPD)矩阵$A$的最小化问题开始。

如果我们接受函数𝑓有一个最小值的事实,这是由正定性得出的,我们通过计算导数找到最小值

然后问:f ‘(𝑥) = 0在哪?

练习 5.55 推导上面的导数公式。(提示:将导数的定义写成$\lim_{h\downarrow 0} …$)注意,这要求$A$是对称的。

对于迭代方法的推导,我们指出,迭代的𝑥以一定的步长$\delta_i$更新。沿着一个搜索方向$p_i$更新。

最佳步长

然后推导出沿着$x_i+ \delta\delta p_i$ 的函数$f$的最小化。

从残差中构建搜索方向是通过归纳证明的,即要求残差是正交的。一个典型的证明,见[5]。

GMRES

在上面关于CG方法的讨论中,指出残差的正交性需要存储所有的残差,以及第$k$次迭代中的𝑘内部积。不幸的是,可以证明CG方法的工作节省,就所有的实际情况而言,在SPD矩阵之外无法找到[62]。

GMRES方法是这种完全正交化方案的一个流行实现。为了将计算成本控制在一定范围内,它通常被实现为一种重启的方法。也就是说,只保留一定数量(比如$k=$5或20)的残差,每迭代$k$次,方法就会重新启动。

还有一些方法没有GMRES那样不断增长的存储需求,例如QMR [69] 和 BiCGstab [194]。尽管根据上面的评论,这些方法不能对残差进行正交,但在实践中仍然具有吸引力。

复杂性

高斯消除法的效率相当容易评估:一个系统的因式分解和求解,确定性地需要$\frac{1}{3}n^3$的操作。对于一个迭代方法来说,运算次数是每个迭代的运算次数与迭代次数的乘积。虽然每个单独的迭代都很容易分析,但没有好的理论来预测迭代次数。(事实上,一个迭代方法甚至可能一开始就不收敛。)此外,高斯消除法的编码方式可以有相当多的缓存重用,使算法在计算机峰值速度的相当大比例下运行。另一方面,迭代方法在每秒钟的运算量上要慢得多。

所有这些考虑使得迭代方法在线性系统求解中的应用介于工艺和黑色艺术之间。在实践中,人们做了相当多的实验来决定迭代方法是否会有回报,如果有的话,哪种方法更可取。

特征值法

在本章中,我们到目前为止只限于线性系统的求解。特征值问题是线性代数应用的另一个重要类别,但它们的兴趣更多地在于数学而不是计算本身。我们对所涉及的计算类型做一个简要的概述。

幂法

幂法(power method)是一个简单的迭代过程:给定一个矩阵𝐴和一个任意的起始矢量𝑣,反复计算

向量 “$v$ “很快就成为与绝对值最大的特征值相对应的特征向量大小,因此$|Av|/|v|$成为该最大特征值的近似值。

对$A^{-1}$的应用被称为反迭代(inverse iteration),它可以得到绝对值最小的特征值的反值。

功率法的另一个变种是移位逆向迭代,它可以用来寻找内部特征值。如果$\sigma$接近一个内部特征值,那么对$A - \sigma I$的反迭代将找到该内部特征值。

正交迭代法

不同特征值的特征向量是正交的,这一事实可以被利用。例如,在找到一个特征向量后,可以在与之正交的子空间中进行迭代。另一个选择是在一个向量块上进行迭代,并在每次幂法迭代后对这个块进行正交。这将产生与区块大小一样多的显性特征值。重新启动的Arnoldi方法[141]是这种方案的一个例子。

全光谱法

刚才讨论的迭代方案只产生局部的特征值。其他方法是计算矩阵的全谱。其中最流行的是QR方法。

并行执行

基于Lanczos的方案比QR方法更容易并行化;讨论见[13]。

延伸阅读

迭代方法是一个非常深入的领域。作为对所涉及问题的实际介绍,你可以阅读 “模板书”[9],在http://netlib.org/templates/。对于更深入的理论处理,请看Saad的书[176],其第一版可以在http://www-users.cs.umn. edu/~saad/books.html下载。

高性能线性代数(一)

在本节中,我们将讨论与并行计算机上的线性代数有关的一些问题。假设处理器的数量是有限的,而且相对于处理器的数量,问题数据总是很大。我们将关注处理器之间通信网络的物理方面问题。

我们将分析各种线性代数操作,包括迭代方法,以及它们在具有有限带宽和有限连接的网络中的行为。本章最后将对由于并行执行而产生的算法中的复杂问题进行各种简短的评论。

集合

集合运算在线性代数运算中起着重要的作用。事实上,操作的可扩展性可以取决于这些集合运算的成本,你将在下面看到。在此,我们对其基本思想做一个简短的讨论;详情请见[28]。

在计算集体操作的成本时,三个架构常数足以给出下限。$\alpha$,发送单个消息的时间;$\beta$,发送数据的时间的倒数(见1.3.2节);以及$\gamma$,执行算术运算的时间的倒数。因此,发送$n$数据项需要时间$\alpha+\beta n $。我们进一步假设,一个处理器一次只能发送一条信息。我们对处理器的连接性不做任何假设;因此,这里得出的下限将适用于广泛的架构。

上述架构模型的主要含义是,在算法的每一步中,活动处理器的数量只能增加一倍。例如,要做一个广播,首先处理器0向1发送,然后0和1可以向2和3发送,然后0-3向4-7发送,等等。这种信息的级联被称为处理器网络的最小生成树(minimum spanning tree),由此可见,任何集体算法都有至少$\alpha \log_2p$与累计延迟有关的成本。

广播

向𝑝处理器广播至少需要$\lceil \log_2p\rceil$步,总延迟为$\lceil \log_2p\rceil \alpha$。由于要发送$n$元素,这至少会增加所有元素离开发送处理器的时间$n\beta $,所以总成本下限为

我们可以用下面的方法来说明生成树的方法。

(在$t=1$时,$p_0$发送至$p_1$;在$t=2$时,$p_0,p_1$发送至$p_2,p_3$) 这个算法的$\log_2 \alpha$项正确,但是处理器0重复发送整个向量。所以带宽成本为$\log_2⋅n\beta $。如果$n$较小,则延迟成本占主导地位,因此我们可以将其描述为一个短向量集合操作(short vector collective operation)。下面的算法将广播实现为散点算法和桶状旅算法的结合。首先是散射:

需要$p-1$条大小为$N/p$的信息,总时间为

然后,桶队算法(bucket brigade algorithm)让每个处理器在每一步都处于活动状态,接受部分信息(除了第一步),并将其传递给下一个处理器。

每个部分信息被发送$p - 1$次,所以这个阶段的复杂度也是

现在的复杂度变成了

在延迟方面并不理想,但如果$n$​较大,则是一种较好的算法,使之成为一种长向量集合操作(long vector collective operation)。

规约

在规约操作中,每个处理器都有$n$数据元素,一个处理器需要将它们进行元素组合,例如计算$n$的和或积。

通过在时间上向后运行广播,我们看到规约操作的通信下限同样为$\lceil \log_2p\rceil \alpha+n\beta $。缩减操作也涉及到计算,按顺序计算总时间为$(p-1)\gamma n$中的每个项目都在$p$处理器上被缩减。由于这些操作有可能被并行化,因此计算的下限$\frac{(p-1)}{p}\gamma n$,总共有

我们举例说明生成树算法,使用符号$x^{(j)}_i$表示最初在处理器$j$上的数据项$i$,而$x_i$表示处理器$j…k$的项目之和。

在时间$t = 1$时,处理器$p_0$、$p_2$从$p_1$、$p_3$接收,在时间$t= 2$时,$p_0$从$p_2$接收。和上面的广播一样,这个算法没有达到下限;相反,它的复杂度为

对于短向量,$\alpha$项占主导地位,所以这个算法就足够了。对于长向量,可以如上所述,使用其他算法[28]。

长向量的减少可以用一个桶状旅,然后用一个聚集来完成。复杂度同上,只是桶队执行部分规约,时间为$\gamma (p-1)N/p$。聚会不进行任何进一步的操作。

Allreduce

Allreduce操作在每个处理器上对𝑛元素进行同样的元素规约计算,但是将结果留在每个处理器上,而不是仅仅留在生成树的根部。这可以实现为先规约后广播的方式,但是存在更聪明的算法。

值得注意的是,Allreduce的成本下限与简单规约的成本下限几乎相同:因为在规约过程中,并不是所有的处理器都在同一时间活动,我们假设额外的工作可以被完美地分散。这意味着,延迟和计算量的下限保持不变。对于带宽,我们的理由如下:为了使通信完全并行化,$\frac{p-1}{p}n$项目必须到达和离开每个处理器。因此,我们有一个总时间为

Allgather

在对𝑛元素的聚集操作中,每个处理器都有$n/p$元素,一个处理器将它们全部收集起来,而不像规约操作那样将它们合并。Allgather计算的是同样的集合,但将结果留在所有处理器上。

我们再次假设有多个目标的集合是同时活动的。由于每个处理器都会生成一个最小生成树,因此我们有$\log_2p \alpha$延迟;由于每个处理器从$p-1$个处理器接收$n/p$元素,因此有$(p-1)\times (n/p)\beta$带宽成本。那么,通过allgather构建一个长度为$n$的向量的总成本是

我们对此进行说明。

在时间$t = 1$时,邻居$p_0$,$p_1$和同样的𝑝2,𝑝3之间有一个交换;在$t = 2$时,$p_0$,$p_2$和同样的$p_1,p_3$之间有两个距离的交换。

Reduce-scatter

在reduce-scatter操作中,每个处理器都有$n$元素,并对它们进行$n$方式的规约。与reduce或allreduce不同的是,结果会被分解,并像散点操作那样进行分配。

形式上,处理器𝑖有一个项目$x^{(i)}$,它需要$\sum_jx_i^{(j)}$。我们可以通过做大小$p$的缩减来实现,在一个处理器上收集向量$(\sum_ix_0^{(i)}, \sum_ix_1^{(i)},…)$,并将结果散布出去。然而,有可能在所谓的双向交换算法中结合这些操作。

reduce-scatter可以被认为是一个反向的allgather运行,加上算术,所以成本是

并行密集型矩阵-向量乘积

在本节中,我们将详细讨论并行密集矩阵-向量乘积的性能尤其是可扩展性。首先,我们将考虑一个简单的案例,并在一定程度上详细讨论并行性方面。

实现块状行的情况

在设计一个算法的并行版本时,人们常常通过对相关对象进行数据分解来进行。在矩阵-向量运算的情况下,例如乘积$y=Ax$,我们可以选择从向量分解开始,并探索其对如何分解矩阵的影响,或者从矩阵开始,并从它推导出向量分解。在这种情况下,似乎很自然地从分解矩阵开始,而不是从分解矢量开始,因为它很可能具有更大的计算意义。我们现在有两个选择。

  1. 我们对矩阵进行一维分解,将其分割成块状行或块状列,并将其中的每一个—或每一组—分配给一个处理器。

  2. 或者,我们可以进行二维分解,将一个或多个一般子矩阵分配给每个处理器。

我们首先考虑以块行的方式进行分解。考虑一个处理器$p$和它所拥有的行的索引集合$I_p$,并让$i \in I_p$是分配给这个处理器的行。行中的元素$i$被用于操作

我们现在推理:

  • 如果处理器$p$拥有所有$x_j$的值,那么矩阵-向量乘积就可以简单地执行,完成后,处理器拥有正确的值$y_j\in I_p$。

  • 这意味着每个处理器都需要有一个$x$的副本,这是很浪费的。同时,这也提出了数据完整性的问题:你需要确保每个处理器都有正确的$x$的值。

  • 在某些实际应用中(例如你之前看到的迭代方法),矩阵-向量乘积的输出直接或间接地成为下一个矩阵-向量操作的输入。对于计算$x、Ax、A^2x、….$ 的功率法来说,情况当然是这样的。由于我们的操作开始时每个处理器拥有整个$x$,但结束时它只拥有$Ax$的局部部分,所以我们有一个不匹配。

  • 也许我们应该假设每个处理器在操作开始时只拥有$x$的局部部分,也就是那些$i \in I_p$的$x$,这样,算法的开始状态和结束状态是一样的。这意味着我们必须改变算法,包括一些通信,使每个处理器能够获得那些$x_i \notin I_p$的值。

练习 6.1 对矩阵被分解成块列的情况进行类似的推理。像上面一样,详细描述并行算法,但不要给出伪代码。

现在让我们来看看通信的细节:我们将考虑一个固定的处理器$p$,并考虑它执行的操作和需要的通信。根据上述分析,在执行语句$y_i = \sum_j a_{ij}x_j$时,我们必须知道$j$值属于哪个处理器。为了确认这一点,我们写道

如果$j \in I_p$,指令$y_i \leftarrow y_i + a_{ij}x_j$只涉及已经属于处理器的量。因此,让我们集中讨论$j\notin I_p$的情况。 如果我们可以直接写出这样的语句就好了

1
y(i) = y(i) + a(i,j)*x(j)

而一些下层将自动把$x^{(j)}$从它所存储的任何处理器转移到本地寄存器。(2.6.5节的PGAS语言旨在做到这一点,但它们的效率远不能保证)。图6.1给出了一个基于这种乐观的并行性观点的实现。

这种 “局部 “方法的直接问题是会有太多的通信发生。

  • 如果矩阵$A$是密集的,那么每一行$i \in I_p$都需要一次元素$x_j$,因此每一行$i \in I_p$都将被取走一次。
  • 对于每个处理器$q\neq p$,将有(很大)数量的元素$x_j \in I_q$ 需要从处理器$q$转移到$p$。 在不同的信息中做这些,而不是一次批量传输,是非常浪费的。

distmvp

buffered implementation

对于共享内存来说,这些问题并不是什么大问题,但在分布式内存的背景下,最好采取缓冲的方式。

我们不需要交流𝑥的各个元素,而是为每个处理器$B_{pq}\neq p$使用一个本地缓冲区,我们从$q$收集需要在$p$上执行乘法的元素。 (见图6.2的说明。)图6.3给出了并行算法。 除了防止一个元素被取走超过一次之外,这也将许多小的消息合并成一个大的消息,这通常更有效率;回顾我们在2.7.8节中对带宽和延迟的讨论。

练习 6.2 给出使用非阻塞操作的矩阵-向量乘积的伪码(第2.6.3.6节) 上面我们说过,在每个处理器上都有一份整个𝑥的副本,这是对空间的浪费。

这里隐含的论点是,一般来说,我们不希望本地存储是处理器数量的函数:理想情况下,它应该只是本地数据的函数。 (这与弱缩放有关;第2.2.5节。) 你看,由于通信方面的考虑,我们实际上已经决定,每个处理器存储整个输入向量是不可避免的,或者至少是最好的。 这种空间和时间效率之间的权衡在并行编程中相当普遍。 对于密集的矩阵-向量乘积,我们实际上可以为这种开销辩护,因为向量存储的顺序比矩阵存储的顺序要低,所以我们的过度分配比例很小。 下面(第6.5节),我们将看到,对于稀疏矩阵-向量乘积,开销会小很多。

我们很容易看到,如果允许我们忽略通信时间,那么上面描述的并行密集矩阵-向量乘积就有完美的加速。 在接下来的几节中,你会发现如果我们考虑到通信,上面的块行实现并不是最佳的。 对于可扩展性,我们需要一个二维分解。 我们先来讨论一下集合体。

密集矩阵-向量乘积的可扩展性

在本节中,我们将对$y \leftarrow Ax$的并行计算进行全面分析,其中$x,y \in \mathbb{R}^n$和$A\in \mathbb{R}^{n \times n}$。我们将假设使用$p$节点,但我们对它们的连接性不做任何假设。我们将看到,矩阵的分布方式对算法的缩放有很大的影响;原始研究见[101, 180, 188],各种缩放形式的定义见2.2.5节。

矩阵-向量乘积,按行划分

划分

其中,$A_i\in \mathbb{R}^{m_i\times n}$且$x_i,y_i\in \mathbb{R}^{m_i}$,$\sum_{i=0}^{p-1}m_i=n$和$m_i\approx n/p$。我们将首先假设$A_i, x_i y_i$和最初被分配的$P_i$。

计算的特点是,每个处理器需要整个向量$x$,但只拥有其中的$n/p$部分。因此,我们对$x$进行了一次全集。之后,处理器可以执行本地产品$y_i \leftarrow A_ix$;此后不需要进一步的通信。

那么,一个对$y = Ax$进行成本计算的算法就可以通过以下方式得到

成本分析:算法的总成本约为。

由于顺序成本为$T_1(n)=2n^2\gamma $,所以速度提升为

和并行效率,由

乐观的观点:现在,如果我们固定$p$,并让$n$变得很大

因此,如果能使问题足够大,最终的并行效率几乎是完美的。然而,这是以无限内存为前提的,所以这种分析并不实用。

悲观的观点:在强可扩展性分析中,我们固定$n$,让$p$变大,以得到

因此,最终并行效率变得几乎不存在了。

现实主义者的观点 在一个更现实的观点中,我们随着数据量的增加而增加处理器的数量。这被称为弱可扩展性,它使可用于存储问题的内存量与$p$成线性比例。

让$M$等于可以存储在单个节点的内存中的浮点数。那么总内存由$Mp$给出。让$n_{max}(p)$等于在$p$节点的总内存中可以存储的最大问题大小。那么,如果所有内存都可以用于矩阵。

现在的问题是,对于可以存储在$p$节点上的最大问题,其并行效率如何。

现在,如果分析一下当节点数量变得很大时发生了什么,就会发现

因此,这种矩阵-向量乘法的并行算法并没有规模。

如果你仔细看一下这个效率表达式,你会发现主要问题是表达式中的$1/\sqrt{p}$部分。这个条款涉及到一个系数$\beta$,如果你跟着推导往后看,你会发现它来自处理器之间发送数据的时间。非正式地说,这可以被描述为消息大小太大,使问题可扩展。事实上,无论处理器的数量如何,消息大小都是恒定的$𝑛$。

另外,一个现实主义者意识到,完成一个计算的时间是有限的,$T_{max}$。在最好的情况下,即通信开销为零的情况下,我们能在$T_{max}$时间内解决的最大问题为

因此

那么,对于可以在$T_max$时间内解决的最大问题,该算法所达到的并行效率为

而当节点数变多时,其并行效率接近

同样,随着处理器数量的增加和执行时间的上限,效率也无法维持。

我们也可以计算出这个操作的等效率曲线,即$n$,$p$之间的关系,对于这个关系,效率保持不变(见 2.2.5.1 节)。如果我们将上述效率简化为 $E(n,p)=\frac{2\gamma}{\beta} \frac{n}{p}$,则$E\equiv c$相当于$n=O(p)$,因此

因此,为了保持效率,我们需要相当快地增加每个处理器的内存。这是有道理的,因为这淡化了通信的重要性。

矩阵-向量乘积,按列划分

划分

其中 $A_{j} \in \mathbb{R}^{n \times n_{j}}$, $x_{j}, y_{j} \in \mathbb{R}^{n_{j}}$ 在$\sum_{j=0}^{p-1} n_{j}=n$,$ n_{j} \approx n / p$.

我们将首先假设$A_j$、$x_j$和$y_j$最初被分配给$P_j$(但现在$A_i$是一个列块)。在这种按列计算的算法中,处理器𝑖可以计算长度$n$向量$A_ix_i$,而不需要事先沟通。然后,这些部分结果必须被加在一起

每个处理器$i$将其结果的一部分$(A_ix_i)_j$分散到处理器$j$中。然后,接收的处理器进行规约,将所有这些片段相加。

然后,有成本的算法由以下方式给出。

成本分析 算法的总成本约为。

请注意,这与成本𝑇 1D-row(𝑛)是相同的,只是将𝛽替换为(𝛽 + 𝛾)。不难看出关于可扩展性的结论是相同的。

二维划分

下面,划分

其中,$A_{i j} \in \mathbb{R}^{n_{i} \times n_{j}}$ ,$x_{i}, y_{i} \in \mathbb{R}^{n_{i}}$,$\sum_{i=0}^{p-1} n_{i}=N$, $n_{i} \approx N / \sqrt{P}$ .

我们将以$r\times c$网格的形式来看待节点,其中$P=rc$,并以$p_{ij}$的形式来看待,其中$i=0, …, r-1$和$j=0, … , c- 1$. 图6.4,对于3×4处理器网格上的12×12矩阵,说明了数据对节点的分配,其中$i, j$ “单元 “显示了$p_{ij}$拥有的矩阵和向量元素。

Distribution of matrix

换句话说,$p_{ij}$拥有矩阵块$A_j$以及$x$和$y$的一部分。这使得以下算法成为可能

算法:

  • 由于$x_j$分布在第𝑗列,该算法首先在每个处理器$p_{ij}$上通过处理器列内的allgather收集$x_j$。

  • 每个处理器$p_{ij}$然后计算$y_{ij}=A_{ij}x_j$ 。这不涉及进一步的沟通。

  • 然后,通过将每个处理器行中的碎片$y_i$收集起来,形成$y_i$,然后将其分布在处理器行中,从而得到结果$y_i$。这两个操作实际上是结合在一起的形成一个规约散射。

  • 如果$r=c$,我们可以在处理器上对$y$数据进行转置,这样它就可以作为后续矩阵向量乘法的输入随后的矩阵-向量乘积。另一方面,如果我们正在计算$AA^tx$,那么$y$现在可以正确地分配给$A^t$乘积。

算法 带有成本分析的算法是

成本分析 算法的总成本约为。

现在我们将进行简化,即$r=c=\sqrt{p}$,所以

由于顺序成本为$T_1(n)=2n^2\gamma$,所以速度提升为

和并行效率,由

我们再次提出一个问题,对于可以存储在$p$的最大问题,其并行效率是多少?节点的最大问题的并行效率是多少。

以至于

然而,$\log_2p$随$p$的增长非常缓慢,因此被认为很像一个常数。在这种情况下,$E_p^{\sqrt{p}\times \sqrt{p}}(n_{max}(p))$下降的速度非常慢,而且该算法被认为是可扩展的,以满足实际需要。

请注意,当$r=p$时,二维算法成为 “按行划分 “的算法,而当$c=p$时,则成为 “按列划分 “的算法。不难看出,只要$r/c$保持不变,当$r=c$时,2D算法在上述分析的意义上是可扩展的。

练习 6.3 计算这个操作的等效率曲线。

并行LU因子化

矩阵-向量和矩阵-乘积在某种意义上是很容易并行化的。输出的元素都可以独立地以任何顺序计算,所以我们在算法的并行化方面有很多自由度。这对于计算LU因子化或用因子化的矩阵求解线性系统来说并不成立。

解决一个三角形系统

三角系统$y=L^{-1}x$ (其中$L$为下三角)的解是一个矩阵-向量运算,因此它与矩阵-向量乘积有其𝑂(𝑁2)的共同复杂性。然而,与乘积运算不同的是,这个求解过程包含了输出元素之间的递推关系。

这意味着并行化并非易事。在稀疏矩阵的情况下,可能会有特殊的策略;见6.10节。在这里,我们将对一般的稠密情况做一些评论。

为了简单起见,我们假设通信不需要时间,而且所有的算术运算都需要相同的单位时间。首先,我们考虑按行分布的矩阵,也就是说,处理器$p$存储的元素$\ell_{p*}$。有了这个,我们可以把三角解法实现为:

  • 处理器1解决了$t_1=\ell_{11}^{-1}x_1$,并将其值发送给下一个处理器。
  • 一般来说,处理器$p$得到的值是$y_1,…,y_{p-1}$来自处理器$p-1$,并计算出$y_p$。
  • 然后每个处理器$p$将$y_1,…,y_p$发送给$p + 1$。

练习 6.4 证明这个算法需要时间$2N^2$,就像顺序算法一样。

这个算法中,每个处理器以流水线的方式将所有计算过的𝑦𝑖值传递给它的后继者。然而,这意味着处理器$p$在最后一刻才收到$y_1$,而这个值在第一步已经被计算出来了。我们可以以这样的方式来制定解决算法,使计算的元素尽快被提供出来。

  • 处理器1解决$y_1$,并将其发送给所有后来的处理器。
  • 一般来说,处理器$p$会等待值为$y_q$的个别消息,因为$q<p$。
  • 然后,处理器$p$计算$y_p$,并将其发送给处理器$q$,而$q > p$。

在通信时间可以忽略不计的假设下,这个算法可以快很多。对于在$p>1$的所有处理器都同时收到$y_1$,并能同时计算$\ell_{p1}y_1$。

练习 6.5 说明如果我们不考虑通信,这个算法变体需要的时间是$O(N)$。如果我们将通信纳入成本,那么成本是多少?

练习 6.6 现在考虑按列分布的矩阵:处理器$𝑝$p储存$\ell_{*p}$ 。概述这种分布下的三角解算法,并说明并行解的时间是$O(N)$。

因式分解,密集型情况

对并行密集$LU$分解的可扩展性的全面分析是相当复杂的,所以我们将声明,无需进一步证明,正如在矩阵-向量的情况下,需要一个二维分布。然而,我们可以发现一个进一步的复杂情况。由于任何类型的因式分解3都是通过矩阵进行的,处理器在部分时间内是不活动的。

练习 6.7 考虑有规律的右看高斯消除法

1
2
3
4
5
for k=1..n
p = 1/a(k,k)
for i=k+1,n
for j=k+1,n
a(i,j) = a(i,j)-a(i,k)*p*a(k,j)

分析运行时间、加速和效率与$N$的函数关系,如果我们假设有一个一维分布,并且有足够的处理器为每个处理器存储一列。表明提速是有限的。也对二维分解进行这种分析,每个处理器存储一个元素。

由于这个原因,使用了过度分解,矩阵被分成比处理器数量更多的块,每个处理器存储几个不相邻的子矩阵。我们在图6.5中说明了这一点,我们将一个矩阵的四个块列划分给两个处理器:每个处理器在一个连续的内存块中存储两个非连续的矩阵列。

接下来,我们在图6.6中说明,在不知道处理器存储了矩阵的非连续部分的情况下,可以对这样的矩阵进行矩阵-向量乘积。只需要输入的向量也是循环分布的。

练习6.8. 现在考虑一个4×4的矩阵和一个2×2的处理器网格。矩阵的行和列都是循环分布的。说明只要输入分布正确,如何使用连续的矩阵存储来进行矩阵-向量乘积。输出是如何分布的?说明比起一维例子的规约,需要更多的通信。

具体来说,在$P<N$处理器的情况下,为简单起见,假设$N=cP$,我们让处理器0存储行0,c,2c,3c,…;处理器1存储行1,c+1,2c+1,…等。这个方案可以概括为二维分布,如果$N=c_1P_1=c_2P_2$和$P=P_1P_2$。这被称为二维循环分布。这个方案可以进一步扩展,考虑块的行和列(块的大小较小),将块的行0,c,2c,….,分配给处理器0。

cyclic-1

cyclic-1-mvp

练习 6.9 考虑一个正方形$n\times n$矩阵,以及一个正方形$p\times p$处理器网格,其中$p$除以$n$无余。考虑上述的过度分解,并对$n=6$,$p=2$的特定情况下的矩阵元素分配做一个草图。也就是说,画一个$n\times n$表,其中位置$(i, j)$包含存储相应矩阵元素的处理器编号。同时,为每个处理器制作一个表格,描述局部到全局的映射,即给出局部矩阵中元素的全局$(i, j)$坐标。(你会发现使用零基编号可以方便地完成这项任务。现在写出$i,j$ 的函数$P,Q,I,J$,描述全局到局部的映射,即矩阵元素$a_{ij}$被存储在处理器$(P(i, j), Q(i, j))$的$(I(i,j),J(i,j))$位置上 。

因式分解,稀疏情况

稀疏矩阵$LU$因式分解是一个众所周知的难题。任何类型的因式分解都涉及到顺序部分,这使得它从一开始就不是简单的事情。为了了解稀疏情况下的问题,假设你按顺序处理矩阵的行。密集情况下,每行有足够多的元素,可以在那里衍生出并行性,但是在稀疏情况下,这个数字可能非常低。

解决这个问题的方法是认识到我们对因式分解本身不感兴趣,而是我们可以用它来解决一个线性系统。由于对矩阵进行置换可以得到相同的解,也许本身就是置换过的,所以我们可以探索具有更高平行度的置换。

矩阵排序的话题已经在第5.4.3.5节中出现了,其动机是填空式规约。我们将在下面考虑具有有利的并行性的排序:第6.8.1节中的嵌套剖析,以及第6.8.2节中的多色排序。

矩阵-矩阵乘积

矩阵三乘法$C\leftarrow A·B$(或$C\leftarrow A·B+\gamma C$,如基本线性代数子程序(BLAS)中使用的那样)有一个简单的并行结构。假设所有大小为$N\times N$的正方形矩阵

  • $N^3$内积$a_{ik}b_{kj}$可以分别被独立计算,之后
  • $N^2$个元素$c_{ij}$是通过独立的求和规约形成的,每个求和规约的时间为对数$\log_2N$。

然而,更有趣的是数据移动的问题。

  • 在$N^3$个操作的$O(N^2)$元素上,有相当多的机会进行数据重用(第1.6.1节)。我们将在第6.4.1节探讨 “Goto算法”。
  • 根据矩阵的遍历方式,TLB的重用也需要被考虑(第1.3.8.2节)。
  • 矩阵-矩阵乘积的分布式内存版本尤其棘手。假设$A$、$B$、$C$都分布在一个处理器网格上,$A$的元素必须通过一个处理器行,而$B$的元素必须通过一个处理器列。我们在下面讨论 “坎农算法 “和外积法。

转到矩阵-矩阵乘积

在第1.6.1节中,我们认为矩阵的矩阵乘积(或BLAS术语中的dgemm)有大量可能的数据重用:在$O(n^3)$数据上有$O(n^2)$操作。现在我们将考虑一个实现,由于Kazushige Goto [85],它确实达到了接近峰值的性能。

矩阵-矩阵算法有三个循环,每个循环我们都可以屏蔽,从而得到一个六路的嵌套循环。由于输出元素上没有递归,所有产生的循环交换都是合法的。结合这个事实,循环阻塞引入了三个阻塞参数,你会发现潜在实现的数量是巨大的。这里我们介绍了支撑Goto实现的全局推理;详细讨论见所引用的论文。

我们首先将乘积$C\leftarrow A\cdot B$ (或者根据Blas标准,$C\leftarrow C+ A\cdot B$)写成一个低秩更新序列。

gotoblas1

gotoblas2

为4,通过积累。

1
2
3
// compute C[i,*] :
for k:
C[i,*] = A[i,k] * B[k,*]

见图6.9。现在这个算法被调整了。

  • 我们需要足够的寄存器来处理$C[i,]$、$A[i,k]$和$B[k,]$。在目前的处理器上,这意味着我们要积累四个$C$元素。
  • $C$的这些元素被积累起来,所以它们停留在寄存器中,唯一的数据转移是对$A$和$B$元素的加载;没有存储!这就是为什么我们要把这些元素放在寄存器中。
  • 元素$A[i,k]$和$B[k,*]$来自L1。
  • 由于相同的$A$块被用于许多连续的$B$片断,我们希望它保持常驻;我们选择$A$的块大小,让它保持在二级缓存中。
  • 为了防止TLB问题,$A$被按行存储。如果我们一开始就用(Fortran)列为主的方式存储矩阵,这意味着我们必须进行复制。由于复制的复杂程度较低,这个成本是可以摊销的。

gotoblas3

这种算法可以在专门的硬件中实现,如谷歌的TPU[84],具有很大的能源效率。

坎农的分布式内存矩阵-矩阵乘积算法

在第6.4.1节中,我们考虑了单处理器矩阵-矩阵乘法的高性能实现。现在我们将简要地考虑这一操作的分布式内存版本。(值得注意的是,这并不是基于第6.2节的矩阵-向量乘积算法的泛化)。

这种操作的一种算法被称为坎农算法。它是一个正方形的处理器网格,处理器$(i,j)$逐渐积累$(i,j)$块 $C_{i,j}=\sum_kA_{i,k}B_{k,j}$;见图6.10。

如果你从左上角开始,你会看到处理器(0,0)同时拥有$A_{00}$和$B_{00}$,所以它可以立即开始做一个局部乘法。处理器(0,1)有$A_{01}$和$B_{01}$,它们不需要在一起,但是如果我们把$B$的第二列向上旋转一个位置,处理器(0,1)将有$A_{01}$,$B_{11}$,这两个确实需要被乘以。同样地,我们将$B$的第三列向上旋转两个位置,这样(0,2)就有$A_{02}$,$B_{22}$。

这个故事在第二行是如何进行的呢?处理器(1,0)有$A_{10}$,$B_{10}$,它们不需要在一起。如果我们将第二行的$A$向左旋转一个位置,它就包含了$A_{11},B_{10}$,这些是部分内积所需要的。而现在处理器(1,1)有$A_{11},B_{11}$。

如果我们继续这个故事,我们从一个矩阵$A$开始,其中的行已经被向左旋转,而𝐵的列已经被向上旋转。在这个设置中,处理器$(i,j)$包含$A_{i,i+j}$和$B_{i+j,j}$,其中加法是按矩阵大小调制的。这意味着每个处理器都可以从做局部积开始。

现在我们观察到,$C_{ij}=\sum_k A_{ik}B_{kj}$意味着下一个局部积项来自于将𝑘增加1。$A$,$B$的相应元素可以通过旋转行和列的另一个位置移入处理器。

分布式矩阵-矩阵乘积的外积法

坎农的算法因需要一个正方形处理器网格的要求而受到影响。外积法更为普遍。它是基于矩阵-矩阵乘积计算的以下安排。

1
2
3
4
for ( k )
for ( i )
for ( j )
c[i,j] += a[i,k] * b[k,j]

也就是说,对于每个$k$,我们取$A$的一列和$B$的一行,并计算秩-1矩阵(或 “外积”)$A_{,k}\cdot B_{k,}$。然后我们对$k$求和。

看一下这个算法的结构,我们注意到在步骤$k$中,每一列$j$收到$A_{,k}$,每一行$i$收到$B_{,k}$。换句话说,$A_{,k}$的元素通过他们的行广播,而$B_{,k}$的元素则通过他们的行广播。

使用MPI库,这些同步广播是通过为每一行和每一列设置一个子通信器来实现的。

稀疏的矩阵-向量乘积

在通过迭代方法求解线性系统时(见第5.5节),矩阵-向量乘积在计算上是一个重要的内核,因为它在每次可能的数百次迭代中都要执行。在本节中,我们先看一下矩阵-向量乘积在单处理器上的性能;多处理器的情况将在第6.5节中得到关注。

单处理器的稀疏矩阵-向量乘积

在迭代方法的背景下,我们并不太担心密集矩阵-向量乘积的问题,因为人们通常不会对密集矩阵进行迭代。在我们处理块状矩阵的情况下,请参考第1.7.11节对密集积的分析。稀疏乘积就比较麻烦了,因为大部分的分析并不适用。

稀疏矩阵-向量乘积中的数据重用 按行执行的密集矩阵-向量乘积和CRS稀疏乘积(第5.4.1.4节)之间有一些相似之处。在这两种情况下,所有的矩阵元素都是按顺序使用的,所以任何加载的缓存线都被充分利用了。然而,CRS的乘积至少在以下方面要差一些。

  • 间接寻址需要加载一个整数向量的元素。这意味着在相同数量的操作中,稀疏产品有更多的内存流量。
  • 源向量的元素不是按顺序加载的,实际上它们可以按随机的顺序加载。这意味着包含源元素的缓存线可能不会被完全利用。另外,内存子系统的预取逻辑(1.3.5节)在这里也不能提供帮助。

由于这些原因,一个由稀疏矩阵-向量乘积主导计算的应用很可能以处理器峰值性能的$\approx$5%运行。

如果矩阵的结构在某种意义上是有规律的,那么就有可能提高这一性能。其中一种情况是,我们处理的是一个完全由小型密集块组成的块状矩阵。这至少会导致索引信息量的减少:如果矩阵由2×2块组成,我们会得到整数数据传输量的4倍减少。

练习 6.10 再给出两个理由,说明这个策略有可能提高性能。提示:缓存线,和重用。

这样的矩阵细分可能会带来2倍的性能提升。假设有这样的改进,即使矩阵不是一个完美的块状矩阵,我们也可以采用这种策略:如果每个2×2的块都包含一个零元素,我们仍然可以得到1.5倍的性能改进[197, 26]。

稀疏乘积中的矢量化 在其他情况下,带宽和重用并不是最主要的问题。

  • 在旧的矢量计算机上,如旧的Craymachine,内存对处理器来说是足够快的,但矢量化是最重要的。这对稀疏矩阵来说是个问题,因为矩阵行中的零数,以及因此的矢量长度,通常都很低。
  • 在GPU上,内存带宽相当高,但有必要找到大量相同的操作。矩阵可以被独立处理,但由于行可能是不等长的,这不是一个合适的并行性来源。

由于这些原因,稀疏矩阵的对角线存储方案的一个变种最近又出现了复兴。这里的观察是,如果你按行数对矩阵的行进行排序,你会得到少量的行块;每个行块都会相当大,而且每个行块中的元素数量都是一样的。

具有这种结构的矩阵对矢量架构来说是很好的[38]。在这种情况下,乘积是通过对角线计算的。

练习 6.11 写出这种情况的伪代码。行的排序是如何改善情况的?

这种排序的存储方案也解决了我们在GPU上注意到的问题[20]。在这种情况下,我们传统的CRS产品算法,我们的并行量等于一个块中的行数。

当然,还有一个复杂的问题,就是我们已经对矩阵进行了置换:输入和输出的向量也需要进行相应的置换。如果乘积操作是迭代方法的一部分,在每次迭代中来回进行这种置换可能会否定任何性能上的提高。相反,我们可以对整个线性系统进行置换,并对置换后的系统进行迭代。

练习 6.12 你能想出这样做的原因吗?为什么不可行呢?

并行稀疏矩阵-向量乘积

在第5.4节中,你看到了关于稀疏矩阵的第一次讨论,仅限于在单个处理器上使用。现在我们将讨论并行性问题。

密集矩阵-向量乘积,正如你在上面看到的,需要每个处理器与其他处理器通信,并有一个基本与全局向量大小相同的本地缓冲区。在稀疏的情况下,需要的缓冲区空间要少得多,通信也要少。让我们分析一下这种情况。我们将假设矩阵是按块行分布的,其中处理器$p$拥有索引在某个集合$I_p$的矩阵行。

行$y_i = y_i +a_{ij}x_j$现在必须考虑到,$a_{ij}$可以是零。特别是,我们需要考虑,对于某些对$i \in I_p$,$j\notin I_p$不需要通信。 为每个$i\in I_p$声明一个稀疏模式集

我们的乘法指令变为

如果我们想避免如上所述的小消息的泛滥,我们把所有的通信合并成每个处理器的一个消息。定义

现在的算法是:

  • 将所有必要的非处理器元素$x_j\in S_p$收集到一个缓冲区。
  • 执行矩阵-向量乘积,从本地存储中读取$x$的所有元素。

这整个分析当然也适用于密集型矩阵。如果我们考虑到稀疏矩阵的来源。让我们从一个简单的案例开始。

回顾一下图4.1,它说明了一个最简单的域(一个正方形)上的离散边界值问题,现在让我们来并行化它。我们通过分割域来做到这一点;每个处理器得到与其子域对应的矩阵行。图6.11显示了处理器之间的联系:元素$a_{ij}$与$i\in I_p, j\notin I_p$现在是模版的 “腿”,伸向处理器边界之外。 所有这些$j$的集合,正式定义为

被称为处理器的幽灵域;见图6.12。

练习 6.13 证明域的一维划分会导致矩阵被划分为块行,但域的二维划分则不会。你可以抽象地做到这一点,也可以举例说明:取一个4×4的域(给出一个大小为16的矩阵),并将其分割到4个处理器上。一维域的划分相当于给每个处理器一个行的域,而二维的划分则给每个处理器一个2×2的子域。画出这两种情况的矩阵。

练习 6.14 图6.13描述了一个大小为$N$的稀疏矩阵,半带宽度$n=\sqrt{N}$。也就是说。

我们在$p$处理器上对该矩阵进行一维分布,其中$p = n = \sqrt{N}$ 。通过计算效率与处理器数量的关系,表明使用该方案的矩阵-向量乘积是弱可扩展的

为什么这个方案不是真正的弱缩放,就像它通常定义的那样?

关于并行稀疏矩阵-向量乘积的一个关键观察是,对于每个处理器,它所涉及的其他处理器的数量是严格限制的。这对操作的效率有影响。

laplaceghost

稀疏矩阵-向量乘积的并行效率

在密集矩阵-向量乘积的情况下(第6.2.2节),将矩阵按(块)行划分到处理器上并没有导致一个可扩展的算法。部分原因是每个处理器需要与之通信的邻居数量增加。图6.11显示,对于5-5点模版的矩阵,这个数量限制为4个。

练习 6.15 取一个正方形域和图6.11中的处理器的变量分区。对于图4.3中的盒子模版,一个处理器需要与之通信的最大邻居数是多少?在三个空间维度上,如果使用7点中心差分模版,那么邻居的数量是多少?

如果我们超越方形域而进入更复杂的物理对象,那么每个处理器只与几个邻居进行通信的观察就保持不变。如果一个处理器收到一个或多或少连续的子域,其邻居的数量将是有限的。这意味着即使在复杂的问题中,每个处理器也只能与少量的其他处理器通信。与密集情况相比,每个处理器不得不从其他每个处理器那里接收数据。很明显,稀疏的情况对互连网络要友好得多。(对于大型系统来说,这也是比较常见的事实,如果你准备购买一台新的并行计算机,可能会影响到对安装网络的选择)。

对于方形域,我们可以使这个论点正式化。让单位域$[0, 1]^2$在$\sqrt{P}$网格中的$P$处理器上进行划分,$\sqrt{P}\times \sqrt{P}$。从图6.11可以看出,每个处理器最多只能与四个邻居进行通信。让每个处理器的工作量为$w$,与每个邻居的通信时间为$c$。那么在单个处理器上执行总工作的时间是$T_1=Pw$,而并行时间是$T_p=w+4c$,给出的速度为

练习 6.16 将$c$和$w$表示为$N$和$P$的函数,并说明在问题的弱标度下,速度的提高是渐近最优的。

练习 6.17 在这个练习中,你将分析一个假设的、但现实的并行机器的并行稀疏矩阵-向量乘积。让机器参数具有以下特点(见第1.3.2节)。

  • 网络延迟:$\alpha=1\mu s=10^{-6}s$。

  • 网络带宽:1𝐺𝑏/𝑠对应于$\beta=10^{-9}$。

  • 计算率。每核运算率为1𝐺flops意味着$\gamma=10^9$。这个数字看起来很低,但请注意,矩阵-向量乘积的重用率低于矩阵-矩阵乘积,后者可以达到接近峰值的性能,而且稀疏矩阵-向量乘积的带宽约束更大。我们进行了渐进式分析和推导具体数字的结合。

    我们假设一个有$10^4$个单核处理器的集群4。我们将其应用于大小为$N=25\cdot 10^{10}$的五点模版矩阵。这意味着每个处理器存储$5\cdot 8\cdot N/p=10^{9}$字节。如果矩阵来自一个正方形领域的问题,这意味着该领域的大小为$n\times n$,$n=\sqrt{N}=5\cdot 10^{5}$。

    情况1:我们不是划分矩阵,而是划分域,我们首先通过大小为$n\times (n/p)$的水平板块进行划分。论证通信复杂度为 $2(\alpha+ n\beta)$,计算复杂度为$10\cdot n\cdot (n/p)$ 。证明所产生的计算量比通信量多出50倍。

    情况2:我们将领域划分为大小为$(n/\sqrt{p})\times (n/\sqrt{p})$的斑块。内存和计算时间与之前相同。推导出通信时间,并表明它比之前的时间要好50倍。

    论证第一种情况不具有弱扩展性:在假设$N/p$是恒定的情况下,效率会下降。(证明速度仍然随着$\sqrt{p}$的增加而渐进地上升。论证第二种情况确实是弱扩展的。

一个处理器只与几个邻居连接的论点是基于科学计算的性质。这对FDM和FEM方法来说是真实的。在边界元素法(BEM)的情况下,任何子域都需要与它周围半径为𝑟的所有东西进行通信。随着处理器数量的增加,每个处理器的邻居数量也会增加。

练习 6.18 请对BEM算法的速度和效率进行正式分析。再假设每个处理器的单位工作量为$w$,每个邻居的通信时间为$c$。由于邻居的概念现在是基于物理距离,而不是基于图形属性,所以邻居的数量会增加。对于这种情况,给出$T_1$、$T_p$、$S_p$、$E_p$。

还有一些情况,稀疏矩阵需要与密集矩阵类似地处理。例如,谷歌的PageRank算法(见第9.4节)的核心是重复操作$x\leftarrow A$,$A$是一个稀疏矩阵,如果网页$j$链接到页面$i$,$A\neq 0$;见第9.4节。 这使得$A$是一个非常稀疏的矩阵,没有明显的结构,所以每个处理器都很可能与其他处理器进行交流。

稀疏矩阵-向量乘积的记忆行为

在1.7.11节中,你看到了在单个处理器上对密集情况下的稀疏矩阵-向量乘积的分析。有些分析可以立即延续到稀疏情况下,比如每个矩阵元素只使用一次,性能受处理器和内存之间的带宽限制。

关于输入和输出向量的重用,如果矩阵是按行存储的,比如CRS格式(第5.4.1.3节),对输出向量的访问将被限制为每行写一次。另一方面,用于获得输入向量的重用的循环解卷技巧不能在这里应用。结合两个迭代的代码如下。

1
2
3
4
5
6
7
8
for (i=0; i<M; i+=2) {
s1 = s2 = 0;
for (j) {
s1 = s1 + a[i][j] * x[j];
s2 = s2 + a[i+1][j] * x[j];
}
y[i] = s1; y[i+1] = s2;
}

这里的问题是,如果$a_{ij}$为非零,就不能保证$a_{i+1,j}$为非零。稀疏模式的不规则性使得优化矩阵-向量乘积变得困难。通过识别矩阵中的小密块部分,可以实现适度的改进[26, 43, 196]。

在GPU上,稀疏的矩阵-向量乘积也受到内存带宽的限制。现在编程更难了,因为GPU必须在数据并行模式下工作,有许多活动线程。

如果我们考虑到稀疏矩阵-向量乘积通常出现的背景,一个有趣的优化就成为可能。这个操作最常见的用途是在线性系统的迭代求解方法中(第5.5节),在那里它被应用于同一个矩阵,可能有数百次迭代。因此,我们可以考虑将矩阵存储在GPU上,只为每个乘积操作复制输入和输出向量。

转置积

在第5.4.1.3节中,你看到正则和转置矩阵-向量乘积的代码都被限制在循环顺序上,其中矩阵的行被遍历。(在第1.6.2节中,你看到了关于循环顺序变化的计算效果的讨论;在这种情况下,我们被存储格式限制在行的遍历上)。

在这一节中,我们将简要地看一下并行转置积。与按行划分矩阵并进行转置乘积相当,我们看一下按列存储和划分的矩阵并进行常规乘积。

列间乘积的算法可以给出:。

在共享内存和分布式内存中,我们都将外迭代分布在处理器上。那么问题来了,每个外部迭代都会更新整个输出向量。这是一个问题:在共享内存中,它导致了对输出位置的多次写入,而在分布式内存中,它需要通信,目前还不清楚。

解决这个问题的一个方法是为每个进程分配一个私有的输出向量$y^{(p)}$。

之后,我们对$y\leftarrow \sum_p y^{(p)}$进行求和。

稀疏矩阵-向量乘积的设置

密集的矩阵-向量乘积依赖于集合体(见第6.2节),而稀疏的情况则使用点对点的通信,也就是说,每个处理器只向几个邻居发送信息,并从几个邻居那里接收信息。这对于来自PDE的稀疏矩阵类型是有意义的,正如你在第4.2.3节中看到的,PDE有明确的结构。然而,有些稀疏矩阵是如此的随机,以至于你基本上不得不使用密集技术;见第9.5节。

然而,发送和接收之间存在着不对称性。对于一个处理器来说,要找出它将从哪些其他处理器那里接收信息是相当容易的。

练习 6.19 假设矩阵是按块行划分给处理器的;见图6.2的说明。还假设每个处理器都知道其他处理器存储了哪些行。(你将如何实现这一知识?)勾勒出一个算法,通过这个算法,处理器可以发现它将从谁那里接收;这个算法本身不应该涉及任何通信。发现向谁发送则更难。

练习 6.20 论证一下,在结构对称矩阵的情况下,这很容易。$a_{ij}\neq 0\Leftrightarrow a_{ji} \neq 0$。在一般情况下,原则上可以要求一个处理器向任何其他处理器发送,所以简单的算法是如下所示。

每个处理器都会列出它所需要的非本地指数的清单。根据上述假设,它知道其他每个处理器拥有的指数范围,然后决定从哪些邻居那里获得哪些指数。

  • 每个处理器向它的每个邻居发送一个指数列表;这个列表对大多数邻居来说都是空的,但我们不能省略发送它。

  • 然后,每个处理器从所有其他处理器那里收到这些列表,并制定出要发送的指数列表。你会注意到,尽管矩阵-向量乘积过程中的通信只涉及每个处理器的几个邻居,给出的成本是处理器数量的𝑂(1),但设置涉及所有对所有的通信,其时间复杂性为$O(\alpha P)$

    如果一个处理器只有几个邻居,上述算法是浪费的。理想情况下,你希望空间和运行时间与邻居的数量成正比。如果我们知道预计有多少消息,我们可以在设置中带来接收时间。这个数字是可以找到的。

  • 每个进程都会产生一个长度为$P$的数组,如果进程需要来自处理器𝑖的任何数据,则ereneed[i]为1,否则为0。

  • 在这个数组上的一个reduce-scatter集体,用一个sum运算符,然后在每个处理器上留下一个数字,表示有多少处理器需要它的数据。

  • 该处理器可以执行那么多的接收调用。reduce-scatter调用的时间复杂度为$\alpha \log P+\beta P$,与之前的算法相同,但可能比例常数较低。通过一些复杂的技巧,设置所需的时间和空间可以减少到$O(\log P)$[63,110]。

迭代方法的计算问题

所有的迭代方法都有以下操作。

  • 矩阵-向量乘积;这在第5.4节讨论了顺序情况,在第6.5节讨论了平行情况。在并行情况下,FEM矩阵的构造有一个复杂的问题,我们将在第6.6.2节讨论。
  • 预处理矩阵$K$的构造$\approx A$,以及系统的解$Kx=y$。这在第5.5.6节的顺序情况下讨论过。下面我们将在第6.7节中讨论并行性问题。
  • 一些向量操作(包括内积,一般来说)。这些将在接下来讨论。

向量操作

在一个典型的迭代方法中,有两种类型的向量运算:向量加法和内积。

练习 6.21 考虑第5.5.11节的CG方法,图5.9,应用于二维BVP的矩阵;公式(4.16),首先考虑无条件的情况$M=I$。证明在矩阵-向量乘积和向量运算中进行的浮点运算数量大致相等。用矩阵大小$N$表示一切,忽略低阶项。如果矩阵每行有20个非零点,这种平衡会是怎样的?

接下来,研究一下5.5.9节中FOM方案的矢量和矩阵操作之间的这种平衡。由于向量操作的数量取决于迭代,考虑前50次迭代,计算在向量更新和内积与矩阵-向量乘积中进行了多少浮点操作。矩阵需要有多少个非零点才能使这些数量相等?

练习 6.22 翻牌计数并不是全部的事实。对于在单个处理器上执行的迭代方法中的向量和矩阵操作的效率,你能说什么?

向量加法

向量加法的典型形式是$x\leftarrow \alpha y$或$x\leftarrow \alpha+y$。如果我们假设所有的向量都以相同的方式分布,这个操作就是完全平行的。

内积

内积是向量操作,但它们在计算上比更新更有趣,因为它们涉及通信。

当我们计算一个内积时,很可能每个处理器都需要接收计算的值。我们使用以下算法。

algorithm 1

规约和广播(可以加入到Allreduce中)结合了所有处理器的数据,所以它们的通信时间随着处理器的数量而增加。这使得内积有可能成为一个昂贵的操作,人们提出了一些方法来减少它们对迭代方法性能的影响。

练习 6.23 迭代方法通常用于稀疏矩阵。在这种情况下,你可以认为内积中涉及的通信对整体性能的影响可能比矩阵-向量乘积中的通信更大。作为处理器数量的函数,矩阵-向量乘和内积的复杂性是什么?

下面是一些已经采取的方法。

  • CG方法每次迭代都有两个相互依赖的内部产品。有可能重写该方法,使其计算相同的迭代结果(至少在精确算术中),但使每次迭代的两个内积可以合并。见[32, 39, 156, 205]。
  • 也许可以将内积计算与其他并行计算重叠[41]。
  • 在GMRES方法中,使用经典的Gram-Schmidt(GS)方法需要的独立内积要比修改后的GS方法少得多,但它的稳定性较差。人们已经研究了决定何时允许使用经典GS方法的策略[138]。

由于计算机算术不是关联性的,当同一计算在两个不同的处理器配置下执行时,内积是导致结果不同的主要原因。在第3.5.5节中,我们勾画了一个解决方案。

有限元矩阵构造

有限元导致了并行计算中一个有趣的问题。为此,我们需要勾勒出这种方法工作的基本轮廓。有限元的名称来自于这样一个事实:建模的物理对象被划分为小的二维或三维形状,即元素,如二维的三角形和正方形,或三维的金字塔和砖块。在每一个元素上,我们要建模的函数被假定为多项式,通常是低度的,如线性或双线性。

关键的事实是,一个矩阵元素$a_{ij}$是所有包含变量𝑖和𝑗的元素的计算之和,特别是某些积分。

每个元素的计算都有许多共同的部分,所以很自然地把每个元素𝑒唯一地分配给一个处理器$P$,然后由它来计算所有的贡献 𝑎(𝑒)。在图6.14中,元素2被分配给处理器0,元素4被分配给处理器1。

fem

现在考虑变量$i$和$j$以及矩阵元素$a_{ij}$。它被构建为域元素2和4的计算之和,这些元素被分配给不同的处理器。因此,无论什么处理器的行$i$被分配到,至少有一个处理器必须传达它对矩阵元素$a_{ij}$的贡献。

显然,不可能对元素$P_e$和变量$P_i$进行分配,从而使$P_e$对所有$i\in e$都能完全计算系数$a_{ij}$。换句话说,如果我们在本地计算贡献,需要有一定量的通信来集合某些矩阵元素。由于这个原因,现代线性代数库如PETSc允许任何处理器设置任何矩阵元素。

迭代方法性能的一个简单模型

上面我们已经说过,迭代方法很少有机会进行数据重用,因此它们被描述为带宽约束型算法。这使得我们可以对迭代方法的浮点性能做出简单的预测。由于迭代方法的迭代次数很难预测,这里的性能是指单次迭代的性能。与线性系统的直接方法不同(例如见第6.8.1节),解的次数很难事先确定。

首先,我们认为我们可以把自己限制在稀疏矩阵向量乘积的性能上:这个操作所花费的时间比向量操作多得多。此外,大多数预调节器的计算结构与矩阵-向量乘积相当相似。

然后让我们考虑CRS矩阵-向量乘积的性能。

  • 首先我们观察到,矩阵元素和列索引的数组没有重复使用,所以加载它们的性能完全由可用带宽决定。缓存和完美流只是隐藏了延迟,但并没有改善带宽。对于矩阵元素与输入矢量元素的每一次乘法,我们加载一个浮点数字和一个整数。根据指数是32位还是64位,这意味着每次乘法都要加载12或16个字节。

  • 对结果向量的存储要求并不重要:每个矩阵行的输出向量只写一次。

  • 输入向量在带宽计算中可以忽略不计。乍一看,你会认为对输入向量的间接索引或多或少是随机的,因此很昂贵。然而,让我们从矩阵-向量乘积的算子角度出发,考虑矩阵所来自的PDE的空间域;见4.2.3节。我们现在看到,看似随机的索引实际上是被紧密地组合在一起的向量元素。这意味着这些向量元素很可能存在于L3缓存中,因此可以用比主存数据更高的带宽(例如,至少5倍的带宽)来访问。

对于稀疏矩阵-向量乘积的并行性能,我们认为在PDE背景下,每个处理器只与几个邻居进行通信。此外,从表面到体积的论证表明,信息量比节点上的计算量要低一阶。

总之,我们得出结论,稀疏矩阵向量乘积的一个非常简单的模型,从而也是整个迭代求解器的模型,包括测量有效带宽和计算每12或16字节加载一次加法和一次乘法操作的性能。

高性能线性代数(二)

并行预处理程序

上面(第5.5.6节,特别是5.5.6.1节)我们看到了$K$的几种不同选择。在本节中,我们将开始讨论并行化策略。讨论将在接下来的章节中继续详细进行。

雅克比预处理

雅可比方法(5.5.3节)使用𝐴的对角线作为预处理。应用这个方法是尽可能的平行:声明$y\leftarrow K^{-1}x$独立地缩放输入向量的每个元素。不幸的是,使用雅可比预处理器对迭代次数的改进是相当有限的。因此,我们需要考虑更复杂的方法,如ILU。与雅可比预处理程序不同的是,并行化并不简单。

与ILU并行的麻烦

上面我们看到,从计算的角度来看,应用ILU预处理(第5.5.6.1节)与做矩阵-向量乘积一样昂贵。如果我们在并行计算机上运行我们的迭代方法,这就不再是真的了。

乍一看,这些操作很相似。矩阵-向量乘积$y=Ax$看起来像

1
2
for i=1..n
y[i] = sum over j=1..n a[i,j]*x[j]

并行时,这看起来像

1
2
for i=myfirstrow..mylastrow
y[i] = sum over j=1..n a[i,j]*x[j]

假设一个处理器拥有它所需要的𝐴和𝑥的所有元素的本地副本,那么这个操作就是完全并行的:每个处理器都可以立即开始工作,如果工作负荷大致相等,它们都会在同一时间完成。然后,矩阵-向量乘积的总时间除以处理器的数量,使速度或多或少得到了提高。

现在考虑正向解$Lx=y$,例如在ILU预处理程序的背景下。

1
2
for i=1..n
x[i] = (y[i] - sum over j=1..i-1 ell[i,j]*x[j]) / a[i,i]

我们可以简单地编写并行代码。

1
2
for i=myfirstrow..mylastrow
x[i] = (y[i] - sum over j=1..i-1 ell[i,j]*x[j]) / a[i,i]

但现在出现了一个问题。我们不能再说 “假设一个处理器拥有右手边所有东西的本地拷贝”,因为向量𝑥同时出现在左手边和右手边。虽然矩阵-向量乘积原则上在矩阵行上是完全并行的,但这个三角解代码是递归的,因此是顺序的。

在并行计算的背景下,这意味着,第二个处理器要启动,需要等待第一个处理器计算的𝑥的某些组件。显然,在第一个处理器完成之前,第二个处理器不能启动,第三个处理器必须等待第二个处理器,以此类推。令人失望的结论是,在并行中,任何时候都只有一个处理器处于活动状态,总时间与顺序算法相同。在密集矩阵的情况下,这实际上不是一个大问题,因为在处理单行的操作中可以找到并行性(见第6.12节),但是在稀疏的情况下,这意味着我们不能使用不完整的因式分解,而需要重新设计。

在接下来的几个小节中,我们将看到不同的策略来寻找能有效地并行执行的前置条件器。

块状雅可比方法

人们提出了各种方法来弥补三角形解法的顺序性。例如,我们可以简单地让处理器忽略那些应该来自其他处理器的𝑥的成分。

1
2
3
for i=myfirstrow..mylastrow
x[i] = (y[i] - sum over j=myfirstrow..i-1 ell[i,j]*x[j])
/ a[i,i]

这在数学上并不等同于顺序算法(从技术上讲,它被称为以ILU为局部解的块状雅可比方法),但由于我们只是在寻找一个近似值$K\approx A$,这只是一个稍微粗糙的近似。

练习 6.24 以你上面写的高斯-赛德尔代码为例,模拟一次并行运行。增加(模拟的)处理器数量的效果如何?

block-jacobi

块方法背后的想法可以通过图片来理解,见图6.15。实际上,我们通过忽略处理器之间的所有连接,得到了一个ILU的矩阵。由于在BVP中,所有的点都是相互影响的(见第4.2.1节),如果在顺序计算机上执行,使用连接较少的预调节器将增加迭代的次数。然而,块状方法是并行的,正如我们上面所观察到的,顺序预处理器在并行情况下效率很低,所以我们容忍了这种迭代次数的增加。

并行ILU

块状雅可比预处理通过解耦领域部分进行操作。虽然这可能会产生一种高度并行的方法,但它可能会比真正的ILU预处理程序产生更多的迭代次数。(可以从理论上论证这种解耦降低了迭代方法的效率;见第4.2.1节)。幸运的是,有可能出现一个并行的ILU方法。由于我们需要即将到来的关于变量重新排序的材料,我们把这个讨论推迟到6.8.2.3节。

排序策略和并行性

在前文中,我们已经提到了一个事实,即解决线性方程组本身就是一个递归活动。对于密集系统来说,与递归长度相比,操作的数量足够多,因此寻找并行性是相当直接的。另一方面,稀疏系统则需要更多的复杂性。在这一节中,我们将研究一些重新排列方程的策略(或者说,等同于排列矩阵),这将增加可用的并行性。

这些策略都可以被认为是高斯消除法的变种。通过对它们进行不完全变体(见第5.5.6.1节),所有这些策略也适用于构建迭代求解方法的前置条件。

嵌套剖析

上面,你已经看到了几个在域中对变量进行排序的例子,而不是用列举式排序。在本节中,你将看到嵌套剖析排序,它最初被设计为一种减少填充的方法。然而,它在并行计算的背景下也是有利的。

嵌套剖析是一个递归的过程,用于确定在一个工作中的未知数的非线性排序。在第一步中,计算域被分割成两部分,在它们之间有一个分隔带;见图6.16。准确地说,这个分隔带足够宽,以至于左右子域之间没有任何联系。由此产生的矩阵𝐴DD具有3×3的结构,对应于域的三个分界。由于子域Ω和Ω不相连,子矩阵𝐴DD和𝐴DD为零。

这种用分离器划分域的过程也被称为域分解或子结构,尽管这个名字也与所产生的矩阵的数学分析有关[14]。在这个矩形域的例子中,找到一个分隔符当然是微不足道的。然而,对于我们从BVP中得到的方程类型,通常可以有效地找到任何域的分离器[146];另见18.6.2节。

现在让我们考虑这个矩阵的𝐿𝑈因式分解。如果我们用3×3块状结构来分解它,我们可以得到

其中

这里的重要事实是

  • 贡献$A_{31}A^{-1}_{11}A_{13}$和$A_{32}A_{22}^{-1}A_{23}$可以同时计算,所以因式分解在很大程度上是并行的;并且
  • 在前向和后向求解中,解的第1和第2部分都可以同时计算,所以求解过程也基本上是并行的。第三块不能以并行方式处理,所以这在算法中引入了一个顺序的部分。算法。我们还需要仔细研究一下$S_{33}$的结构。

练习 6.25 在第5.4.3.1节中,你看到了LU因子化和图论之间的联系:消除一个节点会导致图中的该节点被删除,但会增加某些新的连接。证明在消除前两组变量后,分离器上剩余矩阵的图将是完全连接的。结果是,在消除了第1和第2块中的所有变量后,我们剩下的矩阵$S_{33}$是完全密集的,大小为$n\times n$。

引入分离器后,我们得到了一个双向平行的因式分解。现在我们重复这个过程:我们在第 1 和第 2 块内放置一个分隔符(见图 6.17),这样就得到了以下矩阵结构。

(注意与第5.4.3.4节中的 “箭头 “矩阵的相似性,并回顾一下这导致较低填充的论点)。这个的LU因子化是。

domdecomp2

其中

现在构建因式分解的过程如下。

  • 对于$i=1,2,3,4$,块$A_{ii}$被并行因式化;同样,对于$i=1,2,3,4$,$A_{5i}A_{ii}^{-1}A_{i5}$,对于$𝑖=3,4$,$A_{6i}A_{ii}^{-1}A_{i6}$,$i=1,2,3,4$,$A_{7i}A_{ii}^{-1}A_{i7}$可以被并行构建。
  • 形成舒尔补码$S_5, S_6$,并随后进行并行派生,对$i = 5, 6$的贡献 $A_{7i}S_i^{-1}A_{17}$ 也是并行构建。
  • 舒尔补码$S_7$被形成并被分解。

与上述推理类似,我们得出结论:在消除了第1,2,3,4块后,更新的矩阵$S_5$, $S_6$是大小为$n/2$的密集,在消除了第5,6块后,舒尔补码$S_7$是大小为$n$的密集。

练习 6.26 证明用𝐴DD求解一个系统与构建上述因式分解有类似的并行性。

为了便于以后参考,我们将把集合1和2称为彼此的兄弟姐妹,同样,3和4也称为兄弟姐妹。集合5是1和2的父,6是3和4的父;5和6是兄弟姐妹,7是5和6的父。

域的分解

在图6.17中,我们通过一个递归过程将领域分成四种方式。这就引出了我们对嵌套剖分的讨论。也可以立即将一个域分割成任意数量的条状,或者分割成子域的网格。只要分离器足够宽,这将给出一个具有许多独立子域的矩阵结构。在上面的讨论中,LU因子化的特点是

  • 在因式分解和𝐿, 𝑈求解中,对子域进行并行处理,并且
  • 要在分离器结构上求解的系统。

domdecomp4

练习 6.27 二维BVP的矩阵有一个块状三对角结构。将该域分为四条,即使用三个分离器(见图6.18)。注意这些分离器在原始矩阵中是不耦合的。现在勾勒出所得到的系统的稀疏性结构,即分离器是消除子域的。说明该系统是块状三对角的。

在我们到目前为止讨论的所有领域分割方案中,我们使用的领域都是矩形的,或者说是 “砖 “形的,在两个维度以上。所有这些论点都适用于二维或三维的更一般的域,但是像寻找分离器这样的事情变得更加困难[145],而这对于平行情况来说更是如此。参见第18.6.2节对这个话题的一些介绍。

复杂度

嵌套剖析法重复上述过程,直到子域变得非常小。对于理论分析来说,我们一直在分割,直到我们有大小为1×1的子域,但在实践中,我们可以停止在32这样的大小,并使用一个有效的密集求解器来分解和反转块。

为了推导出算法的复杂度,我们再看一下图6.17,可以看到复杂度论证,一个完整的递归嵌套分解所需要的总空间是以下的总和

  • 在一个大小为$n$的分离器上的一个密集矩阵,加上
  • 大小为$n/2$的分离器上的两个密集矩阵之和。
  • 需要$3/2n^2$的空间和$5/12n^3$的时间。
  • 上述两项在四个大小为$(n/2)\times (n/2)$的子域上重复。

观察到$n=\sqrt{N}$,这就意味着

显然,我们现在的因式分解在很大程度上是平行的,而且是在$O(N \log N)$空间中完成的,而不是在$O(N^{3/2})$(见5.4.3.3节)。因式分解时间也从$O(N^2)$下降到$O(N^{3/2})$。

不幸的是,这种空间节省只发生在二维空间:在三维空间中,我们需要

  • 一个$n$大小的分离器,占用$(n\times n)^2 =N^{4/3}$空间和$1/3\cdot(n\times n)^3=1/3\cdot N^2$时间。
  • 两个大小为$n\times n/2$的分离器,占用$N^{3/2}/2$空间和$1/3\cdot N^2/4$时间。
  • 四个大小为$n/2 \times n/2$ 的分离器,需要$N^{ 3/2}/4$ 的空间和 $1/3\cdot N^{2}/16$的时间。
  • 加起来就是$7/4N^{3/2}$空间和$21/16N^2/3$时间。
  • 在下一级,有8个子域贡献这些条款,$n\rightarrow n/2$,因此$N\rightarrow N/8$。

这使得总空间

以及总的时间

我们不再有二维情况下的巨大节省。一个更复杂的分析表明,对于二维的一般问题,阶次的改进是成立的,而三维一般有更高的复杂度[145]。

并行性

嵌套剖析法显然引入了大量的并行性,我们可以将其描述为任务并行性(第2.5.3节):与每个分离器相关的是对其矩阵进行因式分解的任务,随后是对其变量进行线性系统求解的任务。然而,这些任务并不是独立的:在图6.17中,域7上的因式分解必须等待5和6,而且它们必须等待1,2,3,4。因此,我们的任务以树的形式存在依赖关系:每个分离器矩阵只有在其子矩阵被因子化后才能被因子化。

将这些任务映射到处理器上并非易事。首先,如果我们处理的是共享内存,我们可以使用一个简单的任务队列。

1
2
3
4
5
6
7
8
Queue ← {}
for all bottom level subdomains 𝑑 do
add 𝑑 to the Queue
while Queue is not empty do
if a processor is idle then
assign a queued task to it
if a task is finished AND its sibling is finished then
add its parent to the queue

这里的主要问题是,在某些时候,我们的处理器会多于任务,从而导致负载不平衡。这个问题由于最后的任务也是最重要的,而变得更加严重,因为分离器的大小从一层到另一层是双倍的。(回顾一下,密集矩阵的因式分解工作随着大小的三次方而增加!) 因此,对于较大的分离器,我们必须从任务并行转为中粒度并行,即处理器合作对一个块进行因子化。

有了分布式内存,我们现在可以用一个简单的任务队列来解决并行问题,因为这将涉及移动大量的数据。(但请记住,工作是矩阵大小的高次方,这一次对我们有利,使通信相对便宜)。那么解决方案就是使用某种形式的域分解。在图6.17中,我们可以有四个处理器,与块1、2、3、4相关联。然后,处理器1和2将协商哪一个因素是第5块(类似地,处理器3和4和第6块),或者它们都可以冗余地做这个。

预处理

与所有的因式分解一样,通过使因式分解不完整,可以将嵌套剖分法变成一个预处理程序。(关于不完全因式分解的基本思想,见5.5.6.1节)。然而,这里的因式分解完全是用块矩阵来表述的,除以枢轴元素就变成了一个反转或与枢轴块矩阵的系统解。我们将不进一步讨论这个问题,详情见文献[6, 57, 157]。

red-black-1d

变量的重新排序和着色:独立集

在稀疏矩阵中可以通过使用图形着色(第18.3节)实现并行化。由于 “颜色 “被定义为只与其他颜色相连的点,根据定义它们是相互独立的,因此可以被并行处理。这导致我们采取了以下策略。

  1. 将问题的邻接图分解成少量独立的集合,称为 “颜色”。
  2. 用与颜色数量相等的顺序步骤来解决这个问题;在每个步骤中,都会有大量的可独立处理的点。

红黑相间的颜色

我们从一个简单的例子开始,我们考虑一个三对角矩阵$A$。方程$Ax=b$看起来像

我们观察到,$x_i$直接取决于$x_{i-1}$和$x_{i+1}$,但不取决于$x_{i-2}$或$x_{i+1}$。因此,让我们看看,如果我们把指数排列起来,把每一个其他的组成部分放在一起会发生什么。

图形上,我们把$1, … n$,并将它们涂成红色和黑色(图 6.19),然后我们将它们进行置换,先取所有红色的点,然后取所有黑色的点。相应地,经过置换的矩阵看起来如下。

有了这个经过处理的$A$,高斯-塞德尔矩阵$D_A + L_A$看起来像是

这能给我们带来什么?好吧,让我们拼出一个系统的解决方案$Lx=y$。

显然,该算法有三个阶段,每个阶段在一半的域点上是平行的。这在图6.20中得到了说明。理论上,我们可以容纳的处理器数量是域点数量的一半,但实际上每个处理器都会有一个子域。现在你可以在图6.21中看到这如何导致一个非常适度的通信量:每个处理器最多发送两个红点的数据给它的邻居。

red-black-1d-solve

red-black-1d-solve-par

练习 6.28 论证这里的邻接图是一个二边形图。我们看到,这样的图(以及一般的彩色图)与并行性有关。你还能指出非并行处理器的性能优势吗?

红黑排序也可以应用于二维问题。让我们对点$(i, j)$应用红黑排序,其中$1\leqslant i, j\leqslant n$。在这里,我们首先对第一行的奇数点(1,1),(3,1),(5,1),……,然后对第二行的偶数点(2,2),(4,2),(6,2),……,第三行的奇数点进行连续编号,依此类推。这样对域中一半的点进行编号后,我们继续对第一行的偶数点、第二行的奇数点进行编号,依此类推。正如你在图6.22中看到的,现在红色的点只与黑色的点相连,反之亦然。用图论的术语来说,你已经找到了一个有两种颜色的矩阵图的着色(这个概念的定义见附录18)。

redblack

练习 6.29 对二维BVP(4.12)应用红黑排序。画出所产生的矩阵结构。

红黑排序是图形着色(有时称为多重着色)的一个简单例子。在简单的情况下,如我们在第4.2.3节中考虑的单位正方形域或其扩展到三维,邻接图的色数很容易确定;在不太规则的情况下,则比较困难。

练习6.30。你看到了未知数的红黑排序加上有规律的五点星形模版给出了两个变量子集,它们之间没有联系,也就是说,它们形成了矩阵图的双着色。如果节点由图4.3中的第二个模版连接,你能找到一个着色吗?

一般性着色

对于稀疏矩阵的图形所需的颜色数量有一个简单的约束:颜色的数量最多为$d+1$,其中$d$是图形的度数。为了说明我们可以用$d+1$种颜色来给学位为$d$的图形着色,考虑一个学位为$d$的节点。无论它的邻居是如何着色的,在$d+1$种可用的颜色中总有一种未使用的颜色。

练习 6.31 考虑一个稀疏矩阵,该图可以用$d$种颜色来着色。首先列举第一种颜色的未知数,然后列举第二种颜色的未知数,依此类推,对矩阵进行排列。你能说说所产生的排列矩阵的稀疏性模式是什么?

pilu

如果你在寻找线性系统的直接解,你可以在消除一种颜色后剩下的矩阵上重复着色和置换的过程。在三对角矩阵的情况下,你看到这个剩余的矩阵又是三对角的,所以很清楚如何继续这个过程。这就是所谓的递归翻倍法。如果矩阵不是三对角的,而是块状三对角的,这个操作可以在块上进行。

多色并行ILU

在第6.8.2节中,你看到了图形着色和包络的结合。让$P$是将相似颜色的变量组合在一起的置换,那么$\tilde{A}=P^tAP$是一个具有如下结构的矩阵。

  • $\tilde{A}$具有块状结构,其块数与$A$的头顶相接图中的颜色数相等;并且
  • 每个对角线块是一个对角线矩阵。

现在,如果你正在进行迭代系统求解,并且你正在寻找一个并行的预处理程序,你可以使用这个对角的矩阵。考虑到用包络系统来解决$Ly=x$。我们将通常的算法(第5.3.5节)写为

练习 6.32 证明当从自然排序的ILU因式分解到颜色稀释排序的ILU因式分解时,解决系统$LUx=y$的翻转数保持不变(最高阶项)。

这些着色对我们有什么好处?求解仍然是顺序的……嗯,的确,颜色的外循环是顺序的,但是一种颜色的所有点都是相互独立的,所以它们可以在同一时间被求解。因此,如果我们使用普通的域划分,并结合多色彩(见图6.23),处理器在所有的色彩阶段都是活跃的;见图6.24。好吧,如果你仔细看一下这个图,你会发现在最后一个颜色中,有一个处理器没有活动。在每个处理器有大量节点的情况下,这不太可能发生,但可能有一些负载不平衡。

pilu-solve

剩下的一个问题是如何并行地生成多色。寻找最佳颜色数是NP-hard。拯救我们的是,我们不一定需要最佳数量,因为我们反正是在做不完全因式分解。(甚至有一种观点认为,使用稍大的颜色数量可以减少迭代的次数)。

[116, 149]发现了一种优雅的并行寻找多色的算法。

  1. 给每个变量分配一个随机值。
  2. 找到比所有相邻变量的随机值更高的变量;也就是颜色1。
  3. 然后找到比所有非1色的邻居有更高随机值的变量。这就是颜色2。
  4. 反复进行,直到所有的点都被染上颜色。

不规则迭代空间

在显式时间步进的背景下或作为稀疏矩阵-向量乘积,应用计算模版是并行的。然而,在实践中,分割迭代空间可能并非易事。如果迭代空间是直角坐标砖,这很容易,即使是嵌套式的并行。然而,在对称的情况下,要做到均匀的负载分布就比较困难。一个典型的迭代空间看起来像。

1
2
3
for (i=0; i<N; i++)
for (j=i; j<N; j++)
for (k=j; k<N; k++)

在某些情况下(见[22]),界限可能更加复杂,如j=i+i%2k<max(i+j,N)。在这种情况下,可以做以下工作。

  1. 循环被遍历,以计算内迭代的总数;这将被分成许多部分,因为有进程。
  2. 循环被遍历以找到每个过程的开始和结束的$i$、$j$、$k$值。
  3. 然后重写循环代码,使其能够在这样一个$i,jk$子范围内运行。

缓存效率的排序

模板操作的性能通常相当低。这些操作没有明显的缓存重用;通常它们类似于流操作,从内存中获取长的数据流并只使用一次。如果只做一次模版评估,那就完了。然而,通常我们会做很多这样的更新,我们可以应用类似于1.7.8节中描述的循环叠加的技术。


oblivious-stencil-2

如果我们考虑到模版的形状,我们可以做得比普通平铺更好。图6.25显示了(左)我们如何首先计算一个包含缓存的时空梯形。然后(右)我们计算另一个建立在第一个梯形上的包含缓存的梯形[71]。

解除PDE线性系统的并行性

PDEs的数值求解是一项重要的活动,所要求的精度往往使其成为并行处理的主要候选者。如果我们想知道我们能做到多大程度的并行,特别是可以达到什么样的速度,我们需要区分问题的各个方面。

首先,我们可以问问题中是否存在任何内在的并行性。在全局层面上,通常不会有这种情况(如果问题的一部分完全不耦合,那么它们将是独立的问题,对吗?)但在较小的层面上,可能存在并行性。

例如,看一下时间相关的问题,参考第4.2.1节,我们可以说每一个下一个时间步骤当然都依赖于上一个时间步骤,但不是下一个时间步骤的每一个单独的点都依赖于上一个步骤的每一个点:有一个影响区域。因此,有可能对问题域进行分区并获得并行性。

操作员拆分

在某些情况下,有必要通过一个二维或三维阵列的所有方向进行隐式计算。例如,在第4.3节中,你看到了热方程的隐式解是如何产生重复系统的

在没有证明的情况下,我们指出,与时间有关的问题也可以通过以下方式解决

为合适的𝛽。这个方案不会在每个单独的时间步长上计算出相同的值,但它会收敛到相同的稳定状态。该方案也可以作为BVP情况下的一个预处理程序。

这种方法有相当大的优势,主要体现在运算次数上:原始系统的求解要么是对矩阵进行因式分解,产生填充,要么是通过迭代求解。

练习 6.33 分析这些方法的相对优点,给出大致的运算次数。考虑$\alpha$对$t$有依赖性和没有依赖性的情况。同时讨论各种操作的预期速度.

当我们考虑(6.3)的并行解时,会出现进一步的优势。注意我们有一个二维的变量集$u$,但是算子$I+d^2u/dx^2$只连接$u_{ij},u_{ij-1},u_{ij+1}$。也就是说,每一行对应的𝑖值都可以被独立处理。因此,这两个算子都可以用一个一维分割域来完全并行地解决。另一方面,(6.2)中系统的求解具有有限的并行性。

不幸的是,有一个严重的问题:$x$方向的算子需要在一个方向上对域进行分割,而𝑦方向的算子则需要在另一个方向上进行分割。通常采取的解决方案是在两个解之间对$u_{ij}$值矩阵进行转置,以便同一处理器的分解可以处理这两个问题。这种转置可能会占用每个时间步骤的大量处理时间。

练习 6.34. 讨论使用$P=p\times p$处理器的网格对域进行二维分解的优点和问题。你能提出一个方法来改善这些问题吗?加快这些计算的一个方法是用显式操作代替隐式求解;见6.10.3节。

并行性和隐式操作

在关于IBVP的讨论中(第4.1.2.2节),你看到从数值稳定性的角度来看,隐式运算有很大的优势。然而,你也看到,它们使基于简单操作(如矩阵-向量乘积)的方法与基于更复杂的线性系统求解的方法之间的区别。当你开始并行计算时,隐式方法会有更多的问题。

练习 6.35 设𝐴为矩阵

laplacelower

证明矩阵向量乘积$y\leftarrow Ax$ 和系统解决方案$x\leftarrow A^{-1}y$ ,通过解决三角系统$Ax=y$,而不是通过反转 $A$ 得到,具有相同的操作数。 现在考虑将乘积$y\leftarrow Ax$ 并行化。假设我们有$n$处理器,每个处理器$i$存储$x$和$A$的第$i$行。证明除了第一个处理器外,任何一个处理器都可以在没有闲置时间的情况下计算出𝐴𝑥的结果。

三角形系统$Ax=y$的解也可以这样做吗?显示出直接的实现方式是每个处理器在计算过程中都有$(n-1)/n$的空闲时间。现在我们将看到一些处理这个固有的顺序部分的方法。

波峰

上面,你看到解决一个大小为$N$的下三角系统,其顺序时间复杂度为$N$步。在实践中,事情往往没有那么糟糕。像解三角系统这样的隐式算法本身就是顺序的,但步骤数可能比一开始看到的少。

练习 6.36 再看一下单位面积上的二维BVP的矩阵,用中心差分法进行离散。如果我们按对角线排列未知数,请推导出矩阵结构。对于区块的大小和区块本身的结构,你能说什么?

让我们再看一下图4.1,它描述了二维BVP的有限差分模版。图6.26是下三角因子的模版的相应图片。这描述了下三角解过程的顺序性$x\leftarrow L^{-1}y$ 。

换句话说,如果点$k$的左边(即变量$k-1$)和下面(变量$k-n$)的邻居是已知的,就可以找到它的值。

下方(变量$k-n$)是已知的。

反过来,我们可以看到,如果我们知道$x_1$,我们不仅可以找到$x_2$,还可以找到$x_{n+1}$。在下一步,我们可以确定$x_3$、$x_{n+2}$和$x_{2n+1}$。继续这样下去,我们可以通过波阵来解决$x$:每个波阵上的$x$的值都是独立的,所以它们可以在同一个顺序步骤中被平行解决。

练习 6.37 完成这个论证。我们可以使用的最大处理器数量是多少,顺序步骤的数量是多少?最终的效率是多少?

当然,你不需要使用实际的并行处理来利用这种并行性。相反,你可以使用一个矢量处理器、矢量指令或GPU[148]。

在第5.4.3.5节中,你看到了用于减少矩阵填充的Cuthill-McKee排序。我们可以对这个算法进行如下修改,以得到波阵。

  1. 取一个任意的节点,并称其为 “零级”。
  2. 对于第$n+1$级,找到与第$n$相连的点,这些点本身并不相连。
  3. 对于所谓的 “反向Cuthill-McKee排序”,将层次的编号倒过来。

练习 6.38 这个算法并不完全正确。问题出在哪里,你如何纠正?证明所产生的变换矩阵不再是三对角的,但可能仍有一个带状结构。

递推式翻倍

递归$y_{i+1}=a_iy_i+b_i$, 例如在解双线性方程组时出现的情况(见练习4.4), 似乎是内在的顺序性。然而,你已经在练习1.4中看到,以一些初步操作为代价,计算是可以并行化的。

首先,从(6.4)中获取一般的双线性矩阵,并将其扩展为规范化的形式。

我们把它写成$A=I+B$。

练习 6.39 说明可以通过与对角线矩阵相乘来实现对归一化形式的缩放。解决系统$(I+B)x=y$对解决$Ax=y$有什么帮助?以两种不同的方式求解该系统的运算量是多少?

现在我们做一些看起来像高斯消除的事情,只是我们不从第一行开始,而是从第二行开始。(如果你对矩阵$I+B$进行高斯消除或LU分解,会发生什么?) 我们用第二行来消除$b_{32}$。

我们把它写成$L^{(2)}A=A^{(2)}$。我们还计算了$L^{(2)}y=y^{(2)}$,因此$A^{(2)}x=y^{(2)}$与$Ax=y$有相同的解。解决转换后的系统让我们得到了一点好处:在我们计算了$x_1$之后,$x_2$和$x_3$可以被并行计算。

现在我们重复这个消除过程,用第四行来消除$b_{54}$,第六行来消除$b_{76}$,等等。最后的结果是,总结所有$L(i))$矩阵。

我们把它写成$L(I+B)=C$,而解决$(I+B)x=y$现在变成了$C=L^{-1}y$。

这个最终结果需要仔细研究。

  • 首先,计算$y=L^{-1}y$很简单。(弄清楚细节,有多少并行性可用?)
  • 解决$Cx=y’$仍然是顺序的,但它不再需要$n$步骤:从$x_1$我们可以得到$x_3$,从那里我们得到$x_5$,等等。换句话说,$x$的奇数部分之间只存在顺序关系。
  • $x$的偶数部分并不相互依赖,而只依赖奇数部分。$x_2$来自$x_1$,$x_4$来自$x_3$,依此类推。一旦奇数部分被计算出来,这一步就是完全并行的。

我们可以自行描述奇数部分的顺序解法。

其中$c_{i+1i}=-b_{2n+1,2n}b_{2n.2n-1}$。换句话说,我们已经将一个大小为$n$的顺序问题简化为一个大小为同类的顺序问题和一个大小为$n/2$的并行问题。现在我们可以递归地重复这个过程,将原来的问题还原为一连串的并行操作,每一个都是前者的一半大小。

通过递归加倍计算所有部分和的过程也被称为并行前缀操作。这里我们使用前缀和,但在抽象的情况下,它可以应用于任何关联运算符。

通过显性操作逼近隐性操作,系列扩展

如上所述,隐式运算在实践中是有问题的,有各种原因允许用另一种实际上更有利的方法来代替隐式运算。

  • 只要我们遵守显式方法的步长限制,用显式方法代替隐式方法(第4.3节)同样是合法的。
  • 在迭代方法中修补预处理程序(第5.5.8节)是允许的,因为它只会影响收敛速度,而不会影响方法收敛到的解。你已经在块状雅可比方法中看到了这个一般想法的一个例子;6.7.3节。在本节的其余部分,你将看到预处理程序中的递归,即隐式操作,如何被显式操作所取代,从而带来各种计算优势。

求解线性系统是隐式运算的一个很好的例子,由于这归结为求解两个三角形系统,让我们来看看如何找到替代求解下三角系统的计算方法。如果𝑈是上三角且非 Singular,我们让𝐷是𝑈的对角线,我们写成𝑈 = 𝐷(𝐼 - 𝐵) 其中𝐵是一个对角线为零的上三角矩阵,也称为严格上三角矩阵;我们说𝐼 - 𝐵是一个单位上三角矩阵。

练习 6.40 设$A=LU$是一个LU分解,其中$L$的对角线上有1。说明如何解决$Ax=b$的问题,只涉及单位上下三角系统的解决。证明在系统求解过程中不需要除法。

我们现在感兴趣的操作是解决系统$(I-B)x=y$。我们观察到

和$B^n=0$,其中$n$是矩阵的大小(检查这个!),所以我们可以通过以下方法精确求解$(I-B)x=y$。

当然,我们希望避免明确计算幂$B^𝑘$,所以我们观察到

诸如此类。由此产生的评估$\sum_{k=0}^{n-1}B^ky$的算法被称为霍纳规则,你看它避免了计算矩阵幂$B^k$。

练习 6.41 假设$I - B$是二对角线。证明上述计算需要$n(n+1)$操作。通过三角解法计算$(I-B)x=y$的操作数是多少?

我们现在已经把隐式运算变成了显式运算,但不幸的是,这种运算的次数很高。然而,在实际情况下,我们可以截断矩阵的功率之和。

练习 6.42 设$A$为三对角矩阵

4.2.2节中的一维BVP。

  1. 回顾5.3.4节中对角线支配的定义。这个矩阵是对角线主导的吗?

  2. 证明该矩阵的LU因子化中的枢轴(无枢轴)满足递归。提示:说明经过$n$消除步骤($n\geqslant 0$)后,剩余的矩阵看起来像

    并说明$d_{n+1}$与$d_n$之间的关系。

  3. 证明序列$n\mapsto d_n$是递减的,并推导出其极限值。

  4. 写出以$L$和$U$为单位的$d_n$枢轴的系数。
  5. $L$和$U$因子是对角线主导的吗?

上述练习意味着(注意,我们实际上并没有证明!),对于来自BVP的矩阵,我们发现$B^k \downarrow 0$,在元素大小和规范方面都是如此。这意味着我们可以用诸如$(I-B)x=y$的方法来近似地计算$(I+B)y$或者$x=(I+B+B^2)y$。这样做仍然比直接三角解法有更多的操作数,但至少在两个方面有计算上的优势。

  • 显式算法有更好的流水线行为。
  • 正如你所看到的,隐式算法在并行时有问题;显式算法更容易并行化。

当然,这种近似可能对整个数值算法的稳定性有进一步的影响。

练习 6.43 描述霍纳法则的并行性方面;方程(6.6)。

网格更新

第四章的结论之一是,时间相关问题的显式方法在计算上比隐式方法容易。例如,它们通常涉及矩阵-向量乘积而不是系统解,而且显式操作的并行化相当简单:矩阵-向量乘积的每个结果值都可以独立计算。这并不意味着还有其他值得一提的计算方面。

由于我们处理的是稀疏矩阵,源于一些计算模版,我们从操作者的角度出发。在图6.11和6.12中,你看到了在域的每一点上应用模版是如何引起处理器之间的某些关系的:为了在一个处理器上评估矩阵-向量乘积$y\leftarrow A$,该处理器需要获得其幽灵区域的$x$的值。在合理的假设下,在处理器上划分领域,涉及的信息数量将相当少。

练习 6.44 推理一下,在有限元或有限元分析的背景下,当$h\downarrow 0$时,信息的数量是$O(1)$。

在第1.6.1节中,你看到矩阵-向量乘积几乎没有数据重用,尽管计算有一定的位置性;在第5.4.1.4节中指出,稀疏矩阵-向量乘积的位置性更差,因为稀疏性必须有索引方案。这意味着稀疏乘积在很大程度上是一种受带宽限制的算法。

只看一个单一的乘积,我们对此没有什么办法。然而,我们经常连续做一些这样的乘积,例如作为一个随时间变化的过程的步骤。在这种情况下,可能会对操作进行重新安排,以减少对带宽的需求。作为一个简单的例子,可以考虑

并假设集合$\{x(n)\}$太大,无法装入缓存。这是一个模型,例如,在一个空间维度上热方程的显式$i$方案;4.3.1.1节。从原理上讲。

在普通计算中,我们先计算所有$x(n+1)$,然后再计算所有$x(n+2)$,在$n+1$级别的中间值产生后会从缓存中刷掉,然后再作为$n+2$级别数量的输入带回缓存中。

然而,如果我们计算的不是一个,而是两个迭代,中间值可能会留在缓存中。考虑到$x(n+2)$:它需要$x(n+1)$、$x(n+1)$,而后者又需要$x(n)$、…, $x(n)$。

grid-update-overlap

现在假设我们对中间结果不感兴趣,而只对最后的迭代感兴趣。图 6.27 是一个简单的例子。第一个处理器计算了$n+2$层的4个点。为此,它需要从$n+1$层计算5个点,而这些点也需要从$n$层的6个点中计算出来。我们看到,一个进程显然需要收集一个宽度为2的重影区域,而常规的单步更新只需要一个。第一个处理器计算的一个点是$x(n+2)$,它需要$x(n+1)$。这个点也需要用于计算$x(n+2)$,属于第二个处理器。

最简单的解决方法是让中间层的这种点冗余计算,在需要它的两个区块的计算中,在两个不同的处理器上进行。

练习 6.45 你能想到一个点会被两个以上的处理器冗余计算的情况吗?

我们可以对这种按块计算多个更新步骤的方案给出几种解释。

  • 首先,如上所述,用一个处理器进行计算可以增加locality:如果一个彩色块中的所有点(见图)都适合于缓存,我们就可以重复使用中间的点。
  • 其次,如果我们把它看作是分布式内存计算的化学反应,它减少了信息流量。通常,对于每一个更新步骤,处理器都需要交换他们的边界数据。如果我们接受一些多余的重复工作,我们现在可以消除中间层的数据交换。通信的减少通常会超过工作的增加。

练习 6.46 讨论一下在多核计算中使用这种策略的情况。有哪些节省?有哪些潜在的隐患?

分析

让我们分析一下我们刚刚勾画的算法。如同方程(6.7),我们把自己限制在一个一维的点集和一个三点的函数。描述这个问题的参数是这样的。

  • $N$是要更新的点的数量,$M$表示更新步骤的数量。因此,我们进行$MN$函数评估。

  • $\alpha, \beta, \gamma$是描述延迟、单点传输时间和操作时间(这里认为是$f$评价)的通常参数。

  • $b$是我们挡在一起的步骤数。

每个光环通信由$b$点组成,我们这样做$\sqrt{N}/b$多次。所做的工作包括$MN /p$局部更新,再加上由于晕轮而产生的冗余工作。后者包括$b^2/2$个操作,在处理器域的左边和右边都进行。

将所有这些条款加在一起,我们发现成本为

我们观察到,$\alpha M/b+\gamma Mb$的开销是与$p$无关的。

练习 6.47 计算$b$的最优值,并指出它只取决于结构参数$\alphaa, \beta,\gamma$而不取决于问题参数。

沟通和工作最小化战略

我们可以通过将计算与通信重叠来使这个算法更有效率。如图6.28所示,每个处理器从通信其光环开始,并将此通信与可在本地完成的通信部分重叠。然后,依赖于光环的值将被最后计算。

grid-update-local

练习 6.48 这样组织你的代码(重点是’代码’!)有什么大的实际问题?

如果每个处理器的点数足够大,那么相对于计算来说,通信量就很低,你可以把$b$拿得相当大。然而,这些网格更新大多用于迭代方法,如CG方法(第5.5.11节),在这种情况下,对舍入的考虑使你不能把$b$拿得太大[32]。

练习 6.49 在点被组织成二维网格的情况下,通过对非重叠算法的复杂性分析。假设每个点的更新涉及四个邻居,每个坐标方向上有两个邻居。

上述算法的进一步细化是可能的。图6.29说明有可能使用一个使用不同时间步骤的不同点的晕区。这种算法(见[42])减少了冗余的计算量。然而,现在需要先计算交流的光环值,所以这需要将本地交流分成两个阶段。

grid-update-minimal

多核架构上的块状算法

在第5.3.7节中,你看到某些线性代数算法可以用子矩阵来表述。这个观点对于在共享内存架构(如目前的多核处理器)上高效执行线性代数操作是有益的。

作为一个例子,让我们考虑Cholesky因式分解,它计算$A=LL^t$为一个对称的正定矩阵。

正定矩阵$A$;另见5.3.2节。递归地,我们可以将该算法描述如下。

其中 $\tilde{A}_{21}=A_{21} L_{11}^{-t}, A_{11}=L_{11} L_{11}^{t}$

在实践中,区块实现被应用于一个分区

其中𝑘是当前块行的索引,对于所有索引$<k$,因式分解已经完成。因式分解的写法如下,用Blas的名字表示操作。

并行性能的关键是对指数$>k$进行分区,并以这些块为单位编写算法。

该算法现在得到了一个额外的内循环级别。

现在很明显,该算法具有很好的并行性:每个l环的迭代都可以独立处理。然而,这些循环在外层$k$-循环的每一次迭代中都会变短,所以我们能容纳多少个处理器并不直接。此外,没有必要保留上述算法的操作顺序。例如,在

因式分解$L_2L_2^t=A_{22}$可以开始,即使剩下的$k = 1$迭代仍未完成。因此,比起我们仅仅对内循环进行并行化,可能存在着更多的并行性。

在这种情况下,处理并行性的最好方法是将算法的控制流观点(其中操作顺序是规定的)转变为数据流观点。在后者中,只有数据的依赖性被指出,而且任何服从这些依赖性的操作顺序都是允许的。(从技术上讲,我们放弃了任务的程序顺序,代之以部分排序5) 。表示算法的数据流的最好方法是构建一个任务的有向无环图(DAG)(见第18节关于图的简要教程)。如果任务𝑗使用了任务$i$的输出,我们就在图中添加一条边$(i, j)$。

练习 6.50 在2.6.1.6节中,你学到了顺序一致性的概念:一个线程化的并行代码程序在并行执行时应该给出与顺序执行时相同的结果。我们刚刚说过,基于DAG的算法可以自由地以任何服从图节点的部分顺序来执行任务。讨论一下在这种情况下,顺序一致性是否是一个问题。

在我们的例子中,我们通过为每个内部迭代制定一个顶点任务来构造一个DAG。图6.30显示了4×4块的矩阵的所有任务的DAG。这个图是通过模拟上面的Cholesky算法构建的。

练习 6.51 这个图的直径是多少?识别出位于决定直径的路径上的任务。这些任务在该算法中的意义是什么?这条路径被称为关键路径。它的长度决定了并行计算的执行时间,即使有无限多的处理器可用。

练习 6.52 假设有$T$个任务,都需要一个单位时间来执行,并假设我们有$p$个处理器。理论上执行该算法的最小时间是多少?现在修改这个公式以考虑到关键路径;称其长度为$C$。

在执行任务的过程中,一个DAG可以有几个观察点。

  • 如果有一个以上的更新被加载到锁上,那么让这些更新由同一个进程来计算,可能会更有优势。这样可以简化维护缓存一致性的工作。
  • 如果数据被使用并随后被修改,那么在修改开始之前必须完成使用。如果这两个动作是在不同的处理器上进行的,这甚至可能是真的,因为内存子系统通常会保持缓存一致性,所以修改会影响正在读取数据的进程。这种情况可以通过在主内存中有一个数据的拷贝来补救,给读取数据的进程保留一个数据(见1.4.1节)。

chol4dag

分子动力学

分子动力学是模拟分子的逐个原子行为并从这些原子运动中推导出宏观属性的技术。它适用于生物分子,如蛋白质和核酸,以及材料科学和纳米技术的天然和合成分子。分子动力学属于粒子方法的范畴,其中包括天体力学和天体物理学中的N体问题,这里介绍的许多观点将延续到这些其他领域。此外,还有一些分子动力学的特殊情况,包括从头开始的分子动力学,其中电子被量子力学地处理,因此可以对化学反应进行建模。我们将不讨论这些特殊情况,而是集中讨论经典的分子动力学。

分子动力学的想法非常简单:一组粒子根据牛顿运动定律相互作用,$F = ma$。考虑到初始粒子的位置和速度、粒子的质量和其他参数,以及粒子之间的作用力模型,牛顿运动定律可以通过数值集成来给出每个粒子在未来(和过去)所有时间的运动轨迹。通常情况下,粒子居住在一个具有周期性边界条件的计算箱中。

因此,一个分子动力学的时间步骤由两部分组成。

  1. 计算各粒子的力量

  2. 更新位置(整合)。

力的计算是开销最大的部分。最先进的分子动力学模拟是在并行计算机上进行的,因为力的计算十分复杂,而且合理的模拟长度需要大量的时间步骤。在许多情况下,分子动力学也被应用于具有非常多原子的分子的模拟,例如,对于生物分子和长时间尺度来说,可以达到一百万,对于其他分子和短时间尺度来说,可以达到数十亿。

数值整合技术在分子动力学中也很有意义。对于需要大量时间步长的模拟来说,能量等量的保存比准确度更重要,必须使用的求解器与第四章中介绍的传统ODE求解器不同。

在下文中,我们将介绍用于生物分子模拟的力场,并讨论计算这些力的快速方法。然后,我们将专门讨论短程力的分子动力学并行化和用于长程力快速计算的三维 FFT的并行化。

最后,我们用一个章节来介绍适合于分子动力学模拟的集成技术类别。我们在本章中对分子动力学主题的处理是为了介绍和实用;如果想了解更多的信息,建议阅读文本[68]。

受力计算

力场分析

在经典的分子动力学中,势能和原子间作用力的模型被称为力场。力场是一个可操作但近似的量子力学效应模型,对于大分子来说,计算成本太高,无法确定。不同的力场用于不同类型的分子,也有不同的研究人员用于同一分子,而且没有一个是理想的。

在生化系统中,常用的力场将势能函数建模为键合能、范德瓦尔斯能和静电(库仑)能之和。

势是模拟中所有原子位置的函数。原子上的力是原子位置上的这个势的负梯度。粘合能是由于分子中的共价键造成的。

其中,这三个项分别是所有共价键的总和、两个键形成的所有角度的总和以及三个键形成的所有二面体角度的总和。固定参数$k_i, r_{i,0}$等取决于所涉及的原子类型,对于不同的力场可能有所不同。额外的项或具有不同函数形式的项也是常用的。

势能的其余两个项$E$统称为非键合项。理论上,它们构成了力计算的主体。静电能是由原子电荷引起的,其模型是我们熟悉的

其中,总和为所有原子对,$q_i$和$q_j$是原子$i$和$j$的电荷,$r_{ij}$是原子$i$和$j$之间的间距。最后,范德瓦尔斯能近似于剩余的吸引和排斥效应,通常用伦纳德-琼斯函数来模拟

其中$\epsilon_{ij}$和$\sigma_{ij}$是力场参数,取决于原子类型。在短距离上,排斥性的($r^{12}$)项起作用,而在长距离上,分散(吸引,-$r^6$)项起作用。

分子动力学力计算的并行化取决于这些不同类型的力计算的并行化。键合力是局部计算,即对于一个给定的原子,只需要附近的原子位置和数据。范德瓦耳斯力也是局部的,被称为短程的,因为它们对于大的原子分离来说是可以忽略的。静电力是长程的,各种技术已经被开发出来以加快这些计算。在接下来的两个小节中,我们分别讨论短程和长程非键合力的计算。

计算短程非结合力

对一个粒子的短程非粘合力的计算可以在超过该粒子的截止半径$r_c$时被截断。对某一粒子$i$进行这种计算的天真方法是检查所有其他粒子并计算它们与粒子$i$的距离。对于$n$粒子来说,这种方法的复杂性是$O(n^2)$,相当于计算所有粒子对之间的力。有两种数据结构,即单元格列表和Verlet邻居列表,可以独立用于加速这种计算,还有一种方法是将两者结合起来。


元胞列表

元胞列表的想法经常出现在寻求给定点附近的一组点的问题中。参照图7.1(a),我们用一个二维的例子来说明这个想法,在粒子集合上铺设一个网格。如果网格间距不小于$𝑟_𝑐$,那么为了计算对粒子𝑖的作用力,只需要考虑包含$i$的单元和8个相邻单元的粒子。对所有的粒子进行一次扫描就可以为每个单元构建一个粒子列表。这些元胞列表被用来计算所有粒子的力。在下一个时间步骤中,由于粒子已经移动,必须重新生成或更新元胞列表。这种方法的复杂性是计算数据结构的$O(n)$和计算力的$O(n\times n_c$),其中$n_c$是9个单元的平均粒子数(三维的27个单元)。元胞列表数据结构所需的存储量为$O(n)$。

Verlet近邻表

元胞列表结构有些低效,因为对于每个粒子$i$,$n_c$粒子被考虑,但这远远多于截止点$r_c$内的粒子数量。一个Verlet邻居列表是一个粒子𝑖的截止点内的粒子列表。每个粒子都有自己的列表,因此需要的存储量为$O(n\times n_v)$,其中$n_v$是截止点内粒子的平均数量。一旦构建了这些列表,计算力就会非常快,需要最小的复杂度$O(n \times n_v)$。构建列表的成本较高,需要检查每个粒子的所有粒子,即不低于原来的复杂性$O(n^2)$。然而,其优点是,如果使用扩大的截止点$r_v$,近邻表可以在许多时间步骤中重复使用。参考图7.1(b)中的一个二维例子,只要没有来自两个圆圈外的粒子在内圈内移动,近邻表就可以被重复使用。如果粒子的最大速度可以被估计或限定,那么我们可以确定一个时间步数,在这个时间步数内重复使用邻居列表是安全的。(另外,也可以在任何粒子穿越到截止点内的位置时发出信号)。从技术上讲,Verlet近邻表是在扩展的截止点内的粒子列表,$r_v$。

同时使用元胞和近邻表

混合方法是简单地使用Verlet近邻表,但使用元胞列表来构建近邻表。这减少了需要重新生成近邻表时的高成本。这种混合方法非常有效,也是最先进的分子动力学软件中经常使用的方法。

单元列表和Verlet近邻表都可以被修改,以利用以下事实:由于粒子$f_{ij}$的作用力等于$-f_{ji}$(牛顿第三定律),只需要计算一次。例如,对于元胞列表,只需要考虑8个单元格中的4个(在二维)。

计算长程力

静电力的计算具有挑战性,因为它们是长程的:每个粒子都感受到来自模拟中所有其他粒子的不可忽略的静电力。有时使用的一种近似方法是在某个截止半径后截断粒子的力计算(就像对短程范德瓦尔斯力所做的那样)。然而,这通常会在结果中产生不可接受的假象。

有几种更精确的方法可以加快静电力的计算,避免对所有$n$粒子的$O(n^2)$和。我们在此简要介绍其中一些方法。

分层N体方法

分层$N$体方法,包括Barnes-Hut方法和快速多极方法,在天体物理粒子模拟中非常流行,但对于生物分子模拟所要求的精度来说,通常成本太高。在Barnes-Hut方法中,空间被递归地划分为8个相等的单元(三维),直到每个单元包含零或一个粒子。附近的粒子之间的作用力是单独计算的,就像正常情况一样,但是对于远处的粒子,作用力是在一个单元内的一个粒子和一组远处的粒子之间计算的。准确度的测量被用来确定是否可以使用远处的单元来计算力,或者必须通过单独考虑其子单元来计算。Barnes-Hut方法的复杂度为$O(n\log n)$。快速多极法的复杂度为$O(n)$;该方法计算势,不直接计算力。

粒子网格法

在粒子网格方法中,我们利用泊松方程

它将电势$\phi$与电荷密度$\rho$联系起来,其中$1/\epsilon$是一个比例常数。为了利用这个方程,我们用一个网格来离散空间,给网格点分配电荷,解决网格上的泊松方程,得出网格上的势。力是电位的负梯度(对于保守力,如静电力)。许多技术已经被开发出来,用于将空间中的点电荷分布到一组网格点上,也用于数值插值点电荷上由于网格点的电势而产生的力。许多快速方法可用于解决泊松方程,包括多网格方法和快速傅里叶变换。在术语方面,粒子网格方法与原始的粒子-粒子方法相反,后者是在所有粒子对之间计算力。

事实证明,粒子网格法不是很准确,一个更准确的替代方法是将每个力分成短程、快速变化的部分和长程、缓慢变化的部分。

实现这一目标的一个方法是用一个函数$h(r)$来衡量𝑓,它强调短程部分(小$r$用$1-h(r)$来强调长程部分(大$r$)。短程部分是通过计算截止点内所有粒子对的相互作用来计算的(粒子-粒子方法),长程部分是用粒子-网格方法计算的。由此产生的方法,称为粒子-粒子-网格(PPPM,或P3),是由于霍克尼和伊斯特伍德在1973年开始的一系列论文中提出的。

埃瓦尔德法

Ewald方法是迄今为止描述的生物分子模拟中最流行的静电力的方法,是为周期性边界条件的情况而开发的。该方法的结构与PPPM相似,即力被分成短程和长程两部分。同样,短程部分用粒子-粒子方法计算,长程部分用傅里叶变换计算。Ewald方法的变种与PPPM非常相似,因为长程部分使用网格,而快速傅里叶变换被用来解决网格上的泊松方程。更多的细节,请参见,例如[68]。在第7.3节中,我们描述了三维 FFT的并行化以解决三维 Poisson方程。

并行分解

我们现在讨论力的并行计算。Plimpton[169]创建了一个非常有用的分子动力学并行化方法的分类,确定了原子、力和空间分解的方法。在此,我们紧跟他对这些方法的描述。我们还增加了第四类,这类方法已被公认为与前面的类别不同,称为中性领土方法,这个名字是由Shaw[182]创造的。中立领地方法目前被许多最先进的分子动力学代码所使用。空间分解和中性领土方法对基于截止点的计算的并行化特别有利。

原子分解

在原子分解中,每个粒子被分配给一个处理器,该处理器负责计算粒子的力,并在整个模拟中更新其位置。为了使计算大致平衡,每个处理器被分配到大致相同数量的粒子(随机分布效果好)。原子分解的一个重要观点是,每个处理器一般都需要与所有其他处理器进行通信,以共享更新的粒子位置。

fig-atom1v

fig-atom2v

图7.2: 原子分解,显示了分布在8个处理器中的16个粒子的力矩阵。一个点代表力矩阵中的一个非零条目。在左边,矩阵是对称的;在右边,为了利用牛顿第三定律,只计算一对偏斜对称元素中的一个元素。

图7.2(a)中的力矩阵说明了一个原子分解。对于$n$粒子,力矩阵是一个$n\times n$的矩阵;行和列按粒子指数编号。矩阵中的非零条目$f_{ij}$表示由于粒子$j$而对粒子$i$产生的非零力,必须计算出来。这个力可能是一个非粘结力和/或粘结力。当使用截断时,矩阵是稀疏的,如本例中。如果在所有粒子对之间计算力,矩阵是密集的。由于牛顿第三定律$f_{ij}=-f_{ji}$,该矩阵是歪斜对称的。图7.2(a)中的线条显示了粒子是如何被分割的。在图中,16个粒子被划分到8个处理器中。

算法1从一个处理器的角度展示了一个时间步骤。在时间步骤开始时,每个处理器持有分配给它的粒子的位置。

一个优化是将计算量减半,这是有可能的,因为力矩阵是

1
2
3
4
Algorithm 1 Atom decomposition time step
1: send/receiveparticlepositionsto/fromallotherprocessors
2: (ifnonbondedcutoffsareused)determinewhichnonbondedforcesneedtobecomputed 3: computeforcesforparticlesassignedtothisprocessor
4: updatepositions(integration)forparticlesassignedtothisprocessor

歪斜对称的。为了做到这一点,我们为所有偏斜对称对准确选择$f_i$或$f_{ji}$中的一个,这样每个处理器负责计算的力的数量大致相同。选择力矩阵的上三角或下三角部分是一个不好的选择,因为计算负荷是不平衡的。更好的选择是,如果$i + f_{ij}$在上三角中是偶数,或者如果$i + j$在下三角中是奇数,就计算$j$,如图7.2(b)所示。还有许多其他选择。

当利用力矩阵中的歪斜对称性时,一个处理器所拥有的粒子上的所有力不再由该处理器来计算。例如,在图7.2(b)中,粒子1上的力不再只由第一个处理器计算。为了完成力的计算,处理器必须通过通信来发送其他处理器需要的力,并接收其他处理器计算的力。现在必须修改上述算法,增加一个通信步骤(步骤4),如算法2所示。

1
2
3
4
Algorithm 2 Atom decomposition time step, without redundant calculations 1: send/receiveparticlepositionsto/fromallotherprocessors
2: (ifnonbondedcutoffsareused)determinewhichnonbondedforcesneedtobecomputed
3: computepartialforcesforparticlesassignedtothisprocessor
4: sendparticleforcesneededbyotherprocessorsandreceiveparticleforcesneededbythisprocessor 5: updatepositions(integration)forparticlesassignedtothisprocessor

如果额外的通信量被节省的计算量所抵消,这种算法是有利的。请注意,一般情况下,通信量会增加一倍。

力的分解

在力的分解中,力被分配到各处理器中进行计算。做到这一点的直接方法是将力矩阵划分为块,并将每个块分配给一个处理器。图7.3(a)说明了16个粒子和16个处理器的情况。粒子也需要被分配给处理器(如在原子分解中),目的是让处理器被分配来更新粒子的位置。在图中的例子中,处理器𝑖被分配来更新粒子𝑖的位置;在实际问题中,一个处理器会被分配来更新许多粒子的位置。请注意,同样,我们首先考虑的是倾斜对称的力矩阵的情况。

现在我们来看看一个时间步骤中力的分解所需的通信。考虑处理器3,它计算粒子0、1、2、3的部分力,并需要粒子0、1、2、3以及12、13、14、15的位置。因此,处理器3需要与处理器0、1、2、3,以及处理器12、13、14、15进行通信。在所有处理器计算完力之后,处理器3需要收集由其他处理器计算的对粒子3的力。因此,处理器2需要再次与处理器0、1、2、3进行通信。


算法3显示了从一个处理器的角度来看,在一个时间步骤中所进行的工作。在时间步骤开始时,每个处理器持有分配给它的所有粒子的位置。

1
2
3
4
5
6
7
8
9
10
11
12
Algorithm 3 Force decomposition time step
1: send positions of my assigned particles which are needed by other processors; receive row particle
positions needed by my processor (this communication is between processors in the same processor
row, e.g., processor 3 communicates with processors 0, 1, 2, 3)
2: receivecolumnparticlepositionsneededbymyprocessor(thiscommunicationisgenerallywithpro-
cessors in another processor row, e.g., processor 3 communicates with processors 12, 13, 14, 15)
3: (ifnonbondedcutoffsareused)determinewhichnonbondedforcesneedtobecomputed
4: computeforcesformyassignedparticles
5: send forces needed by other processors; receive forces needed for my assigned particles (this com-
munication is between processors in the same processor row, e.g., processor 3 communicates with
processors 0, 1, 2, 3)
6: updatepositions(integration)formyassignedparticles

一般来说,如果有$p$个处理器(为简单起见,$p$为正方形),那么力矩阵被$\sqrt{p}$个块分割成$\sqrt{p}$个。刚刚描述的力的分解需要处理器分三步进行通信,每一步都有$\sqrt{p}$个处理器。这比需要在所有$p$处理器之间进行通信的原子分解要有效得多。

我们还可以利用牛顿第三定律进行力的分解。像原子分解一样,我们首先选择一个修改过的力矩阵,其中只有$f_i$和$f_{ji}$中的一个被计算。粒子$i$上的力是由一排处理器计算的,现在也由一列处理器计算。因此,每个处理器需要一个额外的通信步骤,从一列处理器中收集分配给它的粒子的力。以前有三个通信步骤,现在利用牛顿第三定律时有四个通信步骤(在这种情况下,通信不是像原子分解那样加倍的)。

对力的分解的修改可以节省一些通信。在图7.4中,各列被重新排序,使用的是块-循环排序。再考虑一下处理器3,它计算粒子0、1、2、3的部分力。它需要来自粒子0、1、2、3的位置,和以前一样,但现在也需要处理器3、7、11、15。后者是与处理器3同列的处理器。因此,所有通信都在同一处理器行或处理器列内,这在基于网状的网络架构上可能是有利的。修改后的方法显示为算法4。


空间分解

在空间分解中,空间被分解为单元。每个单元被分配给一个处理器,负责计算位于该单元内的粒子上的力。图7.5(a)说明了在二维模拟的情况下,空间被分解为64个单元。(这是空间的分解,不能与力矩阵相混淆)。通常情况下,单元的数量被选择为与处理器的数量相等。由于粒子在模拟过程中移动,粒子在单元中的分配也随之改变。这与原子和力的分解相反。

图7.5(b)显示了一个单元(中间的正方形)和包含可能在截止半径𝑟𝑐内的粒子的空间区域(阴影),这些粒子在给定的单元中。阴影区域通常被称为导入区域,因为给定单元必须导入位于该区域的粒子的位置来进行受力计算。请注意,并不是给定单元中的所有粒子都必须与导入区域中的所有粒子相互作用,特别是如果导入区域与截止半径相比很大的话。

1
2
3
4
5
6
7
8
9
10
11
Algorithm 4 Force decomposition time step, with permuted columns of force matrix
1: send positions of my assigned particles which are needed by other processors; receive row particle positions needed by my processor (this communication is between processors in the same processor
row, e.g., processor 3 communicates with processors 0, 1, 2, 3)
2: receivecolumnparticlepositionsneededbymyprocessor(thiscommunicationisgenerallywithpro-
cessors the same processor column, e.g., processor 3 communicates with processors 3, 7, 11, 15)
3: (ifnonbondedcutoffsareused)determinewhichnonbondedforcesneedtobecomputed
4: computeforcesformyassignedparticles
5: send forces needed by other processors; receive forces needed for my assigned particles (this com-
munication is between processors in the same processor row, e.g., processor 3 communicates with
processors 0, 1, 2, 3)
6: updatepositions(integration)formyassignedparticles


图7.5:空间分解,显示了二维计算盒中的粒子,(a)划分为64个单元,(b)一个单元的导入区域。

算法5显示了每个处理器在一个时间步骤中的执行情况。我们假设在时间步骤开始时,每个处理器持有其单元中的粒子的位置。

为了利用牛顿第三定律,进口区域的形状可以减半。现在,每个处理器只计算其单元中的粒子的部分力,并需要接收来自其他处理器的力来计算这些粒子的总力。因此涉及到一个额外的通信步骤。我们把这个问题留给读者去解决修改后的导入区域和这种情况下的伪代码的细节。

在空间分解方法的实施中,每个单元与它的导入区域的粒子列表相关联,类似于Verlet邻居列表。与Verlet邻居列表一样,如果进口区域略有扩大,则不必在每个时间步骤中更新该列表。这使得进口区域列表可以在几个时间步中重复使用,这与粒子穿越扩展区域的宽度所需的时间是一致的。这与Verlet邻居列表完全类似。

总之,空间分解方法的主要优点是它们只需要在对应于附近粒子的处理器之间进行通信。空间分解方法的一个缺点是,对于非常多的处理器来说,与每个单元内包含的粒子数量相比,导入区域很大。

1
2
3
4
5
Algorithm 5 Spatial decomposition time step
1: sendpositionsneededbyotherprocessorsforparticlesintheirimportregions;receivepositionsfor
particles in my import region
2: computeforcesformyassignedparticles
3: updatepositions(integration)formyassignedparticles

Neutral Territory Methods

我们对Neutral Territory Methods方法的描述与Shaw[182]的描述非常接近。Neutral Territory Methods可以被视为结合了空间分解和力分解的各个方面。为了并行化集成步骤,粒子根据空间的划分被分配到处理器。为了使力的计算并行化,每个处理器计算两组粒子之间的力,但这些粒子可能与被分配给处理器进行整合的粒子没有关系。由于这种额外的灵活性,Neutral Territory Methods需要的通信量可能比空间分解方法少得多。

图7.6是一个二维模拟的Neutral Territory Methods的例子。在图中所示的方法中,给定的处理器被指派计算位于横条的粒子与位于竖条的粒子之间的力。因此,这两个区域构成了这个方法的导入区域。通过与图7.6(b)的比较,这种中性领土方法的导入区域比相应的空间分解方法的导入区域小很多。当每个处理器所对应的单元的大小与截止半径相比很小时,这种优势就更大了。

在计算出力之后,给定的处理器将其计算出的力发送给需要这些力进行整合的处理器。因此,我们有了算法6。

1
2
3
4
5
Algorithm 6 Neutral territory method time step
1: sendandreceiveparticlepositionscorrespondingtoimportregions
2: computeforcesassignedtothisprocessor
3: sendandreceiveforcesrequiredforintegration
4: updatepositions(integration)forparticlesassignedtothisprocessor

像其他方法一样,Neutral Territory Methods的进口区域可以被修改以利用牛顿第三定律。我们参考Shaw [182],以了解更多的细节和中立领土方法在三维模拟中的图示。

fig-ntmethod

并行快速傅立叶变换

许多计算长程力的方法的一个共同组成部分是用于解决三维网格上泊松方程的三维FFT。傅里叶变换将泊松算子(称为拉普拉斯)对角化,在求解中需要一个正向和一个反向的FFT变换。考虑离散拉普拉斯算子$L$(具有周期性边界条件)和$\phi$在$-L\phi-\rho$的解。让$F$表示傅里叶变换。原问题相当于

矩阵$FLF^{-1}$是对角线。对$\rho$进行正向傅里叶变换$F$,然后用对角线矩阵的逆值对傅里叶空间分量进行缩放,最后,应用反傅里叶变换$F^{-1}$,得到解$\phi$。

对于现实中的蛋白质大小,通常使用大约1埃斯特朗的网格间距,从而导致一个按许多标准来说相当小的三维网格。 64×64×64,或128×128×128。 并行计算通常不会应用于这种大小的问题,但必须使用并行计算,因为数据𝜌已经分布在并行处理器之间(假设使用了空间分解)。 三维FFT是通过沿三个维度依次计算一维FFT来计算的。 对于64×64×64的网格大小,这就是4096个64维的一维FFT。 并行FFT计算通常受到通信的约束。 FFT的最佳并行化取决于变换的大小和计算机网络的结构。 下面,我们首先描述平行一维FFT的一些概念,然后描述平行三维FFT的一些概念。 对于目前致力于大型一维变换的并行化和高效计算(使用SIMD操作)的软件和研究,我们参考了SPIRAL和FFTW软件包。 这些软件包使用自动调谐来生成对用户的计算机结构来说是有效的FFT代码。

并行一维FFT

无转置的一维FFT

图7.7显示了16点radix-2十进频FFT算法的输入(左边)和输出(右边)之间的数据依赖关系(数据流图)。(图中未显示的是计算中可能需要的位反转变换)。图中还显示了计算在四个处理器之间的划分。在这种并行化中,初始数据不在处理器之间移动,但在计算过程中会发生通信。在图中所示的例子中,通信发生在前两个FFT阶段;最后两个阶段不涉及通信。当通信发生时,每个处理器正好与另外一个处理器进行通信。

转置的一维FFT

使用转置的方式来实现FFT计算的并行化是很常见的。图7.8(a)显示了与图7.7相同的数据流图,但为了清晰起见,删除了水平线并增加了额外的索引标签。和以前一样,前两个FFT阶段是在没有通信的情况下进行的。然后,数据在各处理器之间进行转置。有了这个转置的数据布局,最后两个FFT阶段可以在没有通信的情况下进行。最终的数据不按原来的顺序排列;可能需要额外的转置,或者数据可以按这个转置的顺序使用。图7.8(b)显示了转置前后指数在四个处理器中的分布情况。从这两个图中可以看出,前两个阶段的数据依赖性只涉及同一分区的指数。转置后的后两个阶段的分区情况也是如此。还要注意的是,转置前后的计算结构是相同的。

并行三维FFT

块状分解的三维FFT

图7.9(a)显示了当使用空间分解时,FFT输入数据的块状分解,其大小为8×8×8,分布在以4×4×4拓扑结构排列的64个处理器中。并行一维FFT算法可以在每个维度上应用。对于图中所示的例子,每个一维FFT计算涉及4个处理器。每个处理器同时执行多个一维FFT(本例中为四个)。在每个处理器中,如果穿越其中一个维度,数据是连续排列的,因此在其他两个维度的计算中,数据访问是分层的。分层数据访问可能很慢,因此在计算每个维度的FFT时,可能值得在每个处理器中重新排序数据。

三维FFT与板块分解

图7.9(b)是4个处理器情况下的板块分解图。每个处理器持有输入数据的一个或多个平面。如果输入数据已经分布在板块中,或者可以以这种方式重新分布,就可以使用这种分解。板块平面内的两个一维FFT不需要通信。其余的一维FFT需要通信,可以使用上述两种平行一维FFT的方法之一。板块分解的缺点是,对于大量的处理器来说,处理器的数量可能会超过三维FFT中沿任何一个维度的点的数量。另一个选择是下面的铅笔分解。

fig-fft-first

三维FFT与铅笔式分解

图7.9(c)显示了16个处理器的情况下的铅笔分解情况。每个处理器持有一个或多个输入数据的铅笔。如果原始输入数据像图7.9(a)那样分布在块中,那么一排处理器之间的通信(在一个三维处理器网中)可以将数据分布到铅笔分解中。然后可以在没有通信的情况下执行一维FFT。为了在另一个维度上执行一维FFT,数据需要被重新分配到另一个维度的铅笔中。在整个三维FFT计算中,总共需要四个通信阶段。

分子动力学的整合

为了对分子动力学中的常微分方程系统进行数值积分,需要采用特殊的方法,与第四章中研究的传统ODE求解器不同。这些特殊的方法被称为对称方法,在产生具有恒定能量的解方面比其他方法更好,例如,对于那些被称为哈密尔顿的系统(包括分子动力学中的系统)。当哈密顿系统在很长的时间间隔内以许多时间步长进行积分时,保留结构(如总能量)往往比方法的精度顺序更重要。在这一节中,我们激励了一些想法,并给出了Störmer-Verlet方法的一些细节,该方法足以用于简单的分子动力学模拟。

fig-fft-second

哈密顿系统是一类保存能量的动力系统,它可以被写成一种叫做哈密顿方程的形式。为了对称起见,考虑一下简单的谐波振荡器

其中$u$是单个粒子从平衡点的位移。这个方程可以模拟一个具有单位质量的粒子在一个具有单位弹簧常数的弹簧上。处于$u$位置的粒子受到的力是$-u$。这个系统看起来并不像一个分子动力学系统,但对说明几个观点很有用。

上述二阶方程可以写成一阶方程系统

其中$q=u, p=u’$,这是经典力学中常用的符号。一般解决方案是



简谐振荡器的动能是$p^2/2$,势能是$q^2/2$(势能的负梯度是力,$-q$)。因此,总能量与$q^2+p^2$成正比。

现在考虑用三种方法解决一阶方程组,显式欧拉法、隐式欧拉法和一种叫做Störmer-Verlet的方法。 初始条件是$(q, p)$=(1, 0)。 我们使用$h=0.05$的时间步长,走500步。 我们将$q$和$p$分别绘制在横轴和纵轴上(称为相位图)。 如上所述,准确的解决方案是一个以原点为中心的单位圆。 图7.10显示了这些解。 对于显式欧拉,解决方案是向外螺旋式的,这意味着解决方案的位移和动量随时间而增加。 隐式欧拉方法的情况则相反。 总能量的图表将显示这两种情况下的能量分别增加和减少。 当采取较小的时间步长或使用高阶方法时,解决方案会更好,但这些方法完全不适合在长时间内对共轭系统进行积分。 图7.10(c)显示了使用一种叫做Störmer-Verlet方法的对称方法的解。 该解显示,$q^2+p^2$的保存情况比其他两种方法要好得多。



图7.10:初值(1,0)、时间步长0.05、步长500的三种方法的简谐振荡器解的相位图。对于显式Euler,解是向外旋的;对于隐式Euler,解是向内旋的;总能量在Störmer-Verlet方法中保存得最好。 我们推导出二阶方程的Störmer-Verlet方法

通过简单地用有限差分近似值取代左手边

这可以重新排列,得到的方法是

该公式可以等效地从泰勒级数中导出。该方法与线性多步骤方法类似,需要一些其他技术来提供该方法的初始步骤。该方法也是时间可逆的,因为如果$k+1$和$k-1$互换,公式是一样的。遗憾的是,要解释为什么这种方法是对称性的,已经超出了本介绍的范围。

上面写的方法有一些缺点,最严重的是小$h^2$项的添加会受到灾难性的取消。因此,这个公式不应该以这种形式使用,已经开发了一些数学上等效的公式(可以从上面的公式推导出来)。

一个替代公式是跃迁法。

其中𝑣是第一导数(速度),与位移𝑢相差半步。这个公式没有同样的四舍五入问题,而且还提供了速度,尽管它们需要与位移重新对中,以计算给定步骤的总能量。这对方程中的第二个基本上是一个有限差分公式。

Störmer-Verlet方法的第三种形式是速度Verlet变体。

其中,现在速度是在与位移相同的点上计算的。这些算法中的每一种都可以被实现,从而只需要存储两组量(两个先前的位置,或者一个位置和一个速度)。Störmer-Verlet方法的这些变体由于其简单性而受到欢迎,每一步只需要一个昂贵的力评估。 高阶方法通常不实用。 速度Verlet方案也是分子动力学的多时间步长算法的基础。 在这些算法中,慢速变化的(典型的长程)力的评估频率较低,更新位置的频率也比快速变化的(典型的短程)力低。 最后,许多最先进的分子动力学集成了一个经过修改的哈密尔顿系统,以控制模拟温度和压力。 对于这些系统,已经开发出了复杂得多的对称性方法。

组合算法

本部分我们将简要考虑一些组合算法,如:排序算法以及埃拉托色尼筛选法寻找素数法等等。

在科学计算中,排序并不常见,人们通常在数据库(无论是金融数据库还是生物数据库)中考虑排序。当考虑如自适应网格加密(Adaptive Mesh Refinement,AMR)和其他对数据结构的改进中,排序才会凸显其重要之处。

在这一节中,我们将简要介绍一些基本的算法,以及其并行表达。更多的细节,见[131]和其中的参考文献。

排序算法概要

复杂度

众多排序算法以其计算复杂度来区分:即给定$n$元数组,我们需要对它操作多少次能完成排序。

部分排序算法可以达到的最低复杂度为:$O(n\log n)$,例如快速排序(Quicksort)的最佳情况即为$O(n\log n)$。但由于快速排序算法由于需要选择“基准元素”,如果每次选择的基准元素都是最差的,那么其时间复杂度可以达到$O(n^2)$。

1
2
3
4
5
while the input array has length > 1 do
Find a pivot element of intermediate size
Split the array in two, based on the pivot
Sort the two arrays.
\\Algorithm 2: The quicksort algorithm

另一方面,非常简单的冒泡排序(bubble sort)算法由于其静态结构,因此时间复杂度恒定。

1
2
3
4
5
for pass from 1 to 𝑛 − 1 do 
for 𝑒 from 1 to 𝑛 − pass do
if elements 𝑒 and 𝑒 + 1 are ordered the wrong way then
exchange them
\\Algorithm 3: The bubble sort algorithm

很容易看出,这个算法的复杂度为$O(n^2)$:内循环做了$t$的比较和最多$t$的交换。从1到$n-1$相加,可以得到大约$n^2/2$的比较和最多相同数量的交换。

排序网络

上面我们看到,部分算法的复杂度不受输入数据的影响,而有些算法则并非如此。前者被称为排序网络(sorting network),可以认为排序网络是实现算法的专用硬件,其基本元件为比较、交换元素。它有两个输入$x$,$y$;两个输出$\max(x,y)$与$\min(x,y)$。

下图展示了由比较和交换元素构成的冒泡排序。

bubble-pass

下面我们将讨论排序网络中的一个例子:双调排序。

并行复杂度

串行排序算法最理想的时间复杂度为$O(N \log N)$,理想情况下,如果同时使用$P=N$个处理器计算,我们希望并行排序的时间复杂度为$O(\log N)$。如果并行时间大于这个值,我们将所有处理器上的操作总数定义为串行复杂度(sequential complexity)。

这相当于如果并行算法由一个进程执行。理想情况下,这与单进程算法的操作数相同,但如果线程数过大,该算法就会收到并行算法的额外时间开销惩罚。

例如,下面我们将看到,排序算法的串行复杂度通常为$O(N \log_2 N)$。

奇偶交换排序

观察上图我们发现,pass 2实际上可以在pass 1完成前开始,在给定的时间下,这实际上就是奇偶交换排序(odd-even transposition sort)。

奇偶交换排序是一种简单的并行排序算法,其主要优点是在线性面积的处理器上容易实现;缺点是效率并不理想。

该算法的一个步骤由两个子步骤组成:

  • 偶数的处理器与它的右邻进行比较和交换;然后
  • 奇数处理器与它的右邻进行比较和交换。

定理 2 该算法经过$N/$步完成(其中每步由刚才给出的两个子步骤组成)。通过归纳法进行证明:在每个三联体$2i, 2i+1, 2i+2$中,经过一个偶数和一个奇数步骤后,最大的元素将处于最右的位置。

在并行时间为$N$的情况下,可以得到串行复杂度为$N^2$。

练习 8.1 讨论交换排序的速度和效率,我们对$N$个处理器的$P$个数字进行排序;设$N = P$,即每个处理器都包含一个数字。用比较和交换操作表示执行时间。

  1. 这个并行代码总共需要多少次比较和交换操作?
  2. 该算法需要多少个串行步骤?什么是$T_1, T_p,T_\infty, S_p, E_p$的$N$数排序?平均并行量是多少?
  3. 交换排序可以被认为是冒泡排序的并行实现。现在让$T_1$指的是(顺序的)冒泡排序的执行时间。这对$S_p$和$E_p$有何影响?

快速排序

快速排序是一种递归算法,与冒泡排序不同,它的复杂度并不确定。该算法由两部分组成,是基于序列的排列。

1
2
3
4
Algorithm: Dutch National Flag ordering of an array
Input : An array of elements, and a ‘pivot’ value
Output: The input array with elements ordered as red-white-blue, where red elements are
larger than the pivot, white elements are equal to the pivot, and blue elements are less than the pivot

我们无需证明,这可以在$O(n)$操作中完成。这样一来,快速排序就变成了

1
2
3
4
5
6
7
8
Algorithm: Quicksort
Input : An array of elements
Output: The input array, sorted
while The array is longer than one element do
pick an arbitrary value as pivot
apply the Dutch National Flag reordering to this array
Quicksort( the blue elements )
Quicksort( the red elements )

该算法的不确定性来源于基准元素的选择。最坏情况下,算法所选择的基准元素每次都是数组中唯一最小的元素,如此需要再次进行$n-1$次递归操作。不难看出,此时的运行时间为$O(n^2)$。另一方面,如果基准元素总是(接近)中位数,即大小适中的元素,那么递归调用的运行时间将大致相等,可以得到递归公式:

复杂度为$O(n \log n)$。现在考虑快速排序的并行实现。

在共享内存中进行快速排序

通过共享内存模型和递归调用的线程实现,可以将快速排序进行简单并行化,然而这种方法并不高效。

在一个长度为$n$的数组中,如果有理想的基准元素,在算法的最后阶段会有$n$个线程在活动。最理想的情况是,我们希望并行算法在$O(\log n)$的时间内运行,但是在这里,时间被第一个线程对数组的初始重新排序所支配。

练习 8.2 精确论证这个结论。这样并行化快速排序算法的总运行时间、速度提升和效率是多少?是否有办法使分割数组的效率更高?答案是肯定的,关键是要使用并行的前缀操作,见附录20。如果数组中的数值是$x_1,…,x_n$,我们使用并行前缀来计算有多少元素小于基准$\pi$。

有了这个方法,如果一个处理器看了$x_i$,并且$x_i$小于基准,那么它需要被移到数组中的$X_{i + 1}$的位置,那里的元素是根据基准分割的。同样地,我们可以知道有多少元素大于基准元素,并相应地移动这些元素。

这表明每一个基准步骤可以在$O(\log n)$的时间内完成,由于排序算法有$\log 𝑛$的步骤,整个算法的运行时间为$O((\log n)^2)$。快速排序算法的串行复杂度$(\log_2 N)^2$。

超立方体上的快速排序

从上一节可以看出,为了使快速排序算法有效地并行化,我们也需要使Dutch National Flag reordering并行化。让我们假设数组已经被划分到维度为$p$的超立方体的$d$个处理器上(意味着$p=2d$)。

在并行算法的第一步,我们选择一个基准元素,并将其广播给所有处理器。然后,所有处理器将在其本地数据上独立地应用重新排序。

为了将第一层中的红色和蓝色元素聚集在一起,现在每个处理器都与一个二进制地址相同的处理器配对,但最重要的一位除外。在每一对中,蓝色元素被送到在该位上有1值的处理器;红色元素被送到在该位上有0值的处理器。

经过这次交换(这是本地的,因此是完全并行的),地址为1xx的处理器拥有所有的红色元素,地址为0xx的处理器拥有所有的蓝色元素。前面的步骤现在可以在子库中重复进行。

这种算法在每一步都能保持所有的处理器工作;但是,如果选择的基准元素远离中位数,就容易出现负载不平衡的情况。此外,在排序过程中,这种负载不均衡也不会减少。

在一般的并行处理器上进行快速排序

快速排序也可以在任何具有处理器线性排序的并行机器上进行。我们首先假设每个处理器正好持有一个数组元素,而且,由于标志重排,排序将总是涉及一组连续的处理器。

一个数组(或递归调用中的子数组)的并行快速排序开始于在存储数组的处理器上构建一个二进制树。选择一个基准元素值并在树上广播。然后,树状结构被用来在每个处理器上计算左、右子树中有多少元素小于、等于或大于基准元素值。

有了这些信息,根处理器可以计算出红色/白色/蓝色区域将被储存在哪里。这个信息被下发到树上,每个子树都计算出其子树中元素的目标位置。

如果我们忽略了网络竞争,现在可以在单位时间内完成重新排序,因为每个处理器最多发送一个元素。这意味着每个阶段只需要花费时间来计算子树中蓝色和红色元素的数量,在顶层是$O(\log n)$,在下层是$O(\log n/2)$,依此类推。这使得速度几乎完美。

基数排序

大多数排序算法都是基于比较完整的项值。相比之下,基数排序(radix sort)在数字的位数上做一些部分排序阶段。每个数字值都有一个 “bin “被分配,数字被移入这些bin。将这些仓连接起来就得到了一个部分排序的数组,通过移动数字的位置,数组的排序会越来越多。

考虑一个最多两位数的例子,所以需要两个阶段。

重要的是,一个阶段的部分排序在下一个阶段得到了保留。归纳起来,我们最后会得到一个完全排序的数组。

并行基数排序

分布式内存排序算法已经对数据进行了明显的 “分档”,所以基数排序的并行实现是基于使用𝑃,即进程的数量作为基数。

我们用两个处理器上的例子来说明这一点,也就是说,我们看的是数值的二进制表示。

分析。

  • 确定所考虑的数字,以及确定有多少本地值进入哪个仓都是本地操作。我们可以将其视为一个连接矩阵$C$,其中$C[i, j]$是进程$i$将发送至进程$j$的数据量。每个进程拥有其在该矩阵中的一行。

  • 为了在洗牌中接收数据,每个进程都需要知道将从其他每个进程接收多少数据。这需要对连接矩阵进行 “转置”。在MPI术语中,这是一个全对全操作。MPI_Alltoall。

  • 在这之后,实际的数据可以在另一个all-to-all操作中进行洗牌。然而,由于每个$i, j$组合的数量不同,我们需要MPI_Alltoallv程序。

有效小数的基数排序

完全可以让基数排序的阶段从最重要的数字到最小的数字进行,而不是相反。按顺序,这不会改变任何重要的东西。

然而

  • 我们不是完全洗牌,而是在越来越小的子集里洗牌,所以即使是串行算法也会增加空间局部性(spatial locality)。
  • 一个共享内存的并行版本将显示出类似的局部性改进。
  • 一个MPI版本不再需要全对全的操作。如果我们认识到每一个下一阶段都将在一个子集的进程内,我们可以使用通信器分割机制。

练习 8.3 上面的位置性论证做得有点手忙脚乱。论证该算法可以以广度优先和深度优先的方式进行。讨论一下这种区别与位置性论证的关系。

样本排序

你在快速排序算法(第8.3节)中看到,有可能在排序算法中使用概率元素。我们可以将快速排序法中挑选一个基准的想法扩展为挑选有多少个处理器的基准元素。这不是对元素的一分为二,而是将元素分成与处理器数量一样多的 “桶”。然后,每个处理器对其元素进行完全并行的排序。

显然,如果不仔细选择桶的话,这种算法会出现严重的负载不均衡。随机抽取$p$元素可能还不够好;相反,需要对元素进行某种形式的抽样。相应地,这种算法被称为样本排序(Samplesort)[17]。

虽然桶排序一旦分配即为完全并行,但仍有一些问题:首先,采样是该算法的一个串行瓶颈。另外,将桶分配给处理器的步骤本质上是一个全对全的操作。

为了对此进行分析,假设有$P$个进程首先作为映射器,然后作为规约器发挥作用。让𝑁为数据点的数量,并定义一个块大小$b\equiv N/P$。该算法的处理步骤的成本是:

  • 局部确定每个元素的bin,需要时间$O(b)$;以及
  • 局部排序,我们可以假设其最佳复杂度为$b \log b$。

然而,洗牌步骤是不简单的。除非数据被部分预排序,否则我们可以预期洗牌是一个完全的全对全,其时间复杂度为$P\alpha+b\beta$。另外,这可能会成为一个网络瓶颈。请注意,在超立方体上的快速排序法中,从来没有出现过对导线的争夺。

练习 8.4 论证对于少量的进程,$P <<N$,这个算法有完美的加速,串行复杂度(见上文)为$N \log N$。

将这种算法与双调排序等排序网络相比较,这种排序算法看起来相当简单:它只有一个步骤的网络。前面的问题认为,在 “理想扩展”(工作可以增加,同时保持处理器数量不变)中,串行复杂性与串行算法相同。然而,在我们按比例增加功和处理器的弱缩放分析中,串行复杂度要差很多。

练习 8.5 考虑我们同时扩展$N、P$的情况,保持$b$不变。论证在这种情况下,洗牌步骤在算法中引入了一个$N^2$项。

通过MapReduce排序

terasort基准涉及在一个基于文件的大型数据集上进行排序。因此,它在某种程度上是大数据系统的一个标准。特别是,MapReduce是一个主要的候选者,见http://perspectives.mvdirona.com/2008/07/hadoop-wins-terasort/。

使用MapReduce,该算法的过程如下。

  • 通过抽样或优先信息确定一组键值:键值是这样的,预计每对键值之间的记录数量大致相等。间隔的数量等于规约器进程的数量。
  • 然后,映射器进程产生键/值对,其中键是区间或规约器编号。
  • 然后,规约器进程执行局部排序。

    我们看到,除了术语的变化之外,这实际上是sampleort。

双调排序

为了激励双调排序,假设一个序列$x = ⟨x_0, … … , x_{n - 1}⟩$由升序和降序组成。现在把这个序列分成两个等长的子序列,定义为:

bitonic1

从图中不难看出,$s_1$, $s_2$又是具有升序和降序部分的序列。此外,$s_1$的所有元素都小于$s_2$的所有元素。

我们称(8.1)为升序双调拣器,因为第二个子序列中的元素比第一个子序列中的元素大。同样地,我们可以通过颠倒最大和最小的角色来构造一个降序分拣器。

不难想象这是排序算法中的一个步骤:从这个形式的序列开始,递归应用公式(8.1)得到一个排序的序列。图8.4显示了4个位元排序器,分别在8,4,2,1的距离上,如何对一个长度为16的序列进行排序。

双调序列的实际定义要稍微复杂一些。如果一个序列由一个升序部分和一个降序部分组成,或者是这样一个序列的循环变异,那么它就是比特序列。

bitonic2

练习 8.6 证明根据公式(8.1)分割一个位数序列可以得到两个位数序列。

所以问题是如何得到一个比特尼序列。答案是使用越来越大的双调网络。两个元素的位数排序给你一个排序的序列。如果你有两个长度为2的序列,一个向上排序,另一个向下排序,这就是一个双调序列。因此,这个长度为四的序列可以通过两个比特级的步骤进行排序。

两个长度为四的排序形成一个长度为四的比特序列。可以用三个比特步骤进行排序;等等。

从这个描述中你可以看到,你要用$\log_2N$个阶段来排序$N$个元素,其中第$i$个阶段的长度是$\log_2i$。这使得位数排序的总顺序复杂性为$(\log_2 N )^2$。

图8.5中的操作序列被称为排序网络,由简单的比较-交换元素构成。元素组成的排序网络。与快速排序的情况不同,它不依赖于数据元素的值。

素数查找

埃拉托色尼筛选法是一种非常古老的寻找素数的方法。在某种程度上,它仍然是许多现代方法的基础。

写下从2到某个上限$N$的所有自然数,我们要把这些数字标记为素数或绝对非素数。所有的数字最初都是没有标记的。

  • 第一个没有标记的数是2:把它标记为质数,并把它的所有倍数标记为非质数。
  • 第一个没有标记的数字是3:把它标记为质数,并把它的所有倍数标记为非质数。
  • 下一个数字是4,但它已经被标记过了,所以标记5和它的倍数。
  • 下一个数字是6,但它已经被标记过了,所以标记7和它的倍数。
  • 数字8,9,10已经被标记过了,所以继续标记11。
  • 依此类推。

图论分析

科学计算中的各种问题都可以被表述为图问题(关于图论的介绍见附录18);例如,我们已经遇到了负载平衡(第2.10.4节)和寻找独立集(第6.8.2节)的问题。

许多传统的图算法不能立即适用,或者至少不能有效地适用,因为图往往是分布式的,而传统的图理论假设了对整个图的全局知识。此外,图论通常关注的是寻找最佳算法,而这通常不是一种并行的算法。因此,并行图算法本身就是一个研究领域。

最近,在科学计算中出现了新的图计算类型。这里的图不再是工具,而是研究对象本身。例如,万维网或Facebook的社交图,或一个生物体内所有可能的蛋白质相互作用的图。

由于这个原因,组合计算科学正在成为一门独立的学科。在这一节中,我们看一下图分析:大型图的计算。我们首先讨论一些经典的算法,但我们在代数框架中给出这些算法,这将使并行实现更加容易。

传统图算法

我们首先看一下几个 “经典 “的图算法,并讨论如何以并行方式实现它们。图和稀疏矩阵之间的联系(见附录18)在这里至关重要:许多图算法具有稀疏矩阵-向量乘法的结构。

最短路径算法

有几种类型的最短路径算法。例如,在单源最短路径算法中,人们想知道从一个给定节点到任何其他节点的最短路径。在全对最短路径算法中,人们想知道任何两个节点之间的距离。计算实际路径不是这些算法的一部分;但是,通常很容易包括一些信息,通过这些信息可以在以后重建路径。

我们从一个简单的算法开始:寻找非加权图中的单源最短路径。用广度优先搜索(BFS)来做这件事很简单。

1
2
3
4
5
6
7
8
9
10
11
12
Input : A graph, and a starting node 𝑠
Output: A function 𝑑(𝑣) that measures the distance from 𝑠 to 𝑣
Let 𝑠 be given, and set 𝑑(𝑠) = 0
Initialize the finished set as 𝑈 = {𝑠}
Set 𝑐 = 1
while not finished do
Let 𝑉 the neighbours of 𝑈 that are not themselves in 𝑈
if 𝑉 =∅ then
We’re done
else
Set 𝑑(𝑣)=𝑐+1 for all 𝑣 ∈𝑉. 𝑈←𝑈∪𝑉
Increase 𝑐 ← 𝑐 + 1

这种制定算法的方式在理论上是很有用的:通常可以制定一个谓词,在while循环的每一次迭代中都是真的。这样,你就可以证明该算法终止了,并且它计算了你想要计算的东西。在传统的处理器上,这的确是你对图算法的编程方式。然而,现在的图,如来自Facebook的图,可能是巨大的,而你想对你的图算法进行并行编程。

在这种情况下,传统的表述方式就显得不够了。

  • 它们通常基于队列,节点被添加或减去;这意味着存在某种形式的共享内存。

  • 关于节点和邻居的声明是在不知道这些是否满足任何形式的空间定位的情况下做出的;如果一个节点被触及一次以上,就不能保证时间上的定位。

Floyd-Warshall最短路径

Floyd-Warshall算法是一个全对最短路径算法的例子。它是一种动态编程算法,是基于逐步增加路径的中间节点集。具体来说,在步骤𝑘中,所有的路径$u\rightarrow v$都被考虑,它们的中间节点在集合 $k=\{0, …. \Delta k(u,v)\}$被定义为从$u$到$v$的路径长度,其中所有中间节点都在$k$。最初,这意味着只考虑图边,当$k \equiv |V|$时,我们已经考虑了所有可能的路径,算法完成。

计算的步骤是

也就是说,对距离$\Delta (u, v)$的第 $k$个估计值是旧的估计值和一条新的路径的最小值,现在我们正在考虑节点$k$的可行性。这条路径是由$u \rightarrow k$和$k \rightarrow v$两条路径连接而成的。

用算法来写:

1
2
3
for 𝑘 from zero to |𝑉| do 
for all nodes 𝑢, 𝑣 do
Δ𝑢𝑣 ← 𝑓(Δ𝑢𝑣,Δ𝑢𝑘,Δ𝑘𝑣)

我们看到这个算法的结构与高斯消除法相似,只是在那里,内循环是 “对于所有的$u,v>k$”。

在第5.4.3节中,你看到稀疏矩阵的因式分解会导致填充,所以这里也会出现同样的问题。这需要灵活的数据结构,而且这个问题在并行时变得更加严重,见第9.5节。

代数上。

1
2
for 𝑘 from zero to |𝑉| do
𝐷 ← 𝐷.min[𝐷(∶, 𝑘) min ⋅+ 𝐷(𝑘, ∶)]

Floyd-Warshall算法并不告诉你实际的路径。在上面的距离计算过程中,存储这些路径的成本很高,无论是时间还是内存。一个更简单的解决方案是可能的:我们存储第二个矩阵𝑛(𝑖, 𝑗),它有𝑖和𝑗之间路径的最高节点号。

练习 9.1 将$n(i, j)$的计算包含在 Floyd-Warshall 算法中,并描述在已知$d(⋅, ⋅)$和$n(⋅, ⋅)$的情况下,如何利用它来寻找$i$ 和$j$之间的最短路径。

生成树

在一个无向图$G=⟨V,E⟩$中,如果$T\subset E$是连接和无环的,我们称之为 “树”。如果它的边包含所有的顶点,则被称为生成树。如果图的边权重为$w_i ∶i\in E$,则树的权重为$\sum_{e\in T}w_e$,如果树的权重最小,我们称它是最小生成树。一个最小生成树不需要是唯一的。

Prim算法是Dijkstra最短路径算法的一个小变种,它从一个根开始计算生成树。根的路径长度为零,而所有其他节点的路径长度为无穷大。在每一步中,所有与已知树节点相连的节点都会被考虑,并更新它们的最佳已知路径长度。

1
2
3
4
5
6
for all vertices 𝑣 do l(𝑣) ← ∞
l(𝑠) ← 0
𝑄 ← 𝑉 − {𝑠} and 𝑇 ← {𝑠} while𝑄 ≠∅do
let 𝑢 be the element in 𝑄 with minimal l(𝑢) value remove 𝑢 from 𝑄, and add it to 𝑇
for𝑣 ∈𝑄with(𝑢,𝑣)∈𝐸do
if l(𝑢) + 𝑤𝑢𝑣 < l(𝑣) then Set l(𝑣)l(𝑢) + 𝑤𝑢𝑣

定理3 上述算法计算出每个节点到根节点的最短距离。

证明:本算法正确性的关键在于我们选择$u$为最小$l(u)$值。把到顶点的真正最短路径长度称为$L(v)$。由于我们从一个无限大的$l$值开始,并且只减少它,我们总是有$L(v)\leqslant l(v)$。

我们的归纳假设是,在算法的任何阶段,对于当前租用的$T$中的节点,路径长度的确定是正确的。

当树只由根$s$组成时,这显然成立。现在我们需要证明归纳步骤:如果对于当前树中的所有节点,路径长度都是正确的,那么我们也将得到$L(u)=l(u)$。假设这不是真的,有另一条路径更短。这条路径需要经过目前不在$T$中的某个节点$y$;这在图9.1中得到说明。假设$x$是$T$中的节点,位于$y$之前的据称最短路径。现在我们有$l(u)>L(u)$,因为我们还没有正确的路径长度,并且$L(u)>L(x)+w$,因为在$y$和$u$之间至少有一条边(有正权)。 但$x\in T$,所以$L(x)=l(x)$,$L(x)+y=l(x)+y$。现在我们注意到,当$x$被添加到$T$时,它的邻居被更新,所以$l(y)$是$lx+y$或更少。将这些不等式,我们发现

这与我们选择$u$为最小$l$值的事实相矛盾。

为了并行化这个算法,我们观察到内循环是独立的,因此可以并行化。然而,外循环有一个选择,即最小化一个函数值。计算这个选择是一个减法运算,随后它需要被广播。这种策略使得顺序时间等于$d \log P$,其中$d$是生成树的深度。

在单个处理器上,寻找数组中的最小值是一个$O(N)$的操作,但通过使用优先级队列,这可以减少到$O(\log N)$。对于生成树算法的并行版本,相应的项是$O(\log(N/P))$,还不包括减少的$O(\log P)$成本。

Graph cut

有时,你可能想对一个图进行分区,例如出于并行处理的目的。如果这是通过划分顶点来实现的,那么你就是在切割边,这就是为什么这被称为顶点切割分区。对于什么是好的顶点切割,有各种标准。例如,你希望切割的部分大小大致相等,以平衡平行工作。由于顶点通常对应于通信,你希望顶点的数量(或在加权图中它们的权重之和)要小。图Laplacian(第18.6.1节)是这方面的一个流行算法。

图的切割的另一个例子是二方图的情况:一个有两类节点的图,而且只有从一类到另一类的边。例如,这样的图可以模拟一个人口和一组属性:边表示一个人有某种兴趣。现在你可以做一个边的切割,将边的集合进行分割。这可以给你一些具有类似兴趣的人的集合。这个问题很有意思,比如说,用于定位在线广告。

并行化

许多图的算法,如第9.1节中的算法,并不容易并行化。这里有一些考虑。

首先,与许多其他算法不同,很难针对最外层的循环层,因为这通常是一个 “while “循环,使得并行停止测试成为必要。另一方面,通常有一些宏观步骤是连续的,但其中的一些变量是独立考虑的。因此,确实存在着可以利用的并行性。

图算法中的独立工作是一种有趣的结构。虽然我们可以确定 “for all “循环,它是并行化的候选者,但这些循环与我们以前看到的不同。

  • 传统的公式往往具有逐步建立和删除的变量集的特征。这可以通过使用共享数据结构和任务队列来实现,但这限制了某种形式的共享内存的实现。
  • 接下来,虽然每个迭代操作都是独立的,但其运行的动力学原理意味着将数据元素分配给处理器是很棘手的。固定分配可能会导致很多空闲时间,但动态分配会带来很大的开销。
  • 在动态任务分配的情况下,算法将几乎没有空间或时间上的定位。

由于这些原因,线性代数的表述可能是比较好的。一旦考虑到分布式内存,我们肯定需要这种方法,但即使在多核架构上,它也能为鼓励局部性而付出代价。

在第6.5节中,我们讨论了稀疏矩阵-向量乘积的并行评估问题。由于稀疏性,只有按块行或块列进行划分才有意义。实际上,我们让分区由问题变量之一来决定。这也是唯一对单源最短路径算法有意义的策略。

练习 9.2 你能为将并行化建立在向量的分布上做一个先验的论证吗?这个操作涉及多少数据,多少工作,多少个顺序步骤?

策略

下面是三种图算法的并行化方法,按明显程度递减,按可扩展性递增。(另见[118])。

动态规划

许多图算法建立了一个要处理的顶点的数据结构$V$;他们执行一个包含循环的超步序列

1
2
for all v in V:
// do something with v

如果对一个顶点$v$的处理不影响$V$本身,那么这个循环是并行的,可以通过动态调度来执行。

这实际上是将数据动态分配给处理元素。在这个意义上,它是高效的,没有任何处理元素会与不需要处理的数据元素发生冲突,因此计算机的所有处理能力都得到了充分的利用。另一方面,动态分配带有操作系统的开销,而且会导致大量的数据传输,因为顶点不可能在处理元素的本地内存(如缓存)中。

另一个问题是,这个循环可能看起来像。

1
2
3
for all v in V:
for all neighbours u of v:
// update something on u

现在可能发生的情况是,两个节点$v_1$和$v_2$都在更新一个共享的邻居$u$,这个冲突需要通过缓存一致性来解决。这带来了延迟上的损失,甚至可能需要使用锁,这带来了操作系统上的损失。

线性代数解释

现在我们将表明,图算法通常可以被视为稀疏矩阵算法,这意味着我们可以应用我们为这些算法开发的所有概念和分析。

如果$G$是图的邻接矩阵,我们也可以将最短路径算法类似于一系列矩阵-向量乘法(见附录18.5.4节)。让$x$是列出与源的距离的向量,也就是说,$x_i$是节点$i$与源的距离。对于$i$的任何邻居$j$,到源的距离是$x_i + G_{ij}$,除非已经知道更短的距离。换句话说,我们可以定义

而上述while循环的迭代对应于此定义下的后续矩阵-向量积。

这种算法之所以有效,是因为我们可以在第一次访问时将$d(v)$设置为其最终值:这恰恰发生在等于路径长度的外循环次数之后。内循环执行的总次数等于图中边的数量。加权图就比较麻烦了,因为一个有更多阶段的路径实际上可以用阶段的权重之和来衡量更短。下面是贝尔曼-福特算法。

1
2
3
4
Let 𝑠 be given, and set 𝑑(𝑠) = 0 Set 𝑑(𝑣) = ∞ for all other nodes 𝑣 for |𝐸| − 1 times do
for all edges 𝑒 = (𝑢,𝑣) do
Relax: if 𝑑(𝑢) + 𝑤𝑢𝑣 < 𝑑(𝑣) then
Set 𝑑(𝑣) ← 𝑑(𝑢) + 𝑤𝑢𝑣

这个算法是正确的,因为对于一个给定的节点$u$,在外迭代的$k$步之后,它已经考虑了所有路径$s\rightarrow u$的$k$阶段。

练习 9.3 这个算法的复杂度是多少?如果你对图形有一定的了解,外循环的长度是否可以减少?

我们可以再次将其写成一系列的矩阵-向量积,如果我们将积定义为

这与上述基础基本相同:到𝑗的最小距离是已经确定的距离的最小值,或者到任何节点𝑖的最小距离加上过渡$g_{ij}$。

全对算法的并行化

在上面的单源最短路径算法中,我们没有太多选择,只能通过分布向量而不是矩阵来实现并行化。这种类型的分布在这里也是可能的,它对应于$𝐷(⋅, ⋅)$量的一维分布。

练习 9.4 画出该算法变体的并行实现。证明每个$k$次迭代都涉及到以处理器$k$为根的广播。

然而,这种方法遇到了与使用一维矩阵分布的矩阵-向量乘积相同的缩放问题;见6.2.2节。因此,我们需要使用二维分布。

练习 9.5 做缩放分析。在内存不变的弱缩放情况下,渐近效率是多少?

练习 9.6 使用二维分布的$𝐷(⋅, ⋅)$量勾画Floyd-Warshall算法。

练习 9.7 并行的Floyd-Warshall算法对零点进行了相当多的操作,当然是在早期阶段。你能设计一种算法来避免这些操作吗?

分割

传统的图划分算法[146]并不是简单的可并行化。相反,有两种可能的方法。

  • 使用图Laplacian;第18.6.1节。
    • 使用多层次的方法。
      1 分割图的粗化版本。
      2 逐步解除图的粗化,调整分区。

现实世界 “图

在诸如第4.2.3节的讨论中,你已经看到了PDEs的离散化是如何导致具有图的方面的计算问题的。这种图的特性使它们适合于某些类型的问题。例如,使用FDMs或FEMs对二维或三维物体进行建模,会导致每个节点只与几个邻居相连的图形。这使得它很容易找到分离器,这反过来又允许像嵌套剖分这样的解决方法;见6.8.1节。

然而,有一些应用的计算密集型图问题看起来并不像FEM图。我们将简要地看一下全球网络的例子,以及像谷歌的PageRank这样试图寻找权威节点的算法。

现在,我们将称这种图为随机图,尽管这个术语也有技术含义[61]。

随机图的性质

我们在本课程的大部分内容中看到的图形,其属性源于它们是我们三维世界中物体的模型。因此,两个节点之间的典型距离通常是$O(N^{1/3})$,其中$N$是节点的数量。随机图的表现并非如此:它们通常具有小世界的特性,典型的距离是$O(\log N )$。一个著名的例子是电影演员和他们在同一电影中出现的联系图:根据 “六度分隔”,在这个图中没有两个演员的距离超过六。在图形方面,这意味着图形的直径是六。

小世界图还具有其他特性,例如存在小团体(尽管这些特性在高阶有限元问题中也有)和枢纽:高度的节点。这导致了如下的影响:在这样的图中删除一个随机的节点不会对最短路径产生很大的影响。

练习 9.8 考虑机场图和它们之间存在的航线。如果只有枢纽和非枢纽,论证删除一个非枢纽对其他机场之间的最短路径没有影响。另一方面,考虑到节点在二维网格中的排序,并删除一个任意的节点。有多少条最短路径会受到影响?

超文本算法

有几种基于线性代数的算法用于衡量网站的重要性[139]。我们将简要地定义几个算法并讨论其计算意义。

HITS

在HITS(Hypertext-Induced Text Search)算法中,网站有一个衡量它指向多少其他网站的枢纽得分,以及一个衡量有多少网站指向它的权威得分。为了计算这些分数,我们定义了一个发生率矩阵$L$,其中

权威分数$x$被定义为所有指向$i$的中心分数$y_j$的总和,反之亦然。因此

或$x=LL^tx$,$y=L^tLy$,表明这是一个特征值问题。我们需要的特征向量只有非负项;这被称为非负矩阵的Perron向量,见附录13.4。佩伦向量是通过幂方法计算的;见13.3节。

一个实用的搜索策略是。

  • 找到所有包含搜索词的文档。
  • 建立这些文档的子图,以及可能的与之相关的一到两层的文档。
  • 计算这些文档的权威性和枢纽得分,并将其作为一个有序的列表呈现给用户。

PageRank

PageRank[166]的基本思想与HITS相似:它的模型是 “如果用户不断点击页面上某种程度上最理想的链接,那么总体上最理想的链接集合是什么”。这是通过定义一个网页的排名是所有连接到它的网页的排名之和来建模的。该算法 它通常是以迭代的方式表述。

1
2
while Not converged do for all pages 𝑖 do
rank𝑖 ← 𝜖 + (1 − 𝜖) ∑𝑗 ∶ connected𝑗→𝑖 rank𝑗

其中的排名是这个算法的固定点。$\varepsilon$项解决了这样一个问题:如果一个页面没有出站链接,那么一个会在那里停留的用户就不会离开。

练习 9.9 论证这个算法可以用两种不同的方式来解释,大体上响应雅可比和高斯-赛德尔迭代法;5.5.3节。

练习 9.10 在PageRank算法中,每个页面都给它所连接的页面 “提供排名”。给出这个变体的伪代码。说明它对应于一个列的矩阵向量乘积,而不是上述表述的行。在共享内存并行中实现这个方法会有什么问题?

对于这个方法的分析,包括它是否收敛的问题,最好是完全用线性代数的术语来表述。我们再次定义一个连接矩阵

$e=(1,…,1)$,向量$d^t=e^tM$计算一个页面上有多少个链接。$d_i$是页面$i$上的链接数量。我们构建一个对角矩阵$D = diag(d_1, …)$ 我们将$M$归一为$T= MD^{-1}$。

现在$T$的列和(即任何一列的元素之和)都是1,我们可以表示为$e^t T = e^t$ 其中$e^t =(1,…,1)$。这样的矩阵被称为随机矩阵。它有如下解释。

如果$p$是一个概率向量,即$p_i$是用户正在查看页面$i$的概率,那么$T_p$是用户点击了一个随机链接后的概率向量。

练习 9.11 从数学上讲,概率向量的特点是其元素之和为1。说明随机矩阵与概率向量的乘积确实又是一个概率向量。

上面制定的PageRank算法对应于取一个任意的随机向量$p$,计算权力法$T_p,T^2_p,T^3_p,…,$并看这个序列是否收敛到什么。

这个基本算法有一些问题,例如没有外向链接的网页。一般来说,在数学上我们是在处理 “不变子空间”。例如,考虑一个只有2个页面的网络,并有以下邻接矩阵。

自己检查一下,这对应于第二页没有外链的情况。现在让$p$成为起始向量$p_t = (1, 1)$,并计算几个迭代的功率法。你是否看到,用户在第二页的概率上升到了1?这里的问题是,我们正在处理一个可还原矩阵。

为了防止这个问题,PageRank引入了另一个元素:有时候,用户会因为点击而感到无聊,会去一个任意的页面(对于没有出站链接的页面也有规定)。如果我们把𝑠称为用户点击一个链接的机会,那么进入一个任意页面的机会就是1-𝑠。一起来看,我们现在有这样一个过程

也就是说,如果$p$是一个概率向量,那么$p’$就是一个概率向量,它描述了用户在进行了一次页面转换(无论是通过点击一个链接还是通过 “传送”)之后的位置。

PageRank向量是这个过程的静止点;你可以把它看作是用户进行了无限次转换之后的概率分布。PageRank向量满足以下条件

因此,我们现在不得不想,$I-sT$是否有一个逆。如果逆的存在,它满足

不难看出逆的存在:利用格什哥林定理(附录13.5),你可以看到$T$的特征值满足$|\lambda| \leqslant 1$。现在利用$𝑠<1$,所以部分和的序列收敛。

上述逆的公式也指出了一种通过使用一系列矩阵-向量乘法来计算PageRank向量$p$的方法。

练习 9.12 写出计算PageRank向量的伪代码,给定矩阵$T$。证明你从来不需要明确计算$T$的幂。(这是霍纳规则的一个实例)。

在$𝑠=1$的情况下,也就是说,我们排除了远程传输,PageRank向量满足$p=Tp$,这又是寻找Perron向量的问题;见附录13.4。

我们通过幂级迭代找到佩伦向量(第13.3节)。

这是一个稀疏的矩阵向量乘积,但与BVP的情况不同,稀疏性不太可能有带状结构。在计算上,人们可能必须使用与密集矩阵相同的并行论据:矩阵必须是二维分布的[162]。

大型计算图论

在前面的章节中,你已经看到许多图算法有一个计算结构,使矩阵-向量积成为它们最重要的内核。由于大多数图相对于节点数来说是低度的,所以该乘积是一个稀疏的矩阵-向量乘积。

然后在很多情况下,我们可以像第6.5节那样,对矩阵进行一维分布,由图节点的分布诱导:如果一个处理器拥有一个图节点$i$,它就拥有所有的边$i$,$j$。

然而,往往计算是非常不平衡的。例如,在单源最短路径算法中,只有沿着前面的顶点是活跃的。由于这个原因,有时边的分布而不是顶点的分布是有意义的。为了平衡负载,甚至可以使用随机分布。

Floyd-Warshall算法的并行化(第9.1.2节)沿着不同的路线进行。在这里,我们不计算每个节点的数量,而是计算作为节点对的函数的数量,也就是一个类似矩阵的数量。因此,我们不是分配节点,而是分配节点对的距离。

节点与边的分布

有时我们在处理稀疏图矩阵时需要超越一维分解。我们假设我们要处理的图的结构或多或少是随机的,比如说,对于任何一对顶点来说,有一条边的机会是相同的。另外,假设我们有大量的顶点和边缘,那么每个处理器都会存储一定数量的顶点。那么结论是,任何一对处理器需要交换信息的机会都是一样的,所以信息的数量是$O(P)$。(另一种可视化的方法是看到非零点随机地分布在矩阵中)。这并不能提供一个可扩展的算法。

出路是把这个稀疏矩阵当作密集矩阵,并援引第6.2.2节的论据来决定矩阵的二维分布。(参见[207]中对BFS问题的应用;他们以图的形式制定了他们的算法,但是二维矩阵-向量乘积的结构是清晰可辨的)。二维乘积算法只需要处理器行和列的集合体,所以涉及的处理器数量为$O(\sqrt{P})$。

超稀疏数据结构

在稀疏矩阵的二维分布下,可以想象一些过程会出现行数为空的矩阵。从形式上看,如果非零的数量(渐进地)小于维度,那么一个矩阵被称为超稀疏。

在这种情况下,CRS格式可能是低效的,可以使用称为双压缩存储的东西[24]。

N体问题

在第4章中,我们研究了连续现象,例如在整个区间[0,1]中,受热棒在一定时间内的行为。也有一些应用,你可能对有限数量的点感兴趣。一个这样的应用是研究粒子的集合,可能是非常大的粒子,如行星或恒星,在重力或电力的影响下。(也可能有外力,我们将忽略这一点;我们还假设没有碰撞,否则我们需要纳入最近邻的相互作用)。这种类型的问题被称为N体问题;关于介绍,见http://www.scholarpedia.org/article/N-body_simulations(引力)。

nbody_pic

这个问题的基本算法是很容易的。

  • 选择一些小的时间间隔。
  • 根据所有粒子的位置,计算每个粒子上的力。
  • 移动每个粒子的位置,就像它所受的力在整个时间间隔内保持不变一样。

对于一个足够小的时间间隔,这个算法给出了一个合理的近似事实。最后一步,更新粒子的位置,很容易而且完全并行:问题在于评估力。以一种天真的方式,这种计算足够简单,甚至是完全并行的。

1
2
3
4
5
6
7
for each particle 𝑖
for each particle 𝑗
let 𝑟̄ be the vector between 𝑖 and 𝑗; 𝑖𝑗
then the force on 𝑖 because of 𝑗 is
𝑖𝑗 𝑖𝑗 |𝑟𝑖𝑗|
(where 𝑚𝑖, 𝑚𝑗 are the masses or charges) and
𝑓𝑗𝑖 = −𝑓𝑖𝑗.

该算法的主要缺点为复杂度太高:对于$N$个粒子,操作的数量是 $O(N^2)$。

练习 10.1 如果我们有$N$个处理器,一个更新步骤的计算将需要时间$O(N)$。通信复杂度是多少?提示:是否可以用集合运算?

已经发明了几种算法来使顺序复杂度降低到$O(N \log N)$甚至是$O(N)$。正如可以预期的那样,这些方法比原始的算法更难实现。我们将讨论一种流行的方法:Barnes-Hut算法[8],其复杂度为$O(N \log N )$。

巴恩斯-胡特算法

导致复杂性降低的基本观察是以下几点。如果我们正在计算两个靠近的粒子$i_1,i_2$的力,这些力来自两个同样靠近的粒子$j_1,j_2$,你可以将$j_1,j_2$合并为一个粒子,并将其用于$i_1, i_2$。

接下来,该算法使用了空间的递归划分,在二维的象限和三维的八度空间中;见图10.2。

bh-quadrants

bh-quadrants-filled

该算法如下。首先计算所有层的所有单元的总质量和质量中心。

1
2
3
for each level l, from fine to coarse: for each cell 𝑐 on level l:
compute the total mass and center of mass for cell 𝑐 by considering its children
if there are no particles in this cell, set its mass to zero

这些级别被用来计算与每个粒子的相互作用。

1
2
3
4
5
for each particle 𝑝:
for each cell 𝑐 on the top level
if 𝑐 is far enough away from 𝑝:
use the total mass and center of mass of 𝑐;
otherwise consider the children of 𝑐

bh-quadrants-ratio

对一个细胞是否足够远的测试通常是以其直径与距离足够小的比率来实施的。这有时被称为 “细胞开放标准”。以这种方式,每个粒子与若干同心环的细胞相互作用,每个下一个环的宽度是双倍的;见图10.3。

如果细胞被组织成一棵树,这种算法就很容易实现。在三维的情况下,每个单元有八个孩子,所以这被称为八叉树。

质量中心的计算必须在粒子移动后每次进行。更新可能比从头开始计算的成本要低。另外,可能会发生一个粒子穿过一个单元的边界,在这种情况下,数据结构需要被更新。在最坏的情况下,一个粒子移动到一个原来是空的单元。

快速多极点法

快速多极法(FMM)计算每一点上的势的表达式,而不是像巴恩斯-胡特那样计算力。FMM使用的信息比盒子里的粒子的质量和中心还要多。这种更复杂的扩展更准确,但也更昂贵。作为补偿,FMM使用一组固定的盒子来计算势,而不是一组随精度参数θ和质心位置变化的盒子。

然而,在计算上,FMM很像Barnes-Hut方法,所以我们将共同讨论它们的实施。

全程计算

尽管有上述明智的近似方法,但也有对$N^2$相互作用进行全面计算的努力;例如,见Sverre Aarseth的NBODY6代码;见http://www.ast.cam.ac.uk/~sverre/web/pages/home.htm。这些代码使用高阶积分器和自适应时间步长。在Grape计算机上的快速实现是存在的;由于需要定期的负载平衡,一般的并行化通常是困难的。

执行

八叉树方法在高性能架构上提供了一些挑战。首先,问题是不规则的,其次,不规则性是动态变化的。第二方面主要是分布式内存中的问题,它需要负载再平衡;见2.10节。在本节中,我们集中讨论了单步的力计算。

矢量化

如图10.2的问题结构是很不规则的。这对于小规模的SSE/AVX指令和大规模的矢量流水线处理器的矢量化来说都是一个问题(关于两者的解释见2.3.1节)。程序步骤 “为某一盒子的所有孩子做某事 “将是不规则的长度,而且数据可能不会以规则的方式存储。

这个问题可以通过细分网格来缓解,即使这意味着有空盒子。如果底层被完全划分,将始终有八个(三维)粒子可以操作。更高的层次也可以被填满,但这意味着低层的空盒子数量越来越多,所以在增加工作和提高效率之间要进行权衡。

共享内存的实现

在顺序结构上执行,这个算法的复杂度为$O(N \log N)$。很明显,如果每个粒子变成一个任务,这个算法在共享内存上也可以工作。由于不是所有的单元都包含粒子,任务将有不同的运行时间。

分布式内存的实现

上述Barnes-Hut算法的共享内存版本不能立即用于分布式内存环境,因为每个粒子原则上可以从总数据的任何部分获取信息。使用哈希八叉树有可能实现这种思路的实现,但我们不会坚持这样做。

我们观察到,数据访问的结构化程度比最初看起来要高。考虑一个粒子$p$和它所交互的第l层的单元。位于𝑝附近的粒子将与相同的细胞互动,因此我们可以通过查看第l层的细胞和与之互动的同一层的其他细胞来重新安排互动。

这给我们提供了以下算法[123]:质心的计算变成了计算粒子$𝑔(l)$对第$l$层施加的力。

1
2
3
for level l from one above the finest to the coarsest:
for each cell 𝑐 on level l
let 𝑔(l) be the combination of the 𝑔(l+1) for all children 𝑖 of 𝑐

有了这个,我们就可以计算出一个细胞上的力。

1
2
3
4
5
for level l from one below the coarses to the finest: for each cell 𝑐 on level l:
let 𝑓 (l) be the sum of
1. the force 𝑓 (l−1) on the parent 𝑝 of 𝑐, and 𝑝
2. the sums 𝑔(l) for all 𝑖 on level l that
satisfy the cell opening criterium

我们看到,在每一层,每个细胞现在只与该层的少量邻居互动。在算法的前半部分,我们只使用单元格之间的父子关系来向上攀登。据推测这是很容易的。

算法的后半部分使用更复杂的数据访问。第二项中的单元格𝑖都与我们要计算力的单元格𝑐有一定距离。在图的术语中,这些单元可以被描述为表亲:𝑐的父母的兄弟姐妹的孩子。如果开放的标准更加明确,我们就用二表兄妹:$c$的祖父母的兄弟姐妹的孙子,等等。

练习 10.2 论证这个力计算操作在结构上与稀疏矩阵-向量乘积有很多共同之处。

在共享内存的情况下,我们已经说过,不同的子树需要不同的时间来处理,但是,由于我们可能有更多的任务,而不是处理器核心,这一切都会扯平。对于分布式内存,我们缺乏将工作分配给任意处理器的可能性,所以我们需要谨慎地分配负载。空间填充曲线(SFC)可以在这里得到很好的应用(见2.10.5.2节)。

完整方法的1.5D实施

有可能通过分布粒子,让每个粒子评估来自其他每一个粒子的力,来直接实现完整的𝑁2方法的并行化。

练习 10.3 由于力是对称的,我们可以通过让粒子𝑝𝑖只与粒子𝑝𝑗 > 𝑖相互作用来节省2倍的工作量。在这个方案中,数据(如粒子)的平等分布会导致工作(如相互作用)的不均衡分布。粒子需要如何分布才能得到均匀的负荷分布?

假设我们不关心使用力的对称性所带来的两个系数的收益,而使用粒子的均匀分布,这个方案有一个更严重的问题,即它渐进地没有规模。

这方面的通信成本是

  • $O(P)$延迟,因为需要从所有处理器接收信息。
  • $O(N)$带宽,因为所有粒子都需要被接收。

然而,如果我们分配力的计算,而不是粒子,我们就会得出不同的界限;见[54]和其中引用的参考文献。

随着每个处理器计算边长为$N/\sqrt{P}$的区块中的相互作用,存在粒子的复制,需要收集力。因此,现在的成本是

  • $O(\log p)$广播和还原的延迟
  • $O(N/\sqrt{P}⋅\log P)$带宽,因此,每个处理器对最终的总和贡献了多少力。

问题描述

我们对N体问题进行如下抽象。

  • 我们假设有一个粒子电荷/质量的向量$c(⋅)$和位置信息。
  • 为了计算位置更新,我们需要基于存储图元$C_{ij}=$的对等相互作用$F(⋅,⋅)$。
  • 然后,这些相互作用被汇总为一个力矢量$f(⋅)$。

在并行性综合模型(IMP)模型中,如果我们知道各自的数据分布,就可以充分地描述算法;我们并不立即关注局部计算的情况。

颗粒分布

基于粒子分布的实现以分布为𝑐的粒子向量$c(u)$为起点,并直接计算同一分布上的力向量$f(u)$。我们将计算描述为三个内核的序列,两个是数据移动,一个是局部计算。

最后的内核是一个具有相同的$\alpha$和$\beta$分布的还原,所以它不涉及数据运动。本地计算内核也没有数据运动。这就留下了从初始分布$u(u)$收集$C(u,∗)$的问题。如果没有任何关于分布$u$的进一步信息,这就是对$N$元素的全收集,其成本为$\alpha \log p+\beta N$。特别是,通信成本不会随着$p$的下降而下降。

通过一个合适的基于分布的编程系统,方程组(10.1)可以被转化为代码。

工作分布

粒子分布的实现使用了一个一维分布$F(u,∗)$,用于与粒子分布一致的力。由于$F$是一个二维物体,所以也可以使用二维分布。我们现在将探讨这个选项,它早先在[54]中描述过;我们在这里的目的是要说明如何在IMP框架中实现和分析这个策略。我们不是把算法表达为分布式计算和复制的数据,而是使用分布式的临时,我们把复制方案推导为子通信器上的集体。

对于二维分布的$F$,我们需要$(N/b)\times (N/b)$ 处理器,其中$b$是一个块大小参数。为了便于说明,我们使用$b=1$,给出处理器的数量$P=N^2$。

现在我们将进行必要的推理。图10.4中给出了接近可实现形式的完整算法。为了简单起见,我们使用识别分布$I ∶p \rightarrow \{p\}$。

初始存储在处理器网格的对角线上 我们首先考虑收集图元 $C_{ij}=$。最初,我们决定让$c$向量存储在处理器网格的对角线上;换句话说,对于所有$p$,处理器$$包含$C_p$,而所有其他处理器的内容则未被定义。

为了正式表达这一点,我们让$D$为对角线$\{∶ p \in P \}$。利用部分定义分布的机制,最初的$\alpha-$分布就是

力计算的复制 本地力计算$f_{pq} = f(pq)$对每个处理器$$的数量$pq$,所以我们需要一个$\beta$分布的$C(I,I)$ 。关键是认识到

以至于

(其中$p$代表将每个处理器映射到索引值$p$的分布,$q$ 同理)。

为了找到从$\alpha$到$\beta$分布的转化,我们考虑表达式$C$的转化$(D: )$。一般来说,任何集合$D$都可以写成从第一个坐标到第二个坐标的数值集合的映射。

all-pairs

在我们对角线的特殊情况下,我们有

有了这个,我们就可以写出

请注意,方程(10.4)仍然是$\alpha$分布。$\beta$分布是 𝐶(𝐼,𝑝),这是一个模式匹配的练习,可以看到这是由每一行的广播来实现的,其成本为$\alpha \log \sqrt{p}+\beta N/\sqrt{P}$。

同样地,$C(q,I)$是通过列广播从$\alpha$分布中找到的。我们的结论是,这个变体确实有一个通信成本,并随着处理器数量的增加而成比例地下降。

局部交互计算 $F(I , I)\leftarrow C(I, I)$具有相同的$\alpha$和$\beta$分布,因此它是并行的。

力的求和 我们必须将力矢量$f(⋅)$扩展到我们的二维处理器网格,即$f(⋅, ⋅)$。然而,为了符合对角线上的初始粒子分布,我们只在对角线上求和。该指令

有一个$\beta$ -分布的$I,D∶ ∗$ ,它是由$\alpha$ -分布的$I$,$I$通过聚集在行中形成。

all-pairs-f

蒙特卡洛方法

蒙特卡洛模拟是一个广义概念,指的是使用随机数和统计抽样来解决问题的方法,而非精确建模。从这种抽样的性质来看,结果会有一些不确定性,但统计学上的 “大数定理 “会确保不确定性随着样本数量的增加而下降。

统计抽样的一个重要工具是随机数发生器。参见教程32,了解随机数生成。

激励

在边长为2的正方形中随机选取一个点,它落入正方形内切圆中的概率为$\pi/4$。因此我们可以通过大量随机选取点来估计$\pi$的值。我们甚至可以做一个简单的物理实验:假设院子中存在一个形状不规则的池塘,而院子本身是面积已知的长方形。如果现在向院子里扔卵石,使它们在任何给定的地方都有同样的可能性,那么落在池塘里的卵石和落在外面的卵石的比例就等于面积的比例。

从数学角度讲,令$\Omega\in [0,1]^2$为某个区域,函数$f(\tilde{x})$为$\Omega$的边界,即

现在取随机点$\tilde{x}_0,\tilde{x}_1,\tilde{x}_2在[0,1]^2$,我们可以通过计算$f(\bar{x}_i)$为正或负的频率来估计Ω的面积。

我们可以把这个想法扩展到积分。一个函数在一个区间$(a, b)$上的平均值定义为

另一方面,我们也可以估计其平均数为

如果点$x$的分布合理,并且函数$f$不是太苛刻的话。可以得出

统计理论告诉我们,积分中的不确定性$\sigma_I$与下列因素有关。标准差$\sigma_f$的表达为

且服从正态分布。

吸引人的地方是什么?

到目前为止,蒙特卡洛积分看起来与经典的黎曼和积分没有什么区别,但当增加空间维度时,差别就较为明显。此时,经典积分需要在每个维度上有$N$个点,因此有$d$维上有$N^d$个点。另一方面,在蒙特卡洛方法中,点是从$d$维空间中随机抽取的,而且点的数量少得多。

在计算方面,蒙特卡洛方法很有吸引力,因为所有的函数评估都可以并行进行。

对标准差为$\sigma$的事件进行$N$次取样观察,其平均标准差为:$\sigma / \sqrt{N}$。这意味着更多采样观测将降低平均标准差以提高精度;使蒙特卡洛方法的有趣之处在于,这种准确性的提高与原始问题的维度无关。

蒙特卡洛方法是模拟具有统计性质现象的不二选择,如放射性衰变或布朗运动。而蒙特卡洛方法其他应用范畴则不属于科学计算领域,例如,用于股票期权定价的Black-Scholes模型[15]就使用了蒙特卡洛模拟。

线性方程组也可以通过蒙特卡洛方法求解,但这并不常见。下面我们将以一个用精确方法解答十分复杂的应用为例,说明蒙特卡罗方法所代表的统计抽样手段的便捷之处。

示例

伊辛模型的蒙特卡洛模拟

伊辛模型(介绍见[33])最初是为了模拟铁磁性而提出的。磁性是原子排列其 “自旋 “方向的结果:假设自旋只能是 “向上 “或 “向下”,那么如果有更多的原子自旋向上,而不是向下,那么这种材料就具有磁性,反之亦然。这些原子被称为 “晶格 “结构中的原子。

现在想象一下,加热一种材料会使原子松动。如果在材料上施加一个外部场,原子将开始与场对齐,如果场被移除,磁性又会消失。然而在某个临界温度以下,材料将保留其磁性。我们将使用蒙特卡洛模拟来寻找保留的稳定构型。

假设晶格$\Lambda$有$N$个原子,我们把原子的构型表示为$\sigma=(\sigma_1,…,\sigma_N)$,其中每个$\sigma_i=\pm1$。 晶格的能量模型为

第一个项模拟单个自旋$\sigma_i$与强度为$J$的外部场的相互作用。第二项是对近邻对的求和,模拟原子对的排列:如果原子具有相同的自旋,则乘积$\sigma_i\sigma_j$为正,如果相反则为负。

在统计力学中,构型的概率是

其中 “分区函数 “$Z$定义为

其中,总和在所有$2^N$配置上运行。

如果一个构型的能量在微小扰动下没有减少,那么它就是稳定的。为了探索这一点,我们在晶格上进行迭代,探索改变原子的自旋是否会降低能量。我们引入了一个偶然因素来防止人为的解决方案。(这就是Metropolis算法[155])。

1
2
3
4
for fixed number of iterations do for each atom 𝑖 do
calculate the change Δ𝐸 from changing the sign of 𝜎𝑖
if Δ𝐸 < or exp(−Δ𝐸) greater than some random number then
accept the change

如果我们注意到与稀疏矩阵-向量乘积结构的相似性,这种算法可以被并行化。在该算法中,我们也是通过结合几个近邻的输入来计算一个局部数量。这意味着我们可以对晶格进行分区,在每个处理器收集到一个幽灵区域(ghost region)后计算局部更新。

让每个处理器在晶格中的局部点上迭代,相当于晶格的一个特定的全局排序;为了使并行计算等同于串行计算,我们还需要一个并行随机发生器(第32.3节)。

机器学习

机器学习(Machine Learning,ML)是一些处理 “智能 “问题的技术的统称,如图像识别。抽象地讲,这些问题是从一个特征向量(features)空间(如图像中的像素值)到另一个结果向量空间的映射。在图像识别字母的情况下,这个最终空间可能是26维的,第二个分量的最大值将表明一个 “B “被识别。

ML技术的基本特征是,这种映射是由大量的内部参数描述的,而且这些参数是逐步完善的。这里的学习方面是,通过将输入与基于当前参数的预测输出和预期输出进行比较来完善参数。

神经网络

现在最流行的ML形式是深度学习(DL),或神经网络(neural networks)。神经网络,或深度学习网络,是一个计算数字输出的函数,给定一个(通常是多维的)输入点。假设这个输出被归一化为区间[0, 1],我们可以通过在输出上引入一个阈值来使用神经网络作为一个分类工具。

为什么是 “神经的”网络?

在生物体内,神经元是一个 “发射 “的细胞,也就是说,如果它收到某些输入,就会发出一个高电压。在ML中,我们把它抽象为一个感知器:一个根据某些输入而输出数值的函数。具体来说,输出值通常是输入的线性函数,其结果限制在(0,1)范围内。

单个数据点

在其最简单的形式中,我们有一个输入,其特征是一个特征向量$s\bar{x}$,和一个标量输出$y$。我们可以通过使用相同大小的权重向量和标量偏置𝑏来计算$y$作为$x$的线性函数。

激活函数

为了有一定的尺度不变性,我们引入了一个被称为激活函数的函数$\sigma$,它映射了$\mathbb{R}\rightarrow (0,1)$,我们实际上计算了标量输出$y$为

一种常见的sigmoid函数为

该函数有一个有趣的特性,即

所以计算函数值和导数并不比只计算函数值开销大多少。

对于向量值的输出,我们以点的方式应用sigmoid函数。

1
2
3
4
5
6
7
8
9
10
// funcs.cpp
template <typename V>
void sigmoid_io(const V &m, V &a) {
a.vals.assign(m.vals.begin(),m.vals.end());
for (int i = 0; i < m.r * m.c; i++) {
// a.vals[i]*=(a.vals[i]>0); // values will be 0 if negative, and equal to themselves
if positive
a.vals[i] = 1 / (1 + exp(-a.vals[i]));
}
}

其他激活函数有:$y=tanh(𝑥)$或’ReLU’(矫正线性单元)。

在其他地方(如DL网络的最后一层),softmax函数可能更合适。

多维输出

由$\bar{w}$、$\bar{b}$定义的单层可以实现我们对神经网的所有要求,这一点很罕见。通常情况下,我们使用一个层的输出作为下一个层的输入。这意味着我们计算的不是一个标量$y$,而是一个多维向量$\bar{y}$。

现在我们对输出的每个分量都有权重和偏置,所以

其中$W$现在是一个矩阵。

有几点看法:

  • 如上所述,输出向量通常比输入的分量少,所以矩阵不是正方形,特别是不能倒置。

  • sigmoid函数使整个映射变得非线性。

  • 神经网络通常有多个层次,每个层次都是以$x \rightarrow y$ 的形式进行的映射。

    以上。

卷积

上面关于应用权重的讨论将输入视为一组没有进一步结构的特征。然而,在图像识别等应用中,输入向量是一个图像,有一个结构需要被承认。对输入向量进行线性化后,如果输入向量中的像素在水平方向上很接近,但在垂直方向上则不接近。

因此,我们的动机是找到一个能反映这种位置性的权重矩阵。我们通过引入内核来做到这一点:一个小的 “stencil”被应用于图像的各个点。(见第4.2.4节关于PDEs背景下的stencil的讨论)这样的内核通常是一个小的正方形矩阵,应用它是通过取模版值和图像值的内积来完成的。(这是对信号处理中卷积一词的不精确使用)。

例如:https://aishack.in/tutorials/image-convolution-examples/。

深度学习网络

现在我们将介绍一个完整的神经网络。这种介绍遵循[105]。

使用一个具有$L\geqslant 1$层的网络,其中 $\ell = 1$层是输入层,$\ell = L$层是输出层。

对于$\ell = 1,…,L$,第$\ell$层计算

其中,$a^{(1)}$是输入,$z^{(L+1)}$是最终输出。我们将其简洁地写成

其中我们通常会省略网对$W^{(\ell)}$、$𝑏^{(\ell)}$集的依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// net.cpp
void Net::feedForward(const VectorBatch &input) {
this->layers.front().forward(input); // Forwarding the input

for (unsigned i = 1; i < layers.size(); i++) {
this->layers.at(i).forward(this->layers.at(i - 1).activated_batch);
}
}

// layer.cpp
void Layer::forward(const VectorBatch &prevVals) {
VectorBatch output( prevVals.r, weights.c, 0 );
prevVals.v2mp( weights, output );
output.addh(biases); // Add the bias
activated_batch = output;
apply_activation<VectorBatch>.at(activation)(output, activated_batch);
}

分类

在上述描述中,输入$x$和输出$y$都是向量值。也有一些情况是需要不同类型的输出的。例如,假设我们想描述数字的位图图像;在这种情况下,输出应该是一个整数0⋯9。

我们通过让输出$y$在$\mathbb{R}^{10}$中来解决这个问题,如果$y_5$比其他输出成分足够大,我们就说网络能识别数字5。通过这种方式,我们保持整个故事仍然是实值的。

误差最小化

通常我们有数据点${x_i}_{i=1,𝑁},$有已知的输出$y_i$,我们想让网络预测尽可能好地再现这种映射。从形式上看,我们寻求最小化成本,或者说误差。

在所有选择${W}$,${b}$中。(通常我们不会明确说明这个成本是所有$W^{[\ell]}$权重矩阵和$b^{[\ell]}$偏差的函数。)

1
2
3
4
5
6
7
8
9
10
11
12
13
float Net::calculateLoss(Dataset &testSplit) { 
testSplit.stack(); feedForward(testSplit.dataBatch);
const VectorBatch &result = output_mat();

float loss = 0.0;
for (int vec=0; vec<result.batch_size(); vec++) { // iterate over all items
const auto& one_result = result.get_vector(vec);
const auto& one_label = testSplit.labelBatch.get_vector(vec); assert(one_result.size()==one_label.size() );
for (int i=0; i<one_result.size(); i++) // Calculate loss of result
loss += lossFunction( one_label[i], one_result[i] );
}
loss = -loss / (float) result.batch_size(); return loss;
}

成本最小化意味着选择权重$\{W^{(\ell)}\}_\ell$和偏置$\{b^{(\ell)}\}_\ell$,使得对于每个$x$。

其中$L(N(x),y)$是一个损失函数,描述计算出的输出$N(x)$与预期输出$y$之间的距离。

我们使用梯度下降法找到这个最小值。

其中

这是一个复杂的表达式,我们现在将不做推导。

系数的计算

我们对成本相对于各种权重、偏差和计算量的部分导数感兴趣。对于这一点,引入一个简短的方法是很方便的。

现在应用连锁法则(完整的推导见上面引用的论文),我们得到,用$x \circ y$表示点式(或哈达玛)向量-向量积$\{x_iy_i\}$。

  • 在最后层

  • 递归到前一层级

  • 对偏置量的敏感性

  • 对权重的敏感性

使用特殊形式

给出

DL forward:back

算法

现在我们在图12.1中介绍完整的算法。我们的网络有$\ell = 1, … , L$,其中参数$n_\ell$表示层$\ell$的输入大小。

第1层有输入$x$,第$L$层有输出$y$。考虑到minibatch的使用,我们让$x$,$y$表示一组大小为$b$的输入/输出,因此它们的大小分别为$n_1\times b$和$n_{L+1}\times b$。

计算层面

在本节中,我们将讨论深度学习的高性能计算方面。在标量意义上,我们论证了矩阵-矩阵乘积的存在,它可以被高效率地执行。我们还将讨论并行性,重点是数据并行,其基本策略是分割数据集。

  • 模型并行,其基本策略是分割模型参数;以及
  • 流水线化,在这种情况下,指令可以按照比原始的模型更多的串行执行。

重量矩阵乘积

无论是在应用网,即前向传递,还是在学习,即后向传递中,我们都要对权重矩阵进行矩阵乘向量的乘积。这种操作没有太多的缓存重用,因此性能不高;1.7.11节。

另一方面,如果我们将一些数据点捆绑在一起—这有时被称为小批量—并对它们进行联合操作,我们的基本操作就变成了矩阵乘以矩阵乘积,它能够有更高的性能;1.6.1.2节。

Shapes of arrays in a single layer

我们在图12.2中描述了这一点。

  • 输入批和输出批由相同数量的向量组成。

  • 权重矩阵$W$的行数等于输出大小,列数等于输入大小。相当于输入规模。

gemm内核的这种重要性(1.6.1和6.4.1节)导致人们为其开发专用硬件。

另一种方法是使用权重矩阵的特殊形式。在[143]中,研究了用托普利茨矩阵的近似。这在节省空间方面有优势,而且可以通过FFT进行乘积。

权重矩阵乘积的并行性

我们现在可以考虑有效计算$N(x)$。前面已经说过,矩阵-矩阵多运算是一个重要的内核,但除此之外,我们还可以使用并行处理。图12.3给出了两种并行化策略。

在第一种策略中,批处理被划分到各个进程中(或者说,多个进程同时处理独立的批处理);我们把这称为数据并行。

练习 12.1 考虑在共享内存环境下的这种情况。在这段代码中。

1
2
3
for 𝑏 = 1, ... , batchsize
for 𝑖 = 1,...,outsize
𝑦𝑖,𝑏 ← ∑𝑗 𝑊𝑖,𝑗 ⋅ 𝑥𝑗,𝑏

假设每个线程计算1,…的部分内容。, batchsize的范围。把这一点翻译成你最喜欢的编程语言。你是按行还是列来存储输入/输出向量?为什么?这两种选择的影响是什么?

练习 12.2 现在考虑分布式内存背景下的数据并行,每个进程都在批处理的一个片断(块列)上工作。你看到一个直接的问题了吗?

还有第二种策略,被称为模型并行,其中模型参数,也就是权重和偏差是分布式的。正如在图12.3中看到的,这立即意味着该层的输出向量是分布式计算的。

练习 12.3 概述一下分布式内存中第二个分区的算法。

这些策略的选择取决于模型是否很大,权重矩阵是否需要被分割,或者输入的数量是否很大。当然,也可以把这些结合起来,模型和批处理都是分布式的。

权重更新

权重更新的计算

是一个等级为$b$的外积。它需要两个向量,并从它们中计算出一个低秩矩阵。

练习 12.4 根据$n_\ell$和$b$之间的关系,讨论该操作中(潜在)的数据重用量。为简单起见,假设$n_{\ell+1}\approx n_\ell$。

练习 12.5 讨论图 12.3 的两种分区策略中所涉及的数据移动结构。

除了这些方面,当我们考虑并行处理小批量时,这个操作变得更加有趣。在这种情况下,每个批次都独立地计算一个更新,我们需要对它们进行平均。在假设每个进程计算一个完整的$\Delta W$的情况下,这就变成了一个全还原。这种 “HPC技术 “的应用被开发成Horovod软件[76, 181, 111]。在一个例子中,在涉及40个GPU的配置上显示了相当大的速度。

另一个选择是延迟更新,或以异步方式执行。

练习 12.6 讨论在MPI中实现延迟或异步更新的问题。

流水线

最后一种类型的并行可以通过在各层上应用流水线来实现。简要说明这如何能提高训练阶段的效率。

卷积

应用卷积相当于乘以一个托普利茨矩阵。这比完全通用的矩阵-矩阵乘法的复杂度要低。

稀疏矩阵

权重矩阵可以通过忽略小条目而被稀疏化。这使得稀疏矩阵乘以密集矩阵的乘积成为主要的操作[72]。

硬件支持

从上述内容中,我们可以得出结论,gemm计算内核的重要性。将一个普通的CPU专门用于这一目的是对硅和电力的相当大的浪费。至少,使用GPU是一个节能的解决方案。

然而,通过使用特殊用途的硬件,甚至可以达到更高的效率。下面是一个概述:https://blog.inten.to/hardware-for-deep-learning-part-4-asic-96a542fe6a81 在某种程度上,这些特殊用途的处理器是收缩阵列的再世。

降低精度

见3.7.4.2节。

Stuff

通用近似定理

设$\varphi(⋅)$是一个非常数、有界、单调增加的连续函数。让$𝐼𝑚$表示$𝑚$维的单位超立方体$[0,1]^m$。$𝐼𝑚$上的连续函数空间用$C(I_m)$表示。那么,给定任何函数$f\in C(I_m)$和$\varepsilon>0$,存在一个整数$N$,实常数$v_i,b_i\in \mathbb{R}$和实向量$w_i\in \mathbb{R}^m$,其中$i=1,⋯,N$,这样我们可以定义。

作为函数$f$的近似实现,其中$f$与$\varphi$无关;即。

对于所有$x\in Im$。换句话说,$F(x)$形式的函数在$C(I_m)$中是密集的。

NN可以近似于乘法吗?

https://stats.stackexchange.com/questions/217703/can-deep-neural-network-approximate

传统的神经网络由线性图和Lipschitiz激活函数组成。作为Lischitz连续函数的组合,神经网络也是Lipschitz连续的,但乘法不是Lipschitz连续。这意味着,当$x$或$y$中的一个数字过大时,神经网络工作不能近似于乘法。

调度算法

在服务器逻辑开发设计中,调度算法随处可见,资源的调度,请求的分配,负载均衡的策略等等都与调度算法相关。调度算法没有好坏之分,最适合业务场景的才是最好的。

轮询

轮询是非常简单且常用的一种调度算法,轮询即将请求依次分配到各个服务节点,从第一个节点开始,依次将请求分配到最后一个节点,而后重新开始下一轮循环。最终所有的请求会均摊分配在每个节点上,假设每个请求的消耗是一样的,那么轮询调度是最平衡的调度(负载均衡)算法。

加权轮询

有些时候服务节点的性能配置各不相同,处理能力不一样,针对这种的情况,可以根据节点处理能力的强弱配置不同的的权重值,采用加权轮询的方式进行调度。

加权轮询可以描述为:

  • 调度节点记录所有服务节点的当前权重值,初始化为配置对应值。
  • 当有请求需要调度时,每次分配选择当前权重最高的节点,同时被选择的节点权重值减一。
  • 若所有节点权重值都为零,则重置为初始化时配置的权重值。

最终所有请求会按照各节点的权重值成比例的分配到服务节点上。假设有三个服务节点{a,b,c},它们的权重配置分别为{2,3,4},那么请求的分配次序将是{c,b,c,a,b,c,a,b,c},如下所示:

请求序号 当前权重 选中节点 调整后权重
1 {2,3,4} c {2,3,3}
2 {2,3,3} b {2,2,3}
3 {2,2,3} c {2,2,2}
4 {2,2,2} a {1,2,2}
5 {1,2,2} b {1,1,2}
6 {1,1,2} c {1,1,1}
7 {1,1,1} a {0,1,1}
8 {0,1,1} b {0,0,1}
9 {0,0,1} c {0,0,0}

平滑权重轮询

加权轮询算法比较容易造成某个服务节点短时间内被集中调用,导致瞬时压力过大,权重高的节点会先被选中直至达到权重次数才会选择下一个节点,请求连续的分配在同一个节点上的情况,例如假设三个服务节点{a,b,c},权重配置分别是{5,1,1},那么加权轮询调度请求的分配次序将是{a,a,a,a,a,b,c},很明显节点 a 有连续的多个请求被分配。

为了应对这种问题,平滑权重轮询实现了基于权重的平滑轮询算法。所谓平滑,就是在一段时间内,不仅服务节点被选择次数的分布和它们的权重一致,而且调度算法还能比较均匀的选择节点,不会在一段时间之内集中只选择某一个权重较高的服务节点。

平滑权重轮询算法可以描述为:

  • 调度节点记录所有服务节点的当前权重值,初始化为配置对应值。
  • 当有请求需要调度时,每次会先把各节点的当前权重值加上自己的配置权重值,然后选择分配当前权重值最高的节点,同时被选择的节点权重值减去所有节点的原始权重值总和。
  • 若所有节点权重值都为零,则重置为初始化时配置的权重值。

同样假设三个服务节点{a,b,c},权重分别是{5,1,1},那么平滑权重轮询每一轮的分配过程如下表所示:

请求序号 当前权重 选中节点 调整后权重
1 {5,1,1} a {-2,1,1}
2 {3,2,2} a {-4,2,2}
3 {1,3,3} b {1,-4,3}
4 {6,-3,4} a {-1,-3,4}
5 {4,-2,5} c {4,-2,-2}
6 {9,-1,-1} a {2,-1,-1}
7 {7,0,0} a {0,0,0}

最终请求分配的次序将是{ a, a, b, a, c, a, a},相对于普通权重轮询算法会更平滑一些。

随机

随机即每次将请求随机地分配到服务节点上,随机的优点是完全无状态的调度,调度节点不需要记录过往请求分配情况的数据。理论上请求量足够大的情况下,随机算法会趋近于完全平衡的负载均衡调度算法。

加权随机

类似于加权轮询,加权随机支持根据服务节点处理能力的大小配置不同的的权重值,当有请求需要调度时,每次根据节点的权重值做一次加权随机分配,服务节点权重越大,随机到的概率就越大。最终所有请求分配到各服务节点的数量与节点配置的权重值成正比关系。

最小负载

实际应用中,各个请求很有可能是异构的,不同的请求对服务器的消耗各不相同,无论是使用轮询还是随机的方式,都可能无法准确的做到完全的负载均衡。最小负载算法是根据各服务节点当前的真实负载能力进行请求分配的,当前负载最小的节点会被优先选择。

最小负载算法可以描述为:

  • 服务节点定时向调度节点上报各自的负载情况,调度节点更新并记录所有服务节点的当前负载值。
  • 当有请求需要调度时,每次分配选择当前负载最小(负载盈余最大)的服务节点。

负载情况可以统计节点正在处理的请求量,服务器的 CPU 及内存使用率,过往请求的响应延迟情况等数据,综合这些数据以合理的计算公式进行负载打分。

两次随机选择策略

最小负载算法可以在请求异构情况下做到更好的均衡性。然而一般情况下服务节点的负载数据都是定时同步到调度节点,存在一定的滞后性,而使用滞后的负载数据进行调度会导致产生“群居”行为,在这种行为中,请求将批量地发送到当前某个低负载的节点,而当下一次同步更新负载数据时,该节点又有可能处于较高位置,然后不会被分配任何请求。再下一次又变成低负载节点被分配了更多的请求,一直处于这种很忙和很闲的循环状态,不利于服务器的稳定。

为应对这种情况,两次随机选择策略算法做了一些改进,该算法可以描述为:

  • 服务节点定时向调度节点上报各自的负载情况,调度节点更新并记录所有服务节点的当前负载值。
  • 从所有可用节点列表中做两次随机选择操作,得到两个节点。
  • 比较这两个节点负载情况,选择负载更低的节点作为被调度的节点。

两次随机选择策略结合了随机和最小负载这两种算法的优点,使用负载信息来选择节点的同时,避免了可能的“群居”行为。

一致性哈希

为了保序和充分利用缓存,我们通常希望相同请求 key 的请求总是会被分配到同一个服务节点上,以保持请求的一致性,既有了一致性哈希的调度方式。

划段

最简单的一致性哈希方案就是划段,即事先规划好资源段,根据请求的 key 值映射找到所属段,比如通过配置的方式,配置 id 为[1-10000]的请求映射到服务节点 1,配置 id 为[10001-20000]的请求映射到节点 2 等等,但这种方式存在很大的应用局限性,对于平衡性和稳定性也都不太理想,实际业务应用中基本不会采用。

割环法

割环法的实现有很多种,原理都类似。割环法将 N 台服务节点地址哈希成 N 组整型值,该组整型即为该服务节点的所有虚拟节点,将所有虚拟节点打散在一个环上。

请求分配过程中,对于给定的对象 key 也哈希映射成整型值,在环上搜索大于该值的第一个虚拟节点,虚拟节点对应的实际节点即为该对象需要映射到的服务节点。

如下图所示,对象 K1 映射到了节点 2,对象 K2 映射到节点 3。

割环法实现复杂度略高,时间复杂度为 O(log(vn)),(其中,n 是服务节点个数,v 是每个节点拥有的虚拟节点数),它具有很好的单调性,而平衡性和稳定性主要取决于虚拟节点的个数和虚拟节点生成规则,例如 ketama hash 割环法采用的是通过服务节点 ip 和端口组成的字符串的 MD5 值,来生成 160 组虚拟节点。

二次取模

取模哈希映射是一种简单的一致性哈希方式,但是简单的一次性取模哈希单调性很差,对于故障容灾非常不好,一旦某台服务节点不可用,会导致大部分的请求被重新分配到新的节点,造成缓存的大面积迁移,因此有了二次取模的一致性哈希方式。

二次取模算法即调度节点维护两张服务节点表:松散表(所有节点表)和紧实表(可用节点表)。请求分配过程中,先对松散表取模运算,若结果节点可用,则直接选取;若结果节点已不可用,再对紧实表做第二次取模运算,得到最终节点。如下图示:

二次取模算法实现简单,时间复杂度为 O(1),具有较好的单调性,能很好的处理缩容和节点故障的情况。平衡性和稳定性也比较好,主要取决于对象 key 的分布是否足够散列(若不够散列,也可以加一层散列函数将 key 打散)。

最高随机权重

最高随机权重算法是以请求 key 和节点标识为参数进行一轮散列运算(如 MurmurHash 算法),得出所有节点的权重值进行对比,最终取最大权重值对应的节点为目标映射节点。可以描述为如下公式:result = max(hash(key,N1), hash(key, N2), hash(key, N3), ..., hash(key, Nn))

散列运算也可以认为是一种保持一致性的伪随机的方式,类似于前面讲到的普通随机的调度方式,通过随机比较每个对象的随机值进行选择。

这种方式需要 O(n)的时间复杂度,但换来的是非常好的单调性和平衡性,在节点数量变化时,只有当对象的最大权重值落在变化的节点上时才受影响,也就是说只会影响变化的节点上的对象的重新映射,因此无论扩容,缩容和节点故障都能以最小的代价转移对象,在节点数较少而对于单调性要求非常高的场景可以采用这种方式。

Jump consistent hash

jump consistent hash 通过一种非常简单的跳跃算法对给定的对象 key 算出该对象被映射的服务节点,算法如下:

1
2
3
4
5
6
7
8
9
10
int JumpConsistentHash(unsigned long long key, int num_buckets)
{
long long b = -1, j = 0;
while (j < num_buckets) {
b = j;
key = key * 2862933555777941757ULL + 1;
j = (b + 1) * (double(1LL << 31) / double((key >> 33) + 1));
}
return b;
}

这个算法乍看难以理解,它其实是下面这个算法的一个变种,只是将随机函数通过线性同余的方式改造而来的。

1
2
3
4
5
6
7
8
9
10
11
int ch(int key, int num_buckets) {
random.seed(key) ;
int b = -1; // bucket number before the previous jump
int j = 0; // bucket number before the current jump
while(j < num_buckets){
b = j;
double r = random.next(); // 0<r<1.0
j = floor( (b+1) / r);
}
return b;
}

它也是一种伪随机的方式,通过随机保证了平衡性,而这里随机函数用到的种子是各个请求的 key 值,因此保证了一致性。它与最高随机权重的差别是这里的随机不需要对所有节点都进行一次随机,而是通过随机值跳跃了部分节点的比较。

jump consistent hash 实现简单,零内存消耗,时间复杂度为 O(log(n))。具有很高的平衡性,在单调性方面,扩容和缩容表现较好,但对于中间节点故障,理想情况下需要将故障节点与最后一个节点调换,需要将故障节点和最后的节点共两个节点的对象进行转移。

小结

一致性哈希方式还有很多种类,通常结合不同的散列函实现。也有些或为了更简单的使用,或为了更好的单调性,或为了更好的平衡性等而对以上这些方式进行的改造等,如二次 Jump consistent hash 等方式。另外也有结合最小负载方式等的变种,如有限负载一致性哈希会根据当前负载情况对所有节点限制一个最大负载,在一致性哈希中对 hash 进行映射时跳过已达到最大负载限制的节点,实际应用过程中可根据业务情况自行做更好的调整和结合。

不放回随机抽样算法

不放回随机抽样即从 n 个数据中抽取 m 个不重复的数据。关于不放回随机抽样算法,笔者曾在 km 发表过专门的文章详细演绎和实现了各种随机抽样算法的原理和过程,以及它们的优缺点和适用范围,有兴趣的同学可以前往阅读。

Knuth 洗牌抽样

不放回随机抽样可以当成是一次洗牌算法的过程,利用洗牌算法来对序列进行随机排列,然后选取前 m 个序列作为抽样结果。

Knuth 洗牌算法是在 Fisher-Yates 洗牌算法中改进而来的,通过位置交换的方式代替了删除操作,将每个被删除的数字交换为最后一个未删除的数字(或最前一个未删除的数字)。

Knuth 洗牌算法可以描述为:

  • 生成数字 1 到 n 的随机排列(数组索引从 1 开始)
  • for i from 1 to n-1 do
    • j ← 随机一个整数值 i ≤ j < n
    • 交换 a[j] 和 a[i]

运用 Knuth 洗牌算法进行的随机抽样的方式称为 Knuth 洗牌随机抽样算法,由于随机抽样只需要抽取 m 个序列,因此洗牌流程只需洗到前 m 个数据即可。

占位洗牌随机抽样

Knuth 洗牌算法是一种 in-place 的洗牌,即在原有的数组直接洗牌,尽管保留了原数组的所有元素,但它还是破坏了元素之间的前后顺序,有些时候我们希望原数组仅是可读的(如全局配置表),不会因为一次抽样遭到破坏,以满足可以对同一原始数组多次抽样的需求,如若使用 Knuth 抽样算法,必须对原数组先做一次拷贝操作,但这显然不是最好的做法,更好的办法在 Knuth 洗牌算法的基础上,不对原数组进行交换操作,而是通过一个额外的 map 来记录元素间的交换关系,我们称为占位洗牌算法。

占位洗牌算法过程演示如下:

最终,洗牌的结果为 3,5,2,4,1。

运用占位洗牌算法实现的随机抽样的方式称为占位洗牌随机抽样,同样的,我们依然可以只抽取到前 m 个数据即可。这种算法对原数组不做任何修改,代价是增加不大于O(m)的临时空间。

选择抽样技术抽样

洗牌算法是对一个已经预初始化好的数据列表进行洗牌,需要在内存中全量缓存数据列表,如果数据总量 n 很大,并且单条记录的数据也很大,那么在内存中缓存所有数据记录的做法会显得非常的笨拙。而选择选择抽样技术算法,它不需要预先全量缓存数据列表,从而可以支持流式处理。

选择抽样技术算法可以描述为:

  • 生成 1 到 n 之间的随机数 U
  • 如果 U≥m,则跳转到步骤 4
  • 把这个记录选为样本,m 减 1,n 减 1。如果 m>0,则跳转到步骤 1,否则取样完成,算法终止
  • 跳过这个记录,不选为样本,n 减 1,跳转到步骤 1

选择抽样技术算法过程演示如下:

最终,抽样的结果为 2,5。

可以证明,选择选择抽样技术算法对于每个数被选取的概率都是m/n。

选择抽样技术算法虽然不需要将数据流全量缓存到内存中,但他仍然需要预先准确的知道数据量的总大小即 n 值。它的优点是能保持输出顺序与输入顺序不变,且单个元素是否被抽中可以提前知道。

蓄水池抽样

很多时候我们仍然不知道数据总量 n,上述的选择抽样技术算法就需要扫描数据两次,第一次先统计 n 值,第二次再进行抽样,这在流处理场景中仍然有很大的局限性。

Alan G. Waterman 给出了一种叫蓄水池抽样(Reservoir Sampling)的算法,可以在无需提前知道数据总量 n 的情况下仍然支持流处理场景。

蓄水池抽样算法可以描述为:

  • 数据游标 i←0,将 i≤m 的数据一次放入蓄水池,并置 pool[i] ←i
  • 生成 1 到 i 之间的随机数 j
  • 如果 j>m,则跳转到步骤 5
  • 把这个记录选为样本,删除原先蓄水池中 pool[j]数据,并置 pool[j] ←i
  • 游标 i 自增 1,若i<n,跳转到步骤 2,否则取样完成,算法终止,最后蓄水池中的数据即为总样本

蓄水池抽样算法过程演示如下:

最终,抽样的结果为 1,5。

可以证明,每个数据被选中且留在蓄水池中的概率为m/n。

随机分值排序抽样

洗牌算法也可以认为就是将数据按随机的方式做一个排序,从 n 个元素集合中随机抽取 m 个元素的问题就相当于是随机排序之后取前 m 排名的元素,基于这个原理,我们可以设计一种通过随机分值排序的方式来解决随机抽样问题。

随机分值排序算法可以描述为:

  • 系统维护一张容量为 m 的排行榜单
  • 对于每个元素都给他们随机一个(0,1] 区间的分值,并根据随机分值插入排行榜
  • 所有数据处理完成,最终排名前 m 的元素即为抽样结果

尽管随机分值排序抽样算法相比于蓄水池抽样算法并没有什么好处,反而需要增加额外的排序消耗,但接下来的带权重随机抽样将利用到它的算法思想。

朴素的带权重抽样

很多需求场景数据元素都需要带有权重,每个元素被抽取的概率是由元素本身的权重决定的,诸如全服消费抽奖类活动,需要以玩家在一定时间段内的总消费额度为权重进行抽奖,消费越高,最后中奖的机会就越大,这就涉及到了带权重的抽样算法。

朴素的带权重随机算法也称为轮盘赌选择法,将数据放置在一个假想的轮盘上,元素个体的权重越高,在轮盘上占据的空间就越多,因此就更有可能被选中。

假设轮盘一到四等奖和幸运奖的权重值分别为 5,10,15,30,40,所有元素权重之和为 100,我们可以从[1, 100] 中随机得到一个值,假设为 45,而后从第一个元素开始,不断累加它们的权重,直到有一个元素的累加权重包含 45,则选取该元素。如下所示:

由于权重 45 处于四等奖的累加权重值当中,因此最后抽样结果为四等奖。

若要不放回的选取 m 个元素,则需要先选取一个,并将该元素从集合中踢除,再反复按同样的方法抽取其余元素。

这种抽样算法的复杂度是O(m*n),并且将元素从集合中删除破坏了原数据的可读属性,更重要的是这个算法需要多次遍历数据,不适合在流处理的场景中应用。

带权重的 A-Res 算法蓄水池抽样

朴素的带权重抽样算法需要内存足够容纳所有数据,破坏了原数据的可读属性,时间复杂度高等缺点,而经典的蓄水池算法高效的实现了流处理场景的大数据不放回随机抽样,但对于带权重的情况,就不能适用了。

A-Res(Algorithm A With a Reservoir) 是蓄水池抽样算法的带权重版本,算法主体思想与经典蓄水池算法一样都是维护含有 m 个元素的结果集,对每个新元素尝试去替换结果集中的元素。同时它巧妙的利用了随机分值排序算法抽样的思想,在对数据做随机分值的时候结合数据的权重大小生成排名分数,以满足分值与权重之间的正相关性,而这个 A-Res 算法生成随机分值的公式就是:ki = ui^(1/wi)

其中wi为第 i 个数据的权重值, 是从(0,1]之间的一个随机值。

A-Res 算法可以描述为:

  • 对于前 m 个数, 计算特值ki,直接放入蓄水池中
  • 对于从 m+1,m+2,…,n 的第 i 个数,通过公式计算特征值 ,如若特征值超过蓄水池中最小值,则替换最小值

该算法的时间复杂度为O(m*log(n/m)),且可以完美的运用在流式处理场景中。

带权重的 A-ExpJ 算法蓄水池抽样

A-Res 需要对每个元素产生一个随机数,而生成高质量的随机数有可能会有较大的性能开销,《Weighted random sampling with a reservoir》论文中给出了一种更为优化的指数跳跃的算法 A-ExpJ 抽样(Algorithm A with exponential jumps),它能将随机数的生成量从O(n)减少到O(m*log(n/m)),原理类似于通过一次额外的随机来跳过一段元素的特征值ki的计算。

A-ExpJ 算法蓄水池抽样可以描述为:

  • 对于前 m 个数,计算特征值ki = ui^(1/wi),其中wi为第 i 个数据的权重值,ui是从(0,1]之间的一个随机值,直接放入蓄水池中
  • 对于从 m+1,m+2,…,n 的第 i 个数,执行以下步骤
  • 计算阈值Xw,Xw=log(r)/log(Tw),其中 r 为(0,1]之间的一个随机值,Tw为蓄水池中的最小特征值
  • 跳过部分元素并累加这些元素权重值Wsum,直到第 i 个元素满足Wsum >= Xw
  • 计算当前元素特征值ki = ri^(1/wi),其中ri为(tw,1]之间的一个随机值,tw=Tw^wi,Tw为蓄水池中的最小特征值,wi为当前元素权重值
  • 使用当前元素替换蓄水池中最小特征值的元素
  • 更新阈值Xw = log(r)/log(Tw)

有点不好理解,show you the code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function aexpj_weight_sampling(data_array, weight_array, n, m)
local result, rank = {}, {}
for i=1, m do
local rand_score = math.random() ^ (1 / weight_array[i])
local idx = binary_search(rank, rand_score)
table.insert(rank, idx, {score = rand_score, data = data_array[i]})
end

local weight_sum, xw = 0, math.log(math.random()) / math.log(rank[m].score)
for i=m+1, n do
weight_sum = weight_sum + weight_array[i]
if weight_sum >= xw then
local tw = rank[m].score ^ weight_array[i]
local rand_score = (math.random()*(1-tw) + tw) ^ (1 / weight_array[i])
local idx = binary_search(rank, rand_score)
table.insert(rank, idx, {score = rand_score, data = data_array[i]})
table.remove(rank)
weight_sum = 0
xw = math.log(math.random()) / math.log(rank[m].score)
end
end

for i=1, m do
result[i] = rank[i].data
end

return result
end

排序算法

基础排序

基础排序是建立在对元素排序码进行比较的基础上进行的排序算法。

冒泡排序

冒泡排序是一种简单直观的排序算法。它每轮对每一对相邻元素进行比较,如果相邻元素顺序不符合规则,则交换他们的顺序,每轮将有一个最小(大)的元素浮上来。当所有轮结束之后,就是一个有序的序列。

过程演示如下:

插入排序

插入排序通过构建有序序列,初始将第一个元素看做是一个有序序列,后面所有元素看作未排序序列,从头到尾依次扫描未排序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

过程演示如下:

选择排序

选择排序首先在未排序序列中找到最小(大)元素,存放到已排序序列的起始位置。再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。直到所有元素处理完毕。

过程演示如下:

插入排序是每轮会处理好第一个未排序序列的位置,而选择排序是每轮固定好一个已排序序列的位置。冒泡排序也是每轮固定好一个已排序序列位置,它与选择排序之间的不同是选择排序直接选一个最小(大)的元素出来,而冒泡排序通过依次相邻交换的方式选择出最小(大)元素。

快速排序

快速排序使用分治法策略来把一串序列分为两个子串序列。快速排序是一种分而治之的思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。

快速排序从数列中挑出一个元素,称为”基准”,所有元素比基准值小的摆放在基准前面,比基准值大的摆在基准的后面。一轮之后该基准就处于数列的中间位置。并递归地把小于基准值元素的子数列和大于基准值元素的子数列进行排序。

过程演示如下:

归并排序

归并排序是建立在归并操作上的一种有效的排序算法,也是采用分治法的一个非常典型的应用。归并排序首先将序列二分成最小单元,而后通过归并的方式将两两已经有序的序列合并成一个有序序列,直到最后合并为一个最终有序序列。

过程演示如下:

堆排序

堆排序(Heapsort)是利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆的性质:子结点的键值或索引总是小于(或者大于)它的父节点。

堆排序首先创建一个堆,每轮将堆顶元素弹出,而后进行堆调整,保持堆的特性。所有被弹出的元素序列即是最终排序序列。

过程演示如下:

希尔排序

希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本,但希尔排序是非稳定排序算法。

插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率。但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位。

希尔排序通过将比较的全部元素分为几个区域来提升插入排序的性能。这样可以让一个元素可以一次性地朝最终位置前进一大步。然后算法再取越来越小的步长进行排序,算法的最后一步就是普通的插入排序,但是到了这步,需排序的数据几乎是已排好的了(此时插入排序较快)。

过程演示如下:

分配排序

基础排序是建立在对元素排序码进行比较的基础上,而分配排序是采用“分配”与“收集”的办法。

计数排序

计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

计数排序的特征:当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 。计数排序不是比较排序,排序的速度快于任何比较排序算法。

由于用来计数的数组的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上 1),这使得计数排序对于数据范围很大的数组,需要大量时间和空间。

过程演示如下:

桶排序

桶排序是计数排序的升级版,它利用了函数的映射关系,桶排序高效与否的关键就在于这个映射函数的确定。比如我们可以将排序数据进行除 10 运算,运算结果中具有相同的商值放入相同的桶中,即每十个数会放入相同的桶中。

过程演示如下:

为了使桶排序更加高效,我们需要做到这两点:

  • 在额外空间充足的情况下,尽量增大桶的数量
  • 使用的映射函数能够将输入的所有数据均匀的分配到所有桶中

计数排序本质上是一种特殊的桶排序,当桶的个数取最大值(max-min+1)的时候,桶排序就变成了计数排序。

基数排序

基数排序的原理是将整数按位数切割成不同的数字,然后对每个位数分别比较。基数排序首先按最低有效位数字进行排序,将相同值放入同一个桶中,并按最低位值顺序叠放,然后再按次低有效位排序,重复这个过程直到所有位都进行了排序,最终即是一个有序序列。

过程演示如下:

多路归并排序
多路归并排序算法是将多个已经有序的列表进行归并排序,合成为一组有序的列表的排序过程。

k 路归并排序可以描述为:

  • 初始时取出 k 路有序列表中首个元素放入比较池。
  • 从比较池中取最小(大)的元素加入到结果列表,同时将该元素所在有序列表的下一个元素放入比较池(若有)。
  • 重新复进行步骤 2,直到所有队列的所有元素都已取出。

每次在比较池中取最小(大)的元素时,需要进行一次 k 个数据的比较操作,当 k 值较大时,会严重影响多路归并的效率,为提高效率,可以使用“败者树”来实现这样的比较过程。

败者树是完全二叉树,败者树相对的是胜者树,胜者树每个非终端结点(除叶子结点之外的其它结点)中的值都表示的是左右孩子相比较后的胜者。

如下图所示是一棵胜者树:

而败者树双亲结点表示的是左右孩子比较之后的失败者,但在上一层的比较过程中,仍然是拿前一次的胜者去比较。

如下图所示是一颗败者树:

叶子节点的值是:{7,4,8,2,3,5,6,1},7 与 4 比较,7 是败者,4 是胜者,因此他们的双亲节点是 7,同样 8 与 2 比较,8 是败者,表示在他们双亲节点上,而 7 与 8 的双亲节点需要用他们的胜者去比较,即用 4 与 2 比较,4 是败者,因此 7 与 8 的双亲节点记录的是 4,依此类推。

假设 k=8,败者树归并排序的过程演示如下所示:

首先构建起败者数,最后的胜者是 1,第二次将 1 弹出,取 1 所在的第 8 列的第二个数 15 放入 1 所在的叶子节点位置,并进行败者树调整,此时只需调整原 1 所在分支的祖先节点,最后胜者为 2,后续过程依此类推。最后每轮的最终胜者序列即是最后的归并有序序列。

胜者树和败者树的本质是利用空间换时间的做法,通过辅助节点记录两两节点的比较结果来达到新插入节点后的比较和调整性能。

跳跃表排序

跳跃表(Skip Lists)是一种有序的数据结构,它通过在每个节点中随机的建立上层辅助查找节点,从而达到快速访问节点的目的(与败者树的多路归并排序有异曲同工之妙)。

跳跃列表按层建造,底层是一个普通的有序链表,包含所有元素。每个更高层都充当下面列表的“快速通道”,第 i 层中的元素按某个固定的概率 p(通常为 1/2 或 1/4)随机出现在第 i+1 层中。每个元素平均出现在 1/(1-p)个列表中。

如下是四层跳跃表结构的示意:

在查找目标元素时,从顶层列表、头元素起步,沿着每层链表搜索,直至找到一个大于或等于目标的元素,或者到达当前层列表末尾。如果该元素等于目标元素,则表明该元素已被找到;如果该元素大于目标元素或已到达链表末尾,则退回到当前层的上一个元素,然后转入下一层进行搜索。依次类推,最终找到该元素或在最底层底仍未找到(不存在)。

当 p 值越大,快速通道就越稀疏,占用空间越小,但查找速度越慢,反之,则占用空间大查找速度快,通过选择不同 p 值,就可以在查找代价和存储代价之间获取平衡。

由于跳跃表使用的是链表,加上增加了近似于以二分方式的辅助节点,因此查询,插入和删除的性能都很理想。在大部分情况下,跳跃表的效率可以和平衡树相媲美,它是一种随机化的平衡方案,在实现上比平衡树要更为简单,因而得到了广泛的应用,如 redis 的 zset,leveldb,我司的 apollo 排行榜等都使用了跳跃表排序方案。

百分比近似排序

在流处理场景中,针对大容量的排序榜单,全量存储和排序需要消耗的空间及时间都很高,不太现实。实际应用中,对于长尾数据的排序,一般也只需要显示百分比近似排名,通过牺牲一定的精确度来换取高性能和高实时性。

HdrHistogram 算法

HdrHistogram 使用的是直方图统计算法,直方图算法类似于桶排序,原理就是创建一个直方图,以一定的区间间隔记录每个区间上的数据总量,预测排名时只需统计当前值所在区间及之前区间的所有数量之和与总数据量之间的比率。

区间分割方式可以采用线性分割和指数分割方式:

  • 线性分割,数据以固定长度进行分割,假设数据范围是[1-1000000],以每 100 的间隔划分为 1 个区间,总共需要划分 10000 个区间桶。
  • 指数分割,基于指数倍的间隔长度进行分割,假设数据范围是[1-1000000],以 2 的幂次方的区间[2^k, 2^(k+1)-1]进行划分,总共只需要划分 20 个区间桶。

HdrHistogram 为了兼顾内存和估算的准确度,同时采用了线性分割和指数分割的方式,相当于两层的直方图算法,第一层使用指数分割方式,可以粗略的估算数据的排名范围位置,第二层使用线性分割方式,更加精确的估算出数据的排名位置。线性区间划分越小结果越精确,但需要的内存越多,可以根据业务精确度需求控制线性区间的大小。

直方图算法需要预先知道数据的最大值,超过最大值的数据将存不进来。HdrHistogram 提供了一个自动扩容的功能,以解决数据超过预估值的问题,但是这个自动扩容方式存在一个很高的拷贝成本。

CKMS 算法

HdrHistogram 是一种静态分桶的算法,当数据序列是均匀分布的情况下,有比较好的预测效果,然而实际应用中数据有可能并不均匀,很有可能集中在某几个区间上,CKMS 采用的是动态分桶的方式,在数据处理过程中不断调整桶的区间间隔和数量。

CKMS 同时引入一个可配置的错误率的概念,在抉择是否开辟新桶时,根据用户设置的错误率进行计算判定。判定公式为:区间间隔=错误率* 数据总量。

下图是一个桶合并的例子:

如上所示,假设错误率设置为 0.1,当数据总量大于 10 个时,通过判定公式计算出区间间隔为 1,因此将会对区间间隔小于等于 1 的相邻桶进行合并。

CKMS 算法不需要预知数据的范围,用户可以根据数据的性质设置合适的错误率,以控制桶的空间占用和精确度之间的平衡关系。

TDigest 算法

Tdigest 算法的思想是近似算法常用的素描法(Sketch),用一部分数据来刻画整体数据集的特征,就像我们日常的素描画一样,虽然和实物有差距,但是却看着和实物很像,能够展现实物的特征。它本质上也是一种动态分桶的方式。

TDigest 算法估计具体的百分位数时,都是根据百分位数对应的两个质心去线性插值计算的,和精准百分位数的计算方式一样。首先我们根据百分位 q 和所有质心的总权重计算出索引值;其次找出和对应索引相邻的两个质心;最终可以根据两个质心的均值和权重用插值的方法计算出对应的百分位数。(实际的计算方法就是加权平均)。

由此我们可以知道,百分位数 q 的计算误差要越小,其对应的两个质心的均值应该越接近。TDigest 算法的关键就是如何控制质心的数量,质心的数量越多,显然估计的精度就会越高,但是需要的内存就会越多,计算效率也越低;但是质心数量越少,估计的精度就很低,所以就需要一个权衡。

一种 TDigest 构建算法 buffer-and-merge 可以描述为:

  • 将新加入的数据点加入临时数组中,当临时数组满了或者需要计算分位数时,将临时数组中的数据点和已经存在的质心一起排序。(其中数据点和质心的表达方式是完全一样的:平均值和权重,每个数据点的平均值就是其本身,权重默认是 1)。
  • 遍历所有的数据点和质心,满足合并条件的数据点和质心就进行合并,如果超出权重上限,则创建新的质心数,否则修改当前质心数的平均值和权重。

假设我们有 200 个质心,那么我们就可以将 0 到 1 拆分 200 等份,则每个质心就对应 0.5 个百分位。假如现在有 10000 个数据点,即总权重是 10000,我们按照大小对 10000 个点排序后,就可以确定每个质心的权重(相当于质心代表的数据点的个数)应该在 10000/200 = 500 左右,所以说当每个质心的权重小于 500 时,我们就可以将当前数据点加入当前的质心,否则就新建一个质心。

实际应用中,我们可能更加关心 90%,95%,99%等极端的百分位数,所以 TDigest 算法特意优化了 q=0 和 q=1 附近的百分位精度,通过专门的映射函数 K 保证了 q=0 和 q=1 附近的质心权重较小,数量较多。

另外一种 TDigest 构建算法是 AVL 树的聚类算法,与 buffer-and-merge 算法相比,它通过使用 AVL 二叉平衡树的方式来搜索数据点最靠近的质心数,找到最靠近的质心数后,将二者进行合并。

限流与过载保护

复杂的业务场景中,经常容易遇到瞬时请求量的突增,很有可能会导致服务器占用过多资源,发生了大量的重试和资源竞争,导致响应速度降低、超时、乃至宕机,甚至引发雪崩造成整个系统不可用的情况。

为应对这种情况,通常需要系统具备可靠的限流和过载保护的能力,对于超出系统承载能力的部分的请求作出快速拒绝、丢弃处理,以保证本服务或下游服务系统的稳定。

计数器

计数器算法是限流算法里最简单也是最容易实现的一种算法。计数器算法可以针对某个用户的请求,或某类接口请求,或全局总请求量进行限制。

比如我们设定针对单个玩家的登录协议,每 3 秒才能请求一次,服务器可以在玩家数据上记录玩家上一次的登录时间,通过与本次登录时间进行对比,判断是否已经超过了 3 秒钟来决定本次请求是否需要继续处理。

又如针对某类协议,假设我们设定服务器同一秒内总登录协议请求次数不超过 100 条,我们可以设置一个计数器,每当一个登录请求过来的时候,计数器加 1,如果计数器值大于 100 且当前请求与第一个请求间隔时间还在 1 秒内,那么就判定为达到请求上限,拒绝服务,如果该请求与第一个请求间隔已经超过 1 秒钟,则重置计数器的值为 0,并重新计数。

计数器算法存在瞬时流量的临界问题,即在时间窗口切换时,前一个窗口和后一个窗口的请求量都集中在时间窗口切换的前后,在最坏的情况下,可能会产生两倍于阈值流量的请求。

为此也可以使用多个不同间隔的计数器相结合的方式进行限频,如可以限制登录请求 1 秒内不超过 100 的同时 1 分钟内不超过 1000 次。

漏桶

漏桶算法原理很简单,假设有一个水桶,所有水(请求)都会先丢进漏桶中,漏桶则以固定的速率出水(处理请求),当请求量速率过大,水桶中的水则会溢出(请求被丢弃)。漏桶算法能保证系统整体按固定的速率处理请求。

令牌桶

对于很多应用场景来说,除了要求能够限制请求的固定处理速率外,还要求允许某种程度的突发请求量,这时候漏桶算法可能就不合适了。

令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

令牌桶算法大概描述如下:
-所有的请求在处理之前都需要拿到一个可用的令牌才会被处理。
-根据限流大小,设置按照一定的速率往桶里添加令牌。
-桶设置最大的放置令牌限制,当桶满时、新添加的令牌就被丢弃。
-请求达到后首先要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完业务逻辑之后,将令牌直接删除。

如下图所示:

滑动窗口

计数器,漏桶和令牌桶算法是在上游节点做的限流,通过配置系统参数做限制,不依赖于下游服务的反馈数据,对于异构的请求不太适用,且需要预估下游节点的处理能力。

滑动窗口限频类似于 TCP 的滑动窗口协议,设置一个窗口大小,这个大小即当前最大在处理中的请求量,同时记录滑动窗口的左右端点,每次发送一个请求时滑动窗口右端点往前移一格,每次收到请求处理完毕响应后窗口左端点往前移一格,当右端点与左端点的差值超过最大窗口大小时,等待发送或拒绝服务。

如下图所示:

SRE 自适应限流

滑动窗口是以固定的窗口大小限制请求,而 Google 的 SRE 自适应限流相当于是一个动态的窗口,它根据过往请求的成功率动态调整向后端发送请求的速率,当成功率越高请求被拒绝的概率就越小;反之,当成功率越低请求被拒绝的概率就相应越大。

SRE 自适应限流算法需要在应用层记录过去两分钟内的两个数据信息:

  • requests:请求总量,应用层尝试的请求数
  • accepts:成功被后端处理的请求数

请求被拒绝的概率 p 的计算公式如下:p = max(0, requests - K*accepts) / (requests + 1)

  • K 为倍率因子,由用户设置(比如 2),从算法公式可以看出:
  • 在正常情况下 requests 等于 accepts,新请求被决绝的概率 p 为 0,即所有请求正常通过
  • 当后端出现异常情况时,accepts 的数量会逐渐小于 requests,应用层可以继续发送请求直到 requests 等于K*accepts ,一旦超过这个值,自适应限流启动,新请求就会以概率 p 被拒绝。
  • 当后端逐渐恢复时,accepts 逐渐增加,概率 p 会增大,更多请求会被放过,当 accepts 恢复到使得 K*accepts 大于等于 requests 时,概率 p 等于 0,限流结束。

我们可以针对不同场景中处理更多请求带来的风险成本与拒绝更多请求带来的服务损失成本之间进行权衡,调整 K 值大小:

降低 K 值会使自适应限流算法更加激进(拒绝更多请求,服务损失成本升高,风险成本降低)。
增加 K 值会使自适应限流算法不再那么激进(放过更多请求,服务损失成本降低,风险成本升高)。
如对于某些处理该请求的成本与拒绝该请求的成本的接近场景,系统高负荷运转造成很多请求处理超时,实际已无意义,然而却还是一样会消耗系统资源的情况下,可以调小 K 值。

熔断

熔断算法原理是系统统计并定时检查过往请求的失败(超时)比率,当失败(超时)率达到一定阈值之后,熔断器开启,并休眠一段时间,当休眠期结束后,熔断器关闭,重新往后端节点发送请求,并重新统计失败率。如此周而复始。

如下图所示:

Hystrix 半开熔断器

Hystrix 中的半开熔断器相对于简单熔断增加了一种半开状态,Hystrix 在运行过程中会向每个请求对应的节点报告成功、失败、超时和拒绝的状态,熔断器维护计算统计的数据,根据这些统计的信息来确定熔断器是否打开。如果打开,后续的请求都会被截断。然后会隔一段时间,尝试半开状态,即放入一部分请求过去,相当于对服务进行一次健康检查,如果服务恢复,熔断器关闭,随后完全恢复调用,如果失败,则重新打开熔断器,继续进入熔断等待状态。

如下图所示:

序列化与编码

数据结构序列化是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络传输),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。经过依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。

标记语言

标记语言是一种将文本(Text)以及文本相关的其他信息结合起来,展现出关于文档结构和数据处理细节的计算机文字编码。

超文本标记语言(HTML)

HTML 是一种用于创建网页的标准标记语言。HTML 是一种基础技术,常与 CSS、JavaScript 一起被众多网站用于设计网页、网页应用程序以及移动应用程序的用户界面。网页浏览器可以读取 HTML 文件,并将其渲染成可视化网页。HTML 描述了一个网站的结构语义随着线索的呈现,使之成为一种标记语言而非编程语言。

可扩展标记语言(XML)

XML 是一种标记语言,设计用来传送及携带数据信息。每个 XML 文档都由 XML 声明开始,在前面的代码中的第一行就是 XML 声明。这一行代码会告诉解析器或浏览器这个文件应该按照 XML 规则进行解析。

XML 文档的字符分为标记(Markup)与内容(content)两类。标记通常以<开头,以>结尾;或者以字符&开头,以;结尾。不是标记的字符就是内容。一个 tag 属于标记结构,以<开头,以>结尾。

元素是文档逻辑组成,或者在 start-tag 与匹配的 end-tag 之间,或者仅作为一个 empty-element tag。

JSON

JSON 是以数据线性化为目标的轻量级标记语言,相比于 XML,JSON 更加简洁、轻量和具有更好的可读性。

JSON 的基本数据类型和编码规则:

  • 数值:十进制数,不能有前导 0,可以为负数,可以有小数部分。还可以用 e 或者 E 表示指数部分。不能包含非数,如 NaN。不区分整数与浮点数。
  • 字符串:以双引号””括起来的零个或多个 Unicode 码位。支持反斜杠开始的转义字符序列。
  • 布尔值:表示为 true 或者 false。
  • 数组:有序的零个或者多个值。每个值可以为任意类型。序列表使用方括号[,]括起来。元素之间用逗号,分割。形如:[value, value]
  • 对象:若干无序的“键-值对”(key-value pairs),其中键只能是字符串。建议但不强制要求对象中的键是独一无二的。对象以花括号{开始,并以}结束。键-值对之间使用逗号分隔。键与值之间用冒号:分割。
  • 空值:值写为 null

TLV 二进制序列化

很多高效得数据序列化方式都是采用类 TLV(Tag+Length+Value)的方式对数据进行序列化和反序列化,每块数据以 Tag 开始,Tag 即数据标签,标识接下来的数据类型是什么,Length 即长度,标识接下来的数据总长,Value 即数据的实际内容,结合 Tag 和 Length 的大小即可获取当前这块数据内容。

Protocol Buffers

Protocol Buffers(简称:ProtoBuf)是一种开源跨平台的序列化数据结构的协议,它是一种灵活,高效,自动化的结构数据序列化方法,相比 XML 和 JSON 更小、更快、更为简单。

Protocol Buffers 包含一个接口描述语言.proto 文件,描述需要定义的一些数据结构,通过程序工具根据这些描述产生.cc 和.h 文件代码,这些代码将用来生成或解析代表这些数据结构的字节流。

Protocol Buffers 编码后的消息都是 Key-Value 形式,Key 的值由 field_number(字段标号)和 wire_type(编码类型)组合而成,规则为:key = field_number << 3 | wire type

field_number 部分指示了当前是哪个数据成员,通过它将 cc 和 h 文件中的数据成员与当前的 key-value 对应起来。

wire type 为字段编码类型,有以下几类:

Protocol Buffers 编码特征:

  • 整型数据采用 varint 编码,以节省序列化后数据大小。
  • 对于有符号整型,先进行 zigzag 编码调整再进行 varint 数据编码,以减小负整数序列化后数据大小。
  • string、嵌套结构以及 packed repeated fields 的编码类型是 Length-delimited,它们的编码方式统一为 tag+length+value。

TDR

TDR 是腾讯互娱研发部自研跨平台多语言数据表示组件,主要用于数据的序列化反序列化以及数据的存储。TDR 通过 XML 文件来定义接口和结构的描述,通过程序工具根据这些描述产生.tdr 和.h 文件代码,用于序列化和反序列化这些数据结构。

TDR1.0 的版本是通过版本剪裁方式来序列化反序列化,需要事先维护好字段版本号,序列化反序列化时通过剪裁版本号来完成兼容的方式,只支持单向的高版本兼容低版本数据。

TDR2.0 整体上与 Protocol Buffers 相似,TDR2.0 支持消息协议的前后双向兼容,整型数据同样支持 varint 编码和 zigzag 调整的方式,在对 TLV 中 Length 部分进行处理时,采用定长编解码方式,以浪费序列化空间的代价来获取更高性能,避免了类似 Protocol Buffers 中不必要的内存拷贝(或者是预先计算大小)的过程。

Protocol Buffers 和 TDR 都有接口描述语言,这使得它们的序列化更高效,数据序列化后也更加紧凑。

Luna 序列化

luna 库是开源的基于 C++17 的 lua/C++绑定库,它同时也实现了针对 lua 数据结构的序列化和反序列化功能,用于 lua 结构数据的传输和存储。

Lua 语言中需要传输和存储的数据类型主要有:nil,boolean,number,string,table。因此在序列化过程中,luna 将类型定义为以下九种类型。

序列化方式如下:

整体上也是类似于 Protocol Buffers 和 TDR 的 TLV 编码方式,同时针对 lua 类型结构的特性做了一些效率上的优化。

主要特性如下:

  • Boolean 类型区分为 bool_true 和 bool_false,只需在增加 2 种 type 值就可以解决。
  • 整型 integer 同样采用 varint 压缩编码方式,无需额外字节记录长度。
  • 有符号整型,同样是先进行 zigzag 调整再进行 varint 数据编码。
  • 字符串类型分为 string 和 string_idx,编码过程中会缓存已经出现过的字符串,对于后续重复出现的字符串记录为 string_idx 类型,value 值记录该字符串第一次出现的序号,节约字符串占用的空间。
  • 对于小于 246(255 减去类型数量 9)的小正整型数,直接当成不同类型处理,加上数值 9 之后记录在 type 中,节约空间。
  • Table 为嵌套结构,用 table_head 和 table_tail 两种类型表示开始和结束。key 和 value 分别进行嵌套编码。

Skynet 序列化

Skynet 是一个应用广泛的为在线游戏服务器打造的轻量级框架。但实际上它也不仅仅使用在游戏服务器领域。skynet 的核心是由 C 语言编写,但大多数 skynet 服务使用 lua 编写,因此它也实现了针对 lua 数据结构的序列化和反序列化功能。

序列化方式如下:

主要特性如下:

  • Type 类型通过低 3 位和高 5 位来区分主类型和子类型。
  • Boolean 单独主类型,子类型字段用 1 和 0 区分 true 和 false。
  • 整型使用 number 主类型,子类型分为 number_zero(0 值),number_byte(小于 8 位的正整数),number_word(小于 16 位的正整数),number_dword(小于 32 位的正负整数),number_qword(其他整数),number_real(浮点数)。
  • 字符串类型分为短字符串 short_string 和长字符串 long_string,小于 32 字节长度的字符串记录为 short_string 主类型,低 5 位的子类型记录长度。long_string 又分为 2 字节(长度小于 0x1000)和 4 字节(长度大于等于 0x1000)长字符串,分别用 2 字节 length 和 4 字节 length 记录长度。
  • Table 类型会区分 array 部分和 hash 部分,先将 array 部分序列化,array 部分又分为小 array 和大 array,小 array(0-30 个元素)直接用 type 的低 5 位的子类型记录大小,大 array 的子类型固定为 31,大小通过 number 类型编码。Hash 部分需要将 key 和 value 分别进行嵌套编码。
  • Table 的结束没有像 luna 一样加了专门的 table_tail 标识,而是通过 nil 类型标识。

压缩编码

压缩算法从对数据的完整性角度分有损压缩和无损压缩。

有损压缩算法通过移除在保真前提下需要的必要数据之外的其小细节,从而使文件变小。在有损压缩里,因部分有效数据的移除,恢复原文件是不可能的。有损压缩主要用来存储图像和音频文件,通过移除数据达到比较高的压缩率。

无损压缩,也能使文件变小,但对应的解压缩功能可以精确的恢复原文件,不丢失任何数据。无损数据压缩被广泛的应用于计算机领域,数据的传输和存储系统中均使用无损压缩算法。

接下来我们主要是介绍几种无损压缩编码算法。

熵编码法

一种主要类型的熵编码方式是对输入的每一个符号,创建并分配一个唯一的前缀码,然后,通过将每个固定长度的输入符号替换成相应的可变长度前缀无关(prefix-free)输出码字替换,从而达到压缩数据的目的。每个码字的长度近似与概率的负对数成比例。因此,最常见的符号使用最短的码。

霍夫曼编码和算术编码是两种最常见的熵编码技术。如果预先已知数据流的近似熵特性(尤其是对于信号压缩),可以使用简单的静态码。

游程编码

又称行程长度编码或变动长度编码法,是一种与资料性质无关的无损数据压缩技术,基于“使用变动长度的码来取代连续重复出现的原始资料”来实现压缩。举例来说,一组资料串”AAAABBBCCDEEEE”,由 4 个 A、3 个 B、2 个 C、1 个 D、4 个 E 组成,经过变动长度编码法可将资料压缩为 4A3B2C1D4E(由 14 个单位转成 10 个单位)。

MTF 变换

MTF(Move-To-Front)是一种数据编码方式,作为一个额外的步骤,用于提高数据压缩技术效果。MTF 主要使用的是数据“空间局部性”,也就是最近出现过的字符很可能在接下来的文本附近再次出现。

过程可以描述为:

  • 首先维护一个文本字符集大小的栈表,“recently used symbols”(最近访问过的字符),其中每个不同的字符在其中占一个位置,位置从 0 开始编号。
  • 扫描需要重新编码的文本数据,对于每个扫描到的字符,使用该字符在“recently used symbols”中的 index 替换,并将该字符提到“recently used symbols”的栈顶的位置(index 为 0 的位置)。重复上一步骤,直到文本扫描结束。

块排序压缩

当一个字符串用该算法转换时,算法只改变这个字符串中字符的顺序而并不改变其字符。如果原字符串有几个出现多次的子串,那么转换过的字符串上就会有一些连续重复的字符,这对压缩是很有用的。

块排序变换(Burrows-Wheeler Transform)算法能使得基于处理字符串中连续重复字符的技术(如 MTF 变换和游程编码)的编码更容易被压缩。

块排序变换算法将输入字符串的所有循环字符串按照字典序排序,并以排序后字符串形成的矩阵的最后一列为其输出。

字典编码法

由 Abraham Lempel 和 Jacob Ziv 独创性的使用字典编码器的 LZ77/78 算法及其 LZ 系列变种应用广泛。

LZ77 算法通过使用编码器或者解码器中已经出现过的相应匹配数据信息替换当前数据从而实现压缩功能。这个匹配信息使用称为长度-距离对的一对数据进行编码,它等同于“每个给定长度个字符都等于后面特定距离字符位置上的未压缩数据流。”编码器和解码器都必须保存一定数量的缓存数据。保存这些数据的结构叫作滑动窗口,因为这样所以 LZ77 有时也称作滑动窗口压缩。编码器需要保存这个数据查找匹配数据,解码器保存这个数据解析编码器所指代的匹配数据。

LZ77 算法针对过去的数据进行处理,而 LZ78 算法却是针对后来的数据进行处理。LZ78 通过对输入缓存数据进行预先扫描与它维护的字典中的数据进行匹配来实现这个功能,在找到字典中不能匹配的数据之前它扫描进所有的数据,这时它将输出数据在字典中的位置、匹配的长度以及找不到匹配的数据,并且将结果数据添加到字典中。

霍夫曼(Huffman)编码

霍夫曼编码把文件中一定位长的值看作是符号,比如把 8 位长的 256 种值,也就是字节的 256 种值看作是符号。根据这些符号在文件中出现的频率,对这些符号重新编码。对于出现次数非常多的,用较少的位来表示,对于出现次数非常少的,用较多的位来表示。这样一来,文件的一些部分位数变少了,一些部分位数变多了,由于变小的部分比变大的部分多,所以整个文件的大小还是会减小,所以文件得到了压缩。

要进行霍夫曼编码,首先要把整个文件读一遍,在读的过程中,统计每个符号(我们把字节的 256 种值看作是 256 种符号)的出现次数。然后根据符号的出现次数,建立霍夫曼树,通过霍夫曼树得到每个符号的新的编码。对于文件中出现次数较多的符号,它的霍夫曼编码的位数比较少。对于文件中出现次数较少的符号,它的霍夫曼编码的位数比较多。然后把文件中的每个字节替换成他们新的编码。

其他压缩编码

  • deflate 是同时使用了 LZ77 算法与霍夫曼编码的一个无损数据压缩算法。
  • gzip 压缩算法的基础是 deflate。
  • bzip2 使用 Burrows-Wheeler transform 将重复出现的字符序列转换成同样字母的字符串,然后用 move-to-front 变换进行处理,最后使用霍夫曼编码进行压缩。
  • LZ4 着重于压缩和解压缩速度,它属于面向字节的 LZ77 压缩方案家族。
  • Snappy(以前称 Zippy)是 Google 基于 LZ77 的思路用 C++语言编写的快速数据压缩与解压程序库,并在 2011 年开源,它的目标并非最大压缩率或与其他压缩程序库的兼容性,而是非常高的速度和合理的压缩率。

其他编码

Varint

前文所提到的 Varint 整型压缩编码方式,它使用一个或多个字节序列化整数的方法,把整数编码为变长字节。

Varint 编码将每个字节的低 7bit 位用于表示数据,最高 bit 位表示后面是否还有字节,其中 1 表示还有后续字节,0 表示当前是最后一个字节。当整型数值很小时,只需要极少数的字节进行编码,如数值 9,它的编码就是 00001001,只需一个字节。

如上图所示,假设要编码的数据 123456,二进制为:11110001001000000,按 7bit 划分后,每 7bit 添加高 1 位的是否有后续字节标识,编码为 110000001100010000000111,占用 3 个字节。

对于 32 位整型数据经过 Varint 编码后需要 1~5 个字节,小的数字使用 1 个字节,大的数字使用 5 个字节。64 位整型数据编码后占用 1~10 个字节。在实际场景中小数字的使用率远远多于大数字,因此通过 Varint 编码对于大部分场景都可以起到很好的压缩效果。

ZigZag

zigzag 编码的出现是为了解决 varint 对负数编码效率低的问题。对于有符号整型,如果数值为负数,二进制就会非常大,例如-1 的 16 进制:0xffff ffff ffff ffff,对应的二进制位全部是 1,使用 varint 编码需要 10 个字节,非常不划算。

zigzag 编码的原理是将有符号整数映射为无符号整数,使得负数的二进制数值也能用较少的 bit 位表示。它通过移位来实现映射。

由于补码的符号位在最高位,对于负数,符号位为 1,这导致 varint 压缩编码无法压缩,需要最大变长字节来存储,因此首先将数据位整体循环左移 1 位,最低位空出留给符号位使用,另外,对于实际使用中,绝对值小的负数应用场景比绝对值大的负数应用场景大的多,但绝对值小的负数的前导 1 更多(如-1,全是 1),因此对于负整数,再把数据位按取反规则操作,将前导 1 置换为 0,以达到可以通过 varint 编码能有效压缩的目的。

Base 系列

有的字符在一些环境中是不能显示或使用的,比如&, =等字符在 URL 被保留为特殊作用的字符,比如一些二进制码如果转成对应的字符的话,会有很多不可见字符和控制符(如换行、回车之类),这时就需要对数据进行编码。Base 系列的就是用来将字节编码为 ASCII 中的可见字符的,以便能进行网络传输和打印等。

Base 系列编码的原理是将字节流按固定步长切片,然后通过映射表为每个切片找一个对应的、可见的 ASCII 字符,最终重组为新的可见字符流。

Base16 也称 hex,它使用 16 个可见字符来表示二进制字符串,1 个字符使用 2 个可见字符来表示,因此编码后数据大小将翻倍。

Base32 使用 32 个可见字符来表示二进制字符串,5 个字符使用 8 个可见字符表示,最后如果不足 8 个字符,将用“=”来补充,编码后数据大小变成原来的 8/5。

Base64 使用 64 个可见字符来表示二进制字符串, 3 个字符使用 4 个可见字符来表示,编码后数据大小变成原来的 4/3。

百分号编码

百分号编码又称 URL 编码(URL encoding),是特定上下文的统一资源定位符(URL)的编码机制,实际上也适用于统一资源标志符(URI)的编码。

百分号编码同样也是为了使 URL 具有可传输性,可显示性以及应对二进制数据的完整性而进行的一种编码规则。

百分号编码规则为把字符的 ASCII 的值表示为两个 16 进制的数字,然后在其前面放置转义字符百分号“%”。

URI 所允许的字符分作保留与未保留。保留字符是那些具有特殊含义的字符,例如:斜线字符用于 URL(或 URI)不同部分的分界符;未保留字符没有这些特殊含义。

百分号编码可描述为:

  • 未保留字符不需要编码
  • 如果一个保留字符需要出现在 URI 一个路径成分的内部, 则需要进行百分号编码
  • 除了保留字符和未保留字符(包括百分号字符本身)的其它字符必须用百分号编码
  • 二进制数据表示为 8 位组的序列,然后对每个 8 位组进行百分号编码

加密与校验

CRC

CRC 循环冗余校验(Cyclic redundancy check)是一种根据网络数据包或电脑文件等数据产生简短固定位数校验码的一种散列函数,主要用来检测或校验数据传输或者保存后可能出现的错误。生成的数字在传输或者存储之前计算出来并且附加到数据后面,然后接收方进行检验确定数据是否发生变化。它是一类重要的线性分组码,编码和解码方法简单,检错和纠错能力强,在通信领域广泛地用于实现差错控制。

CRC 是两个字节数据流采用二进制除法(没有进位,使用 XOR 来代替减法)相除所得到的余数。其中被除数是需要计算校验和的信息数据流的二进制表示;除数是一个长度为(n+1)的预定义(短)的二进制数,通常用多项式的系数来表示。在做除法之前,要在信息数据之后先加上 n 个 0。CRC 是基于有限域 GF(2)(即除以 2 的同余)的多项式环。简单的来说,就是所有系数都为 0 或 1(又叫做二进制)的多项式系数的集合,并且集合对于所有的代数操作都是封闭的。

奇偶校验

奇偶校验(Parity Check)是一种校验代码传输正确性的方法。根据被传输的一组二进制代码的数位中“1”的个数是奇数或偶数来进行校验。采用奇数的称为奇校验,反之,称为偶校验。通常专门设置一个奇偶校验位,用它使这组代码中“1”的个数为奇数或偶数。若用奇校验,则当接收端收到这组代码时,校验“1”的个数是否为奇数,从而确定传输代码的正确性。

以偶校验位来说,如果一组给定数据位中 1 的个数是奇数,补一个 bit 为 1,使得总的 1 的个数是偶数。例:0000001, 补一个 bit 为 1 即 00000011。

以奇校验位来说,如果给定一组数据位中 1 的个数是奇数,补一个 bit 为 0,使得总的 1 的个数是奇数。例:0000001, 补一个 bit 为 0 即 00000010。

偶校验实际上是循环冗余校验的一个特例,通过多项式 x + 1 得到 1 位 CRC。

MD 系列

MD 系列算法(Message-Digest Algorithm)用于生成信息摘要特征码,具有不可逆性和高度的离散性,可以看成是一种特殊的散列函数(见 8.1 节),一般认为可以唯一地代表原信息的特征,通常用于密码的加密存储,数字签名,文件完整性验证等。

MD4 是麻省理工学院教授 Ronald Rivest 于 1990 年设计的一种信息摘要算法,它是一种用来测试信息完整性的密码散列函数的实现,其摘要长度为 128 位。它是基于 32 位操作数的位操作来实现的。这个算法影响了后来的算法如 MD5、SHA 家族和 RIPEMD 等

MD5 消息摘要算法是一种被广泛使用的密码散列函数,可以产生出一个 128 位(16 个字符)的散列值,用于确保信息传输完整一致。MD5 由美国密码学家罗纳德·李维斯特(Ronald Linn Rivest)设计,于 1992 年公开,用以取代 MD4 算法。MD5 是输入不定长度信息,输出固定长度 128-bits 的算法。经过程序流程,生成四个 32 位数据,最后联合起来成为一个 128-bits 散列。基本方式为,求余、取余、调整长度、与链接变量进行循环运算,得出结果。

MD6 消息摘要算法使用默克尔树形式的结构,允许对很长的输入并行进行大量散列计算。该算法的 Block size 为 512 bytes(MD5 的 Block Size 是 512 bits), Chaining value 长度为 1024 bits, 算法增加了并行 机制,适合于多核 CPU。相较于 MD5,其安全性大大改进,加密结构更为完善,但其有证明的版本速度太慢,而效率高的版本并不能给出类似的证明。

SHA 系列

SHA(Secure Hash Algorithm)是一个密码散列函数家族,是 FIPS 所认证的安全散列算法。

SHA1 是由 NISTNSA 设计为同 DSA 一起使用的,它对长度小于 264 的输入,产生长度为 160bit 的散列值,因此抗穷举性更好。SHA-1 设计时基于和 MD4 相同原理,并且模仿了该算法。SHA-1 的安全性在 2010 年以后已经不被大多数的加密场景所接受。2017 年荷兰密码学研究小组 CWI 和 Google 正式宣布攻破了 SHA-1。

SHA-2 由美国国家安全局研发,由美国国家标准与技术研究院(NIST)在 2001 年发布。属于 SHA 算法之一,是 SHA-1 的后继者。包括 SHA-224、SHA-256、SHA-384、SHA-512、SHA-512/224、SHA-512/256。SHA-256 和 SHA-512 是很新的杂凑函数,前者以定义一个 word 为 32 位元,后者则定义一个 word 为 64 位元。它们分别使用了不同的偏移量,或用不同的常数,然而,实际上二者结构是相同的,只在循环执行的次数上有所差异。SHA-224 以及 SHA-384 则是前述二种杂凑函数的截短版,利用不同的初始值做计算。

SHA-3 第三代安全散列算法之前名为 Keccak 算法,Keccak 使用海绵函数,此函数会将资料与初始的内部状态做 XOR 运算,这是无可避免可置换的(inevitably permuted)。在最大的版本,算法使用的内存状态是使用一个 5×5 的二维数组,资料类型是 64 位的字节,总计 1600 比特。缩版的算法使用比较小的,以 2 为幂次的字节大小 w 为 1 比特,总计使用 25 比特。除了使用较小的版本来研究加密分析攻击,比较适中的大小(例如从 w=4 使用 100 比特,到 w=32 使用 800 比特)则提供了比较实际且轻量的替代方案。

对称密钥算法

对称密钥算法(Symmetric-key algorithm)又称为对称加密、私钥加密、共享密钥加密,是密码学中的一类加密算法。这类算法在加密和解密时使用相同的密钥,或是使用两个可以简单地相互推算的密钥。事实上,这组密钥成为在两个或多个成员间的共同秘密,以便维持专属的通信联系。与公开密钥加密相比,要求双方获取相同的密钥是对称密钥加密的主要缺点之一。

常见的对称加密算法有 AES、ChaCha20、3DES、Salsa20、DES、Blowfish、IDEA、RC5、RC6、Camellia 等。

对称加密的速度比公钥加密快很多,在很多场合都需要对称加密。

非对称加密算法

非对称式密码学(Asymmetric cryptography)也称公开密钥密码学(Public-key cryptography),是密码学的另一类加密算法,它需要两个密钥,一个是公开密钥,另一个是私有密钥。公钥用作加密,私钥则用作解密。使用公钥把明文加密后所得的密文,只能用相对应的私钥才能解密并得到原本的明文,最初用来加密的公钥不能用作解密。由于加密和解密需要两个不同的密钥,故被称为非对称加密,不同于加密和解密都使用同一个密钥的对称加密。

公钥可以公开,可任意向外发布,私钥不可以公开,必须由用户自行严格秘密保管,绝不透过任何途径向任何人提供,也不会透露给被信任的要通信的另一方。

常用的非对称加密算法是 RSA 算法。

哈希链

哈希链是一种由单个密钥或密码生成多个一次性密钥或密码的一种方法。哈希链是将密码学中的哈希函数h(x)循环地用于一个字符串。(即将所得哈希值再次传递给哈希函数得至其哈希值)。

例:h(h(h(h(x)))),是一个长度为 4 哈希链,记为:h^4(x)

相比较而言,一个提供身份验证的服务器储存哈希字符串,比储存纯文本密码,更能防止密码在传输或储存时被泄露。举例来说,一个服务器一开始存储了一个由用户提供的哈希值h^1000(x)。进行身份验证时,用户提供给服务器h^999(x)。服务器计算h(h^999(x))h^1000(x),并与已储存的哈希值h^1000(x)进行比较。然后服务器将存储h^999(x)以用来对用户进行下次验证。

窃听者即使嗅探到h^999(x)送交服务器,也无法将h^999(x)用来认证,因为现在服务器验证算法传入的参数是h^998(x)。由于安全的哈希函数有一种单向的加密属性,对于想要算出前一次哈希值的窃听者来说它的值是不可逆的。在本例中,用户在整个哈希链用完前可以验证 1000 次之多。每次哈希值是不同的,不能被攻击者再次使用。

缓存淘汰策略

服务器常用缓存提升数据访问性能,但由于缓存容量有限,当缓存容量到达上限,就需要淘汰部分缓存数据挪出空间,这样新数据才可以添加进来。好的缓存应该是在有限的内存空间内尽量保持最热门的数据在缓存中,以提高缓存的命中率,因此如何淘汰数据有必要进行一番考究。缓存淘汰有多种策略,可以根据不同的业务场景选择不同淘汰的策略。

FIFO

FIFO(First In First Out)是一种先进先出的数据缓存器,先进先出队列很好理解,当访问的数据节点不在缓存中时,从后端拉取节点数据并插入在队列头,如果队列已满,则淘汰最先插入队列的数据。

假设缓存队列长度为 6,过程演示如下:

LRU

LRU(Least recently used)是最近最少使用缓存淘汰算法,可它根据数据的历史访问记录来进行淘汰数据,其核心思想认为最近使用的数据是热门数据,下一次很大概率将会再次被使用。而最近很少被使用的数据,很大概率下一次不再用到。因此当缓存容量的满时候,优先淘汰最近很少使用的数据。因此它与 FIFO 的区别是在访问数据节点时,会将被访问的数据移到头结点。

假设缓存队列长度为 6,过程演示如下:

LRU 算法有个缺陷在于对于偶发的访问操作,比如说批量查询某些数据,可能使缓存中热门数据被这些偶发使用的数据替代,造成缓存污染,导致缓存命中率下降。

LFU

LFU 是最不经常使用淘汰算法,其核心思想认为如果数据过去被访问多次,那么将来被访问的频率也更高。LRU 的淘汰规则是基于访问时间,而 LFU 是基于访问次数。LFU 缓存算法使用一个计数器来记录数据被访问的次数,最低访问数的条目首先被移除。

假设缓存队列长度为 4,过程演示如下:

LFU 能够避免偶发性的操作导致缓存命中率下降的问题,但它也有缺陷,比如对于一开始有高访问率而之后长时间没有被访问的数据,它会一直占用缓存空间,因此一旦数据访问模式改变,LFU 可能需要长时间来适用新的访问模式,即 LFU 存在历史数据影响将来数据的”缓存污染”问题。另外对于对于交替出现的数据,缓存命中不高。

LRU-K

无论是 LRU 还是 LFU 都有各自的缺陷,LRU-K 算法更像是结合了 LRU 基于访问时间和 LFU 基于访问次数的思想,它将 LRU 最近使用过 1 次的判断标准扩展为最近使用过 K 次,以提高缓存队列淘汰置换的门槛。LRU-K 算法需要维护两个队列:访问列表和缓存列表。LRU 可以认为是 LRU-K 中 K 等于 1 的特化版。

LRU-K 算法实现可以描述为:

  • 数据第一次被访问,加入到访问列表,访问列表按照一定规则(如 FIFO,LRU)淘汰。
  • 当访问列表中的数据访问次数达到 K 次后,将数据从访问列表删除,并将数据添加到缓存列表头节点,如果数据已经在缓存列表中,则移动到头结点。
  • 若缓存列表数据量超过上限,淘汰缓存列表中排在末尾的数据,即淘汰倒数第 K 次访问离现在最久的数据。

假设访问列表长度和缓存列表长度都为 4,K=2,过程演示如下:

LRU-K 具有 LRU 的优点,同时能够降低缓存数据被污染的程度,实际应用可根据业务场景选择不同的 K 值,K 值越大,缓存列表中数据置换的门槛越高。

Two queues

Two queues 算法可以看做是 LRU-K 算法中 K=2,同时访问列表使用 FIFO 淘汰算法的一个特例。如下图所示:

LIRS

LIRS(Low Inter-reference Recency Set)算法将缓存分为两部分区域:热数据区与冷数据区。LIRS 算法利用冷数据区做了一层隔离,目的是即使在有偶发性的访问操作时,保护热数据区的数据不会被频繁地被置换,以提高缓存的命中。

LIRS 继承了 LRU 根据时间局部性对冷热数据进行预测的思想,并在此之上 LIRS 引入了两个衡量数据块的指标:

  • IRR(Inter-Reference Recency):表示数据最近两次访问之间访问其它数据的非重复个数
  • R (Recency):表示数据最近一次访问到当前时间内访问其它数据的非重复个数,也就是 LRU 的维护的数据。

如下图,从左往右经过以下 8 次访问后,A 节点此时的 IRR 值为 3,R 值为 1。

IRR 可以由 R 值计算而来,具体公式为:IRR=上一时刻的 R-当前时刻的 R,如上图当前时刻访问的节点是 F,那么当前时刻 F 的 R 值为 0,而上一个 F 节点的 R 值为 2,因此 F 节点的 IRR 值为 2。

LIRS 动态维护两个集合:

  • LIR(low IRR block set):具有较小 IRR 的数据块集合,可以将这部分数据块理解为热数据,因为 IRR 低说明访问的频次高。
  • HIR(high IRR block set):具有较高 IRR 的数据块集合,可以将这部分数据块理解为冷数据。

LIR 集合所有数据都在缓存中,而 HIR 集合中有部分数据不在缓存中,但记录了它们的历史信息并标记为未驻留在缓存中,称这部分数据块为 nonresident-HIR,另外一部分驻留在缓存中的数据块称为 resident-HIR。

LIR 集合在缓存中,所以访问 LIR 集合的数据是百分百会命中缓存的。而 HIR 集合分为 resident-HIR 和 nonresident-HIR 两部分,所以会遇到未命中情况。当发生缓存未命中需要置换缓存块时,会选择优先淘汰置换 resident-HIR。如果 HIR 集合中数据的 IRR 经过更新比 LIR 集合中的小,那么 LIR 集合数据块就会被 HIR 集合中 IRR 小的数据块挤出并转换为 HIR。

LIRS 通过限制 LIR 集合的长度和 resident-HIR 集合长度来限制整体大小,假设设定 LIR 长度为 2,resident-HIR 长度为 1 的 LIRS 算法过程演示如下:

  1. 所有最近访问的数据都放置在称为 LIRS 堆栈的 FIFO 队列中(图中的堆栈 S),所有常驻的 resident-HIR 数据放置在另一个 FIFO 队列中(图中的堆栈 Q)。
  2. 当栈 S 中的一个 LIR 数据被访问时,被访问的数据会被移动到堆栈 S 的顶部,并且堆栈底部的任何 HIR 数据都被删除,因为这些 HIR 数据的 IRR 值不再有可能超过任何 LIR 数据了。例如,图(b)是在图(a)上访问数据 B 之后生成的。
  3. 当栈 S 中的一个 resident-HIR 数据被访问时,它变成一个 LIR 数据,相应地,当前在栈 S 最底部的 LIR 数据变成一个 HIR 数据并移动到栈 Q 的顶部。例如,图(c)是在图(a)上访问数据 E 之后生成的。
  4. 当栈 S 中的一个 nonresident-HIR 数据被访问时,它变成一个 LIR 数据,此时将选择位于栈 Q 底部的 resident-HIR 数据作为替换的牺牲品,降级为 nonresident-HIR,而栈 S 最底部的 LIR 数据变成一个 HIR 数据并移动到栈 Q 的顶部。例如,图(d)是在图(a)上访问数据 D 之后生成的。
  5. 当访问一个不在栈 S 中的数据时,它会成为一个 resident-HIR 数据放入栈 Q 的顶部,同样的栈 Q 底部的 resident-HIR 数据会降级为 nonresident-HIR。例如,图(e)是在图(a)上访问数据 C 之后生成的。

解释一下当栈 S 中的一个 HIR 数据被访问时,它为什么一定会变成一个 LIR 数据:这个数据被访问时,需要更新 IRR 值(即为当前的 R 值),使用这个新的 IRR 与 LIR 集合数据中最大的 R 值进行比较(即栈 S 最底部的 LIR 数据),新的 IRR 一定会比栈 S 最底部的 LIR 数据的 IRR 小(因为栈 S 最底部的数据一定是 LIR 数据,步骤 2 已经保证了),所以它一定会变成一个 LIR 数据。

MySQL InnoDB LRU

MySQL InnoDB 中的 LRU 淘汰算法采用了类似的 LIRS 的分级思想,它的置换数据方式更加简单,通过判断冷数据在缓存中存在的时间是否足够长(即还没有被 LRU 淘汰)来实现。数据首先进入冷数据区,如果数据在较短的时间内被访问两次或者以上,则成为热点数据进入热数据区,冷数据和热数据部分区域内部各自还是采用 LRU 替换算法。

MySQL InnoDB LRU 算法流程可以描述为:

  • 访问数据如果位于热数据区,与 LRU 算法一样,移动到热数据区的头结点。
  • 访问数据如果位于冷数据区,若该数据已在缓存中超过指定时间,比如说 1s,则移动到热数据区的头结点;若该数据存在时间小于指定的时间,则位置保持不变。
  • 访问数据如果不在热数据区也不在冷数据区,插入冷数据区的头结点,若冷数据缓存已满,淘汰尾结点的数据。

基数集与基数统计

哈希表

哈希表是根据关键码(Key)而直接进行访问的数据结构,它把关键码映射到一个有限的地址区间上存放在哈希表中,这个映射函数叫做散列函数。哈希表的设计最关键的是使用合理的散列函数和冲突解决算法。

好的散列函数应该在输入域中较少出现散列冲突,数据元素能被更快地插入和查找。常见的散列函数算法有:直接寻址法,数字分析法,平方取中法,折叠法,随机数法,除留余数法等。

然而即使再好的散列函数,也不能百分百保证没有冲突,因此必须要有冲突的应对方法,常见的冲突解决算法有:

  • 开放定址法:从发生冲突的那个单元起,按照一定的次序,从哈希表中找到一个空闲的单元。然后把发生冲突的元素存入到该单元的一种方法。开放定址法需要的表长度要大于等于所需要存放的元素。而查询一个对象时,则需要从对应的位置开始向后找,直到找到或找到空位。根据探查步长决策规则不同,开放定址法中一般有:线行探查法(步长固定为 1,依次探查)、平方探查法(步长为探查次数的平方值)、双散列函数探查法(步长由另一个散列函数计算决定)。
  • 拉链法:在每个冲突处构建链表,将所有冲突值链入链表(称为冲突链表),如同拉链一般一个元素扣一个元素。
  • 再哈希法:就是同时构造多个不同的哈希函数,当前面的哈希函数发生冲突时,再用下一个哈希函数进行计算,直到冲突不再产生。
  • 建立公共溢出区:哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。

使用哈希表统计基数值即将所有元素存储在一个哈希表中,利用哈希表对元素进行去重,并统计元素的个数,这种方法可以精确的计算出不重复元素的数量。

但使用哈希表进行基数统计,需要存储实际的元素数据,在数据量较少时还算可行,但是当数据量达到百万、千万甚至上亿时,使用哈希表统计会占用大量的内存,同时它的查找过滤成本也很高。如 18 号晚微信视频号西城男孩直播夜有 2000 万多的用户观看,假设记录用户的 id 大小需要 8 字节,那么使用哈希表结构至少需要 152.6M 内存,而为了降低哈希冲突率,提高查找性能,实际需要开辟更大的内存空间。

位图(Bitmap)

位图就是用每一比特位来存放真和假状态的一种数据结构,使用位图进行基数统计不需要去存储实际元素信息,只需要用相应位置的 1bit 来标识某个元素是否出现过,这样能够极大地节省内存。

如下图所示,假设要存储的数据范围是 0-15,我们只需要使用 2 个字节组建一个拥有 16bit 位的比特数组,所有 bit 位的值初始化为 0,需要存储某个值时只需要将相应位置的的 bit 位设置为 1,如下图存储了{2,5,6,9,11,14}六个数据。

假设观看西城男孩直播的微信 id 值域是[0-2000 万],采用位图统计观看人数所需要的内存就只需 2.38M 了。

位图统计方式内存占用确实大大减少了,但位图占用的内存和元素的值域有关,因为我们需要把值域映射到这个连续的大比特数组上。实际上观看西城男孩直播的微信 id 不可能是连续的 2000 万个 id 值,而应该按微信的注册量级开辟长度,可能至少需要 20 亿的 bit 位(238M 内存)。

布隆过滤器

位图的方式有个很大的局限性就是要求值域范围有限,比如我们统计观看西城男孩直播的微信 id 总计 2000 万个,但实际却需要按照微信 id 范围上限 20 亿来开辟空间,假如有一个完美散列函数,能正好将观看了直播的这 2000 万个微信 id 映射成[0-2000 万]的不重复散列值,而其余没有观看直播的 19.8 亿微信 id 都被映射为超过 2000 万的散列值,那事情就好办了,但事实是我们无法提前知道哪 2000 万的微信号会观看直播,因此这样的散列函数是不可能存在的。

但这个思想是对的,布隆过滤器就是类似这样的思想,它能将 20 亿的 id 值映射到更小数值范围内,然后使用位图来记录元素是否存在,因为值域范围被压缩了,必然会存在大面积的冲突,为了降低冲突导致的统计错误率,它通过 K 个不同的散列函数将元素映射成一个位图中的 K 个 bit 位,并把它们都置为 1,只有当某个元素对应的这 K 个 bit 位同时为 1,才认为这个元素已经存在过。

假设 K=3,3 个哈希函数将数据映射到 0-15 的位图中存储,过程演示如下:

类似百分比近似排序,布隆过滤器也是牺牲一定的精确度来换取高性能的做法。它仍然存在一定的错误率,不能保证完全准确,比如上图示例中,假设接下来要插入数据 123,它通过 3 个哈希函数分别被映射为:{2,3,6},此时会误判为 123 已经存在了,将过滤掉该数据的统计。

但实际上只要 K 值和位图数组空间设置合理,就能保证错误率在一定范围,这对于大数据量的基数统计,完全能接受这样的统计误差。

布谷鸟过滤器

布谷鸟过滤器是另外一种通过牺牲一定的精确度来换取高性能的做法,也是非常之巧妙。在解释布谷鸟过滤器之前我们先来看下布谷鸟哈希算法。

布谷鸟哈希算法是 8.1 节中讲到的解决哈希冲突的另一种算法,它的思想来源于布谷鸟“鸠占鹊巢”的生活习性。布谷鸟哈希算法会有两个散列函数将元素映射到哈希表的两个不同位置。如果两个位置中有一个位置为空,那么就可以将元素直接放进去。但是如果这两个位置都满了,它就随机踢走一个,然后自己霸占了这个位置。

被踢走的那个元素会去查看它的另外一个散列值的位置是否是空位,如果是空位就占领它,如果不是空位,那就把受害者的角色转移出去,挤走对方,让对方再去找安身之处,如此循环直到某个元素找到空位为止。布谷鸟哈希算法有个缺点是当空间本身很拥挤时,出现“鸠占鹊巢”的现象会很频繁,插入效率很低,一种改良的优化方案是让每个散列值对应的位置上可以放置多个元素。

8.1 节讲到,哈希表可以用来做基数统计,因此布谷鸟哈希表当然也可以用来基数统计,而布谷鸟过滤器基于布谷鸟哈希算法来实现基数统计,布谷鸟哈希算法需要存储数据的整个元素信息,而布谷鸟过滤器为了减少内存,将存储的元素信息映射为一个简单的指纹信息,例如微信的用户 id 大小需要 8 字节,我们可以将它映射为 1 个字节甚至几个 bit 的指纹信息来进行存储。

由于只存储了指纹信息,因此谷鸟过滤器的两个散列函数的选择比较特殊,当一个位置上的元素被挤走之后,它需要通过指纹信息计算出另一个对偶位置(布谷鸟哈希存储的是元素的完整信息,必然能找到另一个散列值位置),因此它采用异或的方式达到目的,公式如下:

1
2
h1(x) = hash(x)
h2(x) = h1(x) ⊕ hash(x的指纹)

位置 h2 可以通过位置 h1 和 h1 中存储的指纹信息计算出来,同样的位置 h1 也可以通过 h2 和指纹信息计算出来。

布谷鸟过滤器实现了哈希表过滤和基数统计的能力,同时存储元素信息改为存储更轻量指纹信息节约了内存,但它损失了一些精确度,比如会出现两个元素的散列位置相同,指纹也正好相同的情况,那么插入检查会认为它们是相等的,只会统计一次。但同样这个误差率是可以接受的。

HyperLogLog

说到基数统计,就不得不提 Redis 里面的 HyperLogLog 算法了,前文所说的哈希表,位图,布隆过滤器和布谷鸟过滤器都是基于记录元素的信息并通过过滤(或近似过滤)相同元素的思想来进行基数统计的。

而 HyperLogLog 算法的思想不太一样,它的基础是观察到可以通过计算集合中每个数字的二进制表示中的前导零的最大数目来估计均匀分布的随机数的多重集的基数。如果观察到的前导零的最大数目是 n,则集合中不同元素的数量的估计是2^n。

怎么理解呢?其实就是运用了数学概率论的理论,以抛硬币的伯努利试验为例,假设一直尝试抛硬币,直到它出现正面为止,同时记录第一次出现正面时共尝试的抛掷次数 k,作为一次完整的伯努利试验。那么对于 n 次伯努利试验,假设其中最大的那次抛掷次数为Kmax。结合极大似然估算的方法,n 和 Kmax 中存在估算关联关系即:n = 2^(Kmax)

对应于基数统计的场景,HyperLogLog 算法通过散列函数,将数据转为二进制比特串,从低位往高位看,第一次出现 1 的时候认为是抛硬币的正面,因此比特串中前导零的数目即是抛硬币的抛掷次数。因此可以根据存入数据中,转化后的二进制串集中最大的首次出现 1 的位置 Kmax 来估算存入了多少不同的数据。

这种估算方式存在一定的偶然性,比如当某次抛硬币特别不幸时,抛出了很大的值,数据会偏差的厉害,为了降低这种极端偶然性带来的误差影响,在 HyperLogLog 算法中,会将集合分成多个子集(分桶计算),分别计算这些子集中的数字中的前导零的最大数量,最后使用调和平均数的计算方式将所有子集的这些估计值计算为全集的基数。例如 redis 会分为 16384 个子集进行分桶求平均统计。

其他常用算法

时间轮定时器

定时器的实现方式有很多,比如用有序链表或堆都可以实现,但是他们或插入或运行或删除的性能不太好。时间轮定时器是一种插入,运行和删除都比较理想的定时器。

时间轮定时器将按照到期时间分桶放入缓存队列中,系统只需按照每个桶到期顺序依次执行到期的时间桶节点中的所有定时任务。

而针对定时任务时间跨度大,且精度要求较高的场景,使用单层时间轮消耗的内存可能比较大,因此还可以进一步优化为采用层级时间轮来实现,层级时间轮就类似我们的时钟,秒针转一圈后,分针才进一格的原理,当内层时间轮运转完一轮后,外层时间轮进一格,接下来运行下一格的内层时间轮。

红包分配

算法很简洁,该算法没有预先随机分配好红包金额列表,而是在每个用户点击抢红包时随机生成金额,该算法只需传入当前剩余的总金额和剩余需要派发的总人数,算法的基本原理是以剩余单个红包的平均金额的 2 倍为上限,随机本次分配的金额。

这个算法的公平性在于每个领红包的人能领取到的金额是从 0 到剩余平均金额的 2 倍之间的随机值,所以期望就是剩余平均金额,假设 100 元发给 5 个人,那么第一个人领取到的期望是 20 元,第 2 个人领取到的期望是(100-20)/ 4 = 20 元,通过归纳法可以证明每个人领取到的期望都是 20 元。但是由于每个人领取到的金额随机范围是不一样的,如第一个人能领取到的范围是 0 到 40 元,而最后一个人能领取到的范围是 0 到 100 元,因此方差跟领取的顺序是有关系。这也告诉我们抢微信红包想稳的人可以先抢,想博的人可以后抢。

这种分配算法的好处是无状态化,不需要在创建红包时预先分配并存储金额列表,在某些场景可能会对性能带来好处。

首先说明一下,在Linux编写多线程程序需要包含头文件pthread.h。也就是说你在任何采用多线程设计的程序中都会看到类似这样的代码:

1
#include <pthread.h>

当然,仅包含一个头文件是不能搞定线程的,还需要连接libpthread.so这个库,因此在程序连接阶段应该有类似这样的指令:

1
gcc program.o -o program -lpthread

第一个例子

在Linux下创建的线程的API接口是pthread_create(),它的完整定义是:

1
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*) void *arg);

当你的程序调用了这个接口之后,就会产生一个线程,而这个线程的入口函数就是start_routine()。如果线程创建成功,这个接口会返回0。

start_routine()函数有一个参数,这个参数就是pthread_create的最后一个参数arg。这种设计可以在线程创建之前就帮它准备好一些专有数据,最典型的用法就是使用C++编程时的this指针。start_routine()有一个返回值,这个返回值可以通过pthread_join()接口获得。

pthread_create()接口的第一个参数是一个返回参数。当一个新的线程调用成功之后,就会通过这个参数将线程的句柄返回给调用者,以便对这个线程进行管理。pthread_create()接口的第二个参数用于设置线程的属性。这个参数是可选的,当不需要修改线程的默认属性时,给它传递NULL就行。具体线程有那些属性,我们后面再做介绍。

好,那么我们就利用这些接口,来完成在Linux上的第一个多线程程序,见代码1所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <pthread.h>
void* thread( void *arg )
{
printf( "This is a thread and arg = %d.\n", *(int*)arg);
*(int*)arg = 0;
return arg;
}
int main( int argc, char *argv[] )
{
pthread_t th;
int ret;
int arg = 10;
int *thread_ret = NULL;
ret = pthread_create( &th, NULL, thread, &arg );
if( ret != 0 ){
printf( "Create thread error!\n");
return -1;
}
printf( "This is the main process.\n" );
pthread_join( th, (void**)&thread_ret );
printf( "thread_ret = %d.\n", *thread_ret );
return 0;
}

可以执行下面的命令来生成可执行文件:

1
$ gcc thread.c -o thread -lpthread

这段代码的执行结果可能是这样:

1
2
3
4
$ ./thread
This is the main process.
This is a thread and arg = 10.
thread_ret = 0.

注意,我说的是可能有这样的结果,在不同的环境下可能会有出入。因为这是多线程程序,线程代码可能先于第24行代码被执行。

我们回过头来再分析一下这段代码。在第18行调用pthread_create()接口创建了一个新的线程,这个线程的入口函数是start_thread(),并且给这个入口函数传递了一个参数,且参数值为10。这个新创建的线程要执行的任务非常简单,只是将显示“This is a thread and arg = 10”这个字符串,因为arg这个参数值已经定义好了,就是10。之后线程将arg参数的值修改为0,并将它作为线程的返回值返回给系统。与此同时,主进程做的事情就是继续判断这个线程是否创建成功了。在我们的例子中基本上没有创建失败的可能。主进程会继续输出“This is the main process”字符串,然后调用pthread_join()接口与刚才的创建进行合并。这个接口的第一个参数就是新创建线程的句柄了,而第二个参数就会去接受线程的返回值。pthread_join()接口会阻塞主进程的执行,直到合并的线程执行结束。由于线程在结束之后会将0返回给系统,那么pthread_join()获得的线程返回值自然也就是0。输出结果“thread_ret = 0”也证实了这一点。

那么现在有一个问题,那就是pthread_join()接口干了什么?什么是线程合并呢?

线程的合并与分离

我们首先要明确的一个问题就是什么是线程的合并。从前面的叙述中读者们已经了解到了,pthread_create()接口负责创建了一个线程。那么线程也属于系统的资源,这跟内存没什么两样,而且线程本身也要占据一定的内存空间。众所周知的一个问题就是C或C++编程中如果要通过malloc()new分配了一块内存,就必须使用free()delete来回收这块内存,否则就会产生著名的内存泄漏问题。既然线程和内存没什么两样,那么有创建就必须得有回收,否则就会产生另外一个著名的资源泄漏问题,这同样也是一个严重的问题。那么线程的合并就是回收线程资源了。

线程的合并是一种主动回收线程资源的方案。当一个进程或线程调用了针对其它线程的pthread_join()接口,就是线程合并了。这个接口会阻塞调用进程或线程,直到被合并的线程结束为止。当被合并线程结束,pthread_join()接口就会回收这个线程的资源,并将这个线程的返回值返回给合并者。

与线程合并相对应的另外一种线程资源回收机制是线程分离,调用接口是pthread_detach()。线程分离是将线程资源的回收工作交由系统自动来完成,也就是说当被分离的线程结束之后,系统会自动回收它的资源。因为线程分离是启动系统的自动回收机制,那么程序也就无法获得被分离线程的返回值,这就使得pthread_detach()接口只要拥有一个参数就行了,那就是被分离线程句柄。

线程合并和线程分离都是用于回收线程资源的,可以根据不同的业务场景酌情使用。不管有什么理由,你都必须选择其中一种,否则就会引发资源泄漏的问题,这个问题与内存泄漏同样可怕。

线程的属性

前面还说到过线程是有属性的,这个属性由一个线程属性对象来描述。线程属性对象由pthread_attr_init()接口初始化,并由pthread_attr_destory()来销毁,它们的完整定义是:

1
2
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destory(pthread_attr_t *attr);

那么线程拥有哪些属性呢?一般地,Linux下的线程有:绑定属性、分离属性、调度属性、堆栈大小属性和满占警戒区大小属性。下面我们就分别来介绍这些属性。

绑定属性

说到这个绑定属性,就不得不提起另外一个概念:轻进程(Light Weight Process,简称LWP)。轻进程和Linux系统的内核线程拥有相同的概念,属于内核的调度实体。一个轻进程可以控制一个或多个线程。默认情况下,对于一个拥有n个线程的程序,启动多少轻进程,由哪些轻进程来控制哪些线程由操作系统来控制,这种状态被称为非绑定的。那么绑定的含义就很好理解了,只要指定了某个线程“绑”在某个轻进程上,就可以称之为绑定的了。被绑定的线程具有较高的响应速度,因为操作系统的调度主体是轻进程,绑定线程可以保证在需要的时候它总有一个轻进程可用。绑定属性就是干这个用的。

设置绑定属性的接口是pthread_attr_setscope(),它的完整定义是:

1
int pthread_attr_setscope(pthread_attr_t *attr, int scope);

它有两个参数,第一个就是线程属性对象的指针,第二个就是绑定类型,拥有两个取值:PTHREAD_SCOPE_SYSTEM(绑定的)和PTHREAD_SCOPE_PROCESS(非绑定的)。代码2演示了这个属性的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <pthread.h>
……
int main( int argc, char *argv[] )
{
pthread_attr_t attr;
pthread_t th;
……
pthread_attr_init( &attr );
pthread_attr_setscope( &attr, PTHREAD_SCOPE_SYSTEM );
pthread_create( &th, &attr, thread, NULL );
……
}

Linux的线程永远都是绑定,所以PTHREAD_SCOPE_PROCESS在Linux中不管用,而且会返回ENOTSUP错误。既然Linux并不支持线程的非绑定,为什么还要提供这个接口呢?答案就是兼容!因为Linux的NTPL是号称POSIX标准兼容的,而绑定属性正是POSIX标准所要求的,所以提供了这个接口。如果读者们只是在Linux下编写多线程程序,可以完全忽略这个属性。

分离属性

前面说过线程能够被合并和分离,分离属性就是让线程在创建之前就决定它应该是分离的。如果设置了这个属性,就没有必要调用pthread_join()pthread_detach()来回收线程资源了。设置分离属性的接口是pthread_attr_setdetachstate(),它的完整定义是:

1
pthread_attr_setdetachstat(pthread_attr_t *attr, int detachstate);

它的第二个参数有两个取值:PTHREAD_CREATE_DETACHED(分离的)和PTHREAD_CREATE_JOINABLE(可合并的,也是默认属性)。代码3演示了这个属性的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <pthread.h>
……
int main( int argc, char *argv[] )
{
pthread_attr_t attr;
pthread_t th;
……
pthread_attr_init( &attr );
pthread_attr_setscope( &attr, PTHREAD_SCOPE_SYSTEM );
pthread_create( &th, &attr, thread, NULL );
……
}

调度属性

线程的调度属性有三个,分别是:算法、优先级和继承权。

Linux提供的线程调度算法有三个:轮询、先进先出和其它。其中轮询和先进先出调度算法是POSIX标准所规定,而其他则代表采用Linux自己认为更合适的调度算法,所以默认的调度算法也就是其它了。轮询和先进先出调度算法都属于实时调度算法。轮询指的是时间片轮转,当线程的时间片用完,系统将重新分配时间片,并将它放置在就绪队列尾部,这样可以保证具有相同优先级的轮询任务获得公平的CPU占用时间;先进先出就是先到先服务,一旦线程占用了CPU则一直运行,直到有更高优先级的线程出现或自己放弃。

设置线程调度算法的接口是pthread_attr_setschedpolicy(),它的完整定义是:

1
pthread_attr_setschedpolicy(pthread_attr_t *attr, int policy);

它的第二个参数有三个取值:SCHED_RR(轮询)、SCHED_FIFO(先进先出)和SCHED_OTHER(其它)。

Linux的线程优先级与进程的优先级不一样,进程优先级我们后面再说。Linux的线程优先级是从1到99的数值,数值越大代表优先级越高。而且要注意的是,只有采用SHCED_RRSCHED_FIFO调度算法时,优先级才有效。对于采用SCHED_OTHER调度算法的线程,其优先级恒为0。

设置线程优先级的接口是pthread_attr_setschedparam(),它的完整定义是:

1
2
3
4
struct sched_param {
int sched_priority;
}
int pthread_attr_setschedparam(pthread_attr_t *attr, struct sched_param *param);

sched_param结构体的sched_priority字段就是线程的优先级了。

此外,即便采用SCHED_RRSCHED_FIFO调度算法,线程优先级也不是随便就能设置的。首先,进程必须是以root账号运行的;其次,还需要放弃线程的继承权。什么是继承权呢?就是当创建新的线程时,新线程要继承父线程(创建者线程)的调度属性。如果不希望新线程继承父线程的调度属性,就要放弃继承权。

设置线程继承权的接口是pthread_attr_setinheritsched(),它的完整定义是:

1
int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched);

它的第二个参数有两个取值:PTHREAD_INHERIT_SCHED(拥有继承权)和PTHREAD_EXPLICIT_SCHED(放弃继承权)。新线程在默认情况下是拥有继承权。

代码4能够演示不同调度算法和不同优先级下各线程的行为,同时也展示如何修改线程的调度属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
#define THREAD_COUNT 12
void show_thread_policy( int threadno )
{
int policy;
struct sched_param param;
pthread_getschedparam( pthread_self(), &policy, ¶m );
switch( policy ){
case SCHED_OTHER:
printf( "SCHED_OTHER %d\n", threadno );
break;
case SCHED_RR:
printf( "SCHDE_RR %d\n", threadno );
break;
case SCHED_FIFO:
printf( "SCHED_FIFO %d\n", threadno );
break;
default:
printf( "UNKNOWN\n");
}
}
void* thread( void *arg )
{
int i, j;
long threadno = (long)arg;
printf( "thread %d start\n", threadno );
sleep(1);
show_thread_policy( threadno );
for( i = 0; i < 10; ++i ) {
for( j = 0; j < 100000000; ++j ){}
printf( "thread %d\n", threadno );
}
printf( "thread %d exit\n", threadno );
return NULL;
}
int main( int argc, char *argv[] )
{
long i;
pthread_attr_t attr[THREAD_COUNT];
pthread_t pth[THREAD_COUNT];
struct sched_param param;
for( i = 0; i < THREAD_COUNT; ++i )
pthread_attr_init( &attr[i] );
for( i = 0; i < THREAD_COUNT / 2; ++i ) {
param.sched_priority = 10;
pthread_attr_setschedpolicy( &attr[i], SCHED_FIFO );
pthread_attr_setschedparam( &attr[i], ¶m );
pthread_attr_setinheritsched( &attr[i], PTHREAD_EXPLICIT_SCHED );
}
for( i = THREAD_COUNT / 2; i < THREAD_COUNT; ++i ) {
param.sched_priority = 20;
pthread_attr_setschedpolicy( &attr[i], SCHED_FIFO );
pthread_attr_setschedparam( &attr[i], ¶m );
pthread_attr_setinheritsched( &attr[i], PTHREAD_EXPLICIT_SCHED );
}
for( i = 0; i < THREAD_COUNT; ++i )
pthread_create( &pth[i], &attr[i], thread, (void*)i );
for( i = 0; i < THREAD_COUNT; ++i )
pthread_join( pth[i], NULL );
for( i = 0; i < THREAD_COUNT; ++i )
pthread_attr_destroy( &attr[i] );
return 0;
}

这段代码中含有一些没有介绍过的接口,读者们可以使用Linux的联机帮助来查看它们的具体用法和作用。

堆栈大小属性

从前面的这些例子中可以了解到,线程的主函数与程序的主函数main()有一个很相似的特性,那就是可以拥有局部变量。虽然同一个进程的线程之间是共享内存空间的,但是它的局部变量确并不共享。原因就是局部变量存储在堆栈中,而不同的线程拥有不同的堆栈。Linux系统为每个线程默认分配了8MB的堆栈空间,如果觉得这个空间不够用,可以通过修改线程的堆栈大小属性进行扩容。

修改线程堆栈大小属性的接口是pthread_attr_setstacksize(),它的完整定义为:

1
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);

它的第二个参数就是堆栈大小了,以字节为单位。需要注意的是,线程堆栈不能小于16KB,而且尽量按4KB(32位系统)或2MB(64位系统)的整数倍分配,也就是内存页面大小的整数倍。此外,修改线程堆栈大小是有风险的。

满栈警戒区属性

既然线程是有堆栈的,而且还有大小限制,那么就一定会出现将堆栈用满的情况。线程的堆栈用满是非常危险的事情,因为这可能会导致对内核空间的破坏,一旦被有心人士所利用,后果也不堪设想。为了防治这类事情的发生,Linux为线程堆栈设置了一个满栈警戒区。这个区域一般就是一个页面,属于线程堆栈的一个扩展区域。一旦有代码访问了这个区域,就会发出SIGSEGV信号进行通知。

虽然满栈警戒区可以起到安全作用,但是也有弊病,就是会白白浪费掉内存空间,对于内存紧张的系统会使系统变得很慢。所有就有了关闭这个警戒区的需求。同时,如果我们修改了线程堆栈的大小,那么系统会认为我们会自己管理堆栈,也会将警戒区取消掉,如果有需要就要开启它。修改满栈警戒区属性的接口是pthread_attr_setguardsize(),它的完整定义为:

1
int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);

它的第二个参数就是警戒区大小了,以字节为单位。与设置线程堆栈大小属性相仿,应该尽量按照4KB或2MB的整数倍来分配。当设置警戒区大小为0时,就关闭了这个警戒区。虽然栈满警戒区需要浪费掉一点内存,但是能够极大的提高安全性,所以这点损失是值得的。而且一旦修改了线程堆栈的大小,一定要记得同时设置这个警戒区。

线程本地存储

内线程之间可以共享内存地址空间,线程之间的数据交换可以非常快捷,这是线程最显著的优点。但是多个线程访问共享数据,需要昂贵的同步开销,也容易造成与同步相关的BUG,更麻烦的是有些数据根本就不希望被共享,这又是缺点。

C程序库中的errno是个最典型的一个例子。errno是一个全局变量,会保存最后一个系统调用的错误代码。在单线程环境并不会出现什么问题。但是在多线程环境,由于所有线程都会有可能修改errno,这就很难确定errno代表的到底是哪个系统调用的错误代码了。这就是有名的“非线程安全(Non Thread-Safe)”的。

此外,从现代技术角度看,在很多时候使用多线程的目的并不是为了对共享数据进行并行处理。更多是由于多核心CPU技术的引入,为了充分利用CPU资源而进行并行运算(不互相干扰)。换句话说,大多数情况下每个线程只会关心自己的数据而不需要与别人同步。

为了解决这些问题,可以有很多种方案。比如使用不同名称的全局变量。但是像errno这种名称已经固定了的全局变量就没办法了。在前面的内容中提到在线程堆栈中分配局部变量是不在线程间共享的。但是它有一个弊病,就是线程内部的其它函数很难访问到。目前解决这个问题的简便易行的方案是线程本地存储,即Thread Local Storage,简称TLS。利用TLS,errno所反映的就是本线程内最后一个系统调用的错误代码了,也就是线程安全的了。

Linux提供了对TLS的完整支持,通过下面这些接口来实现:

1
2
3
4
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
int pthread_key_delete(pthread_key_t key);
void* pthread_getspecific(pthread_key_t key);
int pthread_setspecific(pthread_key_t key, const void *value);

pthread_key_create()接口用于创建一个线程本地存储区。第一个参数用来返回这个存储区的句柄,需要使用一个全局变量保存,以便所有线程都能访问到。第二个参数是线程本地数据的回收函数指针,如果希望自己控制线程本地数据的生命周期,这个参数可以传递NULL。

pthread_key_delete()接口用于回收线程本地存储区。其唯一的参数就要回收的存储区的句柄。

pthread_getspecific()pthread_setspecific()这个两个接口分别用于获取和设置线程本地存储区的数据。这两个接口在不同的线程下会有不同的结果不同(相同的线程下就会有相同的结果),这也就是线程本地存储的关键所在。

代码5展示了如何在Linux使用线程本地存储,注意执行结果,分析一下线程本地存储的一些特性,以及内存回收的时机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define THREAD_COUNT 10

pthread_key_t g_key;
typedef struct thread_data{
int thread_no;
} thread_data_t;

void show_thread_data()
{
thread_data_t *data = pthread_getspecific( g_key );
printf( "Thread %d \n", data->thread_no );
}
void* thread( void *arg )
{
thread_data_t *data = (thread_data_t *)arg;
printf( "Start thread %d\n", data->thread_no );
pthread_setspecific( g_key, data );
show_thread_data();
printf( "Thread %d exit\n", data->thread_no );
}
void free_thread_data( void *arg )
{
thread_data_t *data = (thread_data_t*)arg;
printf( "Free thread %d data\n", data->thread_no );
free( data );
}
int main( int argc, char *argv[] )
{
int i;
pthread_t pth[THREAD_COUNT];
thread_data_t *data = NULL;
pthread_key_create( &g_key, free_thread_data );
for( i = 0; i < THREAD_COUNT; ++i ) {
data = malloc( sizeof( thread_data_t ) );
data->thread_no = i;
pthread_create( &pth[i], NULL, thread, data );
}
for( i = 0; i < THREAD_COUNT; ++i )
pthread_join( pth[i], NULL );
pthread_key_delete( g_key );
return 0;
}

线程的同步

虽然线程本地存储可以避免线程访问共享数据,但是线程之间的大部分数据始终还是共享的。在涉及到对共享数据进行读写操作时,就必须使用同步机制,Linux提供的线程同步机制主要有互斥锁和条件变量。其它形式的线程同步机制用得并不多,本书也不准备详细讲解,有兴趣的读者可以参考相关文档。

互斥锁

首先我们看一下互斥锁。所谓的互斥就是线程之间互相排斥,获得资源的线程排斥其它没有获得资源的线程。Linux使用互斥锁来实现这种机制。既然叫锁,就有加锁和解锁的概念。当线程获得了加锁的资格,那么它将独享这个锁,其它线程一旦试图去碰触这个锁就立即被系统“拍晕”。当加锁的线程解开并放弃了这个锁之后,那些被“拍晕”的线程会被系统唤醒,然后继续去争抢这个锁。

从互斥锁的这种行为看,线程加锁和解锁之间的代码相当于一个独木桥,同意时刻只有一个线程能执行。从全局上看,在这个地方,所有并行运行的线程都变成了排队运行了。比较专业的叫法是同步执行,这段代码区域叫临界区。同步执行就破坏了线程并行性的初衷了,临界区越大破坏得越厉害。所以在实际应用中,应该尽量避免有临界区出现。实在不行,临界区也要尽量的小。

互斥锁在Linux中的名字是mutex。Linux初始化和销毁互斥锁的接口是pthread_mutex_init()pthead_mutex_destroy(),对于加锁和解锁则有pthread_mutex_lock()pthread_mutex_trylock()pthread_mutex_unlock()。这些接口的完整定义如下:

1
2
3
4
5
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destory(pthread_mutex_t *mutex );
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

从这些定义中可以看到,互斥锁也是有属性的。只不过这个属性在绝大多数情况下都不需要改动,所以使用默认的属性就行。方法就是给它传递NULL。

phtread_mutex_trylock()比较特别,用它试图加锁的线程永远都不会被系统“拍晕”,只是通过返回EBUSY来告诉程序员这个锁已经有人用了。至于是否继续“强闯”临界区,则由程序员决定。系统提供这个接口的目的可不是让线程“强闯”临界区的。它的根本目的还是为了提高并行性,留着这个线程去干点其它有意义的事情。当然,如果很幸运恰巧这个时候还没有人拥有这把锁,那么自然也会取得临界区的使用权。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
#include <pthread.h>
pthread_mutex_t g_mutex;
int g_lock_var = 0;
void* thread1( void *arg )
{
int i, ret;
time_t end_time;
end_time = time(NULL) + 10;
while( time(NULL) < end_time ) {
ret = pthread_mutex_trylock( &g_mutex );
if( EBUSY == ret ) {
printf( "thread1: the varible is locked by thread2.\n" );
} else {
printf( "thread1: lock the variable!\n" );
++g_lock_var;
pthread_mutex_unlock( &g_mutex );
}
sleep(1);
}
return NULL;
}
void* thread2( void *arg )
{
int i;
time_t end_time;
end_time = time(NULL) + 10;
while( time(NULL) < end_time ) {
pthread_mutex_lock( &g_mutex );
printf( "thread2: lock the variable!\n" );
++g_lock_var;
sleep(1);
pthread_mutex_unlock( &g_mutex );
}
return NULL;
}
int main( int argc, char *argv[] )
{
int i;
pthread_t pth1,pth2;
pthread_mutex_init( &g_mutex, NULL );
pthread_create( &pth1, NULL, thread1, NULL );
pthread_create( &pth2, NULL, thread2, NULL );
pthread_join( pth1, NULL );
pthread_join( pth2, NULL );
pthread_mutex_destroy( &g_mutex );
printf( "g_lock_var = %d\n", g_lock_var );
return 0;
}

最后需要补充一点,互斥锁在同一个线程内,没有互斥的特性。也就是说,线程不能利用互斥锁让系统将自己“拍晕”。解释这个现象的一个很好的理由就是,拥有锁的线程把自己“拍晕”了,谁还能再拥有这把锁呢?但是另外情况需要避免,就是两个线程已经各自拥有一把锁了,但是还想得到对方的锁,这个时候两个线程都会被“拍晕”。一旦这种情况发生,就谁都不能获得这个锁了,这种情况还有一个著名的名字——死锁。死锁是永远都要避免的事情,因为这是严重损人不利己的行为。

条件变量

条件变量关键点在“变量”上。与锁的不同之处就是,当线程遇到这个“变量”,并不是类似锁那样的被系统给“拍晕”,而是根据“条件”来选择是否在那里等待。等待什么呢?等待允许通过的“信号”。这个“信号”是系统控制的吗?显然不是!它是由另外一个线程来控制的。

如果说互斥锁可以比作独木桥,那么条件变量这就好比是马路上的红绿灯。更深一步理解,条件变量是一种事件机制。由一类线程来控制“事件”的发生,另外一类线程等待“事件”的发生。为了实现这种机制,条件变量必须是共享于线程之间的全局变量。而且,条件变量也需要与互斥锁同时使用。
初始化和销毁条件变量的接口是pthread_cond_init()pthread_cond_destory();控制“事件”发生的接口是pthread_cond_signal()pthread_cond_broadcast();等待“事件”发生的接口是pthead_cond_wait()pthread_cond_timedwait()。它们的完整定义如下:

1
2
3
4
5
6
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
int pthread_cond_destory(pthread_cond_t *cond);
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_timedwait(pthread_cond_t *cond,pthread_mutex_t *mutex, const timespec *abstime);
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);

对于等待“事件”的接口从其名称中可以看出,一种是无限期等待,一种是限时等待。后者与互斥锁的pthread_mutex_trylock()有些类似,即当等待的“事件”经过一段时间之后依然没有发生,那就去干点别的有意义的事情去。而对于控制“事件”发生的接口则有“单播”和“广播”之说。所谓单播就是只有一个线程会得到“事件”已经发生了的“通知”,而广播就是所有线程都会得到“通知”。对于广播情况,所有被“通知”到的线程也要经过由互斥锁控制的独木桥。

对于条件变量的使用,可以参考代码7,它实现了一种生产者与消费者的线程同步方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define BUFFER_SIZE 5

pthread_mutex_t g_mutex;
pthread_cond_t g_cond;
typedef struct {
char buf[BUFFER_SIZE];
int count;
} buffer_t;
buffer_t g_share = {"", 0};
char g_ch = 'A';

void* producer( void *arg )
{
printf( "Producer starting.\n" );
while( g_ch != 'Z' ) {
pthread_mutex_lock( &g_mutex );
if( g_share.count < BUFFER_SIZE ) {
g_share.buf[g_share.count++] = g_ch++;
printf( "Prodcuer got char[%c]\n", g_ch - 1 );
if( BUFFER_SIZE == g_share.count ) {
printf( "Producer signaling full.\n" );
pthread_cond_signal( &g_cond );
}
}
pthread_mutex_unlock( &g_mutex );
}
printf( "Producer exit.\n" );
return NULL;
}
void* consumer( void *arg )
{
int i;
printf( "Consumer starting.\n" );
while( g_ch != 'Z' ) {
pthread_mutex_lock( &g_mutex );
printf( "Consumer waiting\n" );
pthread_cond_wait( &g_cond, &g_mutex );
printf( "Consumer writing buffer\n" );
for( i = 0; g_share.buf[i] && g_share.count; ++i ) {
putchar( g_share.buf[i] );
--g_share.count;
}
putchar('\n');
pthread_mutex_unlock( &g_mutex );
}
printf( "Consumer exit.\n" );
return NULL;
}
int main( int argc, char *argv[] )
{
pthread_t ppth, cpth;
pthread_mutex_init( &g_mutex, NULL );
pthread_cond_init( &g_cond, NULL );
pthread_create( &cpth, NULL, consumer, NULL );
pthread_create( &ppth, NULL, producer, NULL );
pthread_join( ppth, NULL );
pthread_join( cpth, NULL );
pthread_mutex_destroy( &g_mutex );
pthread_cond_destroy( &g_cond );
return 0;
}

从代码中会发现,等待“事件”发生的接口都需要传递一个互斥锁给它。而实际上这个互斥锁还要在调用它们之前加锁,调用之后解锁。不单如此,在调用操作“事件”发生的接口之前也要加锁,调用之后解锁。这就面临一个问题,按照这种方式,等于“发生事件”和“等待事件”是互为临界区的。也就是说,如果“事件”还没有发生,那么有线程要等待这个“事件”就会阻止“事件”的发生。更干脆一点,就是这个“生产者”和“消费者”是在来回的走独木桥。但是实际的情况是,“消费者”在缓冲区满的时候会得到这个“事件”的“通知”,然后将字符逐个打印出来,并清理缓冲区。直到缓冲区的所有字符都被打印出来之后,“生产者”才开始继续工作。

为什么会有这样的结果呢?这就要说明一下pthread_cond_wait()接口对互斥锁做什么。答案是:解锁。pthread_cond_wait()首先会解锁互斥锁,然后进入等待。这个时候“生产者”就能够进入临界区,然后在条件满足的时候向“消费者”发出信号。当pthead_cond_wait()获得“通知”之后,它还要对互斥锁加锁,这样可以防止“生产者”继续工作而“撑坏”缓冲区。另外,“生产者”在缓冲区不满的情况下才能工作的这个限定条件是很有必要的。因为在pthread_cond_wait()获得通知之后,在没有对互斥锁加锁之前,“生产者”可能已经重新进入临界区了,这样“消费者”又被堵住了。也就是因为条件变量这种工作性质,导致它必须与互斥锁联合使用。

本地POSIX线程库

在Linux操作系统中,本地POSIX线程库(NPTL)是一种软件特性,它可让Linux的内核,高效地运行那些使用POSIX风格的线程所编写的程序。

测试中,NPTL在一个IA-32处理器上,成功地同时跑了10万个线程,启动这些线程只用了不到2秒。比较起来,在不支持NPTL的内核上,这个测试花费了大约15分钟。

以前(也就是在2.6内核以前),Linux把进程当作其调度实体,内核并不真正支持线程。可是,它提供了一个clone()系统调用——创建一个调用进程的拷贝,这个拷贝与调用者共享地址空间。LinuxThreads项目就是利用这个系统调用,完全在用户级模拟了线程;不幸的是,它与真正的POSIX标准在一致性上面存在大量的问题,在信号处理,任务调度,以及进程间同步原语方面尤为突出。

要改进LinuxThreads,显然需要一些内核方面的支持,并重写线程库。针对于这一需求的两个竞争项目启动了——NGPT,或称下一代POSIX线程,由包括来自IBM的开发者在内的一个团队进行开发;NPTL是由Red Hat的开发者来开发的,两者同时进行。但是,NGPT已在2003年年中就被放弃了。

NPTL的使用与LinuxThreads极为相似,这是由于,其主要的抽象依然被内核认为是一个进程,而且新线程的创建,使用的还是clone()系统调用(来自NPTL的调用)。可是,NPTL需要专有的内核支持来实现在竞争情况下可使线程睡眠或被再唤醒的同步原语。用在这儿的原语,被认为是一个Futex(不要与mutex相混淆)。

NPTL号称是1x1的线程库,这是由于用户所创建的线程(通过pthread_create()库函数)与内核的调度实体(在Linux内是进程)1-1对应。这是最简单的合理线程实现了。一个备选方案是m x n的,就是说用户级线程要多于调度实体,如果以这种方式实现的话,由线程库负责在可用的调度实体上调度用户线程。这会使得线程上下文切换非常的快,因为它避免了系统调用,但是它也增加了复杂性和优先级反转的可能性。

NPTL的第一版发布在Red Hat 9.0中。老式的POSIX线程库众所周知的问题是有些时候线程会拒绝向系统让出控制权,因为这种事情发生时,它得不到让出控制权的机会。还有些事情Windows会做得更好。Red Hat在Java的站点上的一篇关于Java在Red Hat 9上的文章中声称NPTL已经解决了这些问题。

自从Red Hat Enterprise Linux第3版开始,NPTL就已经成为它的一部分,现在它已经完全的集成到Glibc中了。

lecture 1

指令级并行(ILP)

  • 事实上,处理器确实利用并行执行使程序运行得更快,这对程序员来说是不可见的
  • 想法:指令必须看起来是按程序顺序执行的。但处理器可以同时执行独立的指令,而不会影响程序的正确性
  • 超标量执行:处理器在指令序列中动态查找独立指令并并行执行

下图是ILP的原理,第一行是三个可以并行的指令,之后只能串行。

ILP和处理器频率的提升已经很缓慢,所以并不能持续用这两种方法实现并行加速。单指令流性能扩展率已降低(几乎为零)

lecture 2

使用泰勒展开式计算sin(x): sin(x)=x - x^3/3! + x^5/5! - x^7/7!+ ...,对于N个浮点数数组的每个元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void sinx(int N, int terms, float* x, float* result) 
{
for(int i=0; i<N; i++) {
float value = x[i];
float numer = x[i] * x[i] * x[i];
int denom = 6; // 3!
int sign = ‐1;

for (int j=1; j<=terms; j++)
{
value += sign * numer / denom;
numer *= x[i] * x[i];
denom *= (2*j+2) * (2*j+3);
sign *= ­‐1;
}
result[i] = value;
}
}

对中间的for循环中的每个x[i],如果没有并行的话,每个指令都单步执行,在前三条指令中没有ILP。如果可能的话可以每个时钟解码/执行两个指令。

下图是Pentium 4的图,可以看到有两个简单指令解码器,就可以同时解码。

前多核处理器时代:大多数芯片晶体管用于执行有助于单个指令流快速运行的操作。

更多的晶体管=更大的缓存,更智能的无序逻辑,更智能的分支预测器,等等。(还有:更多晶体管→更小晶体管→更高的时钟频率)

在多核时代,有几个想法

  • 使用增加晶体管数向处理器添加更多内核
  • 而不是使用晶体管来提高处理器逻辑的复杂性,从而加速单个指令流(例如,无序和推测性操作)

如果有两个核,可以并行计算两个元素。可以使用更简单的内核:每个内核只有解码器、运算器、上下文等,没有cache和分支预测逻辑之类的,在运行单个指令流时都比我们原来的内核慢(例如,慢25%)。但是现在有两个核心:2×0.75=1.5(加速潜力!)

上边的计算程序没啥并行性,只能有一个线程执行,如果每个简单的核比正常的核慢25%,我们的程序在这样的核上只能有之前75%的性能。

可以使用pthreads实现并行性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
void sinx(int N, int terms, float* x, float* result) 
{
for (int i=0; i<N; i++)
{
float value = x[i];
float numer = x[i] * x[i] * x[i];
int denom = 6; // 3!
int sign = ‐1;
for (int j=1; j<=terms; j++)
{
value += sign * numer / denom;
numer *= x[i] * x[i];
denom *= (2*j+2) * (2*j+3);
sign *= -1;
}
result[i] = value;
}
}

typedef struct {
int N;
int terms;
float* x;
float* result;
} my_args;

void parallel_sinx(int N, int terms, float* x, float* result)
{
pthread_t thread_id;
my_args args;
args.N = N/2;
args.terms = terms;
args.x = x;
args.result = result;
pthread_create(&thread_id, NULL, my_thread_start, &args); // launch thread
sinx(N - args.N, terms, x + args.N, result + args.N); // do work
pthread_join(thread_id, NULL);
}

void my_thread_start(void* thread_arg)
{
my_args* thread_args = (my_args*)thread_arg;
sinx(args­‐>N, args­‐>terms, args‐>x, args­‐>result); // do work
}

如果有四个核,可以并行计算四个元素。

增加ALU以提高计算能力:分摊跨多个ALU管理指令流的成本/复杂性,改为SIMD单指令、多数据流,向所有ALU广播的相同指令,在所有ALU上并行执行指令。

矢量程序(使用AVX内部函数)使用256位向量寄存器上的向量指令同时处理八个数组元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <immintrin.h>
void sinx(int N, int terms, float* x, float* result)
{
float three_fact = 6; // 3!
for (int i=0; i<N; i+=8)
{
__m256 origx = _mm256_load_ps(&x[i]);
__m256 value = origx;
__m256 numer = _mm256_mul_ps(origx, _mm256_mul_ps(origx, origx));
__m256 denom = _mm256_broadcast_ss(&three_fact);
int sign = -1;
for (int j=1; j<=terms; j++)
{
// value += sign * numer / denom
__m256 tmp = _mm256_div_ps(_mm256_mul_ps(_mm256_set1ps(sign), numer), denom);
value = _mm256_add_ps(value, tmp);
numer = _mm256_mul_ps(numer, _mm256_mul_ps(origx, origx));
denom = _mm256_mul_ps(denom, _mm256_broadcast_ss((2*j+2) * (2*j+3)));
sign *= ­‐1;
}
_mm256_store_ps(&result[i], value);
}
}

如果是有条件跳转的执行呢?不是所有的ALU执行相同的指令,会降低性能。经过了这一段if之后才会重新全速执行。

术语

  • 指令流一致性(“一致执行”)
    • 相同的指令序列适用于同时操作的所有元件
    • 一致执行对于有效利用SIMD处理资源是必要的
    • 由于每个内核都具有获取/解码不同指令流的能力,因此一致执行对于跨内核的高效并行不是必需的
  • “发散”执行
    • 缺乏指令流的连贯性
  • 注意:不要将指令流一致性与“缓存一致性”(本课程后面的一个主要主题)混淆

在现代CPU上执行SIMD

  • SSE指令:128位操作:4x32位或2x64位(4宽浮点向量)
  • AVX指令:256位操作:8x32位或4x64位(8宽浮点向量)
  • 指令由编译器生成
    • 程序员使用内部函数明确请求的并行性
    • 使用并行语言语义传达的并行性(例如,forall)
    • 通过循环依赖性分析推断出的并行性(困难的问题是,即使是最好的编译器也无法处理任意C/C++代码)
  • 术语:“显式SIMD”:SIMD并行化在编译时执行
    • 可以检查程序二进制文件并查看指令(vstoreps、vmulps等)

在许多现代GPU上执行SIMD

  • “隐式SIMD”
    • 编译器生成标量二进制(标量指令)
    • 但N个程序实例在处理器上“始终”一起运行
    • 换句话说,硬件本身的接口是数据并行的
    • 硬件(不是编译器)负责在SIMD ALU上的不同数据上同时执行来自多个实例的同一指令
  • 大多数现代GPU的SIMD宽度范围为8到32
    • 分支可能是一个大问题

摘要:并行执行

  • 现代处理器中几种形式的并行执行
    • 多核:使用多个处理核
      • 提供线程级并行:在每个内核上同时执行完全不同的指令流
      • 软件决定何时创建线程(例如,通过pthreadsapi)
    • SIMD:使用由同一指令流控制的多个ALU(在一个内核内)
      • 数据并行工作负载的高效设计:在多个ALU上摊销控制
      • 矢量化可以由编译器(显式SIMD)完成,也可以在运行时由硬件完成
      • 在执行之前已知没有依赖关系(通常由程序员声明,但可以由高级编译器通过循环分析推断)
    • 超标量:在指令流中利用ILP。并行处理来自同一指令流的不同指令(在内核内)
      • 硬件在执行过程中自动动态发现并行性(程序员不可见)

stalls:

  • 由于依赖于上一条指令,处理器无法运行指令流中的下一条指令时会“暂停”(stalls)。
  • 访问内存是暂停的主要来源
  • 内存访问时间约为100个周期
    • 内存“访问时间”是对延迟的度量

当数据驻留在缓存中时,处理器会高效运行,cache减少了stalls的时间长度(隐藏延迟)

  • 所有现代CPU都具有将数据预取到缓存中的逻辑
    • 动态分析程序的访问模式,预测它将很快访问什么
  • 减少暂停,因为访问时数据驻留在缓存中
  • 注意:如果猜测错误,预取也会降低性能(占用带宽,污染缓存)

多线程也能减少stalls:

  • 想法:在同一个内核上交错处理多个线程以隐藏暂停
  • 与预取一样,多线程也是一种延迟隐藏技术,而不是一种减少延迟的技术

面向吞吐量的系统的关键思想:潜在地增加任何一个线程完成工作的时间,以便在运行多个线程时提高总体系统吞吐量。在结束引起stalls的异常之后,此线程是可运行的,但处理器不会执行它,内核正在运行其他线程,需要等待操作系统进行调度。

存储执行上下文:有限资源下执行上下文的片上存储问题。如果有16个线程的话,将之前整个的context storage改为16个小块,存储每个线程的小工作上下文。

硬件支持的多线程

  • Core管理多线程的执行上下文
    • 从可运行线程运行指令(处理器决定运行每个时钟运行的线程,而不是操作系统)
    • Core仍然拥有相同数量的ALU资源:多线程只在面临内存访问等高延迟操作时有助于更有效地使用它们
  • 交错多线程(又称时态多线程)
    • 每个时钟,内核选择一个线程,并从ALU上的线程运行一条指令
  • 同步多线程(SMT)
    • 每个时钟,内核从多个线程中选择指令在ALU上运行
    • 超标量CPU设计的扩展
    • 示例:英特尔超线程(每个核心2个线程)

GPU:面向吞吐量的处理器

  • “回”字形黄色方框=SIMD功能单元,16个单元共享控制(每个时钟1个MUL-ADD)
  • 两个取指/编码器,共32个SIMD功能单元
  • 指令一次操作32条数据(称为“warp”)。
  • warp=发出32条宽向量指令的线程
  • 多达48条warps同时交错
  • 一个核心可同时处理1500多个元素
  • 为什么warp是32个元素,只有16个SIMD ALU?
    • 这有点复杂:ALU的运行速度是芯片其他部分时钟频率的两倍。因此,每条解码指令在两个ALU时钟上运行在16个ALU上的32条数据上。(但对于程序员来说,它的行为类似于32宽的SIMD操作)

带宽是一项关键资源,高性能并行程序将:

  • 组织计算以减少从内存中提取数据的频率
    • 重用以前由同一线程加载的数据(传统的线程内时间局部性优化)
    • 跨线程共享数据(线程间协作)
  • 减少请求数据的频率(相反,多做算术运算:它是“免费的”)
    • 有用术语:“算术强度”-指令流中数学运算与数据访问运算的比率
    • 要点:为了有效利用现代处理器,程序必须具有较高的运算强度,也就是运算指令要大于取值指令的数量

总结

  • 所有现代处理器在不同程度上采用的三大理念
    • 使用多个处理核心
      • 更简单的内核(采用线程级并行而不是指令级并行)
    • 在多个ALU上摊销指令流处理(SIMD)
      • 以很少的额外成本提高计算能力
    • 使用多线程来更有效地利用处理资源(隐藏延迟、填充所有可用资源)
  • 由于现代芯片的高运算能力,许多并行应用程序(在CPU和GPU上)都有带宽瓶颈
  • GPU架构使用与CPU相同的吞吐量计算思想:但GPU将这些概念推向了极限

总结:

  • 最开始普通的串行程序+普通的仅有(取指、ALU、上下文存储)三部分的处理器,
  • 改进为有两套取指+ALU的超标量处理器,这样可以在单指令流中每个时钟同时执行两个没有依赖关系的指令
  • 创建多线程程序的话,需要在一个处理器上设置两套(取指、ALU、上下文存储),每个核在每个时钟只执行一个指令
  • 多线程+超标量,两个核+两套(取指、ALU、上下文存储)
  • 四核处理器,四个核每个核一套(取指、ALU、上下文存储)
  • 进化到SIMD时代,四个核每个核都有一个取指器,八个ALU执行运算,一个上下文存储器存储上下文。
  • 在SIMD基础上增加多线程,每个核除了一个取指器,八个ALU,再来两个存放上下文的切换器。
    • 观察:内存操作有很长的延迟
    • 解决方案:通过执行其他迭代的算术指令来隐藏一次迭代加载数据的延迟
    • 多线程SIMD四核处理器:从每个核上的一条指令流中,每个时钟执行一条SIMD指令。但当遇到暂停时,可以切换到处理其他指令流。
  • 四个超标量、SIMD、多线程内核
    • 多线程、超标量、SIMD四核处理器:从每个核上的一条指令流中,每个时钟最多执行两条指令(在本例中:一条SIMD指令+一条标量指令)。当遇到暂停时,处理器可以切换到执行其他指令流。
  • 以上,上下文切换器提供了进行切换指令流的能力;有多个取指器的话能同时执行两个指令流

lecture 3

Intel SPMD Program Compiler (ISPC)

在ISPC上计算之前的sin(x)函数,sin(x) = x - x^3/3! + x^5/5! - x^7/7! + ...

C++ code: main.cpp

1
2
3
4
5
6
7
8
9
#include “sinx_ispc.h”

int N = 1024;
int terms = 5;
float* x = new float[N];
float* result = new float[N];
// initialize x here
// execute ISPC code
sinx(N, terms, x, result);

ISPC code: sinx.ispc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export void sinx( 
uniform int N,
uniform int terms,
uniform float* x,
uniform float* result)
{
// assume N % programCount = 0
for (uniform int i=0; i<N; i+=programCount)
{
int idx = i + programIndex;
float value = x[idx];
float numer = x[idx] * x[idx] * x[idx];
uniform int denom = 6; // 3!
uniform int sign = -1;
for (uniform int j=1; j<=terms; j++)
{
value += sign * numer / denom
numer *= x[idx] * x[idx];
denom *= (2*j+2) * (2*j+3);
sign *= ­‐1;
}
result[idx] = value;
}
}

SPMD编程抽象:对ISPC函数的调用产生“一组”ISPC“程序实例”,所有实例同时运行ISPC代码,将数组元素“交错”分配给程序实例。返回后,所有实例都已完成。

ISPC关键字:

  • programCount:组中同时执行的实例数(统一值)
  • programIndex:组中当前实例的id。(非均匀值:“变化”)
  • uniform:类型修饰符。所有实例对此变量具有相同的值。它的使用纯粹是一种优化。不需要正确性。

程序实例到循环迭代的交错分配

SPMD编程抽象:

  • 对ISPC函数的调用会产生一组ISPC“程序实例”
  • 所有实例同时运行ISPC代码
  • 返回后,所有实例都已完成

ISPC编译器生成SIMD实现:

  • 组中的实例数是硬件的SIMD宽度(或SIMD宽度的小倍数)
  • ISPC编译器使用SIMD指令生成二进制(.o)
  • 与常规文件一样的C++代码链接

将元素“分块”分配给实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export void sinx(
uniform int N,
uniform int terms,
uniform float* x,
uniform float* result)
{
// assume N % programCount = 0
uniform int count = N / programCount;
int start = programIndex * count;
for (uniform int i=0; i<count; i++)
{
int idx = start + i;
float value = x[idx];
float numer = x[idx] * x[idx] * x[idx];
uniform int denom = 6; // 3!
uniform int sign = ‐1;
for (uniform int j=1; j<=terms; j++)
{
value += sign * numer / denom
numer *= x[idx] * x[idx];
denom *= (j+3) * (j+4);
sign *= ‐1;
}
result[idx] = value;
}
}

第一个版本是轮转的方式,使用_mm_load_ps1SSE指令为四个实例分别分配值,这四个元素在内存中是连续的,因此很高效。但是分块的方式在每次为四个实例分配值的时候,会按照“0,4,8,12”、“1,5,9,13”的方式分配,现在涉及内存中的四个非连续值。需要执行“gather”指令(gather是一种更复杂、更昂贵的SIMD指令:在2013年开始作为AVX2的一部分提供)

使用foreach提高抽象级别:foreach是关键的ISPC语言构造

  • foreach声明并行循环迭代
    • 表示:这些是团队中的实例必须协同执行的迭代
  • ISPC实现将迭代分配给组中的程序实例
    • 当前ISPC实现将执行静态交错分配

错误的sum 规约

1
2
3
4
5
6
7
8
9
10
11
export uniform float sumall1( 
uniform int N,
uniform float* x)
{
uniform float sum = 0.0f;
foreach (i = 0 ... N)
{
sum += x[i];
}
return sum;
}

正确的sum 规约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export uniform float sumall2( 
uniform int N,
uniform float* x)
{
uniform float sum;
float partial = 0.0f;
foreach (i = 0 ... N)
{
partial += x[i];
}
// from ISPC math library
sum = reduce_add(partial);
return sum;
}

sum的类型为uniform float(所有程序实例都有一个变量副本),x[i]不是统一表达式(每个程序实例的值不同)结果:编译时类型错误。

并行计算所有数组元素的总和。每个实例累积一个私有部分和(无通信)。

使用reduce_add()通信原语将部分和相加。结果是所有程序实例的总和相同(reduce_add()返回一个统一的浮点数)。

下面的ISPC代码将以类似于下面的手写C+AVX intrinsics实现的方式执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export uniform float sumall2( 
uniform int N,
uniform float* x)
{
uniform float sum;
float partial = 0.0f;
foreach (i = 0 ... N)
{
partial += x[i];
}
// from ISPC math library
sum = reduce_add(partial);
return sum;
}

float sumall2(int N, float* x) {
float tmp[8]; // assume 16­‐byte alignment
__mm256 partial = _mm256_broadcast_ss(0.0f);
for (int i=0; i<N; i+=8)
partial = _mm256_add_ps(partial, _mm256_load_ps(&x[i]));
_mm256_store_ps(tmp, partial);
float sum = 0.f;
for (int i=0; i<8; i++)
sum += tmp[i];
return sum;
}

ISPC task

  • ISPC组抽象由单核上的SIMD指令实现。
  • ISPC包含另一个抽象:用于实现多核执行的“task”。

用pthreads表示并行性

用ISPC表示并行性:

  • 用于指定同时执行(真正的并行性)
  • 用于指定独立工作(可能并行)

三种通信模式

  1. 共享地址空间
  2. 消息传递
  3. 数据并行

共享地址空间模型(抽象):

  • 线程通过读/写共享变量进行通信
  • 共享变量就像一个大公告板
    • 任何线程都可以读取或写入共享变量

两个线程如下:

1
2
3
int x = 0;
spawn_thread(foo, &x);
x = 1;

1
2
3
4
void foo(int* x) { 
while (x == 0) {}
print x;
}

同步原语也是共享变量:例如锁

1
2
3
4
5
6
int x = 0;
Lock my_lock;
spawn_thread(foo, &x, &my_lock);
mylock.lock();
x++;
mylock.unlock();

1
2
3
4
5
6
7
void foo(int* x, lock* my_lock) 
{
my_lock‐>lock();
x++;
my_lock­‐>unlock();
print x;
}

共享地址空间模型(抽象)

  • 线程通过以下方式进行通信:
    • 读取/写入共享变量
      • 线程间通信隐含在内存操作中
      • 线程1存储到X
      • 稍后,线程2读取X(并观察线程1对值的更新)
    • 操作同步原语
      • 例如,通过使用锁确保相互排斥
  • 这是顺序编程的自然扩展
    • 事实上,到目前为止,我们在课堂上的所有讨论都假设有一个共享的地址空间!

共享地址空间的硬件实现:每个处理器可以直接访问任何内存地址。

非统一内存访问(NUMA)

  • 所有处理器都可以访问任何内存位置,但是内存访问的成本(延迟和/或带宽)对于不同的处理器是不同的
    • 在系统中保持统一访问时间的问题:可扩展性
      • 好:开销是一致的,坏:它们是一致的坏(内存是一致的远)
    • NUMA设计更具可扩展性
      • 对本地内存的低延迟访问
      • 为本地内存提供高带宽
  • 开销是程序员为性能调优所做的工作增加
    • 发现、利用局部性对性能非常重要(希望大多数内存访问都指向本地内存)

下面的图中对x的访问,如果x在1-4核上,访问开销远远小于5-8核。

消息传递(实现)

  • 流行软件库:MPI(消息传递接口)
  • 硬件不需要实现系统范围的加载和存储来执行消息传递程序(只需要能够传递消息)

ISPC中的数据并行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ISPC code:
export void absolute_value(
uniform int N,
uniform float* x,
uniform float* y)
{
foreach(i = 0 ... N)
{
if (i > 0 && x[i] < 0)
y[i-1] = x[i];
else
y[i] = x[i];
}
}

将循环体视为函数,foreach构造是一个映射。给定此程序,可以将该程序视为将循环体映射到数组X和Y的每个元素上。

1
2
3
4
5
6
// main C++ code: 
const int N = 1024;
float* x = new float[N];
float* y = new float[N];
// initialize N elements of x here
absolute_value(N, x, y);

但如果我们想说得更准确一些:该系列不是一流的ISPC概念。它是由程序如何实现数组索引逻辑隐式定义的。

这个程序是不确定的!循环体的多次迭代可能写入同一内存位置。数据并行模型(foreach)没有规定迭代发生的顺序,模型不提供用于细粒度互斥/同步的原语)。它不是为了帮助程序员用这种结构编写程序。

一种更“合适”的数据并行方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const int N = 1024; 
stream<float> x(N); // define collection
stream<float> y(N); // define collection
// initialize N elements of x here
// map function absolute_value onto
// streams (collections) x, y
absolute_value(x, y);

// kernel:
void absolute_value(float x, float y)
{
if (x < 0)
y = ‐x;
else
y = x;
}

注意:这不是ISPC语法(更多的是Kayvon编造的语法),以这种函数形式表示的数据并行性有时被称为流编程模型。

  • stream:元素的集合。元素可以独立处理
  • kernel:没有副作用的函数。对集合进行元素操作

gather/scatter:两个关键的数据并行通信原语

把absolute_value映射到gather产生的流上:

1
2
3
4
5
6
7
const int N = 1024; 
stream<float> input(N);
stream<int> indices;
stream<float> tmp_input(N);
stream<float> output(N);
stream_gather(input, indices, tmp_input);
absolute_value(tmp_input, output);

用ISPC 等价于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export void absolute_value( 
uniform float N,
uniform float* input,
uniform float* output,
uniform int* indices)
{
foreach (i = 0 ... n)
{
float tmp = input[indices[i]];
if (tmp < 0)
output[i] = ‐tmp;
else
output[i] = tmp;
}
}

把absolute_value映射到scatter的值上:

1
2
3
4
5
6
7
const int N = 1024; 
stream<float> input(N);
stream<int> indices;
stream<float> tmp_output(N);
stream<float> output(N);
absolute_value(input, tmp_output);
stream_scatter(tmp_output, indices, output);

用ISPC等价于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export void absolute_value( 
uniform float N,
uniform float* input,
uniform float* output,
uniform int* indices)
{
foreach (i = 0 ... n)
{
if (input[i] < 0)
output[indices[i]] = ‐input[i];
else
output[indices[i]] = input[i];
}
}

gather操作:

1
gather(R1, R0, mem_base);

概要:数据并行模型

  • 基本结构:将函数映射到大量数据集合上
    • 功能性:无副作用执行
    • 不同函数调用之间没有通信(允许以任何顺序调度调用,包括并行调度)
  • 实际上,这就是许多简单程序的工作原理
  • 但是许多现代面向性能的数据并行语言并不严格执行这种结构
    • ISPC、OpenCL、CUDA等。
    • 他们选择命令式C风格语法的灵活性/熟悉性,而不是功能更强大的形式的安全性:这是他们采用命令式C风格的关键
    • 观点:功能性思维是很好的,但编程系统确实应该采用结构来促进实现高性能的实现,而不是阻碍它们

现代实践:混合编程模型

  • 在集群的多核节点内使用共享地址空间编程,在节点之间使用消息传递
    • 在实践中非常非常普遍
    • 使用共享地址空间的便利性(在节点内)可以有效地实现,需要在其他地方进行显式通信
  • 数据并行编程模型支持内核中的共享内存式同步原语
    • 允许有限形式的迭代间通信(如CUDA、OpenCL)
  • CUDA/OpenCL使用数据并行模型扩展到多个内核,但采用共享地址空间模型,允许在同一内核上运行的线程进行通信。

lecture 4

如何创建一个并行程序

  • 剖分
  • 分配给线程/进程
    • 负载平衡,可以动态/静态分配
  • 编排依赖关系
  • 在并行机器上并行执行,通信

阿姆达尔定律:依赖性限制了并行性带来的最大加速比

  • 运行顺序程序。。。
  • 设S=固有顺序的顺序执行部分(依赖项阻止并行执行)
  • 然后是并行执行带来的最大加速≤ 1/S

一个使用pthread的例子,进行了任务的划分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef struct { 
int N, terms;
float* x, *result;
} my_args;
void parallel_sinx(int N, int terms, float* x, float* result)
{
pthread_t thread_id;
my_args args;
args.N = N/2;
args.terms = terms;
args.x = x;
args.result = result;
// launch second thread, do work on first half of array
pthread_create(&thread_id, NULL, my_thread_start, &args);
// do work on second half of array in main thread
sinx(N ‐ args.N, terms, x + args.N, result + args.N);
pthread_join(thread_id, NULL);
}
void my_thread_start(void* thread_arg)
{
my_args* thread_args = (my_args*)thread_arg;
sinx(args‐>N, args‐>terms, args‐>x, args‐>result); // do work
}

循环迭代分解任务

  • 静态分配
  • 以块的方式(一个连续的部分)将迭代各步分配给pthreads(数组的前半部分分配给派生线程,后半部分分配给主线程)

使用ISPC task进行动态分配:ISPC在运行时将任务分配给工作线程

  • 分配策略:完成当前任务后,工作线程检查列表并为自己分配下一个未完成的任务。
1
2
3
4
5
6
7
void foo(uniform float* input, 
uniform float* output,
uniform int N)
{
// create a bunch of tasks
launch[100] my_ispc_task(input, output, N);
}

编排Orchestration

  • 涉及:
    • 结构化通信
    • 如有必要,添加同步以保留依赖项
    • 在内存中组织数据结构
    • 调度任务
  • 目标:降低通信/同步成本,保留数据引用的位置,减少开销等。
  • 机器细节会影响许多决策
    • 如果同步比较昂贵,可能会少用

映射到硬件

  • 将“线程”(“工作线程”)映射到硬件执行单元
  • 示例1:操作系统映射
    • 例如,将pthread映射到CPU核心上的硬件执行上下文
  • 示例2:编译器的映射
    • 将ISPC程序实例映射到向量指令通道
  • 示例3:硬件映射
    • 将CUDA线程块映射到GPU内核
  • 一些有趣的映射决策:
    • 将相关线程(协作线程)放在同一处理器上(最大化本地性、数据共享、最小化通信/同步成本)
    • 将不相关的线程放在同一个处理器上(一个可能是带宽受限的,另一个可能是计算受限的),以更有效地使用机器

共享地址空间表达式

  • 程序员负责同步
  • 通用同步原语:
    • 锁(提供互斥):一次仅在关键区域中有一个线程
    • barrier:等待线程到达此点
      • barrier是表示依赖关系的一种保守方式
      • barrier将计算分为几个阶段
      • 在barrier开始后的任何线程中的任何计算之前,barrier之前所有线程的所有计算都已完成
  • 保持原子性的机制
    • 锁定/解锁关键部分周围的互斥锁
    • 硬件支持的原子读修改写操作的内部函数
    • 有些语言对代码块的原子性具有一流的支持atmoic

lecture 5

GPU结构和CUDA编程

在CPU上,操作系统把程序加载到内存中,选择CPU的执行上下文,执行中断,加载上下文,运行。在GPU上,

NVIDIA Tesla architecture(2007)

  • 第一个GPU硬件的非图形特定(“计算模式”)接口(GeForce 8xxx系列GPU)
  • 应用程序可以在GPU内存中分配缓冲区,并将数据复制到缓冲区或从缓冲区复制数据
  • 应用程序(通过图形驱动程序)为GPU提供单一内核二进制程序
  • 应用程序告诉GPU以SPMD模式“运行N个实例”

CUDA程序由并发线程的层次结构组成,线程ID可以是三维的(下面的2D示例)。多维线程ID对于自然为N-D的问题非常方便。

基本的CUDA语法:

  • 主机和设备执行的代码是被程序员人为分开的
  • host代码:串行执行
    • 在CPU上作为普通C/C++应用程序的一部分运行
    • 最后一行代码大量启动多个CUDA线程,“启动CUDA线程块网格”,调用在所有线程终止时返回
1
2
3
4
5
6
7
8
9
const int Nx = 12; 
const int Ny = 6;
dim3 threadsPerBlock(4, 3, 1);
dim3 numBlocks(Nx/threadsPerBlock.x,
Ny/threadsPerBlock.y, 1);
// assume A, B, C are allocated Nx x Ny float arrays
// this call will trigger execution of 72 CUDA threads:
// 6 thread blocks of 12 threads each
matrixAdd<<<numBlocks, threadsPerBlock>>>(A, B, C);
  • 设备内核函数(device kernel function)的SPMD执行:
    • 每个线程从其在其块中的位置(threadIdx)和其块在网格中的位置(blockIdx)计算其整个网格线程id。
    • device代码:内核函数(__global__表示CUDA内核函数)在GPU上运行
1
2
3
4
5
6
7
8
9
10
11
12
__device__ float doubleValue(float x) {
return 2 * x;
}

// kernel definition
__global__ void matrixAdd(float A[Ny][Nx],
float B[Ny][Nx],
float C[Ny][Nx])
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
int j = blockIdx.y * blockDim.y + threadIdx.y;
C[j][i] = A[j][i] + doubleValue(B[j][i]);

SPMD线程数在程序中是显式的,内核调用的数量不是由数据的大小决定。

CUDA中GPU设备的内存和CPU的内存是完全分开的,需要数据时用cudaMemcpy从CPU中拷到GPU上。

CUDA中有三种不同的内存

  • 每个线程自己的内存,只能被线程读写
  • 每个block自己的内存,能被block中所有的线程读写
  • 全局内存,能被所有的线程读写。

举例子:1D卷积:output[i] = (input[i] + input[i+1] + input[i+2]) / 3.f

1
2
3
4
5
6
7
8
#define THREADS_PER_BLK 128
__global__ void convolve(int N, float* input, float* output) {
int index = blockIdx.x * blockDim.x + threadIdx.x; // thread local variable
float result = 0.0f; // thread-local variable
for (int i=0; i<3; i++) // each thread computes result for one element
result += input[index + i];
output[index] = result / 3.f; // write result to global memory
}

host上的代码:

1
2
3
4
5
int N = 1024 * 1024 
cudaMalloc(&devInput, sizeof(float) * (N+2) ); // allocate array in device memory
cudaMalloc(&devOutput, sizeof(float) * N); // allocate array in device memory
// property initialize contents of devInput here ...
convolve<<<N/THREADS_PER_BLK, THREADS_PER_BLK>>>(N, devInput, devOutput);

每个输出元素一个线程:在每个块共享内存中暂存输入数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define THREADS_PER_BLK 128 
__global__ void convolve(int N, float* input, float* output) {
__shared__ float support[THREADS_PER_BLK+2]; // per-block allocation
int index = blockIdx.x * blockDim.x + threadIdx.x; // thread local variable
support[threadIdx.x] = input[index];
if (threadIdx.x < 2) {
support[THREADS_PER_BLK + threadIdx.x] = input[index+THREADS_PER_BLK];
}
// 所有线程协同地将块的支持区域从全局内存加载到共享内存中(总共130条加载指令,而不是3*128条加载指令)

__syncthreads(); // barrier (all threads in block)
float result = 0.0f; // thread-local variable
for (int i=0; i<3; i++)
result += support[threadIdx.x + i];
output[index] = result / 3.f; // write result to global memory
}

CUDA同步结构

  • __syncthread()
    • 屏障:等待块中的所有线程到达该点
  • 原子操作
    • 例如,float atomicAdd(float* addr, float amount)
    • 全局内存和共享内存变量上的原子操作
  • 主机/设备同步
    • 内核返回时跨越所有线程的隐式屏障

CUDA摘要

  • 执行:线程层次结构
    • 大量启动多个线程
    • 两级层次结构:线程被分组到线程块中
  • 分布式地址空间
    • 用于在主机和设备地址空间之间复制的内置memcpy原语
    • 三种不同类型的设备地址空间
    • 分为三个层级的内存:每个线程、每个块(“共享”)或每个程序(“全局”)
  • 线程块中线程的屏障同步原语
  • 用于附加同步的原子原语(共享和全局变量)

启动超过100万个CUDA线程(超过8K个线程块)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define THREADS_PER_BLK 128 
__global__ void convolve(int N, float* input, float* output) {
__shared__ float support[THREADS_PER_BLK+2]; // per-block allocation
int index = blockIdx.x * blockDim.x + threadIdx.x; // thread local var
support[threadIdx.x] = input[index];
if (threadIdx.x < 2) {
support[THREADS_PER_BLK+threadIdx.x] = input[index+THREADS_PER_BLK];
}
__syncthreads();
float result = 0.0f; // thread-local variable
for (int i=0; i<3; i++)
result += support[threadIdx.x + i];
output[index] = result / 3.f;
}
// host code //////////////////////////////////////////////////////
int N = 1024 * 1024;
cudaMalloc(&devInput, N+2); // allocate array in device memory
cudaMalloc(&devOutput, N); // allocate array in device memory
// property initialize contents of devInput here ...
convolve<<<N/THREADS_PER_BLK, THREADS_PER_BLK>>>(N, devInput, devOutput);

8k个线程blocks分布在Grid上,blocks所需资源:(包含在已编译的内核二进制文件中)

  • 128线程
  • 520字节的共享内存
  • (128 x B)字节的本地内存

从主机中执行的启动命令launch(blockDim, convolve)

  • 主要CUDA假设:线程块执行可以以任何顺序执行(块之间没有依赖关系)
  • GPU实现使用尊重资源需求的动态调度策略将线程块(“工作”)映射到内核

我们常见设计模式的另一个实例:

  • 最佳实践:创建足够的worker来“填充”并行机,不再:
    • 每个并行执行资源(例如,CPU核心、核心执行上下文)一个worker thread
    • 每个核心可能需要N个工作线程(其中N足够大,可以隐藏内存/IO延迟)
    • 为每个worker预先分配资源
    • 动态地将任务分配给工作线程(对许多任务重用分配)
  • 其他例子:
    • ISPC执行发射任务的情况
    • 为CPU上的每个超线程创建一个pthread。线程在程序的其余部分保持活动状态
    • 线程数是内核数的函数,而不是未完成请求数的函数

回想一下,CUDA内核作为SPMD程序执行。在NVIDIA GPU上,32个CUDA线程组共享一个指令流。这些组织被称为“warp”。

convolve线程块由4个warp执行(4个warp x 32个线程/warp = 每个block 128个CUDA线程)(WAPS是一个重要的GPU实现细节,但不是CUDA抽象!)

每个时钟时SMX核心操作:

  • 从驻留在SMM core上的64个线程中选择最多四个可运行的warp(线程级并行)
  • 每个warp最多选择两条可运行指令(指令级并行)

运行GPU的流程:

  • convolve的运行需要:
    • 每个线程block必须执行128个线程
    • 每个线程block必须分配130*sizeof(float)=520Bytes内存
    • 让我们假设数组大小N非常大,因此主机端内核启动会生成数千个线程块。
    • #define THREADS_PER_BLK 128
    • convolve<<<N/THREADS_PER_BLK, THREADS_PER_BLK>>>(N, input_array, output_array);
  • 步骤1:主机向CUDA设备(GPU)发送命令(“执行此内核”)

  • 步骤2:调度器将块0映射到核心0(为128个线程和520字节的共享存储保留执行上下文)
  • 步骤3:调度器继续将块映射到可用的执行上下文(显示交错映射)

  • 步骤3:调度器继续将块映射到可用的执行上下文(显示交错映射)。一个内核上只能容纳两个线程块(第三个线程块无法容纳,因为共享存储不足3 x 520字节>1.5 KB)

  • 步骤4:线程块0在核心0上完成

  • 步骤5:在核心0上调度块4(映射到执行上下文0-127)
  • 步骤6:线程块2在核心0上完成
  • 步骤7:线程块5在核心0上调度(映射到执行上下文128-255)

复习:什么是“warp”?

  • warp是NVIDIA GPU上的CUDA实现细节
  • 在现代NVIDIA硬件上,线程块中32个CUDA线程组使用32宽SIMD执行同时执行。
    • 这32个逻辑CUDA线程共享一个指令流,因此由于执行不一致,性能可能会受到影响。
    • 此映射类似于ISPC在一个组中运行程序实例的方式。
  • 共享一个指令流的32个线程组称为warp。
    • 在thread block 中,thread0-31落在同一warp中(thread32-63等也落在同一warp中)
    • 因此,包含256个CUDA线程的线程块映射到8个warp。
    • 我们上次讨论的GTX 980中的每个“SMM”核心都能够调度和交错执行多达64个warp。
    • 因此,“SMM”内核能够并发执行多个CUDA线程块。

在这个虚构的NVIDIA GPU示例中:Core维护12个warp的上下文,并选择一个warp来运行每个时钟

为什么为block中的所有线程分配执行上下文?

  • 假设一个线程块有256个CUDA线程
  • 假设一个虚构的SMM内核,在硬件中只有4个可并行执行的warp(如上图所示)
  • 为什么不运行四个warp(线程0-127)以完成,然后运行下四个warp(线程128-255)以完成,以便执行整个线程块?

因为CUDA内核可能会在块中的线程之间创建依赖关系。

  • 最简单的例子是__syncthreads()
  • 当存在依赖项时,系统不能以任何顺序执行块中的线程。
  • CUDA语义:块中的线程同时运行。如果块中的线程是可运行的,那么它最终将运行!(没有deadlock)

CUDA抽象的实现

  • 系统可以按任何顺序安排线程块
    • 系统假定块之间没有依赖关系
    • 逻辑并发
  • 同一块中的CUDA线程不会同时运行
    • 当块开始执行时,所有线程都在运行(这些语义对系统施加调度约束)
    • CUDA线程块本身就是一个SPMD程序(类似于一组ISPC程序实例)
    • 线程块中的线程是并发的、协作的“工作线程”
  • CUDA实施:
    • GPU warp具有类似于ISPC实例组的性能特征(但与ISPC实例组不同,warp概念不存在于编程模型)
    • 线程块中的所有warp都调度到同一个内核上,允许通过共享内存变量进行高带宽/低延迟通信
    • 当块中的所有线程完成时,块资源(共享内存分配、warp执行上下文)将可用于下一个块

CUDA摘要

  • 执行语义
    • 将问题划分为线程块符合数据并行模型的精神(旨在与机器无关:系统将块调度到任意数量的核上)
    • 线程块中的线程实际上是并发运行的(它们必须并发运行,因为它们相互协作)
    • 单线程块内部:SPMD共享地址空间编程
    • 这些执行模式之间存在细微但显著的差异。
  • 内存语义
    • 分布式地址空间:主机/设备存储器
    • 设备内存中的线程本地/块共享/全局变量
    • 加载/存储在它们之间移动数据(因此将本地/共享/全局内存视为不同的地址空间是正确的)
  • 主要实施细节:
    • 线程块中的线程被调度到同一GPU内核上,以允许通过共享内存进行快速通信
    • 线程块中的线程被分组为warp,以便在GPU硬件上执行SIMD

lecture 6

高性能编程

  • 优化并行程序的性能是一个优化分解、分配和编排选择的迭代过程
  • 关键目标
    • 将工作负载平衡到可用的执行资源上
    • 减少通信(避免停顿)
    • 减少额外的工作(开销),以提高并行性、管理分配、减少通信等。

平衡各进程间的工作量

  • 理想情况下:所有处理器在程序执行期间都在计算(它们同时计算,同时完成部分工作)
  • 回顾阿姆达尔定律:
    • 少量的负载不平衡就能显著限制最大加速比
    • P4多做20%的工作→P4完成所需时间延长20%→并行程序的20%运行是串行执行,就很严重了。

静态赋值

  • 线程的工作分配是预先确定的
    • 不一定在编译时确定(分配算法可能取决于运行时参数,如输入数据大小、线程数等)
  • 示例:为每个线程分配相等数量的网格单元
    • 我们讨论了两种静态工作分配(分块和交替)
  • 静态赋值的良好特性:简单,基本上零运行时开销(在本例中:实现赋值的额外工作是一点索引的计算)

静态分配何时适用?

  • 当工作的成本(执行时间)和工作量是可预测的(这样程序员就可以提前完成一个好的任务)
  • 当工作是可预测的,但不是所有的工作都有相同的开销
  • 当已知执行时间统计信息时(例如,平均成本相同)

“半静态”分配

  • 工作成本在短期内是可预测的
    • 想法:最近的过去很好地预测了不久的将来
  • 应用程序定期配置应用程序并重新调整分配
    • 对于重新调整之间的间隔,分配是“静态”的
  • 自适应网格:网格随着对象移动或流过对象的更改而更改,但更改速度较慢(颜色表示网格部分已分配给处理器)
  • 粒子模拟:粒子在模拟过程中移动时重新分布(如果运动缓慢,则不需要经常进行重新分布)

动态分配:程序在运行时动态确定分配,以确保负载分布均匀。(任务的执行时间或任务总数是不可预测的。)

顺序程序(独立循环迭代)

1
2
3
4
5
6
7
8
9
int N = 1024; 
int* x = new int[N];
bool* prime = new bool[N];
// initialize elements of x here
for (int i=0; i<N; i++)
{
// unknown execution time
is_prime[i] = test_primality(x[i]);
}

并行程序(多线程执行SPMD,共享地址空间模型)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int N = 1024; 
// assume allocations are only executed by 1 thread
int* x = new int[N];
bool* is_prime = new bool[N];
// initialize elements of x here
LOCK counter_lock;
int counter = 0; // shared variable
while (1) {
int i;
lock(counter_lock);
i = counter++;
unlock(counter_lock);
if (i >= N)
break;
is_prime[i] = test_primality(x[i]);
}

使用工作队列的动态分配

  • 子问题(也称为“任务”、“工作”)
  • 共享工作队列:要做的工作的列表(现在,让我们假设每个工作都是独立的)
  • 工作线程:从共享工作队列中提取数据,在创建新工作时将其推送到队列中

在分配任务时,如果是一个元素就分配一次的话,可能具有良好的工作负载平衡(许多小任务),但是可能导致高同步成本(关键部分的序列化)。因此可以每多少个元素成为一个任务,降低同步或者加锁的开销。

选择任务大小

  • 拥有比处理器多得多的任务非常有用(许多小任务通过动态分配实现良好的工作负载平衡)
  • 但希望尽可能少的任务,以最大限度地减少
    • 鼓励大粒度任务
  • 理想的粒度取决于许多因素,必须了解您的工作负载和您的机器

如果某些任务比其他任务更耗时,不平衡问题的一种可能解决方案:

  • 将工作划分为大量较小的任务
    • 希望最长的任务相对于总执行时间变得更短
    • 可能会增加同步开销
    • 可能不可能(也许长任务基本上是连续的)
  • 另一个解决方案:智能调度
    • 先安排长任务
    • 执行长任务的线程执行的总体任务较少,但与其他线程的工作量大致相同。
    • 需要一些工作量方面的知识(一些成本的可预测性)

使用一组分布式队列减少同步开销(避免所有work在单个工作队列上同步),工作线程需要:

  • 从自己的工作队列中提取数据
  • 将新工作推送到自己的工作队列
  • 当本地工作队列为空时从另一个工作队列窃取工作

分布式工作队列

  • 窃取期间会发生代价高昂的同步/通信
    • 但并非每次线程都有新的工作
    • 只有在确保良好负载平衡的必要情况下才会发生抢夺
  • 导致局部性增加(好事啊)
    • 常见情况:线程处理它们创建的任务(生产者-消费者位置)
  • 实施挑战
    • 偷谁的?
    • 偷多少?
    • 如何检测程序终止?
    • 确保本地队列访问速度快(同时保持互斥)

总结

  • 挑战:实现良好的工作负载平衡
    • 希望所有处理器始终工作(否则,资源将处于空闲状态!)
    • 但我们需要低成本的解决方案来实现这一平衡
    • 最小化计算开销(例如,调度/分配逻辑)
    • 最小化同步成本
  • 静态分配与动态分配
    • 尽可能使用有关工作负载的预先知识,以减少负载不平衡和任务管理/同步成本(在极限情况下,如果系统知道一切,则使用完全静态分配)

通用并行编程模式

  • 线程并行性的显式管理:
    • 每个执行单元(或每个所需并发量)创建一个线程
    • 下面的示例:带有pthreads的C代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct thread_args { 
float* A;
float* B;
};
int thread_id[MAX_THREADS];
thread_args args;
args.A = A;
args.B = B;
for (int i=0; i<num_cores; i++) {
pthread_create(&thread_id[i], NULL, myFunctionFoo, &args);
}
for (int i=0; i<num_cores; i++) {
pthread_join(&thread_id[i]);
}

考虑分治算法

1
2
3
4
5
6
7
8
9
10
11
12
13
// sort elements from ‘begin’ up to (but not including) ‘end’ 
void quick_sort(int* begin, int* end) {
if (begin >= end-1)
return;
else {
// choose partition key and partition elements
// by key, return position of key as `middle`
int* middle = partition(begin, end);
quick_sort(begin, middle);
quick_sort(middle+1, last);
// independent work
}
}

fork-join模式

  • 表示分治算法固有的独立工作的自然方式
  • 本课程的代码示例将使用Cilk Plus
    • C++语言扩展
    • 最初由麻省理工学院开发,现在改为开放标准(在GCC、英特尔ICC中)

cilk_spawn foo(args)

  • 语义:调用foo,但与标准函数调用不同,调用方可以继续异步执行foo

cilk_sync

  • 语义:当前函数生成的所有调用完成时返回。
  • 注意:在包含cilk_sync的每个函数的末尾都有一个隐式cilk_barrier(暗示:当cilk函数返回时,与该函数相关的所有工作都已完成)

基本Cilk示例

1
2
3
4
// foo() and bar() may run in parallel
cilk_spawn foo();
bar();
cilk_sync;

1
2
3
4
// foo() and bar() may run in parallel 
cilk_spawn foo();
cilk_spawn bar();
cilk_sync;
1
2
3
4
5
6
// foo, bar, fizz, buzz, may run in parallel 
cilk_spawn foo();
cilk_spawn bar();
cilk_spawn fizz();
buzz();
cilk_sync;

Cilk Plus中的并行快速排序

如果问题规模足够小,则按顺序排序(生成的开销超过了潜在并行化的好处)

1
2
3
4
5
6
7
8
9
void quick_sort(int* begin, int* end) { 
if (begin >= end - PARALLEL_CUTOFF)
std::sort(begin, end);
else {
int* middle = partition(begin, end);
cilk_spawn quick_sort(begin, middle);
quick_sort(middle+1, last);
}
}

编写fork-join程序

  • 主要思想:使用cilk_spawn向系统公开独立工作(潜在并行性)
  • 回忆并行编程的经验法则
    • 需要至少和并行执行能力一样多的工作(例如,程序可能产生至少和内核一样多的工作)
    • 需要更多的独立工作而不是执行能力,以便在核心上实现所有工作的良好工作负载平衡
    • “并行松弛”=独立工作与机器并行执行能力的比率(实际上,~8是一个很好的比率)
    • 但是不要做太多的独立工作,这样工作的粒度就太小了(太多的松弛会导致管理细粒度工作的开销)

调度fork-join程序

  • 考虑非常简单的调度器:
    • 使用pthread_create为每个cilk_sync启动pthread
    • cilk_sync转换为适当的pthread_join调用
  • 潜在的性能问题?
    • 重量级spawn操作
    • 并发运行的线程比内核多得多
    • 上下文切换开销
    • 工作集比需要的工作集大,缓存位置少

以下程序的工作步骤?

1
2
3
cilk_spawn foo();
bar(); 
cilk_sync;

  • 每线程工作队列存储“要做的工作”
    • 到达cilk_spawn foo()后,线程将后续工作(bar())放入其工作队列,并开始执行foo()
  • 空闲线程从繁忙线程“窃取”工作
    • 空闲线程在忙线程的队列中查找工作
    • 如果线程1处于空闲状态(也就是说,它自己的队列中没有工作),那么它会在线程0的队列中查找要做的工作
    • 空闲线程将工作从繁忙线程的队列移动到自己的队列
    • 空闲线程开始执行任务。

1
2
3
4
for (int i=0; i<N; i++) {
cilk_spawn foo(i);
}
cilk_sync;
  • 先运行后续工作
    • 调用线程在执行任何迭代之前生成所有迭代的工作
    • 思考:调用图的宽度优先遍历。O(N)生成工作的空间(最大空间)
    • 如果没有窃取,执行顺序与删除cilk_spawn的程序非常不同
  • 先运行孩子线程
    • 调用线程只创建一个要窃取的项(表示所有剩余迭代的延续)
    • 若并没有发生窃取,线程将继续从工作队列中弹出后续,将新的后续排入队列(更新后的值为i)
    • 执行顺序与删除spawn的程序相同。
    • 思考:调用图的深度优先遍历
    • 若后续被窃取,则线程将生成并执行下一次迭代
    • 排队继续,i前进1
    • 可以证明具有T线程的系统的工作队列存储不超过单线程执行的堆栈存储的T倍

实现工作窃取:每个工作线程实现一个dequeue

  • 作为dequeue实现的工作队列(双端队列)
    • 本地线程从“尾部”(底部)推动/弹出
    • 远程线程从“头”(顶部)窃取
    • 存在有效的无锁出列实现
  • 空闲线程随机选择要尝试从中窃取的线程
  • 从出列的顶端窃取工作
    • 减少与本地线程的争用:本地线程访问的出列部分与窃取线程访问的出列部分不同!
    • 窃取在调用树开始方向的工作:这是一个“更大”的工作,因此执行窃取的成本在未来较长的计算时间内摊销
    • 最大化局部性:(结合运行子级优先策略)局部线程在调用树的局部部分工作


1
2
3
4
5
6
7
8
9
10
void recursive_for(int start, int end) { 
while (start <= end - GRANULARITY) {
int mid = (end - start) / 2;
cilk_spawn recursive_for(start, mid);
start = mid;
}
for (int i=start; i<end; i++)
foo(i);
}
recursive_for(0, N);

两种sync的实现方法

  • “暂停”加入策略
    • 启动fork的线程必须执行同步,因此,它将等待所有生成的工作完成,在这种情况下,线程0是启动fork的线程,它也将等待所有其他线程完成工作后继续执行之后的任务
  • 贪心
    • 当启动fork的线程处于空闲状态时,它看起来会窃取新的工作
    • 到达连接点的最后一个线程在同步后继续执行

lecture 7

关于消息传递示例的说明

  • 计算
    • 数组索引相对于本地地址空间(而不是全局网格坐标)
  • 通讯:
    • 通过发送和接收消息来执行
    • 批量传输:一次传输整行(而不是单个元素)
  • 同步:
    • 通过发送和接收消息来执行
  • 为方便起见,消息传递库通常包括更高级的原语(通过发送和接收实现)

同步(阻塞)发送和接收

  • send():当发送方收到消息数据驻留在接收方地址空间的确认时,调用返回
  • recv():当接收到的消息中的数据复制到接收方的地址空间并将确认发送回发送方时,调用返回

call SEND(foo):

  • 将数据从发送方地址空间中的缓冲区“foo”复制到网络缓冲区
  • send message
  • receive ack
  • SEND()返回

Call RECV(bar):

  • 接收消息
  • 将数据复制到接收方地址空间的缓冲区“bar”中
  • send ack
  • RECV()返回

非阻塞异步发送/接收

  • send():调用立即返回
    • 调用线程无法修改提供给send()的缓冲区,因为消息处理与线程执行同时发生
    • 调用线程可以在等待消息发送时执行其他工作
  • recv():发布打算在将来接收的内容,立即返回
    • 使用checksend()checkrecv()确定发送/接收的实际状态
    • 调用线程可以在等待接收消息时执行其他工作

一种简单的非流水线通信模型:T(n) = T0 + n/B

  • T(n)=传输时间(操作的总延迟)
  • T0=启动延迟(例如,直到第一位到达目的地的时间)
  • n=操作中传输的字节数
  • B=传输速率(链路带宽)

如果处理器仅在上一条消息发送完成后发送下一条消息,“有效带宽”=n/T(n),有效带宽取决于传输大小(大传输分摊启动延迟)。

比较通用的通信开销模型:总通信时间=开销+占用率+网络延迟

  • 开销(处理器在通信上花费的时间,调用API,缓冲区拷贝等)
  • 占用率(数据通过系统最慢组件的时间)
  • 网络延迟(所有其他)

流水线通信:

  • 当网络忙时,消息被缓冲,直到之前的数据发送完
  • 由于网络缓冲区已满,发送者无法发送其他数据

通信计算比

  • 通信量(bytes)/计算量(指令数)
  • 如果分母是计算的执行时间,则比率给出代码的平均带宽要求
  • “运算强度”=1/通信与计算比率
  • 高效利用现代并行处理器需要高运算强度(低通信计算比),因为计算能力与可用带宽的比率很高

良好的剖分可以减少固有的通信开销(增加运算强度)

  • 一个是N/P,另一个是1/2

人为通信

  • 固有通信:基本上必须在处理器之间移动的信息,以执行给定分配的算法(假设无限容量缓存、最小粒度传输等)
  • 人为通信:所有其他通信(人为通信源于系统实现的实际细节)
  • 系统可能具有最小的传输粒度(结果:系统必须传输比所需更多的数据)
    • 程序加载一个4字节浮点值,但必须从内存传输整个64字节缓存线(通信量比需要多16倍)
  • 系统可能具有导致不必要通信的操作规则:
    • 程序存储16个连续的4字节浮点值,因此整个64字节缓存线从内存加载,然后存储到内存中(开销为2倍)
  • 数据在分布式内存中的位置不佳(数据不在访问最多的处理器附近)
  • 有限的复制容量(同一数据多次传输到处理器,因为缓存太小,无法在访问之间保留)

通过融合循环改进时间局部性

下面的程序中的两个函数,都先执行两个load,再执行一个数学运算,进行一次store(计算强度=1/3),总的计算强度就是1/3。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void add(int n, float* A, float* B, float* C) { 
for (int i=0; i<n; i++)
C[i] = A[i] + B[i];
}
void mul(int n, float* A, float* B, float* C) {
for (int i=0; i<n; i++)
C[i] = A[i] * B[i];
}
float* A, *B, *C, *D, *E, *tmp1, *tmp2;
// assume arrays are allocated here
// compute E = D + ((A + B) * C)
add(n, A, B, tmp1);
mul(n, tmp1, C, tmp2);
add(n, tmp2, D, E);

四个load,每3个数学运算一个load(计算强度=3/5)

1
2
3
4
5
6
void fused(int n, float* A, float* B, float* C, float* D, float* E) { 
for (int i=0; i<n; i++)
`E[i] = D[i] + (A[i] + B[i]) * C[i];
}
// compute E = D + (A + B) * C
fused(n, A, B, C, D, E);

上面的代码更加模块化(例如,基于数组的数学库,如Python中的Numaray)。下面的代码执行得更好。

通过共享数据提高算法强度

  • 利用共享:将在同一数据上运行的任务放在同一位置
    • 在同一处理器上同时调度在同一数据结构上工作的线程
    • 减少固有的通信
  • 示例:CUDA的线程块
    • CUDA程序中用于本地化相关处理的抽象
    • 块中的线程经常协作执行操作(利用CUDA共享内存快速访问/同步)
    • 因此,GPU实现总是在同一GPU内核上调度来自同一块的线程

利用空间局部性

  • 通信的粒度可能很重要,因为它可能会引入伪通信
    • 通信/数据传输的粒度
    • 缓存一致性的粒度

通信粒度导致的人为通信

  • 假设:通信粒度是cache line,cache line包含四个元素
  • 良好的空间局部性,便于对上下行的非局部访问
  • 对左右列的非本地访问的空间局部性较差
  • 本质上需要来自左右邻域的一个元素,但系统必须通信四个元素。

竞争:

  • 资源可以在给定吞吐量(单位时间内的事务数)下执行操作
    • 内存、通信链路、服务器等。
  • 当在一个小的时间窗口内对一个资源发出许多请求时(该资源是一个“热点”),就会发生争用
  • 示例:CUDA中的内存系统争用
1
2
3
4
5
6
7
8
9
10
#define THREADS_PER_BLK 128 
__global__ void my_cuda_program(int N, float* input, float* output)
{
__shared__ float local_data[THREADS_PER_BLK];
int index = blockIdx.x * blockDim.x + threadIdx.x;
// COOPERATIVELY LOAD DATA HERE
local_data[threadIdx.x] = input[index];
// WAIT FOR ALL LOADS TO COMPLETE
__syncthreads();
}

所有线程都会访问内存,因此没有线程可以运行,因为所有线程要么正在访问内存,要么在屏障处被阻塞。

一般来说,CUDA编程时的一个好的经验法则是确保调整线程块的大小,以便GPU可以在每个GPU内核上安装几个线程块。(这允许一个线程块中的线程覆盖分配给同一内核的另一个块中线程的延迟。)

示例:在大型并行机(例如GPU)上创建粒子网格数据结构,这一般用在N-body问题上,也有其他的方法

解决方案1:在cell上并行化

  • 一个可能的答案是按cell剖分:对于每个cell,独立计算其中的粒子(消除争用,因为不需要同步)
  • 并行性不足:只有16个并行任务,但需要数千个独立任务才能有效利用GPU)
  • 工作效率低下:在单元中执行粒子计算的次数是顺序算法的16倍
1
2
3
4
5
list cell_lists[16];      // 2D array of lists 
for each cell c // in parallel
for each particle p // sequentially
if (p is within c)
append p to cell_lists[c]

解决方案2:在粒子上并行化

  • 另一个答案:为每个CUDA线程指定一个粒子。线程计算包含粒子的单元,然后原子地更新列表。
  • 大规模争用:数千个线程争用更新单个共享数据结构的权限
1
2
3
4
5
6
7
list cell_list[16]; // 2D array of lists  
lock cell_list_lock;
for each particle p // in parallel
c = compute cell containing p
lock(cell_list_lock)
append p to cell_list[c]
unlock(cell_list_lock)

解决方案3:使用更细粒度的锁

  • 通过使用每cell锁缓解单个全局锁的争用
    • 假设粒子在二维空间中均匀分布~比解决方案2少16倍的争用
1
2
3
4
5
6
7
list cell_list[16];     // 2D array of lists 
lock cell_list_lock[16];
for each particle p // in parallel
c = compute cell containing p
lock(cell_list_lock[c])
append p to cell_list[c]
unlock(cell_list_lock[c])

解决方案4:计算部分结果+合并

  • 另一个答案是:并行生成N个“部分”网格,然后合并
    • 示例:创建N个线程块(至少与SMX内核的线程块数量相同)
    • 线程块中的所有线程更新相同的网格
    • 支持更快的同步:争用减少了N倍,而且同步成本更低,因为它是在块本地变量上执行的(在CUDA共享内存中)
    • 需要额外的工作:在计算结束时合并N个网格
    • 需要额外的内存占用:存储N个列表网格,而不是1个

解决方案5:数据并行方法

  • 步骤1:计算每个粒子被哪个cell包含(对输入粒子是平行处理的)
  • 步骤2:按cell序号对结果排序(基于排序排列的粒子索引数组)
  • 步骤3:查找每个cell的开始/结束(基于粒子索引元素的平行)
  • 此解决方案保持了大量并行性,并消除了细粒度同步的需要。。。以对数据进行排序和额外传递为代价(额外BW)
1
2
3
4
5
6
7
8
9
cell = grid_index[index] 
if (index == 0)
cell_starts[cell] = index;
else if (cell != grid_index[index-1]) {
cell_starts[cell] = index;
cell_ends[grid_index[index-1]] = index;
}
if (index == numParticles-1) // special case for last cell
cell_ends[cell] = index+1;

降低通信成本

  • 减少与发送方/接收方的通信开销
    • 发送更少的消息,使消息更大(分摊开销)
    • 将许多小消息合并成大消息
  • 减少延迟
    • 应用程序编写器:重新构造代码以利用局部性
    • 硬件实现者:改进通信架构
  • 减少争用
    • 复制争用资源(例如,本地副本、细粒度锁)
    • 错开对竞争资源的访问
  • 增加通信/计算重叠
    • 应用程序编写器:使用异步通信(例如,异步消息)
    • 硬件实现者:流水线、多线程、预取、无序执行
    • 在应用程序中需要额外的并发性(并发性大于执行单元的数量)

总结:优化通信

  • 固有的通信
    • 考虑到问题是如何分解的,工作是如何分配的,固有的通信是最基本的
    • 人为通信取决于机器实现细节(通常与固有通信对性能同样重要)
  • 提高程序性能
    • 识别和利用位置:减少通信(增加运算强度)
    • 减少开销(更少、更大的消息)
    • 减少争用
    • 最大化通信和处理的重叠(隐藏延迟,以免产生成本)

lecture 8

一些case study,讲解多个并行应用示例

  • 海洋模拟
  • 星系模拟(Barnes-Hut 算法)
  • 平行扫描
  • 数据并行分段扫描
  • 光线追踪

下图中方框对应于网格上的计算,线条表示网格上计算之间的依赖关系,“网格求解器”对应于应用程序的这些部分。这个图中表示了网格内的并行(数据并行)和不同网格之间的操作。该实现仅利用数据并行性。

海洋实现细节

  • 分解:
    • 网格的空间划分:每个处理器接收网格的二维剖分
  • 分配
    • 将剖分静态分配给处理器
  • 同步
    • barrier(将不同的计算阶段分开)
    • 更新共享变量时锁定互斥(“diff”的原子更新)

一种对区域格点进行分割的方法:

  • 叶节点是粒子,中间节点是方框,存储着若干点
  • 内部节点存储所有子实体的质心 + 总质量
  • 要计算每个物体上的力,请遍历树…累积所有其他物体的力
    • 如果 L/D < ϴ,则聚合内部节点计算力,否则下降到子节点
  • 预期接触节点数 ~ lg N / ϴ2

Barnes-Hut 树形结构的挑战:

  • 每个进程的工作量不统一,通信不均匀(取决于物体的局部密度)
  • 格点移动:因此成本和沟通模式会随着时间而变化
  • 不规则、细粒度的计算
  • 但是,计算中有很多局部性(空间附近的物体需要类似的数据来计算力)

工作分配

  • 挑战:
    • 每个处理器的主体数量相等!= 每个处理器的工作量相等
    • 希望每个处理器的工作量均等,并且分配应保留局部性
  • 观察:物体的空间分布变化缓慢
  • 使用半静态赋值
    • 每个时间步长,对于每个主体,记录与其他主体的交互次数
    • 计算成本低。 只需增加本地的 per-body 计数器
    • 使用值定期重新计算分配

Barnes-Hut:工作集

  • 工作集 1:计算体-体(或体-节点)对之间的力所需的数据
  • 工作集 2:在整个树遍历中遇到的数据
    • 一个物体接触的预期节点数:~ lg N / ϴ^2
    • 计算具有高度局部性:连续处理的物体就在附近,因此对一个点的处理几乎在完全相同的节点!

应该是一个树形的扫描结构,用来遍历或者广播。

Up-sweep:

1
2
3
for d=0 to (log2(n) - 1) do
forall k=0 to n-1 by 2^(d+1) do
a[k + 2^(d+1) - 1] = a[k + 2^(d) - 1] + a[k + 2^(d+1) - 1]

Down-sweep:

1
2
3
4
5
6
x[n-1] = 0
for d=(log2(n) - 1) down to 0 do
forall k=0 to n-1 by 2^(d+1) do
tmp = a[k + 2^(d) - 1]
a[k + 2^(d) - 1] = a[k + 2^(d+1) - 1]
a[k + 2^(d+1) - 1] = tmp + a[k + 2^(d+1) - 1]

加速光线相交场景

  • 预处理场景以构建数据结构,加速沿射线寻找“最接近”的几何体
  • 想法:对空间接近的对象进行分组(如 Barnes-Hut 中的四叉树)
    • 分层分组适应场景对象的非均匀密度

简单的光线追踪器(使用 BVH)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// stores information about closest hit found so far 
struct ClosestHitInfo {
Primitive primitive;
float distance;
};

trace(Ray ray, BVHNode node, ClosestHitInfo hitInfo)
{
if (!intersect(ray, node.bbox) || (closest point on box is farther than hitInfo.distance))
return;
if (node.leaf) {
for (each primitive in node) {
(hit, distance) = intersect(ray, primitive);
if (hit && distance < hitInfo.distance) {
hitInfo.primitive = primitive;
hitInfo.distance = distance;
}
}
} else {
trace(ray, node.leftChild, hitInfo);
trace(ray, node.rightChild, hitInfo);
}
}

射线打包追踪:程序一次明确地将一组光线与 BVH 相交

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
RayPacket 
{
Ray rays[PACKET_SIZE];
bool active[PACKET_SIZE];
};
trace(RayPacket rays, BVHNode node, ClosestHitInfo packetHitInfo)
{
if (!ANY_ACTIVE_intersect(rays, node.bbox) || (closest point on box (for all active rays) is farther than hitInfo.distance))
return;

update packet active mask

if (node.leaf) {
for (each primitive in node) {
for (each ACTIVE ray r in packet) {
(hit, distance) = intersect(ray, primitive);
if (hit && distance < hitInfo.distance) {
hitInfo[r].primitive = primitive;
hitInfo[r].distance = distance;
}
}
}
} else {
trace(rays, node.leftChild, hitInfo);
trace(rays, node.rightChild, hitInfo);
}
}

首先按照多叉树进行分割,再把光线进行打包追踪

数据包的优点

  • 将数据包操作映射到宽 SIMD 执行
    • 每条射线一个矢量
  • Amortize BVH 数据获取:包中的所有光线同时访问节点
    • 为数据包中的所有光线加载一次 BVH 节点(不是每条光线一次)
    • 注意:使数据包大于 SIMD 宽度是有价值的!(例如,大小 = 64)
  • 摊销工作(数据包是光线的层次结构)
    • 使用区间算法保守地针对节点 bbox 测试整个光线集(例如,将数据包视为光束)
    • 当所有光线共享原点时,可以进行进一步的算术优化
    • 注意:使数据包比 SIMD 宽度大得多是有价值的!

数据包的缺点

  • 如果任何光线必须访问一个节点,它会拖动数据包中的所有光线与它一起)
  • 效率损失:节点遍历、交叉等,分摊在少于一个数据包的射线价值上
  • 并非所有 SIMD 通道都在做有用的工作

当光线不相干时,数据包的好处会显着降低。本例:数据包访问所有树节点。

通过光线重新排序改进数据包跟踪:想法:当数据包利用率低于阈值时,重排光线并继续使用较小的数据包

  • 提高 SIMD 利用率
  • 这项工作用小包更好

示例:考虑 8-wide SIMD 处理器和 16-ray 数据包(对数据包中的所有光线执行每个操作需要 2 个 SIMD 指令)

16 射线包:16 条射线中的 7 条处于活动状态,重新排列光线,重新计算活动射线的间隔/边界,使用 8 射线数据包继续跟踪:8 条射线中的 7 条处于活动状态

数据包跟踪的最佳实践

  • 对眼睛/反射/点光阴影光线或更高级别的 BVH 使用大包
    • 相干的光线始终位于树的顶部
  • 当数据包利用率低于阈值时切换到单射线(射线内 SIMD)
    • 对于宽 SIMD 机器,分支因子 4 BVH 适用于数据包遍历和单射线遍历
  • 可以使用数据包重新排序来推迟切换时间
    • 重新排序允许数据包提供利用树的好处
    • 由于实现复杂度高,在实践中不经常使用

lecture 9

粒子分块的并行实现

串行方法:

1
2
3
4
list cell_lists[16];      // 2D array of lists 
for each particle p
c = compute cell containing p
append p to cell_lists[c]

并行实现1:

1
2
3
4
5
6
7
list cell_list[16];    // 2D array of lists 
lock cell_list_lock;
for each particle p // in parallel
c = compute cell containing p
lock(cell_list_lock)
append p to cell_list[c]
unlock(cell_list_lock)

并行实现2:

1
2
3
4
5
list cell_lists[16]; // 2D array of lists 
for each cell c // in parallel
for each particle p // sequentially
if (p is within c)
append p to cell_lists[c]

实现3:每个进程构建一个网格再合并

实现4:数据并行排序

  • 计算每个粒子包含在哪个cell中
  • 根据cell号对粒子排序
  • 找到每个cell包含粒子下标的起始和结束

下图是一个海洋求解器在海洋网格规模固定时的加速比,最上方的是超线性加速,有了足够的处理器,分配给每个处理器的块开始适合于缓存(关键的工作集适合于每个处理器的缓存)。另一个例子是,如果问题大小对单个机器来说太大,那么工作集可能不适合内存,从而导致磁盘的抖动(这将会使加速比在有更大的的内存的机器上看起来令人惊讶!)

理解扩展性

  • 在问题的大小和并行计算机的大小之间可能有复杂的关系。
    • 可以影响负载平衡,开销,算术强度,数据访问的位置
  • 用一个有固定问题大小的问题去评估一个机器可能是有问题的
    • 问题过小:
      • 并行性的开销掩盖了并行性的优势(甚至可能导致性能下降)
      • 问题大小可能适合小型机器,但对大型机器不合适(不反映大机器的实际使用!)
    • 过大的问题
      • 关键的工作集可能不“适合”在小机器(导致对磁盘的攻击),或者关键工作集超过缓存容量,或者根本不能运行
      • 当有问题的工作集更“适合”在一个大型机器上,而不适合小的机器上,可以发生超线性加速比

资源导向的扩展性属性

工作问题约束(PC):

  • 使用一个并行计算机更快的解决问题
  • 加速比:(time 1 processor) / (time p processor)

工作时间约束(TC)

  • 固定时间内完成更多的工作
  • 加速比:(work done by p processor) / (work done by 1 processor)
  • 如何衡量“工作”?
    • 挑战:“工作完成”可能不是问题输入值的线性函数(例如矩阵乘法是O(N^3),对O(N^2)大小输入的工作)
    • 一种方法:“工作完成”是通过单个处理器的相同计算的执行时间定义的
    • 理想情况下,一项工作是:
      • 理解简单
      • 随顺序运行时间保持线性扩展(因此理想的加速保持线性P)

内存约束(MC)

  • 在不溢出内存的情况下运行的最大问题,且每个处理器的内存是固定的,任务和执行时间都不是固定的
  • 加速比:(work(P processors) ✖ time(1 processor)) / (work(1 processors) ✖ time(p processor))
    • 可以化简为:(work per unit time on P processors) / (work per unit time on 1 processors)
  • 例如:大规模N体问题,大规模机器学习等

对一个有N^2个格点,P个进程的海洋求解器,需要O(N^2)内存,在O(N)下迭代收敛,总共的工作量是O(N^3)。每个处理器计算N^2/P个格点,每个进程的通信量是N/√P个。

在问题规模确定的情况下,N是固定的

  • 执行时间是O(1/P)
  • 每个处理器O(1/P)个格点
  • 每个处理器的通信O(1/P^(1/2))
  • 通信计算比O(P^(1/2))

在内存确定的情况下

  • 让网格大小是NP^(1/2) * NP^(1/2)
  • 执行时间是O(NP^(1/2)^3 / P) = O(P^(1/2))
  • 每个处理器N^2个格点
  • 通信计算比1/N

在时间固定情况下

  • 让网格大小固定为K * K
  • 假定线性加速比:K^3 / P = N^3,所以K = NP^(1/3)
  • 执行时间是固定的O(N^3)
  • 每个处理器K^2/P个格点,即N^2 / P^(1/3)
  • 每个处理器通信K/P^(1/2) = O(1/P^(1/6))
  • 通信计算比O(P^(1/6))

关于问题扩展性的警告

  • 在前面的例子中,问题大小是一个参数n
  • 在实践中,问题大小是参数的组合
    • 回忆海洋的例子:问题大小是=(n,Δt,T)的函数
    • 问题参数通常相关(不独立)

一个有用的性能分析策略

  • 您可以确定您的性能是否受到计算、内存带宽(或内存延迟)或同步的限制?
  • 试着建立“高水准”
    • 你在实践中能做的最好的是什么?
    • 你的实现离最好的case有多近?

使用roofline模型:利用微基准计算机器的峰值性能,作为应用的计算强度函数。然后,将应用程序的性能与已知的峰值值进行比较。斜线是受内存带宽的限制,水平区域是受计算能力的限制了。

使用不同级别的优化方法,得到的曲线图

建立高水准程序

  • 添加“数学”(运算指令而非内存指令)
    • 执行时间与运算数量的增长是线性增加的吗?(如果是这样的话,这就是代码是指令限制的证据)
  • 将所有数组访问更改为A[0]
    • 你的代码得到了多少速度?(这为改善数据访问的局部性建立了一个上限)
  • 删除所有原子操作或锁
    • 你的代码得到了多少速度?(如果它仍然做了大约相同数量的工作)(这在降低同步开销的好处上建立了一个上限。)

计算、内存访问和同步几乎无法完全重叠。因此,整体性能很少会完全通过计算或带宽或同步来决定。即便如此,性能对上述程序修改的敏感性可以很好地表明主要开销

lecture 10

以下是一个64位cache line:

回顾:写回、写分配的行为。当处理器执行int x = 1;时:

  1. 工作处理器希望在没有驻留在缓存中的地址写
  2. 缓存选择位置在缓存中放置行,如果目前这个位置有脏标记,这个脏的cache line被写到内存中
  3. 工作缓存从内存中加载x到这个行(“在缓存分配行”)
  4. 更新32位缓存行
  5. 缓存行标记为脏(因为是执行了把1写到x的操作)

共享内存多处理器

  • 处理器读取和写入共享变量
    • 更准确地说:处理器发布加载和存储指令
  • 对内存的合理期望是:
    • 读取地址X中的值应该返回写入的最后一个值,以处理任何处理器的X

缓存一致性问题

  • 现代处理器在本地缓存中复制内存的内容
  • 问题:处理器可能观察相同内存位置出现不同值

下边的图表显示了变量foo(在地址X中存储的)和每个处理器缓存中的值。假设在地址X中存储的初始值为0,假设回写缓存行为。这是一个由存储在本地缓存中的地址X中的数据引起的问题(硬件实现细节)。

内存一致性问题

  • 内存系统的逻辑行为:地址X的读取值应该返回写入的最后一个值,以处理任何处理器的X。
  • 由于存在全局存储(主内存)和每个处理器本地存储(处理器缓存)实现单个共享地址空间的抽象,因此,内存一致性问题就存在了。

下图中32KB的L1是每个处理器私有的,8路组相联,每次延迟只有4-6个钟。256KB的L2是每个处理器私有的,8路组相联且为写回策略,8MB的L3是每个片私有的,16路组相联。

共享内存的期望

  • 内存系统的逻辑行为:地址X的读取值应该返回写入的最后一个值,以处理任何处理器的X。
  • 在单处理器上,提供这种行为是相当简单的,因为通常来自一个处理器。
    • 异常:设备通过直接内存访问(DMA)进行I/O

一致性是单个CPU系统中的一个问题。常见解决方案:

  • CPU使用未缓存的存储(例如,琼代码)写入共享缓冲区。
  • OS支持:
    • 标记虚拟内存页面,包含可访问的共享缓冲区
    • 当I / O完成时,明确地将页面从缓存中刷新
  • 在实践中,与CPU load和store相比,DMA传输是不常见的(因此这些重量级的软件解决方案是可以接受的)

案例1:

  • 处理器写入主内存中的缓冲区。处理器告诉网络卡异步发送缓冲区。
  • 问题:如果处理器的写入(反映在缓存的数据副本中)没有刷新到内存中,则会发送陈旧的数据

案例2:

  • 网络卡接收消息。网络卡使用DMA把数据传输到在主内存中的缓冲区中。网卡通知CPU消息已被接收,缓冲区已经就绪读取。
  • 问题:如果网络卡更新的地址还只在缓存中,CPU可能会读取陈旧的数据

直觉行为:读取地址X的值应该返回由任何处理器写入地址X的最后一个值。

  • “最后一个”是什么意思?
    • 如果两个处理器同时写怎么办?
    • 如果P1的写操作之后紧接着P2的读操作发生的时间非常接近,以至于无法及时通知P2的写操作,该怎么办?
  • 在顺序程序中,“last”由程序顺序(而不是时间)决定。
    • 在并行程序的一个线程为真正的最后一个
    • 但我们需要想出一个有意义的方式来描述并行程序中的所有线程

在以下情况下,内存系统是一致的:

  • 并行程序的执行结果是,对于每个内存位置,所有程序操作(由所有处理器执行)到与执行结果一致的位置都有一个假定的串行顺序,并且:
    • 任何一个处理器发出的内存操作按照处理器发出的顺序进行
    • 读取返回的值是最后一次写入位置…时写入的值

在以下情况下,内存系统是一致的:

  1. 处理器P对地址X的读取,紧接着处理器P对地址X的写入,应返回P写入的值(假设其间没有其他处理器写入X)
  2. 在处理器P2对X的写入之后,处理器P1对地址X的读取返回写入的值,如果读取和写入在时间上“足够分离”(假设其间没有对X进行其他写入)
  3. 对同一地址的写入被序列化:任何两个处理器对地址X的两次写入被所有处理器以相同的顺序观察到。
  4. 示例:如果将值1和2写入地址X,则没有处理器观察到X在值1之前有值2
  • 条件1:遵守程序顺序(如单处理器系统所预期的)
  • 条件2:“写入传播”:写入通知最终必须到达其他处理器。请注意,一致性的定义中没有明确规定何时传播有关写入的信息。
  • 条件3:“写序列化”

实施一致性

  • 基于软件的解决方案
    • 操作系统使用页面错误机制来传播写操作
    • 可用于在工作站集群上实现内存一致性
    • 我们不会讨论这些解决方案
  • 基于硬件的解决方案
    • 基于“监听”的一致性实现
    • 基于目录的一致性实现

共享缓存:一致性变得容易

  • 一个由所有处理器共享的单一缓存
    • 消除了在多个缓存中复制状态的问题
  • 明显的可扩展性问题(因为缓存的关键是本地和快速)
    • 由多个客户端引起的干扰/争用
  • 但共享缓存有以下好处:
    • 促进细粒度共享(重叠工作集)
    • 一个处理器的加载/存储可能会预取另一个处理器的行

缓存缓存一致性方案

  • 主要思想:所有与一致性相关的活动都会广播到系统中的所有处理器(更具体地说:广播到处理器的缓存控制器)
  • 缓存控制器监视内存操作,并相应地作出反应以保持内存一致性
  • 注意:现在缓存控制器必须响应“两端”的操作:
    • 来自本地处理器的LD/ST请求
    • 通过芯片互连进行一致性相关活动广播

非常简单的一致性实现。让我们假设:

  1. write-through缓存
  2. 一致性的粒度是cache line
  • 写入时,缓存控制器广播失效消息
  • 因此,从其他处理器的下一次读取将触发缓存未命中(由于直写策略,处理器从内存中检索更新的值)

说明

  • 我们将要描述的逻辑由每个处理器的缓存控制器执行,以响应:
    • 由本地处理器加载和存储
    • 它从其他缓存接收的消息
  • 如果所有高速缓存控制器都按照所描述的协议操作,则将保持一致性
    • 缓存“合作”以确保保持一致性

写直达(write-through)失效状态图。蓝色虚线表示远端处理器发起事务,黑色实线表示本地处理器发起事务。

  • 两种状态(与单处理器缓存中无效的含义相同)
    • 无效(I)
    • 有效(V)
  • 两个处理器操作(由本地处理器触发)
    • PrRd(已读)
    • PrWr(写入)
  • 两个总线事务(来自远程缓存)
    • BusRd(另一个处理器打算读取)
    • BusRw(另一个处理器打算写入)

互连的要求:

  1. 所有缓存控制器可见的所有写事务
  2. 所有缓存控制器以相同顺序发现所有写入事务

简化此处的假设:

  1. 互连和内存事务是原子事务
  2. 处理器在发出下一个内存操作之前,将等待上一个内存操作完成
  3. 作为接收失效广播的一部分,立即申请失效

写直达策略效率低下

  • 每个写操作都会输出到内存中
    • 非常高的带宽要求
  • 写回缓存在缓存命中时吸收大部分写流量
    • 显著降低带宽需求
    • 但现在我们如何确保写入传播/序列化?
    • 这需要更复杂的一致性协议

具有写回缓存的缓存一致性

  • cache line的脏状态现在表示独占所有权
    • 独占:缓存是唯一具有行的有效副本的缓存(可以安全地写入)
    • 所有者:这个缓存行所在的处理器负责在其他处理器尝试从内存加载该行时将其提供给其他处理器(否则,来自其他处理器的加载将从内存中获取过时数据)

基于失效的写回协议关键思想:

  • 处于“独占”状态的行可以在不通知其他缓存的情况下进行修改
  • 处理器只能写入处于独占状态的行
    • 因此,他们需要一种方法来告诉其他缓存,他们希望以独占方式访问该线路
    • 他们将通过向所有其他处理器发送缓存消息来实现这一点
  • 当缓存控制器监听对其包含的cache line的独占访问请求时
    • 它必须使自己缓存中的行无效

MSI写回失效协议

  • 协议的关键任务
    • 确保处理器获得写入的独占访问权
    • 在缓存未命中上查找cache line数据的最新副本
  • 三种缓存线状态
    • 无效(I):与单处理器缓存中无效的含义相同
    • 共享(S):在一个或多个缓存中有效的行
    • 修改(M):行在一个缓存中有效(也称为“脏”或“独占”状态)
  • 两个处理器操作(由本地CPU触发)
    • PrRd(已读)
    • PrWr(写入)
  • 三个一致性相关总线事务(来自远程缓存)
    • BusRd:获取cache line副本,无需修改
    • BusRdX:获取cache line副本,以便修改
    • 刷新:将脏行写入内存

小结:MSI

  • 可以在不通知其他缓存的情况下修改处于M状态的行
    • 没有其他缓存具有常驻行,因此其他处理器无法读取这些值(不生成内存读取事务)
  • 处理器只能写入处于M状态的行
    • 若处理器对缓存中非独占的行执行写操作,则缓存控制器必须首先广播读独占事务,以将该行移动到该状态
    • Read exclusive告诉其他缓存即将写入的信息(“你不能再读取了,因为我要写了”)
    • 即使行在处理器的本地缓存中有效(但不是独占的…它处于s状态),也需要读独占事务
    • 脏状态意味着排他性
  • 当缓存控制器监听其包含的行的“只读独占”时
    • 必须使缓存中的行无效
    • 因为如果没有,那么多个缓存都将有这一行(因此它在另一个缓存中不是独占的!)

MSI是否满足一致性?

  • 写传播
    • 通过组合BusRdX上的失效和从其他处理器在后续BusRd/BusRdX上的M状态刷新来实现
  • 写序列化
    • 出现在互连上的写入按它们出现在互连上的顺序排列(BusRdX)
    • 显示在互连上的读取按它们在互连上的显示顺序排序(BusRd)
    • 未出现在互连上的写入(PrWr到cache line已处于M状态):
      • 对cache line的写入序列位于线路的两个互连事务之间
      • 由同一处理器P按顺序执行的所有写入操作(该处理器肯定会按正确的顺序观察它们)
      • 所有其他处理器仅在cache line的互连事务之后才观察这些写入的通知。因此,所有写入都在事务之前。
      • 因此,所有处理器都以相同的顺序看到写入。

MESI失效协议

  • 即使应用程序根本没有共享,也存在这种低效率
  • 解决方案:添加附加状态E(“exclusive clean”)
    • 尚未修改行,但只有此缓存具有该行的副本
    • 将排他性与行所有权分离(行不脏,所以内存中的副本是数据的有效副本)
    • 从E升级到M不需要互连事务
  • MSI需要两个互连事务,用于读取地址然后写入地址的常见情况
    • 事务1:BusRd从I状态移动到S状态
    • 事务2:BusRdX从S状态移动到M状态

  • 当缓存线处于另一个缓存的E或S状态时,谁应该提供缓存未命中的数据?
    • 可以从内存中获取缓存线数据,也可以从另一个缓存中获取数据
  • 如果源是另一个缓存,应该由哪个缓存提供?
    • 缓存到缓存的传输增加了复杂性,但通常用于减少数据访问的延迟和减少应用程序所需的内存带宽

提高效率(和复杂性)

  • MESIF(基于五阶段失效的协议)
    • 与MESI类似,但一个缓存在F状态而不是S状态下保存共享cache line(F=“forward”)
    • cache line处于F状态服务未命中
    • 简化了应丢失哪个缓存的决策(基本MESI:所有缓存都响应)
    • 由英特尔处理器使用
  • MOESI(基于五阶段失效的协议)
    • 在MESI协议中,从M到S的转换需要刷新到内存
    • 作为替代,从M转换到O(O=“拥有,但不独占”),并且不刷新到内存
    • 其他处理器将共享cache line保持在S状态,一个处理器将cache line保持在O状态
    • 内存中的数据已过时,因此cache line处于O状态的缓存必须为缓存未命中提供服务
    • 用于AMD Opteron

Dragon写回更新协议

  • 状态:(无无效状态,但在第一次加载之前可以认为行无效)
    • 独占清除(E):只有一个缓存具有最新的行、内存
    • 共享清理(SC):多个缓存可能有这一行,内存可能是最新的,也可能不是最新的
    • 共享修改(SM):多个缓存可能有这一行,内存不是最新的
      • 对于给定的行,只有一个缓存可以处于这种状态(但其他缓存可以处于SC状态)
      • 行处于SM状态的缓存是数据的“所有者”。必须在退出时更新内存
    • 修改(M):只有一个缓存有行,它是脏的,内存不是最新的
      • 缓存是数据的所有者。更换时必须更新内存
  • 处理器操作:
    • PrRd,PrWr,PrRdMiss,PrWrMiss
  • 总线事务:
    • 总线读取(BusRd)、刷新(提供线路)、总线更新(BusUpd)

现实:多级缓存层次结构

  • 挑战:对一级缓存中的数据所做的更改可能对二级缓存控制器不可见,而只是监听互连。
  • 监听如何在缓存层次结构中工作?
    • 所有缓存监听是否独立互连?(效率低下)
    • 保持“包容”

缓存的包含性

  • 离处理器近的缓存中的所有行也位于离处理器较远的缓存中
    • 例如,L1的内容是L2内容的子集
    • 因此,与L1相关的所有事务也与L2相关,因此仅L2监听互连就足够了
  • 若线路在L1中处于自有状态(MSI/MESI中为M),则在L2中也必须处于自有状态
    • 允许L2确定总线事务是否在L1中请求修改的cache line,而不需要L1提供信息

如果L2大于L1,是否自动保持包含?

  • 考虑这个例子:
    • 让二级缓存的大小是一级缓存的两倍
    • 让L1和L2具有相同的行大小,是2路组相联,并使用LRU替换策略
    • 让A、B、C映射到同一组L1缓存
      有以下事务:
  • 处理器访问A(L1+L2未命中)
  • 处理器访问B(L1+L2未命中)。
  • 处理器多次访问A(所有L1命中)。
  • 处理器现在访问C,触发L1和L2未命中。L1和L2可能会选择逐出不同的行,因为访问历史记录不同。
  • 因此,包容不再适用!

当二级缓存中的行X由于来自另一个缓存的BusRdX而无效时,还必须使L1中的X行无效。

  • 一种解决方案:每个L2行包含一个额外的状态位,指示L1中是否也存在该行
  • 该位告诉L2的这个cache line失效,因为一致性通信需要传播到一级

保持包含:L1写命中

  • 假设L1是回写缓存。处理器写入X行(L1写入命中)
  • 二级缓存中的X行在一致性协议中处于修改状态,但它有过时的数据!
  • 当一致性协议要求从二级刷新X时(例如,另一个处理器加载X),二级缓存必须从一级缓存请求数据。
  • 为“修改但过时”添加另一位(刷新“修改但过时”L2行需要首先从L1获取真实数据。)

实施一致性的硬件影响

  • 每个缓存必须侦听并响应互连广播的所有一致性通信,造成互连网络上的额外流量
    • 在扩展到更高的核心数时可能非常重要
  • 大多数现代多核CPU实现缓存一致性
  • 迄今为止,多数多核GPU未实现缓存一致性
    • 到目前为止,对于图形和科学计算应用程序,一致性的开销被认为是不值得的(NVIDIA GPU提供单一共享L2+原子内存操作)
    • 但最新的Intel集成GPU确实实现了缓存一致性

虚假共享问题,此代码的潜在性能问题是什么?

1
2
// allocate per-thread variable for local per-thread accumulation 
int myPerThreadCounter[NUM_THREADS];

为什么这样更好?因为每个线程都可以把自己要读取的数据加载到一个cache line里。

1
2
3
4
5
6
// allocate per thread variable for local accumulation 
struct PerThreadState {
int myPerThreadCounter;
char padding[CACHE_LINE_SIZE ‐ sizeof(int)];
};
PerThreadState myPerThreadCounter[NUM_THREADS];

虚假分享

  • 两个处理器写入不同地址,但地址映射到同一cache line的情况
  • 写入处理器缓存之间的cache line摆动,由于一致性协议,产生大量通信
  • 没有内在的通信,这完全是人为的通信
  • 在为缓存一致性体系结构编程时,错误共享可能是一个因素

概述:基于监听的一致性

  • 缓存一致性问题的存在是因为单个共享地址空间的抽象不是由单个存储单元实现的
    • 存储分布在主内存和本地处理器缓存之间
    • 在本地缓存中复制数据以提高性能
  • 基于监听的缓存一致性的主要思想:每当发生可能影响一致性的缓存操作时,缓存控制器都会向所有其他缓存控制器广播通知
    • 硬件架构师面临的挑战:最小化一致性实现的开销
    • 软件开发人员面临的挑战:由于一致性协议(例如,虚假共享),要警惕人为造成的通信
  • 监听实现的可扩展性受到向所有缓存广播一致性消息的能力的限制!
    • 下次:通过基于目录的方法扩展缓存一致性

lecture 11

监听缓存一致性协议依赖于通过芯片互连向所有处理器广播一致性信息。每次发生缓存未命中时,触发未命中的缓存都会与所有其他缓存通信!我们讨论了传达了哪些信息以及采取了哪些行动来实施一致性协议。但是我们没有讨论如何在互连上实现广播。 (一个例子是使用共享总线进行互连)

问题:将缓存一致性扩展到大型机器

  • 调用非统一内存访问 (NUMA) 共享内存系统
  • 想法:在处理器附近定位内存区域可提高可扩展性:它会产生更高的总带宽并减少延迟(尤其是在应用程序中存在局部性时)。但是……如果一致性协议也不能扩展,那么 NUMA 系统的效率就没有多大用处!
  • 考虑这种情况:处理器访问附近的内存(好情况),但为了确保一致性仍然必须向所有其他处理器广播它正在这样做(坏事情)

一些术语:

  • cc-NUMA = “缓存一致的非统一内存访问”
  • 分布式共享内存系统 (DSM):缓存一致、共享地址空间,但架构由物理分布式内存实现

一种可能的解决方案:分层监听。在每个级别使用监听一致性。另一个例子是:使用处理器组本地化内存,而不是集中式

  • 好处
    • 构建相对简单(由于多级缓存,已经必须处理类似问题)
  • 缺点
    • 网络的根节点可能成为瓶颈
    • 比直接通信更大的延迟
    • 不适用于更通用的网络拓扑(网格、立方体)

使用目录的可扩展缓存一致性

  • 基于监听的方案广播一致性消息以确定其他缓存中的行的状态
  • 另一种想法:通过在一个地方存储有关线路状态的信息来避免广播:“目录”
    • 缓存行的目录条目包含有关所有缓存中缓存行状态的信息。
    • 缓存根据需要从目录中查找信息
    • 缓存一致性由缓存之间的点对点消息在“需要知道”的基础上维护(而不是通过广播机制)

分布式目录

  • 线路的“主节点”:具有保存线路相应数据的内存的节点
    • 例子:节点0是黄线的home节点,节点1是蓝线的home节点
  • “请求节点”:包含处理器请求行的节点

第一个例子是一个干净的缓存行读缺失。蓝线的处理器 0 从主内存中读取:cache line不脏。

  • 读未命中消息发送到请求cache line的主节点
  • 主目录检查行的条目
  • 如果缓存行的脏位为 OFF,则响应内存中的内容,将Presence[0]设置为 true(表示行被处理器 0 缓存)

示例 2:读取未命中脏行

  • 处理器 0 从主内存中读取蓝cache line:缓存是脏的(P2 缓存中的内容)
    • 如果脏位为 ON,则数据必须来自另一个处理器(具有该行的最新副本)
    • 主节点必须告诉请求节点在哪里可以找到数据
    • 回复提供线路所有者身份的消息(“从 P2 获取”)

  1. 如果脏位为 ON,则数据必须来自另一个处理器
  2. 这个cache line归属的节点响应提供cache line的owner身份的消息
  3. 请求节点向owner请求数据
  4. Owner 将缓存中的状态更改为 SHARED(只读),响应请求节点
  5. Owner也响应home节点,home清除dirty,更新presence bits,更新内存

示例 3:写未命中

  • 由处理器 0 写入内存:行是干净的,但驻留在 P1 和 P2 的缓存中

请求写缺失的缓存行

归属节点返回这个cache line的owner信息

请求节点发送cache line失效的消息

另两个处理器返回失效确认

目录优势

  • 在读取时,目录会告诉请求节点确切的位置
    • 来自主节点(如果cache line干净)
    • 或者来自拥有节点(如果cache line脏)
    • 无论哪种方式,检索数据都只涉及点对点通信
  • 在写入时,目录的优势取决于共享cache line的处理器数量
    • 在限制中,如果所有缓存都共享数据,则所有cache必须相互通信(就像在监听协议中广播)

一般而言,写入期间只有少数共享者

  • 访问模式
    • “主要读取”对象:很多共享者但写入很少,因此对性能的影响最小(例如,Barnes-Hut 中的根节点)
    • 迁移对象(一个处理器读/写一段时间,然后是另一个,等等):很少的共享者,计数不随处理器数量而扩展
    • 频繁读/写对象:频繁失效,但共享者数量很少,因为共享的数量不能在失效之间的短时间内建立(例如,共享任务队列)
    • 低争用锁:不经常失效,没有性能问题
    • 高争用锁:可能是一个挑战,因为当锁释放时会出现许多读者
  • 含义 1:目录可用于限制一致性流量
    • 不需要广播机制来“告诉所有人”
  • 含义 2:建议优化目录实现的方法(减少存储开销)

非常简单的目录存储要求

  • 一个cache line内存
  • 每个内存缓存行都有一个目录条目
  • P 存在位:指示处理器 P 的缓存中是否有行
  • 脏位:指示处理器缓存之一中的行是脏的

全位向量目录表示

  • 每个节点一个存在位
  • 存储与 P x M 成正比
    • P = 节点数(例如,处理器)
    • M = 内存中的行数
  • 存储开销随 P 增加
    • 假设 64 字节高速cache line大小(512 位)
    • 64 个节点 (P=64) → 12% 开销
    • 256 个节点 (P=256) → 50% 开销
    • 1024 个节点 (P=1024) → 200% 开销

减少目录的存储开销

  • 全位向量方案的优化
    • 增加cache line大小(减少 M 项)
    • 将多个处理器分组到一个目录“节点”中(减少 P 项)
      • 每个节点只需要一个目录位,每个处理器不需要一个位
      • 分层:可以使用监听协议来保持节点中处理器之间的一致性,目录跨节点
  • 我们现在将讨论两种替代方案
    • 有限的指针方案(减少 P)
    • 稀疏目录

有限的指针方案

  • 由于预计数据一次只会出现在几个缓存中,因此每个目录条目存储有限数量的指针就足够了(只需要一个包含行的有效副本的节点列表!)
  • 一个有着1024处理器的系统,全位向量方案每行需要 1024 位。作为优化,这1024位可以存储 100 个指向保存该行的节点的指针(每个指针log2(1024)=10 位)

在有限的指针方案中管理溢出

  • 回退到广播(如果存在广播机制)
    • 当超过最大共享者数时,恢复广播
  • 如果机器上没有广播机制
    • 不允许超过最大数量的共享者
    • 溢出时,最新的共享者替换现有的共享者(必须使旧共享者缓存中的行无效)
  • 向量回退
    • 恢复到位向量表示表示
    • 每一位对应K个节点
    • 在写入时,使所有节点无效

有限指针方案是巧妙理解和优化常见情况的一个很好的例子:

  1. 工作负载驱动观察:一般情况下缓存行共享器的数量很少
  2. 使常见情况简单快速:前 N 个共享者的指针数组
  3. 不常见的情况仍然正确处理,只是使用了更慢,更复杂的机制(程序仍然有效!)
  4. 复杂解决方案的额外开销是可以容忍的,因为它很少发生

限制目录大小:稀疏目录

  • 关键观察:大部分内存并不驻留在缓存中。并且为了执行一致性协议,系统只需要共享当前在缓存中的行的信息
  • 大多数目录条目大部分时间都是空的
  • 例如,1 MB 缓存,1 GB 内存,在单节点系统中,≥ 99.9% 的目录条目为空

稀疏目录

  • 主节点的目录只维护指向一个节点缓存行的指针(不是共享者列表)
  • 指向列表中下一个节点的指针作为额外信息存储在缓存行中(就像行的标签、脏位等)
  • 保存在某个缓存中的内存的每个缓存行一个目录条目
    • 读取未命中:将请求节点添加到列表的头部
    • 写入未命中:沿列表传播失效
    • 关于缓存换出:需要修补链表(链表移除)

  • 好处:
    • 低内存存储开销(每行一个指向列表头的指针)
    • 额外的目录存储与缓存大小成正比(存储在 SRAM 中的列表)
    • 写入流量仍然与共享者数量成正比
  • 坏处:
    • 写入延迟与共享者数量成正比(行的无效是连续的)
    • 更高的实现复杂度

干预转发

读缺失时向cache line的拥有者请求

拥有者向保有此cache line缓存的p2转发读请求。p2把数据和dir返回给拥有者

拥有者再把cache line发给请求者

原始的基于目录的协议一共五次总线事务,其中 4 个事务在“关键路径”上(事务 4 和 5 可以并行完成)。干预转发则总共四个总线事务(更少的流量)但所有四次事务都在“关键路径”上。

请求转发

读缺失时向cache line的拥有者请求

拥有者向保有此cache line缓存的p2转发读请求

p2把数据发给拥有者和请求者

请求转发一共四次总线事务,只有三个事务在关键路径上(事务 3 和 4 可以并行完成)。注意:系统不再是纯请求/响应(因为 P0 向 P1 发送请求,但从 P2 接收响应)

Intel Core i7 CPU 中的目录一致性

  • L3 作为 L3 缓存中所有行的集中目录(注意包含属性的重要性……L2 中的任何行都会有一个目录条目)
  • 目录维护包含行的 L2 缓存列表
  • 不向所有 L2 广播一致性流量,只向包含该行的 L2 发送一致性消息(Core i7 互连是环,不是总线)
  • 目录维度:
    • P=4
    • M = L3 缓存行数

Xeon Phi

  • 芯片上的英特尔 NUMA
  • 50+个 x86 内核
    • 4 路超线程
    • 每个有 1–2 个向量单位
  • 缓存一致性内存系统
  • 整体系统:
    • 最大 8GB内存
    • 最大 2 TFLOPS
    • 0.004 字节/flop
    • 300 瓦

Xeon Phi围绕双向环发送的消息

  • 将所有内容都集成在单芯片上可实现非常广泛的通信路径
  • 可以通过在整个环中循环消息来获得广播的效果
  • 优于点对点

Xeon Phi目录结构

  • 目录跟踪哪些线路驻留在本地 L2 中
    • 与单节点系统相同
  • P 读取或写入的最坏情况内存:
    • 检查本地缓存
    • 请求某些line时,围绕环循环请求
    • 围绕环向内存控制器发送请求

lecture 12

缓存的包含属性

  • 靠近处理器的缓存的所有行也位于离处理器更远的缓存中
    • 例如,L1 的内容是 L2 内容的子集
    • 因此,所有与 L1 相关的事务也与 L2 相关,因此只有 L2 监听互连就足够了
  • 如果cache line在 L1 中处于拥有状态(MSI/MESI 中的 M),则它在 L2 中也必须处于拥有状态
    • 允许 L2 确定总线事务是否正在请求 L1 中修改的缓存行,而无需来自 L1 的信息

维护包含关系:处理失效

  • 当 L2 缓存中的 X 行由于来自另一个缓存的 BusRdX 无效时。 还必须使 L1 Invalidate 中的 X 行无效
  • 一种解决方案:每个 L2 行包含一个额外的状态位,指示 L1 中是否也存在该行
  • 该位告诉由于一致性流量需要将缓存行的 L2 失效需要传播到 L1。

保持包含性:L1 写入命中

  • 假设 L1 是写回缓存。 处理器写入 X 行(L1 写入命中)
  • L2 缓存中的 X 行在一致性协议中处于修改状态,但它有陈旧的数据!
  • 当一致性协议要求从 L2 刷新 X(例如,另一个处理器加载 X)时,L2 缓存必须从 L1 请求数据。
  • 因为“已修改但已过时”,所以要添加额外的一位(刷新“已修改但已过时”的 L2 行需要首先从 L1 获取真实数据。)

死锁活锁和饥饿

死锁的必要条件

  1. 互斥:一个处理器可以同时持有一个给定的资源
  2. 保持并等待:处理器必须保持资源,同时等待完成操作所需的其他资源
  3. 无抢占:处理器在他们希望执行的操作完成之前不会放弃资源
  4. 循环等待:等待的处理器相互依赖(资源依赖图中存在循环)

活锁是一种状态,系统正在执行许多操作,但没有线程正在取得有意义的进展。计算机系统示例:操作不断中止并重试。

饥饿是一种系统正在取得整体进展,但某些进程没有进展的状态。饥饿通常不是永久状态。

监听的基本实现(假设是原子总线)

考虑一个基本的系统设计

  • 每个处理器一个未完成的内存请求
  • 每个处理器的单级写回缓存
  • 缓存可以在处理器执行一致性操作时停止处理器
  • 系统互连是一个原子共享总线(一次一个缓存通信)

原子总线上的事务

  1. 客户端被授予总线访问权(仲裁结果)
  2. 客户端在总线上放置命令(也可以在总线上放置数据)
  3. 总线上另一个总线客户端对命令的响应
  4. 下一个客户端获得总线访问权(仲裁)

单处理器上的缓存未命中逻辑

  1. 确定缓存集(使用适当的地址位)
  2. 检查缓存标签(以确定行是否在缓存中)
  3. 断言访问总线的请求
  4. 等待总线授权(由总线仲裁员决定)
  5. 在总线上发送地址+命令
  6. 等待命令被接受
  7. 接收总线上的数据

原子总线在多处理器场景中意味着什么?

  • BusRd、BusRdX:在发出地址和接收数据之间不允许其他总线事务
  • Flush:地址和数据同时发送,在允许任何其他事务之前由内存接收

多处理器缓存控制器行为的挑战:来自处理器和总线的请求都需要标签查找

  • 如果总线获得优先权:在总线事务期间,处理器被锁定在它自己的缓存之外。
  • 如果处理器获得优先权:在处理器缓存访问期间,缓存无法响应监听结果(因此即使不存在任何形式的共享,也会延迟其他处理器)

缓解争用:允许处理器端和监听控制器同时访问

  • 选项 1:缓存重复标签
  • 选项 2:多端口标签存储器
  • 注意:标签必须保持同步以确保正确性,因此一个控制器的标签更新仍然需要阻止另一个控制器(但与检查标签相比,修改标签并不常见)

报告监听结果:红色的线是额外的总线硬件,是所有的处理器的“或”结果。

何时报告监听结果?

  • 内存控制器可以立即开始访问 DRAM,但如果来自另一个缓存的监听结果表明它有最新数据的副本,则不会响应
    • 缓存应该提供数据,而不是内存
  • 内存可以假设其中一个缓存将为请求提供服务,直到监听结果有效(如果监听指示没有缓存有数据,则内存必须响应)

处理回写

  • 回写涉及两个总线事务
    • 传入线路(处理器请求的线路)
    • 输出行(缓存中被驱逐的脏行,必须刷新)
  • 理想情况下希望处理器尽快继续运行(它不应该等待刷新完成)
  • 解决方案:回写缓冲区
    • 要在回写缓冲区中放被刷新的cache line
    • 立即加载请求的行(允许处理器继续)
    • 稍后刷新回写缓冲区的内容

带有回写缓冲区的缓存

  • 如果总线上出现对回写缓冲区中数据地址的请求怎么办?
  • 除了缓存标签之外,监听控制器还必须检查回写缓冲区地址。
  • 如果有回写缓冲区匹配:
    • 响应来自写回缓冲区而不是缓存的数据
    • 取消未完成的总线访问请求(用于回写)

取回死锁

  • P1 有缓存行 B 的修改副本
  • P1 正在等待总线,因此它可以在缓存线 A 上发出 BusRdX
  • 当 P1 正在等待时,B 的 BusRd 出现在总线上
  • 为避免死锁,P1 必须能够在等待发出请求时为到来的事务提供服务

活锁

  • 两个处理器写入缓存线 B
  • P1 获取总线,发出 BusRdX
  • P2 失效
  • 在 P1 执行缓存行更新之前,P2 获取总线,发出 BusRdX
  • P1 无效
  • 为了避免livelock,必须允许获得独占所有权的写入在独占所有权放弃之前完成。

自检:何时写入“提交”

  • 当读独占事务出现在总线上并被所有其他缓存确认时,写操作提交
    • 此时,写入已“提交”
    • 将来的所有读取都将反映此写操作的值(即使来自P的数据尚未写入P的脏缓存线或内存)
    • 关键思想:总线上的事务顺序定义并行程序中全局写入顺序(写入序列化)

饥饿

  • 多处理器竞争总线接入
    • 必须小心避免(或尽量减少)饥饿
    • 例如,如果具有“最低id”的处理器获胜怎么办。
  • 实现更大公平性的示例政策:
    • 先进先出仲裁
    • 基于优先级的启发式(频繁的总线用户优先级下降)

前半部分总结:一致性实现中的并行性和并发性是复杂性的来源

  • 处理器、缓存和总线都是并行运行的资源
    • 经常争夺共享资源:
    • 处理器和总线争夺缓存
    • 缓存争用总线访问
  • 体系结构将“内存操作”抽象为原子操作(例如,加载、存储),通过涉及所有这些硬件组件的多事务来实现
  • 性能优化通常需要将操作拆分为几个较小的事务
    • 将工作拆分为更小的事务显示出更多的并行性
    • 开销:需要更多的硬件来利用额外的并行性
    • 开销:需要注意确保抽象仍然有效(机器是正确的)

围绕非原子总线事务构建系统

分割事务的总线

总线事务分为两个事务:

  1. 请求
  2. 回应

基本设计

  • 一次最多八个未完成的请求(全系统)
  • 响应的顺序不必与请求的顺序相同
    • 但是请求顺序确定了系统的总顺序
  • 通过否定确认(NACKs)进行流量控制
    • 当缓冲区已满时,客户端可以NACK事务,从而导致重试

发起请求:可以将分割事务总线看作两个独立的总线:请求总线和响应总线。

  • 请求总线:cmd+地址
  • 响应总线:数据

  • 步骤1:请求者请求总线访问

  • 步骤2:总线仲裁器授予访问权,为事务分配一个标记
  • 步骤3:请求者在请求总线上放置命令+地址

读取未命中:逐周期总线行为:

  • addr req/请求仲裁:高速缓存控制器向总线提供地址请求(许多高速缓存可能在同一周期内执行此操作)
  • grant/请求解析:地址总线仲裁器为一个请求者授权,为请求分配一个请求表条目
  • addr/总线“获胜者”将命令/地址放置在总线上
  • dcd/缓存执行监听:查找标记、更新缓存状态等。内存操作在此提交!(没有总线)
  • addr ack/缓存确认此监听结果已准备就绪(或在此发出无法及时完成监听的信号
  • data req/数据响应仲裁:响应者表示打算用标记T响应请求(许多缓存或内存可能在同一个周期内这样做)
  • grant/数据总线仲裁器授予一个响应器总线访问权限
  • tag check/原始请求者表示准备接收响应(或缺少响应:请求者此时可能很忙)
  • 响应程序将响应数据放置在数据总线上
  • 缓存为请求提供带有数据的监听结果
  • 请求表项被释放
  • 这里:假设128字节缓存线→256位总线上的4个周期

为什么在并行系统中有队列?

  • 答:适应可变(不可预测)的生产和消费率。
  • 只要A和B平均以相同的速度生产和消费,两个工人就可以全速运转。
  • 无队列:注意A暂停等待B接受新输入(B有时暂停等待A产生新输入)。

多级缓存层次结构:

  • 假设每个处理器有一个未完成的内存请求。
  • 考虑获取死锁问题:Cache必须能够在等待响应自身请求时服务请求(层次结构增加响应延迟)
  • 调整所有缓冲区的大小以适应总线上最大数量的未完成请求是避免死锁的一种解决方案。

因为队列满导致的死锁:

  • 传出读取请求(由处理器启动)
  • 传入读取请求(由于另一个缓存)
  • 这两个请求生成的响应都需要另一个队列中的空间(循环依赖)

使用单独的请求/响应队列避免缓冲区死锁

  • 系统将所有事务分类为请求或响应
  • 响应可以在不生成进一步事务的情况下完成!
  • 请求会增加队列长度
  • 但是响应减少了队列长度
  • 在尝试发送请求时,缓存必须能够为响应提供服务。
  • 响应将取得进展(它们不会生成新的工作,因此不存在循环依赖),最终为请求释放资源

lecture 13

内存coherence与内存consistency

  • 内存coherence定义了对同一内存位置的读取和写入行为的观察要求
    • 所有处理器必须就读/写 X 的顺序达成一致
    • 换句话说:可以将涉及 X 的操作放在时间线上,以便所有处理器的观察结果与该时间线一致
  • 内存consistency定义了对不同位置的读写行为(其他处理器观察到的)
    • Coherence 仅保证对地址 X 的写入最终会传播到其他处理器
    • Consistency处理何时写入 X 传播到其他处理器,相对于读取和写入其他地址

Coherence vs. consistency

  • Coherence的目标是确保并行计算机中的内存系统表现得好像缓存不存在一样
    • 就像单处理器系统中的内存系统表现得好像缓存不存在一样
  • 没有缓存的系统不需要缓存Coherence
    • Consistency定义了对并行系统中不同地址的加载和存储的允许行为
    • 无论是否存在缓存,都应该指定内存的允许行为(这就是内存一致性模型所做的)

内存操作排序

  • 程序定义了加载和存储的序列(这是加载和存储的“程序顺序”)
  • 四种内存操作顺序
    • W→R:写入 X 必须在随后从 Y 读取之前提交
    • R→R:从 X 读取必须在随后从 Y 读取之前提交
    • R→W:读取到 X 必须在随后写入 Y 之前提交
    • W→W:写入 X 必须在后续写入 Y 之前提交
  • 顺序一致的内存系统维护所有四种内存操作顺序

顺序一致性

  • 一个并行系统是顺序一致的,如果任何一个并行执行的结果是相同的,就好像所有的内存操作都是按照某种顺序执行的,并且任何一个处理器的内存操作都是按照程序顺序执行的。
  • 存在与观察值一致的所有内存操作的序列表

快速示例
线程 1(在 P1 上)

1
2
3
A = 1;
if (B == 0)
print("hello");

线程 2(在 P2 上)

1
2
3
B = 1;
if (A == 0)
print("world");

假设 A 和 B 被初始化为 0。想象一下线程 1 和 2 同时运行,在双处理器系统上,会打印什么?

答案:假设写入立即传播(例如,直到 P2 观察到对 A 的写入,P1才会继续“if”语句),然后代码将打印“hello”或“world”,但不是两者兼而有之。

放宽对内存操作顺序的限制

  • 顺序一致的内存系统维护所有四种内存操作顺序(W→R、R→R、R→W、W→W)
  • 宽松的内存一致性模型允许违反某些顺序

放宽一致性的动机:隐藏延迟

  • 为什么我们对放宽顺序的要求感兴趣?
    • 获得性能的提升
    • 具体来说,隐藏内存延迟:当它们独立时,内存访问操作与其他操作重叠
    • 请记住,缓存一致性系统中的内存访问可能需要比简单地从内存中读取位(查找数据、发送无效等)更多的工作,当然了,需要同步操作、加锁等。

允许读取先于写入

  • 四种内存操作顺序
    • W→R:写入必须在后续读取之前完成(划掉了,可能不是必要)
    • R→R:读取必须在后续读取之前完成
    • R→W:读取必须在后续写入之前完成
    • W→W:写入必须在后续写入之前完成
  • 允许处理器隐藏写入延迟
    • Total Store Ordering (TSO)
    • Processor Consistency (PC)

写缓冲示例

  • 写入缓冲是一种常见的处理器优化,它允许读取在先前的写入之前进行
    • 当 store 被发出时,处理器缓冲区存储在写缓冲区中(假设 store 是地址 X)
    • 处理器立即开始执行后续load,前提是它们没有访问地址 X(在程序中利用 ILP)
    • 也可以进一步写入“写入缓冲区”(写入缓冲区是按顺序处理的,没有W→W重新排序)
  • 写缓冲放宽了 W→R 排序
  • 不要将写缓冲区(此处显示)与缓存的回写缓冲区混淆。两个缓冲区的存在都是为了隐藏内存操作的延迟。但是,写入缓冲区保存了处理器已发出但尚未在系统中提交的写入。回写缓冲区包含必须刷新到内存的脏缓存行,以便内存保持最新。这些行很脏,因为很久以前处理器完成了对它们的一些写入。

允许读取先于写入

  • Total store ordering (TSO)
    • 处理器 P 可以在对 A 的写入被所有处理器看到之前读取 B(处理器可以将自己的读取移动到自己的写入之前)
    • 在所有处理器都观察到对 A 的写入之前,其他处理器的读取无法返回 A 的新值
  • Processor consistency (PC)
    • 任何处理器都可以在所有处理器观察到写入之前读取 A 的新值
  • 在TSO 和PC 中,只有W→R 顺序是放宽的。 W→W 约束仍然存在。同一线程的写入不会重新排序(它们按程序顺序发生)

澄清

  • 缓存一致性问题的存在是因为优化了在多个处理器缓存中复制数据。 数据的副本必须保持一致。
  • 宽松的内存一致性问题源于对内存重新排序操作的优化。(一致性与系统中是否有缓存无关。)

允许重新排序写入

  • 四种内存操作顺序
    • W→R:写入必须在后续读取之前完成(已被消除)
    • R→R:读取必须在后续读取之前完成
    • R→W:读取必须在后续写入之前完成
    • W→W:写入必须在后续写入之前完成(当前要解决的)
  • Partial Store Ordering (PSO)
    • 执行可能与程序 1 上的顺序一致性不匹配(P2 可能在观察到 A 的更改之前观察到标志的更改)

P1

1
2
A = 1;
flag = 1;

P2

1
2
while (flag == 0); 
print A;

为什么允许更激进的内存操作重新排序会很有用?

  • W→W:处理器可能会对写缓冲区中的写操作重新排序(例如,一个是缓存未命中,另一个是命中)
  • R→W,R→R:处理器可能会对指令流中的独立指令重新排序(乱序执行)
  • 请记住,如果程序由单个指令流组成,这些都是有效的优化

允许所有重新排序

  • 四种内存操作顺序
    • W→R:写入必须在后续读取之前完成(已消除)
    • R→R:读取必须在后续读取之前完成(当前要解决的)
    • R→W:读取必须在后续写入之前完成(当前要解决的)
    • W→W:写入必须在后续写入之前完成(已消除)
  • 示例:
    • Weak ordering(WO)
    • Release Consistency(RC)
      • 处理器支持特殊的同步操作
      • 内存barrier指令之前的内存访问必须在barrier发出之前完成
      • 在barrier指令完成之前,barrier后的内存访问不能开始
1
2
3
4
5
6
7
8
9
reorderable reads 
and writes here
...
MEMORY FENCE
...
reorderable reads
and writes here
...
MEMORY FENCE

示例:在宽松模型中表达同步

  • Intel x86/x64 ~ total store ordering
  • 如果软件需要一致性模型无法保证的特定指令顺序,则提供同步指令
    • mm_lfence (“load fence”: wait for all loads to complete)
    • mm_sfence (“store fence”: wait for all stores to complete)
    • mm_mfence (“mem fence”: wait for all me operations to complete)

获取/释放语义

  • 具有获取语义的操作 X:防止程序顺序中在X之后的任何加载/存储与 X 重新排序
    • 其他处理器在所有后续操作的效果之前看到 X 的效果
    • 示例:获取锁必须具有获取语义
  • 具有释放语义的操作 X:防止程序顺序中在X之前的任何加载/存储与 X 重新排序
    • 其他处理器在看到 X 的效果之前看到所有先前操作的效果。
    • 示例:释放锁必须具有释放语义

C++11的atomic<T>操作

  • 提供整个对象的原子读、写、读-修改-写
    • 原子性可以由互斥体实现或由处理器支持的原子指令有效地实现(如果 T 是基本类型)
  • 为原子操作前后的操作提供内存排序语义
    • 默认:顺序一致性
    • std::memory_order或更多细节

冲突的数据访问

  • 不同处理器的两次内存访问发生冲突,如果……
    • 他们访问相同的内存位置
    • 至少一个是写
  • 不同步的程序
    • 未按同步排序的冲突访问(例如,栅栏、具有释放/获取语义的操作、屏障等)
    • 不同步的程序包含数据竞争:程序输出取决于处理器的相对速度(非确定性程序结果)

同步程序

  • 同步程序无数据竞争
  • 在实践中,你遇到的大多数程序都会通过同步库中实现的锁、屏障等被同步
  • 而不是像前面的“四个示例程序”幻灯片那样通过临时读/写共享变量

总结:宽松的一致性

  • 动机:通过允许记录内存操作来获得更高的性能(顺序一致性不允许重新排序)
  • 一个开销是软件复杂性:程序员或编译器必须正确插入同步以确保在需要时某些特定的操作顺序
    • 但在实践中,复杂性封装在库中,提供直观的原语,如锁定/解锁、屏障(或较低级别的原语,如围栏)
    • 针对常见情况进行优化:大多数内存访问都没有冲突
  • 宽松一致性模型的不同之处在于它们忽略的内存排序约束

分布式系统中的最终一致性

  • 宽松的内存一致性将是在分布式环境中编写 Web 级程序的关键因素
  • “最终一致性”
    • 假设机器 A 写入共享分布式数据库中的对象 X
    • 存在许多数据库副本用于性能扩展和冗余
    • 最终一致性保证,如果 X 没有其他更新,系统中的所有其他节点最终都会观察到 A 的更新(注意:不保证何时,因此对对象 X 和 Y 的更新可能会以不同的方式传播到不同的客户端)

lecture 14

可扩展性的网站的基础知识

在为多核 Web 服务器设置 进程数N 值时,您会考虑哪些因素?

  • Parallelism:使用服务器的所有内核
  • 延迟隐藏:隐藏长时间延迟的磁盘读取操作(通过工作进程之间的上下文切换)
  • 并发:许多未完成的请求,想要在处理长请求的同时为快速请求提供服务(例如,大文件传输不应阻止服务 index.html)
  • 占用空间:不要太多线程,以免所有线程的gather工作集导致抖动

为什么将服务器划分为进程,而不是线程?

  • 保护
    • 不希望一个工作进程的崩溃导致整个网络服务器瘫痪
    • 经常想在服务器操作中使用非线程安全库(例如第三方库)
  • 父进程可以定期回收子线程(对内存泄漏的鲁棒性)
  • 当然,也存在多线程 Web 服务器解决方案(例如,Apache 的“worker”模块)

“横向扩展”以增加吞吐量:使用多个 Web 服务器来满足站点的吞吐量目标。负载均衡器维护可用 Web 服务器的列表以及每个服务器的负载估计。将请求分发到 Web 服务器池。与会话相关的所有请求都被定向到同一服务器(又名会话亲缘关系,“粘性会话”)

站点配置

  • 站点性能监视器检测到高负载
    • 实例化新的 Web 服务器实例
    • 通知负载平衡器有关新服务器的存在
  • 站点性能监视器检测到低负载
    • 卸载额外的服务器实例(以节省运营成本)
    • 通知负载平衡器有关服务器卸载的信息

在处理请求时可能有很多重复步骤:

  • 与数据库沟通
  • 执行查询
  • 将数据库结果转化为脚本语言的对象模型
  • 生成页面

请记住,DB 可能难以扩展!所以要降低DB的负载,解决方案就是缓存。

  • 缓存经常访问的对象
    • 示例:memcached,内存键值存储(例如,大哈希表)
    • 减少数据库负载(更少的查询)
    • 减少网络服务器负载:
    • 减少数据库响应和脚本环境之间的数据混洗
    • 存储常见处理的中间结果

  • 当然,在存在写入的情况下保持缓存与数据库中的数据同步是很复杂的
    • 必须使缓存无效
    • 非常简单的“第一步”解决方案:只缓存只读对象
    • 更现实的解决方案提供了一定程度的一致性

CDN缓存示例图

lecture 15

互连网络的用途是什么?连接!

  • 处理器内核与其他内核
  • 处理器和内存
  • 处理器核心和缓存
  • 缓存和缓存
  • 输入/输出设备

为什么互连网络的设计很重要?

  • 系统可扩展性
    • 可以构建多大的系统?
    • 添加更多节点(例如核心)有多容易
  • 系统性能和能源效率
    • 核心、缓存、内存的通信速度有多快
    • 内存延迟有多长?
    • 通信花费了多少能量?

设计问题

  • 拓扑:交换机如何通过链路连接
    • 影响路由、吞吐量、延迟、复杂性/实施成本
  • 路由:消息如何在网络中从其源头到达其目的地
    • 可以是静态的(消息采用预定路径)或基于负载自适应
  • 缓冲和流量控制
    • 网络中存储了哪些数据? 数据包,部分数据包? 等等。
    • 网络如何管理缓冲区空间?

互连拓扑的属性

  • 路由距离
    • 沿两个节点之间路由的链接数(“跳数”)
  • 直径:最大路由距离
  • 平均距离:所有有效路由的平均路由距离
  • 直接与间接网络
    • 直接网络:端点位于网络“内部”
    • 例如,mesh 是直接网络:每个节点既是端点又是交换机
  • 对分带宽:
    • 递归拓扑的通用性能指标
    • 将网络一分为二,所有被切断链路的总带宽
    • 警告:可能会产生误导,因为它没有考虑交换和路由效率
  • 阻塞与非阻塞:
    • 如果可以连接任何配对节点,则网络是非阻塞的(否则,它是阻塞的)

示例:阻塞与非阻塞

  • 此网络是阻塞的还是非阻塞的?
    • 考虑从 0 到 1 和 3 到 7 的同步消息。
    • 考虑从 1 到 6 和 3 到 7 的同步消息。 屏蔽!!!

网络的负载延迟行为由以下几部分组成

  • 零负载或空闲延迟(拓扑+路由+流量控制)
  • 路由算法给出的最小延迟
  • 拓扑给出的最小延迟
  • 饱和吞吐量(由流量控制给出)
  • 路由给出的吞吐量
  • 拓扑给出的吞吐量

总线互连

  • 好:
    • 简单的设计
    • 对少量节点具有成本效益
    • 易于实现一致性(通过监听)
  • 差:
    • 争用:所有节点争用共享总线
    • 有限的带宽:所有节点通过相同的线路(一个一次沟通)
    • 高电气负载 = 低频率、高功率

交叉互连

  • 每个节点都连接到每个其他节点(非阻塞、间接)
  • 好:
    • O(1) 延迟和高带宽
  • 差:
    • 不可扩展:O(N^2) 个开关
    • 成本高
    • 难以大规模仲裁

环状

  • 好:
    • 简单的
    • 便宜:O(N) 成本
  • 差:
    • 高延迟:O(N)
    • 添加节点后二分带宽保持不变(可扩展性问题)
  • 用于最近的 Intel 架构
    • 酷睿 i7
      • 四环
        • 请求
        • 监听
        • 确认
        • 数据(32 字节)
      • 六个互连节点:L3 缓存的四个“切片”+系统代理+图形
      • 每组 L3 连接到环形总线两次
      • 3.4 GHz 时从内核到 L3 的理论峰值带宽约为 435 GB/秒

  • 直接网络
  • 在基于网格的应用程序中呼应局部性
  • O(N) 成本
  • 平均延迟:O(sqrt(N))
  • 易于在芯片上布局:固定长度的链接
  • 路径多样性:消息从一个节点传播到另一个节点的多种方式
  • 使用者:
    • Tilera 处理器
    • 原型英特尔芯片

圆环

  • 网状拓扑的特性根据节点是靠近网络边缘还是中间而有所不同(环面拓扑引入了新的链路来避免这个问题)
  • 仍然是 O(N) 成本,但成本高于 2D 网格
  • 比网格更高的路径多样性和二分带宽
  • 更高的复杂性
    • 难以在芯片上进行布局
    • 不等的链接长度

树型

  • 平面、分层拓扑
  • 像mesh/torus,当流量具有局部性时很好
  • 延迟:O(lg N)
  • 使用“胖树”来缓解根带宽问题(靠近根的更高带宽链接)

超立方体

  • 低延迟:O(lg N)
  • 基数:O(lg N)
  • 链接数 O(N lg N)
  • 64 核中使用的 6D 超立方体

多级结构

  • 终端间具有多个交换机的间接网络
  • 成本:O(N lg N)
  • 延迟:O(lg N)
  • 许多变体:Omega、蝴蝶、Clos 网络等……

电路交换与分组交换

  • 电路交换建立在发送消息之前在发送方和接收方之间完整路径(获取所有资源)
    • 建立路由(保留链接)然后发送消息的所有数据
    • 更高的带宽传输(无每包链路管理开销)
    • 是否会产生设置/拆除路径的开销
    • 保留链接会导致利用率低
  • 数据包交换为每个数据包做出路由决定
    • 单独路由每个数据包(可能通过不同的网络链接)
    • 有机会在链接空闲时为数据包使用链接
    • 传输过程中动态切换逻辑导致的开销
    • 没有设置/拆卸开销

通信粒度

  • 讯息
    • 网络客户端之间的传输单位(例如,内核、内存)
    • 可以使用多个数据包传输
  • 数据包
    • 网络传输单位
    • 可以使用多个 flit 传输(稍后讨论)
  • Flit(流量控制位)
    • 数据包分成更小的单位,称为“flits”
    • Flit:(“流量控制位”)网络中流量控制的单位
    • Flit 成为路由/缓冲的最小粒度

数据包格式

  • 一个数据包包括:
    • 标题:
      • 包含路由和控制信息
      • 在到路由器的数据包开始时可以提前开始转发
    • Payload/body:包含要发送的数据
    • 尾巴
      • 包含控制信息,例如错误代码
      • 通常位于数据包的末尾,因此可以在“出路”时生成(发送方计算校验和,将其附加到数据包的末尾)

处理竞争

  • 两个包需要同时在同一个节点上进行路由。
  • 解决办法有多个:
    • 缓存一个包,待会再发送
    • 扔掉一个包
    • 将一个包重新路由

电路交换路由

  • 高粒度资源分配
    • 主要思想:沿整个网络路径为消息预先分配所有资源(跨多个交换机的链接)
  • 成本
    • 需要设置阶段(“探测”)来设置路径(并在消息完成时将其拆除并释放资源)
    • 较低的链接利用率。 两个消息的传输不能共享同一链路(即使在传输过程中不再使用预分配路径上的某些资源)
  • 好处
    • 由于预分配,传输过程中无争用,因此无需缓冲
    • 任意消息大小(设置路径后,发送数据直到完成)

存储转发(基于数据包的路由)

  • 在移动到下一个节点之前,数据包被完全复制到网络交换机中
  • 流量控制单元是一个完整的数据包
    • 来自同一消息的不同数据包可以采用不同的路由,但一个数据包中的所有数据都通过相同的路由传输
  • 需要在每个路由器中缓冲整个数据包
  • 每个数据包的高延迟(延迟 = 链路上的数据包传输时间 x 网络距离)

直通(cut-flow)流量控制(也基于数据包)

  • 一旦收到包头,交换机就开始在下一个链路上转发数据(包头决定了包需要多少链路带宽+路由到哪里)
  • 结果:减少传输延迟
    • 上一张幻灯片中的存储和转发解决方案:3 跳 x 4 个时间单位在单个链路上传输数据包 = 12 个时间单位
    • 直通解决方案:数据包头部到达目的地的 3 个延迟步骤 + 其余数据包的 3 个时间单位 = 6 个时间单位

直通流量控制

  • 如果输出链路被阻塞(不能传输头),传输
    尾部可以继续
    • 最坏的情况:整个消息被吸收到交换机的缓冲区中(在这种情况下,直通流控制退化为存储转发)
    • 要求交换机对整个数据包进行缓冲,就像存储转发一样

虫洞流量控制

  • Flit(流量控制位)
    • 数据包分成更小的单位,称为“flits”
    • Flit:(“流量控制位”)网络中流量控制的单位
    • Flit 成为路由/缓冲的最小粒度
    • 回想一下:到目前为止,数据包是传输和流量控制和缓冲(存储转发、直通路由)的粒度

虫洞流量控制

  • 路由信息仅在 head flit 中
  • 身体跟随头部,尾部流向身体
  • 如果 head flit 阻塞,则其余数据包停止
  • 完全流水线传输
    • 对于长消息,延迟几乎完全独立于网络距离。

虚拟通道流量控制

  • 在单个物理信道上复用多个操作
  • 将交换机的输入缓冲区分成共享一个物理通道的多个缓冲区
  • 减少队头阻塞

虚拟通道的其他用途

  • 死锁避免
    • 可用于打破资源的循环依赖
    • 通过确保请求和响应使用不同的虚拟通道来防止循环
    • “Escape” VCs:保留至少一个使用无死锁路由的虚拟通道
  • 流量类别的优先级
    • 提供服务质量保证
    • 一些虚拟通道的优先级高于其他频道

概括

  • 现代多处理器中互连网络的性能对整体系统性能至关重要
    • 总线不能扩展到许多节点
  • 网络拓扑在性能、成本、复杂性权衡方面有所不同
    • 例如,crossbar、ring、mesh、torus、multi-stage network、fat tree、hypercube
  • 挑战:通过网络高效路由数据
    • 互连是一种宝贵的资源(通信是昂贵的!)
    • 基于Flit的流量控制:细粒度的流量控制,充分利用可用的链路带宽

lecture 16

运行一个线程意味着什么?

  • 处理器通过在硬件执行上下文中执行其指令来运行逻辑线程。
  • 如果操作系统希望进程 P 的线程 T 运行,它:
    • 选择 CPU 执行上下文
    • 它将该上下文中的寄存器值设置为线程的最后状态(例如,将 PC 设置为指向线程必须运行的下一条指令,设置堆栈指针、VM 映射等)
    • 然后处理器开始运行……它根据PC抓取下一条指令,并执行它:
      • 如果指令是:add r0, r1, r2;,处理器将 r1 和 r2 相加并将结果存储在 r0 中
      • 如果指令是:ld r0 mem[r1];,处理器获取 r1 的内容,根据执行上下文引用的页表将其转换为物理地址,并将该地址处的值加载到 r0

操作系统将逻辑线程映射到执行上下文

  • 由于线程多于执行上下文,因此操作系统必须在处理器上交错执行线程。
  • 操作系统将定期:
    • 中断处理器
    • 将当前映射到执行上下文的线程的寄存器状态复制到内存中的OS数据结构中
    • 将它现在想要运行的其他线程的寄存器状态复制到处理器执行上下文寄存器上
    • 告诉处理器继续
      • 现在这些逻辑线程正在处理器上运行

但是如何在每个时钟只能运行一条指令的内核上运行 2 个执行上下文呢?

  • 处理器有责任(没有操作系统干预)选择如何在单个内核的资源上交错执行来自多个执行上下文的指令。

同步事件的三个阶段

  • 获取方法
    • 线程如何尝试访问受保护的资源
  • 等待算法
    • 线程如何等待被授予对共享资源的访问权限
  • 释放方法
    • 当线程在同步区域中的工作完成时,线程如何使其他线程获得资源

忙等待

  • 忙着等待(又名“自旋”)
  • 忙等待是不好的:为什么?

“阻塞”同步

  • 思路:如果因为无法获取资源而无法取得进展,则希望为另一个线程释放执行资源(抢占正在运行的线程)
  • pthreads信号量的例子
1
2
pthread_mutex_t mutex;
pthread_mutex_lock(&mutex);

忙等待 vs. 阻塞

  • 在以下情况下,忙等待可能比阻塞更可取:
    • 调度开销大于预期的等待时间
    • 其他任务不需要处理器的资源
      • 这在并行程序中很常见,因为在运行性能关键的并行应用程序时我们通常不会超额使用系统(例如,没有多个 CPU 密集型程序同时运行)
    • 澄清:注意不要将上述声明与多线程的价值(多线程/任务的交错执行以隐藏内存操作的长延迟)与同一应用程序中的其他工作混淆。

基于测试和设置的锁使用原子测试和设置指令:

1
2
3
4
5
6
7
8
9
ts R0, mem[addr] // load mem[addr] into R0
// if mem[addr] is 0, set mem[addr] to 1

lock:
ts R0, mem[addr] // load word into R0
bnz R0, lock // if 0, lock obtained

unlock:
st mem[addr], #0 // store 0 to address

考虑一致性

x86 cmpxchg用于比较和交换(与lock前缀一起使用时是原子的)

理想的锁性能特征

  • 低延迟
    • 如果锁是空闲的并且没有其他处理器试图获取它,则处理器应该能够快速获取锁
  • 低互连流量
    • 如果所有处理器都试图一次获取锁,它们应该以尽可能少的流量连续获取锁
  • 可扩展性
    • 延迟/流量应根据处理器数量合理扩展
  • 存储成本低
  • 公平
    • 避免饥饿或严重的不公平
    • 一个理想情况:处理器应该按照他们请求访问的顺序获取锁

Test-and-test-and-set lock

1
2
3
4
5
6
7
8
9
10
void Lock(int* lock) {
while (1) {
while (*lock != 0);
if (test_and_set(*lock) == 0)
return;
}
}
void Unlock(volatile int* lock) {
*lock = 0;
}

Test-and-test-and-set特性

  • 在无竞争的情况下比测试和设置稍高的延迟
    • 必须测试…然后测试并设置
  • 产生更少的互连流量
    • 每个等待处理器、每个锁释放一个失效(O(P) 失效)
    • 如果所有处理器都缓存了锁,则这是 O(P^2) 互连流量
    • 每次测试时,测试和设置锁为每个等待处理器生成一个失效
  • 更具可扩展性(由于流量更少)
  • 存储成本不变(一个int)
  • 仍然没有公平条款

带回退的test-and-set lock:获取锁失败,延迟一段时间再重试

  • 与test-and-set相同的无竞争延迟,但在争用情况下可能有更高的延迟。
  • 生成的流量比 test-and-set 少(不会不断尝试获取锁)
  • 提高可扩展性(由于流量减少)
  • 存储成本不变(锁仍然是一个 int)
  • 指数退避会导致严重的不公平
    • 较新的请求者在更短的时间间隔内退出
1
2
3
4
5
6
7
8
9
void Lock(volatile int* l) { 
int amount = 1;
while (1) {
if (test_and_set(*l) == 0)
return;
delay(amount);
amount *= 2;
}
}

test-and-set 风格锁的主要问题:释放后,所有等待的处理器尝试使用 test-and-set 获取锁。所以提出了ticket lock。

1
2
3
4
5
6
7
8
9
10
11
struct lock { 
volatile int next_ticket;
volatile int now_serving;
};
void Lock(lock* l) {
int my_ticket = atomic_increment(&l->next_ticket); // take a “ticket”
while (my_ticket != l->now_serving); // wait for number to be called
}
void unlock(lock* l) {
l->now_serving++;
}

无需原子操作即可获取锁(仅读取)

  • 结果:每次锁定释放只有一次失效(O(P) 互连流量)

基于数组的锁

  • 每个处理器在不同的内存地址上旋转,利用原子操作在尝试获取时分配地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct lock { 
volatile padded_int status[P]; // padded to keep off same cache line
volatile int head;
};

int my_element;

void Lock(lock* l) {
my_element = atomic_circ_increment(&l­->head); // assume circular increment
while (l->status[my_element] == 1);
}
void unlock(lock* l) {
l->status[my_element] = 1;
l->status[circ_next(my_element)] = 0; // next() gives next index
}

回忆 CUDA 7 原子操作

1
2
3
4
5
6
7
8
9
10
11
12
13
int   atomicAdd(int* address, int val);
float atomicAdd(float* address, float val);
int atomicSub(int* address, int val);
int atomicExch(int* address, int val);
float atomicExch(float* address, float val);
int atomicMin(int* address, int val);
int atomicMax(int* address, int val);
unsigned int atomicInc(unsigned int* address, unsigned int val);
unsigned int atomicDec(unsigned int* address, unsigned int val);
int atomicCAS(int* address, int compare, int val);
int atomicAnd(int* address, int val); // bitwise
int atomicOr(int* address, int val); // bitwise
int atomicXor(int* address, int val); // bitwise

实现原子fetch-and-op

1
2
3
4
5
6
7
// atomicCAS:
// atomic compare and swap performs this logic atomically
int atomicCAS(int* addr, int compare, int val) {
int old = *addr;
*addr = (old == compare) ? val : old;
return old;
}

如何不使用atomicCAS()构建原子fetch-and-op?使用atomic_min()

1
2
3
4
5
6
7
8
int atomic_min(int* addr, int x) { 
int old = *addr;
int new = min(old, x);
while (atomicCAS(addr, old, new) != old) {
old = *addr;
new = min(old, x);
}
}

C++ 11的atomic<T>

  • 提供整个对象的原子读、写、读-修改-写
    • 原子性可以由互斥体实现或由处理器支持的原子指令有效地实现(如果 T 是基本类型)
  • 为原子操作前后的操作提供内存排序语义
    • 默认:顺序一致性
1
2
3
4
5
6
atomic<int> i;
i++; // atomically increment i
int a = i;
// do stuff
i.compare_exchange_strong(a, 10); // if i has same value as a, set i to 10
bool b = i.is_lock_free(); // true if implementation of atomicity is lock free

实现集中式barrier(基于共享计数器)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct Barrier_t {
LOCK lock;
int arrive_counter; // initialize to 0 (number of threads that have arrived)
int leave_counter; // initialize to P (number of threads that have left barrier)
int flag;
};
// barrier for p processors
void Barrier(Barrier_t* b, int p) {
lock(b->lock);
if (b->arrive_counter == 0) { // if first to arrive...
if (b->leave_counter == P) { // check to make sure no other threads “still in barrier”
b->flag = 0; // first arriving thread clears flag
} else {
unlock(lock);
while (b->leave_counter != P); // wait for all threads to leave before clearing
lock(lock);
b->flag = 0; // first arriving thread clears flag
}
}
int num_arrived = ++(b->arrive_counter);
unlock(b->lock);
if (num_arrived == p) { // last arriver sets flag
b->arrive_counter = 0;
b->leave_counter = 1;
b->flag = 1;
}
else {
while (b->flag == 0); // wait for flag
lock(b->lock);
b->leave_counter++;
unlock(b->lock);
}
}

中心化barrier:流量

  • 每个屏障的互连上的 O(P) 流量:
    • 所有线程:2P 个写事务以获取屏障锁和更新计数器(假设锁获取以 O(1) 方式实现,则为 O(P) 流量)
    • 最后一个线程:2 个写入事务以写入标志并重置计数器(O(P) 流量,因为有许多标志的共享者)
    • P-1个读取更新标志的事务
  • 但在单个共享锁上仍然存在序列化
    • 所以整个操作的跨度(延迟)是 O(P)

Barrier的树实现

  • 树可以更好地利用互连拓扑中的并行性
    • lg(P) 跨度(延迟)
    • 策略在总线上意义不大(所有流量仍然在单个共享总线上串行化)
  • Barrier获取:当处理器到达屏障时,执行父计数器的递增
    • 进程递归到root
  • Barrier释放:从根开始,通知孩子释放

lecture 17

当两个线程需要同时在链表上对一个节点进行插入操作时,需要对节点进行加锁。

解决方案1:用单锁保护列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct Node { 
int value;
Node* next;
};
struct List {
Node* head;
Lock lock;
};

void insert(List* list, int value) {
Node* n = new Node;
n->value = value;
lock(list->lock);
// assume case of inserting before head of
// of list is handled here (to keep slide simple)
Node* prev = list->head;
Node* cur = list->head->next;
while (cur) {
if (cur->value > value)
break;
prev = cur;
cur = cur->next;
}
n->next = cur;
prev->next = n;
unlock(list->lock);
}
void delete(List* list, int value) {
lock(list->lock);
// assume case of deleting first element is
// handled here (to keep slide simple)
Node* prev = list->head;
Node* cur = list->head->next;
while (cur) {
if (cur->value == value) {
prev->next = cur->next;
delete cur;
unlock(list->lock);
return;
}
prev = cur;
cur = cur->next;
}
unlock(list->lock);
}

每个数据结构的单个全局锁

  • 好处:
    • 对数据结构操作实现正确的互斥相对比较简单
  • 坏处:
    • 数据结构上的操作是序列化的 - 可能会限制并行应用程序的性能

解决方案2:细粒度锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
struct Node { 
int value;
Node* next;
Lock* lock;
};
struct List {
Node* head;
Lock* lock;
};
void insert(List* list, int value) {
Node* n = new Node;
n->value = value;
// assume case of insert before head handled
// here (to keep slide simple)
Node* prev, *cur;
lock(list->lock);
prev = list->head;
cur = list->head->next;
lock(prev->lock);
unlock(list->lock);
if (cur) lock(cur->lock);
while (cur) {
if (cur->value > value)
break;
Node* old_prev = prev;
prev = cur;
cur = cur->next;
unlock(old_prev->lock);
if (cur) lock(cur->lock);
}
n->next = cur;
prev->next = n;
unlock(prev->lock);
if (cur) unlock(cur->lock);
}
void delete(List* list, int value) {
// assume case of delete head handled here
// (to keep slide simple)
Node* prev, *cur;
lock(list->lock);
prev = list->head;
cur = list->head->next;
lock(prev->lock);
unlock(list->lock);
if (cur) lock(cur->lock)
while (cur) {
if (cur->value == value) {
prev->next = cur->next;
unlock(prev->lock);
unlock(cur->lock);
delete cur;
return;
}
Node* old_prev = prev;
prev = cur;
cur = cur->next;
unlock(old_prev->lock);
if (cur) lock(cur->lock);
}
unlock(prev->lock);
}

细粒度锁

  • 目标:在数据结构操作中启用并行性
    • 减少对全局数据结构锁的争用
    • 在前面的链表示例中:单个单体锁过于保守(对链表不同部分的操作可以并行进行)
  • 挑战:难以确保正确性
    • 确定何时需要互斥
    • 死锁/活锁?
  • 开销?
    • 每个遍历步骤锁定的开销(额外指令 + 遍历现在涉及内存写入)
    • 额外的存储成本(每个节点一个锁)

阻塞算法/数据结构

  • 阻塞算法允许一个线程无限期地阻止其他线程完成对共享数据结构的操作
  • 示例:
    • 线程 0 锁定我们链表中的一个节点
    • 线程 0 被操作系统换出,或者崩溃,或者非常慢等。
    • 现在,没有其他线程可以完成对数据结构的操作
  • 无论锁实现是使用自旋还是抢占,使用锁的算法都是阻塞的

无锁算法

  • 如果保证某个线程取得进展(“系统范围的进展”),则非阻塞算法是无锁的
  • 在无锁的情况下,不可能在不合时宜的时间抢占其中一个线程并阻止系统其余部分的进展
  • 注意:这个定义不会阻止任何一个线程的饥饿

单读、单写限界队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct Queue {
int data[N];
int head; // head of queue
int tail; // next free element
};
void init(Queue* q) {
q->head = q->tail = 0;
}
// return false if queue is full
bool push(Queue* q, int value) {
// queue is full if tail is element before head
if (q->tail == MOD_N(q->head - 1))
return false;
q.data[q->tail] = value;
q->tail = MOD_N(q->tail + 1);
return true;
}
// returns false if queue is empty
bool pop(Queue* q, int* value) {
// if not empty
if (q->head != q->tail) {
*value = q->data[q->head];
q->head = MOD_N(q->head + 1);
return true;
}
return false;
}

  • 只有两个线程(一个生产者,一个消费者)同时访问队列
  • 线程从不同步或相互等待
    • 当队列为空时(弹出失败),当队列满时(推送失败)
  • 目前假设一个顺序一致的内存系统(或存在适当的内存栅栏,或 C++ 11 atomic<>

单读单写无界队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct Node {
Node* next;
int value;
};
struct Queue {
Node* head;
Node* tail;
Node* reclaim;
};
void init(Queue* q) {
q->head = q->tail = q->reclaim = new Node;
}
void push(Queue* q, int value) {
Node* n = new Node;
n->next = NULL;
n->value = value;
q->tail->next = n;
q->tail = q->tail->next;
while (q->reclaim != q->head) {
Node* tmp = q->reclaim;
q->reclaim = q->reclaim->next;
delete tmp;
}
}
// returns false if queue is empty
bool pop(Queue* q, int* value) {
if (q->head != q->tail) {
*value = q->head->next->value;
q->head = q->head->next;
return true;
}
return false;

  • 尾部指向添加的最后一个元素
  • Head 指向 BEFORE 队列头元素
  • 由同一个线程(生产者)执行的分配和删除

ABA问题:线程0执行pop()操作时,线程B同时执行pop()push()操作,导致栈结构破坏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct Node {
Node* next;
int value;
};
struct Stack {
Node* top;
int pop_count;
};
void init(Stack* s) {
s->top = NULL;
}
void push(Stack* s, Node* n) {
while (1) {
Node* old_top = s->top;
n->next = old_top;
if (compare_and_swap(&s->top, old_top, n) == old_top)
return;
}
}
Node* pop(Stack* s) {
while (1) {
int pop_count = s->pop_count;
Node* top = s->top;
if (top == NULL)
return NULL;
Node* new_top = top->next;
if (double_compare_and_swap(&s->top, top,new_top, &s->pop_count, pop_count, pop_count+1))
return top;
}
}
  • 维护pop操作的计数器
  • 要求机器支持“双重比较和交换”(DCAS) 或双字 CAS
  • 还可以通过节点分配和/或元素重用策略解决 ABA 问题

在 x86 上比较和交换

  • x86 支持“宽”比较和交换指令
    • 不完全是上一张幻灯片代码中使用的“双重比较和交换”
    • 但可以简单地确保堆栈的计数和顶部字段在内存中是连续的,以使用下面的 64 位宽单比较和交换指令。
  • cmpxchg8b
    • “比较和交换八个字节”
    • 可用于两个 32 位值的比较和交换
  • cmpxchg16b
    • “比较和交换 16 个字节”
    • 可用于两个 64 位值的比较和交换

另一个问题:引用释放的内存

  • 危险指针:避免释放节点,直到确定所有其他线程不持有对节点的引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
struct Node { 
Node* next;
int value;
};
struct Stack {
Node* top;
int pop_count;
};
// per thread ptr (node that cannot
// be deleted since the thread is
// accessing it)
Node* hazard;
// per-thread list of nodes thread
// must delete
Node* retireList;
int retireListSize;
void init(Stack* s) {
s->top = NULL;
}
void push(Stack* s, int value) {
Node* n = new Node;
n->value = value;
while (1) {
Node* old_top = s->top;
n->next = old_top;
if (compare_and_swap(&s->top, old_top, n) == old_top)
return;
}
}
int pop(Stack* s) {
while (1) {
Stack old;
old.pop_count = s->pop_count;
old.top = s->top;
if (old.top == NULL)
return NULL;
hazard = old.top;
Stack new_stack;
new_stack.top = old.top->next;
new_stack.pop_count = old.pop_count+1;
if (doubleword_compare_and_swap(&s, &old, new_stack)) {
int value = old.top->value;
retire(old.top);
return value;
}
hazard = NULL;
}
}
// delete nodes if possible
void retire(Node* ptr) {
push(retireList, ptr);
retireListSize++;
if (retireListSize > THRESHOLD)
for (each node n in retireList) {
if (n not pointed to by any thread’s hazard pointer) {
remove n from list
delete n;
}
}
}

无锁链表插入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct Node { 
int value;
Node* next;
};
struct List {
Node* head;
};
// insert new node after specified node
void insert_after(List* list, Node* after, int value) {
Node* n = new Node;
n->value = value;
// assume case of insert into empty list handled
// here (keep code on slide simple for class discussion)
Node* prev = list->head;
while (prev->next) {
if (prev == after) {
while (1) {
Node* old_next = prev->next;
n->next = old_next;
if (compare_and_swap(&prev->next, old_next, n) == old_next)
return;
}
}
prev = prev->next;
}
}

与细粒度锁定实现相比:

  • 没有获取锁的开销
  • 没有每个节点的存储开销

在实践中:为什么要无锁数据结构?

  • 在本课程中优化并行程序时,您通常假设只有您的程序在使用机器
    • 因为你关心性能
    • 科学计算、图形、数据分析等中的典型假设。
  • 在这些情况下,编写良好的带锁代码可以与无锁代码一样快(或更快)
  • 但在某些情况下,带锁的代码可能会遇到棘手的性能问题
    • 当线程处于临界区时可能发生页面错误、抢占等的多程序情况
    • 产生 OS 类中经常讨论的问题,如优先级反转、护送、临界区崩溃等

概括

  • 使用细粒度锁定来减少共享数据结构操作中的争用(最大化并行度)
    • 但细粒度会增加代码复杂度(错误)并增加执行开销
  • 无锁数据结构:非阻塞解决方案,避免因锁造成的开销
    • 但实现起来可能很棘手(确保无锁设置的正确性有其自身的开销)
    • 在现代宽松的一致性硬件上仍然需要适当的内存栅栏
  • 注意:无锁设计并不能消除争用
    • 比较和交换可能会在激烈的争用下失败,需要旋转

lecture 18

你应该知道的

  • 什么是事务
  • 原子代码块和锁定/解锁原语之间的区别(语义上)
  • 事务内存实现的基本设计空间
    • 数据版本控制政策
    • 冲突检测策略
    • 检测粒度
  • 事务内存硬件实现的基础知识

使用事务编程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void deposit(Acct account, int amount) 
{
lock(account.lock);
int tmp = bank.get(account);
tmp += amount;
bank.put(account, tmp);
unlock(account.lock);
}
void deposit(Acct account, int amount)
{
atomic {
int tmp = bank.get(account);
tmp += amount;
bank.put(account, tmp);
}
}

  • 原子结构是声明性的
    • 程序员陈述要做什么(保持代码的原子性),而不是如何去做
    • 没有明确使用或管理锁
  • 系统根据需要实现同步以确保原子性
    • 系统可以使用锁实现原子性
    • 今天讨论的实现使用乐观并发:仅在真正争用(R-W 或 W-W 冲突)的情况下进行序列化
  • 声明性:程序员定义应该做什么
    • 执行所有这些独立的 1000 个任务
  • 必要的:程序员说明应该如何做
    • 产生 N 个工作线程。 通过从共享任务队列中删除工作来将工作分配给线程
    • 原子地执行这组操作
    • 获取锁,执行操作,释放锁

事务内存 (Transaction Memory, TM)

  • 内存事务
    • 一个原子的和隔离的内存访问序列
    • 受数据库事务的启发
  • 原子性(全有或全无)
    • 事务提交后,事务中的所有内存写入立即生效
    • 在事务中止时,似乎没有任何写入生效(就好像事务从未发生过一样)
  • 隔离
    • 在事务提交之前没有其他处理器可以观察写入
  • 可串行化
    • 事务似乎以单个串行顺序提交
    • 但是事务的语义不能保证提交的确切顺序
  • 换句话说……我们为一致内存系统中的单个地址维护的许多属性,我们希望为事务中的读和写集维护。
  • 这些内存事务要么全部被其他处理器观察到,要么都不被其他处理器观察到。(有效地全部同时发生)

同步HashMap

  • Java 1.4 解决方案:同步层
    • 将任何映射转换为线程安全变体
    • 使用程序员指定的显式粗粒度锁定
1
2
3
4
5
public Object get(Object key) {
synchronized (myHashMap) { // guards all accesses to hashMap
return myHashMap.get(key);
}
}
  • 简单地将所有操作包含在原子块中
    • 原子块的语义:系统保证块内逻辑的原子性
1
2
3
4
5
public Object get(Object key) { 
atomic { // System guarantees atomicity
return m.get(key);
}
}
  • 事务HashMap
    • 好:线程安全,易于编程
    • 性能和可扩展性如何?
      • 取决于atomic的工作量和实现

事务的例子,两个事务执行后没有读写冲突,事务不会写在另一事务中的元素。

如果两个事务同时写入3号点,则引起冲突。事务在此时必须是串行的。

失败的原子性:锁

1
2
3
4
5
6
7
8
9
10
11
void transfer(A, B, amount) { 
synchronized(bank)
{
try {
withdraw(A, amount);
deposit(B, amount);
}
catch(exception1) { /* undo code 1*/ }
catch(exception2) { /* undo code 2*/ }
}
}

  • 手动捕获异常的复杂性
    • 程序员根据具体情况提供“撤消”代码
    • 复杂性:必须跟踪要撤消的内容以及如何……
    • 其他线程可能会看到某些副作用
    • 例如,一个未捕获的case可能会导致系统死锁……

失败的原子性:事务

  • 系统现在负责处理异常
    • 所有异常(除了那些由程序员明确管理的异常)
    • 事务被中止,内存更新被撤销
    • 回想:事务要么提交要么不提交:其他线程看不到部分更新
    • 例如,失败的线程没有持有锁……
1
2
3
4
5
6
7
void transfer(A, B, amount) 
{
atomic {
withdraw(A, amount);
deposit(B, amount);
}
}

可组合性:锁

  • 编写基于锁的代码可能很棘手
    • 需要系统范围的策略才能正确
    • 系统范围的策略可以打破软件模块化
  • 可能会有额外的锁和很难实现的地方
    • 粗粒锁:低性能
    • 细粒度锁:有利于性能,但会导致死锁

以下是死锁的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void transfer(A, B, amount) { 
synchronized(A) {
synchronized(B) {
withdraw(A, amount);
deposit(B, amount);
}
}
}

void transfer2(A, B, amount) {
synchronized(B) {
synchronized(A) {
withdraw(A, 2*amount);
deposit(B, 2*amount);
}
}
}

可组合性:事务

  • 事务优雅地组合(理论上)
    • 程序员声明全局意图(传输的原子执行)
    • 无需了解全局实施策略
    • transfer中的事务包含withdraw和deposit中定义的任何内容
    • 最外层事务定义原子性边界
  • 系统管理并发以及可能的序列化
    • transfer(A, B, 100)transfer(B, A, 200)的序列化
    • transfer(A, B, 100)transfer(B, A, 200)的并发

事务内存的优点

  • 易于使用的同步结构
    • 程序员很难正确同步
    • 程序员声明需要原子性,系统实现的很好
    • 声明:事务与粗粒度锁一样易于使用
  • 通常与细粒度锁的性能一样好
    • 提供自动读-读并发和细粒度并发
    • 性能可移植性:4 个 CPU 的锁定方案可能不是 64 个 CPU 的最佳方案
  • 故障原子性和恢复
    • 线程失败时不会丢失锁
    • 故障恢复 = 事务中止 + 重启
  • 可组合性
    • 安全且可扩展的软件模块组合

与 OpenMP 的集成示例

  • 示例:OpenTM = OpenMP + TM
    • OpenMP:主从并行模型
    • 易于指定并行循环和任务
    • TM:原子和隔离执行
    • 易于指定同步和推测
  • OpenTM 特性
    • 事务、事务循环和事务部分
    • TM 的数据指令(例如,线程私有数据)
    • TM 的运行时系统提示
  • 代码示例:
1
2
3
4
#pragma omp transfor schedule (static, chunk=50)
for (int i=0; i<N; i++) {
bin[A[i]]++;
}

anomic{}lock() + unlock()

  • 区别
    • Atomic:原子性的高级声明
      • 不指定原子性的实现
    • 锁:低级阻塞原语
      • 本身不提供原子性或隔离性
  • 牢记
    • 锁可用于实现原子块
    • 锁可用于原子性以外的目的
      • 不能用原子区域替换所有使用的锁
    • Atomic 消除了许多数据竞争,但使用原子块编程仍然会受到原子性违规的影响:例如,程序员错误地将应该是原子的序列拆分为两个原子块

TM 实施基础

  • TM 系统必须提供原子性和隔离性
    • 不牺牲并发性
  • 基本实施要求
    • 数据版本控制(允许事务中止)
    • 冲突检测和解决(何时中止)
  • 实施选项
    • 硬件事务内存 (HTM)
    • 软件事务存储器 (STM)
    • 混合事务内存
    • 例如,硬件加速的 STM

数据版本控制:管理未提交的(新的)和以前提交的(旧的)并发事务的数据版本

  1. 急切的版本控制(基于撤销日志)
  2. 延迟版本控制(基于写缓冲区)

急切的版本控制立即更新内存,维护“undo log”以防中止。当事务开始时,线程对内存进行修改, 同时将之前的值放入undo log,提交事务后,undo log被清理;当事务中断时,使用undo log将内存恢复到事务开始之前。

懒惰的版本控制:在事务写入缓冲区中记录内存更新,提交时刷新缓冲区。把事务中所有写入都放入缓冲区,当事务结束后,再把内存地址中的最终值写入内存。

数据版本控制

  • 管理未提交(新)和已提交(旧)版本的并发事务的数据
  • 急切版本控制(基于撤销日志)
    • 在写入时直接更新内存位置
    • 在日志中维护撤消信息(产生每个store的开销)
    • 好:更快的提交(数据已经在内存中)
    • 不好:中止速度较慢,容错问题(考虑在事务中间崩溃)
    • 急切的版本控制理念:立即写入内存,希望事务不会中止(但在必须时处理中止)
  • 延迟版本控制(基于写缓冲区)
    • 在写入缓冲区中缓冲数据直到提交
    • 在提交时更新实际内存位置
    • 好:更快的中止(只是清除日志),没有容错问题
    • 不好:提交速度较慢
    • 懒惰的版本控制理念:仅在必须进行冲突检测时才写入内存

冲突检测

  • 必须检测和处理事务之间的冲突
    • 读写冲突:事务 A 读取地址 X,该地址由待处理事务 B 写入
    • 写-写冲突:事务 A 和 B 都未决,并且都写入地址 X
  • 系统必须跟踪事务的读集和写集
    • 读取集:在事务中读取的地址
    • 写集:在事务中写入的地址

悲观检测

  • 检查加载或存储期间的冲突
    • 硬件实现将通过一致性操作检查冲突
    • 理念:“我怀疑可能会发生冲突,所以让我们总是在每次内存操作后检查是否发生了冲突……如果我必须回滚,不妨现在就做,以免浪费工作。”
  • 当检测到冲突时“争用管理器”决定停止或中止事务
    • 各种优先级策略,以快速处理常见情况

两个线程共同进行事务,case1中没有冲突,所以成功;case2中T1发现T0在写就直接stall;case3中包含了case2的情况。

乐观检测

  • 当事务尝试提交时检测冲突
    • 硬件:使用一致性操作验证写入集
    • 获得对写集中缓存行的独占访问权限
    • 直觉:“让我们抱最好的希望,只有在事务尝试提交时才能解决所有冲突”
  • 发生冲突时,优先提交事务
    • 其他事务可能会在稍后中止
    • 在提交事务之间发生冲突时,使用争用管理器来决定优先级
  • 注意:可以同时使用乐观方案和悲观方案
    • 一些 STM 系统使用乐观的读取和悲观的写入

发现乐观锁是在提交的时候才检查冲突的,如果有冲突就重启事务

冲突检测权衡

  • 悲观冲突检测(又名“eager”)
    • 好:及早发现冲突(撤消较少的工作,将一些中止转为停顿)
    • 不好:没有前进的保证,在某些情况下更多的中止
    • 不好:细粒度的通信(检查每个加载/存储)
    • 不好:关键路径上的检测
  • 乐观冲突检测(又名“懒惰”或“提交”)
    • 好:前进保证
    • 好:批量通信和冲突检测
    • 差:发现冲突较晚,仍可能存在公平性问题

硬件事务内存 (HTM)

  • 数据版本控制在缓存中实现
    • 缓存写缓冲区或撤消日志
    • 添加新的缓存行元数据以跟踪事务读取集和写入集
  • 通过缓存一致性协议进行冲突检测
    • 一致性查找检测事务之间的冲突
    • 与监听和目录一致性一起使用
  • 注意:

    • 还必须在事务开始时进行注册检查点(以在中止时恢复执行上下文状态)
  • 缓存行注释以跟踪读取集和写入集

    • R 位:表示事务读取的数据(加载时设置)
    • W 位:表示事务写入的数据(在存储上设置)
    • R/W 位可以是字或缓存行粒度
    • R/W 位在事务提交或中止时清除
    • 对于急切的版本控制,需要为撤消日志进行第二次缓存写入
  • 一致性请求检查 R/W 位以检测冲突
    • 观察到 W-word 的共享请求是读写冲突
    • 观察到对 R 字的独占(意图写)请求是写-读冲突
    • 观察到对 W-word 的独占(意图写)请求是写-写冲突

lecture 19

异构并行和硬件专业化

更多异构:添加离散 GPU

  • 除非图形密集型应用程序需要,否则保持独立(耗电)GPU
  • 将集成的低功耗图形用于基本图形/窗口管理器/UI

FPGA(现场可编程门阵列)

  • ASIC 和处理器之间的中间地带
  • FPGA 芯片提供逻辑块阵列,通过互连连接
  • 由 FGPA 直接实现的程序员定义的逻辑

异质性的挑战

  • 异构系统:每个任务的首选处理器
    • 硬件设计师面临的挑战:什么是正确的资源组合?
    • 面向吞吐量的资源太少(并行工作负载的峰值吞吐量较低)
    • 顺序处理资源太少(受工作负载的顺序部分限制)
    • 应该为特定功能(例如视频)分配多少芯片面积? (这些资源从通用处理中拿走)
    • 必须在芯片设计时预期工作平衡
    • 系统无法适应使用情况随时间、新算法等的变化。
    • 对软件开发人员的挑战:如何将程序映射到异构资源集合上?
    • 挑战:“为工作选择合适的工具”:设计算法可以很好地分解为组件,每个组件都可以很好地映射到机器的不同处理组件
    • 异构系统上的调度问题更复杂
    • 可用的资源混合可以决定算法的选择
    • 软件可移植性噩梦

降低能耗

  • 理念1:使用专门的处理
  • 理念2:移动更少的数据

数据移动的能源成本很高

  • 移动系统设计的经验法则:始终寻求减少从内存传输的数据量
    • 在课堂早些时候,我们讨论了最小化通信以减少停顿(性能不佳)。 现在,我们希望减少通信以减少能源消耗

能源优化计算的三大趋势

  • 减少计算!
    • 计算消耗能源:即使运行速度更快,并行算法的工作量也比顺序算法多
  • 专业化计算单元:
    • 异构处理器:类 CPU 内核 + 吞吐量优化内核(类 GPU 内核)
    • 固定功能单元:音频处理、“运动传感器处理”视频解码/编码、图像处理/计算机视觉?
    • 专用指令:扩展AVX向量指令集,新的AES加密加速指令(AES-NI)
    • 可编程软逻辑:FPGA
  • 降低带宽要求
    • 利用局部性(重构算法以尽可能多地重用片上数据)
    • 积极使用压缩:在传输到内存之前执行额外的计算以压缩应用程序数据(可能会看到固定功能的硬件以减少一般数据压缩/解压缩的开销)

lecture 20

领域特定编程系统

这是一个巨大的挑战

  • 性能特征截然不同的机器
  • 更糟:同一台机器内不同规模的不同性能特征
  • 为了提高效率,软件必须针对硬件特性进行优化
    • 一机一级也难
    • 考虑复杂机器或不同机器时优化的组合复杂性
    • 失去软件可移植性

特定领域的编程系统

  • 主要思想:提高表达程序的抽象层次
  • 引入特定于应用程序域的高级编程原语
    • 高效:使用直观,跨机器移植,原语对应于经常用于解决目标领域问题的行为
    • 高性能:系统使用领域知识来提供高效、优化的实现
    • 给定一台机器:系统知道要使用什么算法,该领域要采用的并行化策略
    • 优化超越了软件到硬件的高效映射! 硬件平台本身也可以针对抽象进行优化
  • 成本:丧失一般性/完整性

Lizst:一种在网格上求解偏微分方程的语言

  • 在网格上运行Lizst程序
  • Liszt 程序定义并计算网格上定义的字段的值
1
2
3
4
val Position = FieldWithConst[Vertex,Float3](0.f, 0.f, 0.f)
val Temperature = FieldWithConst[Vertex,Float](0.f)
val Flux = FieldWithConst[Vertex,Float](0.f)
val JacobiStep = FieldWithConst[Vertex,Float](0.f)

Liszt的拓扑算子

  • 用于访问与某些输入顶点、边、面等相关的网格元素。拓扑运算符是在 Liszt 程序中访问网格数据的唯一方法
  • 注意有多少运算符返回集合(例如,“这个面的所有边缘”)

限制依赖分析的语言

  • 语言限制:
    • 网格元素只能通过内置的拓扑函数访问:cells(mesh)
    • 单一静态分配:val va = head(e)
    • 字段中的数据只能使用网格元素访问:Pressure(b)
    • 没有递归函数

限制允许编译器自动推断循环迭代的模板。

关键:确定程序依赖

  • 识别并行性
    • 没有依赖意味着代码可以并行执行
  • 识别数据局部性
    • 基于依赖的分区数据(本地化依赖计算以加快同步)
  • 需要同步的原因
    • 需要同步以尊重依赖性(必须等到计算所依赖的值已知)

在一般程序中,编译器无法在全局范围内推断依赖关系:a[f(i)] += b[i](必须执行f(i)才能知道在循环迭代 i 中是否存在依赖关系)

可移植并行性:使用依赖来实现不同的并行执行策略

  • 网格分块
  • 网格着色

Liszt的分布式内存实现:Mesh + Stencil→Graph→Partition

考虑分布式内存实现:在集群中的每个节点上存储网格区域(注:ParMETIS 是用于划分网格的工具)

每个处理器还需要相邻单元的数据来执行计算(“halo单元”)。 Listz 分配halo区域存储并发出所需的通信以实现拓扑算子。

Liszt小结

  • 生产力:
    • 网格的抽象表示:顶点、边、面、场
    • 直观的拓扑运算符
  • 可移植性
    • 相同的代码在大型 CPU (MPI) 和 GPU
  • 高性能
    • 语言被限制为允许编译器跟踪依赖项
    • 用于分布式内存实现中的位置感知分区
    • 用于 GPU 实现中的图形着色
    • 编译器知道如何为不同平台选择不同的并行化策略
    • 底层网格表示可以根据使用和平台由系统自定义(例如,如果代码不需要,则不要存储边缘指针,为每个顶点字段选择数组结构与结构数组)

lecture 21

图计算的领域专门语言

Page Rank也是基于图算法的,node代表了网页,边代表了两个网页之间的链接

GraphLab

  • 一个描述图迭代计算的系统
  • 作为 C++ 运行时实现
  • 在共享内存机器上运行或分布在集群中
      • GraphLab 运行时负责并行调度工作、跨机器集群划分图、主机之间的通信等。

GraphLab 程序:状态

  • 图用G = (V, E)表示
    • 应用程序在每个顶点和有向边上定义数据块
    • D(v) = 与顶点 v 相关的数据
    • D(u→v) = 与有向边 u→v 相关的数据
  • 只读全局数据
    • 可以将其视为每图数据,而不是每顶点或每边数据)
  • 注意:我总是先描述程序状态,然后描述哪些操作可以操作这个状态

GraphLab 操作:顶点程序

  • 在顶点的本地邻域上定义每个顶点的操作
  • 顶点的邻域(又名“范围”):
    • 当前顶点
    • 相邻边缘
    • 相邻顶点

Page Rank改写程序

1
2
3
4
5
6
7
8
9
PageRank_vertex_program(vertex i) { 
// (Gather phase) compute the sum of my neighbors rank
double sum = 0;
foreach(vertex j : in_neighbors(i)) {
sum = sum + j.rank / num_out_neighbors(j);
}
// (Apply phase) Update my rank (i)
i.rank = (1-0.85)/num_graph_vertices() + 0.85*sum;
}

GraphLab:数据访问

  • 应用程序的顶点程序按顶点执行
  • 顶点程序定义:
    • 哪些相邻边是计算的输入
    • 每条边执行什么计算
    • 如何更新顶点的值
    • 计算修改了哪些相邻边
    • 如何更新这些输出边
  • 注意 GraphLab 如何要求程序告诉它所有将被访问的数据,以及它是读访问还是写访问

PageRank:GraphLab顶点程序(C++代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct web_page {
std::string pagename;
double pagerank;
web_page(): pagerank(0.0) { }
}
typedef graphlab::distributed_graph<web_page, graphlab::empty> graph_type;
class pagerank_program:
public graphlab::ivertex_program<graph_type, double>,
public graphlab::IS_POD_TYPE {
public:
// we are going to gather on all the in-edges
edge_dir_type gather_edges(icontext_type& context, const vertex_type& vertex) const {
return graphlab::IN_EDGES;
}
// for each in-edge gather the weighted sum of the edge.
double gather(icontext_type& context, const vertex_type& vertex, edge_type& edge) const {
return edge.source().data().pagerank / edge.source().num_out_edges();
}
// Use the total rank of adjacent pages to update this page
void apply(icontext_type& context, vertex_type& vertex, const gather_type& total) {
double newval = total * 0.85 + 0.15;
vertex.data().pagerank = newval;
}
// No scatter needed. Return NO_EDGES
edge_dir_type scatter_edges(icontext_type& context, const vertex_type& vertex) const {
return graphlab::NO_EDGES;
}
};

  • 图的每个顶点都有 web_page 类型的记录,边上没有数据
  • 定义要在“聚集阶段”聚集的边
  • 计算每条边的累加值
  • 更新顶点等级
  • PageRank 示例不执行分散

顶点信号:GraphLab 生成新任务的机制

  • 迭代更新所有 R[i] 的 10 次
  • 使用通用的“信号”原语
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct web_page { 
std::string pagename;
double pagerank;
int counter;
web_page(): pagerank(0.0),counter(0) { }
}
// Use the total rank of adjacent pages to update this page
void apply(icontext_type& context, vertex_type& vertex, const gather_type& total) {
double newval = total * 0.85 + 0.15;
vertex.data().pagerank = newval;
vertex.data().counter++;
if (vertex.data().counter < 10)
vertex.signal();
}

信号:调度工作的通用原语

  • 图的一部分可能以不同的速率收敛(迭代 PageRank 直到收敛,但只针对需要它的顶点)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class pagerank_program: 
public graphlab::ivertex_program<graph_type, double>,
public graphlab::IS_POD_TYPE {
private:
bool perform_scatter;
public:
// Use the total rank of adjacent pages to update this page
void apply(icontext_type& context, vertex_type& vertex, const gather_type& total) {
double newval = total * 0.85 + 0.15;
double oldval = vertex.data().pagerank;
vertex.data().pagerank = newval;
perform_scatter = (std::fabs(prevval - newval) > 1E-3);
}
// Scatter now needed if algorithm has not converged
edge_dir_type scatter_edges(icontext_type& context, const vertex_type& vertex) const {
if (perform_scatter)
return graphlab::OUT_EDGES;
else return graphlab::NO_EDGES;
}
// Make sure surrounding vertices are scheduled
void scatter(icontext_type& context, const vertex_type& vertex, edge_type& edge) const {
context.signal(edge.target());
}
};

同步并行执行

  • 顶点的局部邻域(顶点的“范围”)可以由顶点程序读取和写入
  • 程序指定他们希望 GraphLab 运行时提供的原子性粒度(“一致性”):这决定了可用并行性的数量
    • “完全一致性”:实现确保在 v 的顶点程序运行时没有其他执行读取或写入 v 范围内的数据。
    • “边缘一致性”:没有其他执行读取或写入 v 中或与 v 相邻的边缘中的任何数据
    • “顶点一致性”:没有其他执行读取或写入 v …

GraphLab 实现了几种工作调度策略

  • 同步:同时更新所有顶点(顶点程序没有观察到在同一“轮”中运行在其他顶点上的程序的更新)
  • 循环:顶点程序观察最近的更新
  • 图形着色
  • 动态:基于信号创建的新作品

应用程序开发人员可以灵活选择一致性保证和调度策略

  • 含义:调度的选择会影响程序的正确性/输出

大规模图的内存占用挑战

  • 挑战:对于大规模图,无法在内存中拟合所有边
    (图形顶点可能适合)
  • 考虑图形表示:
    • 每条边在图形结构中表示两次(作为输入/输出边)
    • 每条边 8 个字节表示邻接
  • 可能还需要存储每条边的值(例如,每条边的权重为 4 个字节)
    • 10 亿条边(适度):约 12 GB 内存用于边信息
  • 算法可能需要每个边结构的多个副本(当前、上一个数据等)
  • 可以使用机器集群在内存中存储图形
    • 而不是在磁盘上存储图形
  • 更愿意在一台机器上处理大图
    • 管理机器集群很困难
    • 分区图很昂贵(也需要大量内存)并且很困难

“流式”图形计算

  • 图操作“随机”访问图数据(与顶点 v 相邻的边可以在整个存储中任意分布)
    • 单次遍历图的边缘可能会对磁盘进行数十亿次细粒度访问
  • 流数据访问模式
    • 对慢速存储进行大型、可预测的数据访问(实现高带宽数据传输)
    • 将数据从慢速存储加载到快速存储中,然后在丢弃之前尽可能多地重复使用(实现高算术强度)

分片图表示

  • 将图顶点划分为区间(调整大小以便区间的子图适合内存)
  • 存储顶点并且只有这些顶点的传入边被一起存储在一个分片中
  • 按源顶点 id 对分片中的边进行排序

图压缩

  • 回忆:图操作通常受 BW 限制
  • 含义:使用 CPU 指令来降低 BW 要求可以提高整体性能(无论如何处理器都在等待内存!)
  • 想法:将压缩的图形存储在内存中,当操作想要读取数据时即时解压

一个压缩的例子,用边与边的差压缩

lecture 22

针对大量数据,让我们设计一个runMapReduceJob的实现

步骤1:运行mapper函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// called once per line in file 
void mapper(string line, multimap<string,string>& results) {
string user_agent = parse_requester_user_agent(line);
if (is_mobile_client(user_agent))
results.add(user_agent, 1);
}
// called once per unique key in results
void reducer(string key, list<string> values, int& result) {
int sum = 0;
for (v in values)
sum += v;
result = sum;
}
LineByLineReader input(“hdfs://15418log.txt”);
Writer output(“hdfs://…”);
runMapReduceJob(mapper, reducer, input, output);

步骤1:在文件的所有行上运行mapper函数

  • 问题:如何将工作分配给节点?
  • 想法1:使用输入块列表的工作队列来处理动态分配:空闲节点获取下一个可用块
  • 想法2:基于数据分布的分配:每个节点处理本地存储的输入文件块中的行。

步骤2和3:收集数据,运行规约器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// called once per line in file 
void mapper(string line, map<string,string> results) {
string user_agent = parse_requester_user_agent(line);
if (is_mobile_client(user_agent))
results.add(user_agent, 1);
}
// called once per unique key in results
void reducer(string key, list<string> values, int& result) {
int sum = 0;
for (v in values)
sum += v;
result = sum;
}
LineByLineReader input(“hdfs://15418log.txt”);
Writer output(“hdfs://…”);
runMapReduceJob(mapper, reducer, input, output);

  • 步骤2:为减速器准备中间数据
  • 步骤3:在所有键上运行规约器功能
    • 问题:如何分配任务?
    • 问题:如何将密钥的所有数据获取到正确的工作节点上?

作业调度器职责

  • 利用数据局部性:“将计算移动到数据”
    • 在包含输入文件的节点上运行mapper作业
    • 在已经具有某个键的大部分数据的节点上运行reducer作业
  • 处理节点故障
    • 计划程序检测作业失败并在新计算机上重新运行作业
    • 这是可能的,因为输入驻留在持久存储(分布式文件系统)中
    • 调度器在多台计算机上复制作业(减少节点故障引起的总体处理延迟)
  • 处理速度慢的机器
    • 调度程序在多台计算机上复制作业

spark:内存中的容错分布式计算

  • 目标
    • 集群规模计算的编程模型,其中中间数据集的重用非常重要
    • 迭代机器学习与图算法
    • 交互式数据挖掘:将大型数据集加载到集群的聚合内存中,然后执行多个即时查询
    • 不希望导致将中间文件写入持久分布式文件系统的效率低下(希望将其保留在内存中)
  • 挑战:高效实现大规模分布式内存计算的容错。
    • 复制所有计算
      • 昂贵的解决方案:降低峰值吞吐量
    • 检查点和回滚
      • 定期将程序状态保存到永久性存储器
      • 从节点失败时的最后一个检查点重新启动
    • 维护更新日志(命令和数据)
      • 维护日志的高开销
    • map-reduce解决方案:
      • 通过将结果写入文件系统,在每个映射/减少步骤后设置检查点
      • 调度程序的未完成(但尚未完成)作业列表是一个日志
      • 程序的功能结构允许以单个映射器或reducer调用的粒度重新启动(不必重新启动整个程序)

弹性分布式数据集(RDD)是Spark的关键编程抽象:

  • 记录的只读集合(不可变)
  • RDD只能通过对持久存储或现有RDD中的数据进行确定性转换来创建
  • RDD上的操作将数据返回到应用程序

Spark样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// create RDD from file system data 
var lines = spark.textFile(“hdfs://15418log.txt”);
// create RDD using filter() transformation on lines
var mobileViews = lines.filter((x: String) => isMobileClient(x));
// instruct Spark runtime to try to keep mobileViews in memory
mobileViews.persist();
// create a new RDD by filtering mobileViews
// then count number of elements in new RDD via count() action
var numViews = mobileViews.filter(_.contains(“Safari”)).count();
// 1. create new RDD by filtering only Chrome views
// 2. for each element, split string and take timestamp of
// page view
// 3. convert RDD to a scalar sequence (collect() action)
var timestamps = mobileViews.filter(_.contains(“Chrome”))
.map(_.split(“ ”)(0))
.collect();

lecture 23

编写良好的程序利用局部性来避免CPU和内存之间的冗余数据传输(关键思想:将频繁访问的数据放在处理器附近的缓存/缓冲区中)

  • 现代处理器具有对本地内存的高带宽(低延迟)访问
    • 具有数据访问局部性的计算可以重用局部存储器中的数据
  • 软件优化技术:对计算进行重新排序,以便缓存数据在被逐出之前被多次访问
  • 有性能意识的程序员努力改进程序的缓存位置

示例1:为局部性重新构造循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Program1
void add(int n, float* A, float* B, float* C) {
for (int i=0; i<n; i++)
C[i] = A[i] + B[i];
}
void mul(int n, float* A, float* B, float* C) {
for (int i=0; i<n; i++)
C[i] = A[i] * B[i];
}
float* A, *B, *C, *D, *E, *tmp1, *tmp2;
// assume arrays are allocated here
// compute E = D + ((A + B) * C)
add(n, A, B, tmp1);
mul(n, tmp1, C, tmp2);
add(n, tmp2, D, E);

Program2
void fused(int n, float* A, float* B, float* C, float* D, float* E) {
for (int i=0; i<n; i++)
E[i] = D[i] + (A[i] + B[i]) * C[i];
}
// compute E = D + (A + B) * C
fused(n, A, B, C, D, E);

Program1两次load,一次运算,计算密集度是0.333;而Program2有4次load和3次运算,密集度为0.6。

下图中是内存系统示意图。

DRAM中每行有2K个bit,缓冲区有2K个bit。

当需要一个Byte时,首先找到这个byte所在的一行,预先充电激活这一行,将它复制到缓冲区,选出这一个byte所在的列,发送到总线。当继续需要这一行的其他byte时,可以从缓冲区中直接拿。

DRAM访问延迟不是固定的

  • 最佳情况延迟:从活动行读取
    • 列访问时间(CAS)
  • 最坏情况延迟:位线未就绪,从新行读取
    • 预充电(PRE)+行激活(RAS)+列访问(CAS)
    • 预充电准备位线并将行缓冲区内容写回DRAM阵列(读取是破坏性的)
  • 问题1:何时执行预充电?
    • 每列访问之后?
    • 仅当访问新行时?
  • 问题2:如何处理DRAM访问的延迟?

问题:由于访问延迟,只有在数据发送到总线时才用到引脚,引脚利用率低。可以通过将多个字节合并发送提高利用率

DRAM芯片由多个存储组组成

  • 所有存储组共享相同的PIN总线
  • 存储组允许内存请求的流水线
    • 预充电/激活行/向存储组发送列地址,同时从另一存储组传输数据
    • 实现高数据引脚利用率

将多个芯片组织到一个DIMM中

  • 示例:八个DRAM芯片(64位内存总线)
  • 注意:DIMM显示为内存控制器的单个、更大容量、更宽接口DRAM模块。更高的聚合带宽,但最小传输粒度现在是64位。

读取一条64字节(512位)cache line

  • 内存控制器将物理地址转换为DRAM组、行、列
  • 物理地址以字节粒度在DRAM芯片之间交错
  • DRAM芯片并行传输前64位

DRAM控制器从新列请求数据,DRAM芯片并行传输下一个64位

内存控制器是一个内存请求调度器

  • 从Last level cache(LLC)接收加载/存储请求
  • 冲突的调度目标
    • 最大化吞吐量,最小化延迟,最小化能耗
    • 通用调度策略:FR-FCFS(先准备,先到先服务)
    • 当前先打开行的服务请求(最大化行位置)
    • 以FIFO顺序向其他行发送服务请求
    • 控制器可以将多个小请求合并成大的连续请求(利用DRAM的“burst模式”)

双通道存储系统

  • 通过添加内存通道提高吞吐量(有效地拓宽总线)
  • 下面:每个通道可以发出独立的命令
    • 在每个通道中读取不同的行/列
    • 更简单的设置:使用单个控制器将同一命令驱动到多个通道

嵌入式DRAM(eDRAM):另一个层次的内存层次结构

  • Intel Broadwell/Skylake处理器的CPU包中包含128 MB的嵌入式DRAM(eDRAM)
  • 50 GB/s读取+50 GB/s写入

通过芯片堆叠增加带宽,降低功耗

  • 使能技术:DRAM芯片的3D堆叠
    • DRAM通过穿过芯片的硅通孔(TSV)连接
    • TSV在逻辑层和DRAM之间提供高度并行连接
    • 堆栈的底层“逻辑层”是内存控制器,管理来自处理器的请求
    • 硅“插入器”用作DRAM堆栈和处理器之间的高带宽互连

想法:在没有处理器的情况下执行复制,修改内存系统以支持加载、存储和大容量复制。

  1. 激活A行
  2. 传输行
  3. 激活B行
  4. 传输行

缓存压缩

  • 想法:通过压缩驻留在缓存中的数据提高缓存的有效容量
    • 想法:扩展计算(压缩/解压缩)以节省带宽
    • 缓存命中次数越多=传输次数越少
  • 必须使用硬件压缩/解压缩方案
    • 简单到可以在硬件中实现
    • 快一点:解压在负载的关键路径上
    • 无法显著增加缓存命中延迟

一个拟议的例子:B∆I压缩[Pekhimenko 12]

  • 观察:位于cache line的数据通常具有较低的动态范围(使用base+offset对一行中的位块进行编码)
  • 如何快速找到较好的base?
    • 使用第一行中的第一个字
    • 行的压缩/解压缩是数据并行的

一个0和一个包含八个1字节差异的数组。因此,整个cache line数据可以使用12个字节而不是32个字节来表示,从而节省了最初使用的20个字节的空间。

总结:内存墙正在以多种方式解决

  • 由应用程序程序员编写
    • 安排计算以最大化局部性(最小化所需的数据移动)
  • 通过新的硬件架构
    • 智能DRAM请求调度
    • 使数据更接近处理器(深度缓存层次结构,eDRAM)
    • 增加带宽(更宽的内存系统、3D内存堆叠)
    • 在内存中或内存附近定位有限形式计算的持续研究
    • 正在进行的硬件加速压缩研究
  • 一般原则
    • 在处理器附近定位数据存储器
    • 将计算转移到数据存储
    • 数据压缩(为减少数据传输而权衡额外计算)

lecture 24

几种块状稠密矩阵乘法

1
2
3
4
5
6
7
8
9
10
11
for (int j=0; j<BLOCKSIZE_J; j++) { 
for (int i=0; i<BLOCKSIZE_I; i+=SIMD_WIDTH) {
simd_vec C_accum = vec_load(&C[jblock+j][iblock+i]);
for (int k=0; k<BLOCKSIZE_K; k++) {
// C = A*B + C
simd_vec A_val = splat(&A[jblock+j][kblock+k]); // load a single element in vector register
simd_muladd(A_val, vec_load(&B[kblock+k][iblock+i]), C_accum);
}
vec_store(&C[jblock+j][iblock+i], C_accum);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
// assume blocks of A and C are pre-­‐transposed as Atrans 
for (int j=0; j<BLOCKSIZE_J; j+=SIMD_WIDTH) {
for (int i=0; i<BLOCKSIZE_I; i++) {
simd_vec C_accum = vec_load(&Ctrans[iblock+i][jblock+j]);
for (int k=0; k<BLOCKSIZE_K; k++) {
// C = A*B + C
simd_vec A_val =);
simd_muladd(vec_load(&Atrans[kblock+k][jblock+j], vec_load(&B[kblock+k][iblock+i]), C_accum);
}
vec_store(&Ctrans[iblock+i][jblock+j], C_accum);
}
}

1
2
3
4
5
6
7
8
9
for (int j=0; j<BLOCKSIZE_J; j++) 
for (int i=0; i<BLOCKSIZE_I; i++) {
float C_scalar = C[jblock+j][iblock+i];
for (int k=0; k<BLOCKSIZE_K; k+=SIMD_WIDTH) {
// C_scalar = dot(A,B) + C_scalar
C_scalar += simd_dot(vec_load(&A[jblock+j][kblock+k]), vec_load(&Btrans[iblock+i][[kblock+k]);
}
C[jblock+j][iblock+i] = C_scalar;
}

训练

  • 目标:学习网络参数的良好值,以便网络输出任何输入图像的正确分类结果
  • 想法:尽量减少所有示例的损失(已知正确答案)
  • 直觉:如果网络对各种培训示例的答案都是正确的,然后,希望它已经了解了参数值,这些参数值可以为将来提供正确的答案还有图像。

梯度下降

  • 假设您有一个包含隐藏参数p1和p2的函数f
  • 对于一些输入x,你的训练数据说函数应该输出0。
  • 但对于p1和p2的当前值,它当前输出10。
  • 假设我也给出了f的导数的表达式和P1和P2,这样你就可以计算它们在x的值。
  • 如何调整值p1和p2以减少此示例的错误?

基本梯度下降

1
2
3
4
while (loss too high): 
for each item x_i in training set:
grad += evaluate_loss_gradient(f, loss_func, params, x_i)
params += -­‐grad * step_size;

小批量随机梯度下降(Mini-batch SGD):选择训练示例的随机(小)子集,在while循环的每次迭代中计算梯度

集群规模计算的挑战

  • 节点间通信速度慢
    • 集群没有超级计算机典型的高性能互连
  • 具有不同性能的节点(即使计算机相同)
    • 屏障处的工作负载不平衡(节点之间的同步点)
  • 现代解决方案:利用异步执行的SGD特性!

设置参数服务器,有多个worker,将数据分块拷贝到worker,参数的拷贝复制到workers,worker自己计算自己数据集上的梯度,再把梯度合并到参数服务器上,params += -subgrad * step_size

摘要:异步参数更新

  • 想法:避免每次SGD迭代之间所有参数更新的全局同步
    • 设计反映了群集计算的现实:
    • 慢互连
    • 不可预测的机器性能
  • 解决方案:异步(和部分)次梯度更新
  • 将影响SGD的汇合
    • 在迭代i上工作的节点N可能没有导致i-1之前SGD迭代结果的参数值

切分参数服务器

  • 跨服务器的分区参数
  • Worker将子渐变块发送到所属参数服务器

Parallelizing mini-batch on one machine

1
2
3
for each item x_i in mini-­‐batch: 
grad += evaluate_loss_gradient(f, loss_func, params, x_i)
params += -­‐grad * step_size;

Asynchronous update on one node

1
2
3
for each item x_i in mini-­‐batch: 
grad += evaluate_loss_gradient(f, loss_func, params, x_i)
params += -­‐grad * step_size;

https://sites.google.com/lbl.gov/cs267-spr2020/

lecture 2

单处理器上大部分性能被浪费,主要是因为数据迁移时间太久。

编译器管理内存和寄存器,进行寄存器分配,决定什么时候load/store,什么时候重用。编译器通过图染色的方式决定寄存器重用。

编译器的优化:

  • 循环展开、合并、重排
  • 删除死代码
  • 指令重排来实现指令流水、提高寄存器重用
  • 代码强度降低,比如把乘法变成移位

多级cache:

- 片上cache更快,但是更小。
- 更大的cache有延迟,硬件需要更长的时间来检查地址,更多关联度也会延长时间
- 可以利用第四级cache作为被置换出的cache的cache

其他的cache包括:寄存器,TLB等。

处理内存延迟:

- 在小而快的内存中保存数据并重用
- 将一整块数据存入内存并使用
- 程序利用向量化实现一条指令进行多次读或写
- 程序预取或者延迟写

blocking或者tiling是提高cache性能的好办法,使用分治将问题分割到适合cache的大小。

SSE、SSE2:适合16bytes的类型,比如4个float、2个double或者16个byte。可以并行执行加、乘。但是需要对齐,连续。

矩阵是2维数组,在存储上需要根据cache的特性存储,Fortran是列优先,对cache不友好。采用分块的方法进行并行化,存在理论上的最优化分块方法。这种算法就需要在递归时适当地切割。

1
2
3
4
5
6
7
8
9
10
func C = RMM(A, B, n)
if n = 1
C = A * B
else {
C11 = RMM(A11, B11, n/2) + RMM(A12, B21, n/2);
C12 = RMM(A11, B12, n/2) + RMM(A12, B22, n/2);
C21 = RMM(A21, B11, n/2) + RMM(A22, B21, n/2);
C22 = RMM(A21, B12, n/2) + RMM(A22, B22, n/2);
}
return

递归的数据分布,可以提高数据局部性,最小化内存延迟,例如画Z字的方式,或者看“cache oblivious algorithm”。好处是可以在任何cache大小下实现较好的性能,不好的是计算index很困难。

删除假的依赖,比如使用局部变量,进行指令重排:

1
2
a[i] = b[i] + c;
a[i+1] = b[i+1] * d;

可能会有假的数据依赖,借助局部变量改成

1
2
3
4
5
float f1 = b[i];
float f2 = b[i+1];

a[i] = f1 + c;
a[i+1] = f2 * d;

或者对频繁使用的变量进行预先加载,使寄存器中保持有变量。

循环展开促进指令级并行,提高流水线性能,或者向量化。

再设计数据结构时注意cache局部性,比如多使用struct。

strassen’s矩阵乘法,将8次乘+4次加优化到7次乘+18次加,能做到O(n^2.81):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Let M = | m11 m12| = |a11 a12| |b11 b12|
| m21 m22| |a21 a22| |b21 b22|

Let p1 = (a12 - a22) * (b21 + b22)
p2 = (a11 + a22) * (b11 + b22)
p3 = (a11 - a21) * (b11 + b12)
p4 = (a11 + a12) * b22
p5 = a11 * (b12 - b22)
p6 = a22 * (b21 - b11)
p7 = (a21 + a22) * b11

Then m11 = p1 + p2 - p4 + p6
m12 = p4 + p5
m21 = p6 + p7
m22 = p2 - p3 + p5 - p7

其他一些矩阵矩阵乘算法:

  • 世界纪录是O(n^2.37548)
  • 2.37548 reduced to 2.37293
    • Virginia Vassilevska Williams, UC Berkeley & Stanford, 2011
  • 2.37293 reduced to 2.37286
    • Francois Le Gall, 2014
  • 大概能做到O(n^2+e)
    • Cohn, Umans, Kleinberg, 2003

CNN在计算什么?

1
2
3
for k=1:K, for h=1:H, for w=1:W, for r=1:R,
for s=1:S, for c=1:C, for b=1:B
Out(k, h, w, b) += Image(r+w, s+h, c, b) * Filter( k, r, s, c )

lecture 3

几种并行的模型

如何并行化下边的程序,每一个y依赖于前一个:

1
2
3
y[0] = 0;
for i = 1 : n
y[i] = y[i-1] + x[i];

这个图应该是以树形计算前缀和的示意图,最上边是原始数组,中间是逐次计算两两和,最下边是最终结果。

lecture 4

SIMD:数据并行的语言非常成功,不规则的数据(稀疏矩阵乘向量)比较适配,但是不规则的计算(分治、递归等)不太行。

共享内存多处理器时代,出现了一些共享内存模型,POSIX Threads、OpenMP等

集群时代,出现了MPI。

每个线程有一些私有变量,如本地栈。也有一些共享的变量,如通过malloc的变量,静态变量等。线程通过读写共享变量实现数据通信。

pthreads是POSIX线程接口,用来创建和同步线程,但是没有对通信的显示支持,因为共享内存是隐式的。pthread_join意思是等待,直到线程结束。创建线程的开销是不能被忽略的,因为涉及了系统调用。

1
2
3
4
5
6
7
8
9
10
11
int pthread_create(pthread_t*, const pthread_attr_t*, void *(*)(void*), void*);

int main() {
pthread_t threads[16];
int tn;
for (tn = 0; tn < 16; tn ++)
pthread_create(&threads[tn], NULL, function, NULL);
for (tn = 0; tn < 16; tn ++)
pthread_join(threads[tn], NULL);
return 0;
}

数据竞争即为各个线程竞争同一个变量。

mutexes是互斥锁。当线程需要访问一个变量时,需要加锁。信号量允许k个线程同时访问资源,适用于有限个资源的时候。

1
2
3
4
5
pthread_mutex_t amutex = PTHREAD_MUTEX_INITIALIZER;
// or pthread_mitex_init(&amutex, NULL);

int pthread_mutex_lock(amutex);
int pthread_mutex_unlock(amutex);

POSIX线程基于OS,可以被多种语言使用,但是创建线程的开销大,数据竞争的bug也有很多。

openmp有C/C++和Fortran的接口:

  • 预处理指令
  • 库函数
  • 环境变量

它是一个方便的线程级内存共享编程工具,允许把程序分成穿兴趣和并行区,而不是纯线程。也不需要进行栈的管理,提供了同步命令。

最经常用的OpenMP命令如下:

OpenMP的基本语法:

1
2
3
4
5
6
7
#pragma omp construct [clause [clause]...]

#pragma omp parallel private(x)
{
}

#include <omp.h>

OpenMP使用的是fork-join模型,主线程派生一系列线程,等待子线程结束后继续执行,遇到需要多线程的代码块时再fork线程。

1
2
3
4
5
6
7
double A[1000];
omp_set_num_threads(4);
#pragma omp parallel
{
int id = omp_get_thread_num();
pooh(id, A);
}

各个线程共享A数组,但是每个线程都有一个id变量。

可以通过omp_get_thread_num请求创建多少个线程,但不是你要创建多少线程就是多少线程,一旦请求了一个数量的线程数并创建,系统就不会减少这个数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static long num_steps = 100000;
double step;
int main()
{
double pi, sum = 0.0;
step = 1.0/(double)num_steps;

#pragma omp parallel
{
int i, id, nthreads;
double x;
id = omp_get_thread_num();
nthrds = omp_get_num_threads();
if (id == 0)
nthreads = nthrds;
for (i = id; i < num_steps; i += nthrds) {
x = (i+0.5) / step;
sum[id] += 4 / (1.0 + x*x);
}
}
for (i = 0; i < nthreads; i ++)
pi += step * sum[i];
}

每个处理器有自己的多级cache,多处理器系统中存在cache一致性问题。内存中的一个数字被放到处理器P1的cache中,又被放到P2的cache中,如果P1修改了数字,P2是不知道的,这样就不一致了。写回策略中,数据被写回是取决于什么时候,被哪个处理器写回的。

使用cache一致性协议来写回,但是在多处理器中并不具有可扩展性。

内存总线是一个广播的机制,cache中保存着它们存在哪个地址,cache控制器监控着总线上的所有cache事务,一个事务是一个相关的事务,如果涉及的cache行在本地处理器的cache中。

如果独立的数据元素碰巧位于同一个缓存行上,每次更新都会导致缓存行在线程之间“来回晃动”……这称为“假共享”。

如果将标量提升到数组以支持创建 SPMD 程序,则数组元素在内存中是连续的,因此共享缓存行导致可扩展性较差。解决方案:填充数组,以便您使用的元素位于不同的缓存行上。

同步用于施加顺序约束并保护对共享数据的访问,包括:

  • 互斥区
  • barrier

被互斥区包裹起来的部分每次只能有一个线程访问,barrier是直到所有线程都到这个barrier了才一起往下执行。

for循环可以被并行化,其中的i是默认私有的,各个线程等到for循环完成后再一起执行:

1
2
3
4
5
6
7
#pragma omp parallel
{
#pragma omp for
for (i = 0; i < N; i ++) {

}
}

schedule命令决定了任务怎么映射到每个进程。有static和dynamic两种,static是静态的,在编译时决定;dynamic会动态调度或者从任务队列中分配,在执行过程中决定。

reduction即规约,进行最大、最小、平均等操作。每一个列表中的变量会被复制到每个线程,并初始化,对局部变量进行操作,局部变量再规约到主线程。

1
#pragma omp parallel for reduction (+:ave)

因为barrier很耗时,而for循环结束之前都会有一个默认的barrier,所以可以在for循环之前使用nowait实现不barrier。

可以针对不同变量选择不同的共享策略,比如:shared、private(为变量创建一个本地拷贝,且不初始化,且原来的全局变量不变)、firstprivate。

1
2
3
4
5
#pragma omp parallel for private(tmp)
int tmp = 0;
for (int j = 0; j < 1000; j ++)
tmp += j;
printf("%d\n", tmp); // tmp为0

default(none)强制您为出现在范围内的变量定义存储属性……如果失败,编译器会报错。可以将default子句放在parallel 和parallel + workshare 结构上。j和y没有规定存储策略,编译器会报错。

1
2
3
4
#pragma omp parallel for default(none) reduction(*:x)
for (i = 0; i < N; i ++)
for (j = 0; j < 3; j ++)
x += foobar(i, j, y);

tasks是一系列独立的work,由执行代码和计算的数据组成,线程被分配执行每个task的工作。

下例中组成一个task的二叉树,直到一个task之前的所有task被完成,这个task才能被完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int fib(int n) {
int x, y;
if (n < 2) return n;

#pragma omp task shared(x);
x = fib(n-1);
#pragma omp task shared(y);
y = fib(n-2);
#pragma omp taskwait;
return x+y;
}

int main()
{
int NM = 5000;

#pragma omp parallel
{
#pragma omp single
fib(NM);
}
}

single表示这个代码块只能被一个线程执行,这个代码块之后有一个默认的barrier。

flush操作

  • 定义一个序列点,在该点线程强制执行一致的内存视图。
  • 对于其他线程可见并与刷新操作(flush-set)相关联的变量
    • 编译器不能在刷新周围进行刷新集的加载/存储:
    • 该线程之前对flush-set 的所有读/写都已完成
    • 没有发生此线程对刷新集的后续读/写
    • 刷新集中的变量从临时存储移动到共享内存。
    • 刷新后刷新集中变量的读取从共享内存加载

lecture 5

使用性能模型或工具,以预测架构的性能。需要假定运行时间与需要的运算、数据移动的次数相关,其中,数据移动的次数更相关,因为需要理解cache的行为。

串行机器在访问内存时会遇到延迟,而并行机器在同步、点对点通信、规约时会遇到延迟,所以可以使用算法的依赖链进行分类。

数据移动复杂性

  • 假设运行时间与访问(或移动)的数据量相关
  • 易于计算访问的数据量……计数数组访问
  • 移动的数据更加复杂,因为它需要了解缓存行为……

计算深度

  • 多核处理器甚至单核都依赖于并行性
  • 一些算法有一些固有的串行路径
  • 深度是最长的依赖链
  • 考虑在无限数量的处理器上运行而没有OMP、通信或循环开销

在分布式内存中,通过在处理器之间发送消息来进行通信。消息传递时间可能受到几个组件的限制……

  • 开销(发送/接收消息的 CPU 时间)
  • 延迟(时间消息在网络中;可以隐藏)
  • 消息吞吐量(发送小消息的速率……消息/秒)
  • 带宽(可以发送大消息的速率 GBytes/s)

我们算法的分布式内存版本可以根据 N 和 P(#processors)由这些部分不同地描述

可以用多种方法建模性能,前三个是roofline模型,中间两个是LogCAche模型,后三个是LogGPa模型:

  • 浮点数操作:Flop/s
  • cache数据操作:GB/s
  • DRAM数据操作:GB/s
  • 总线数据移动:PCIe带宽
  • Depth:OMP嵌套深度
  • MPI数据大小:带宽
  • MPI发送/等待比例:网络延迟

很多模型都追踪延迟来预测性能,延迟隐藏衍生出多种方法,例如乱序执行(硬件发现并行性),硬件预取(硬件自动家在数据),大规模线程并行(独立的线程提高并行)。

roofline模型是基于吞吐的模型,追踪比例而不是时间。

DRAM的roofline:人们可以希望永远达到峰值性能(FLOP/s),然而,有限的局部性(重用)和带宽限制了性能。我们假定:

  • 理想化的处理器/缓存
  • 冷启动(DRAM 中的数据)


Arithmetic Intensity (AI) = Flops / Bytes (as presented to DRAM )

Roofline 中最重要的概念是算术强度

  • 数据局部性的度量(数据重用)
  • 执行的总Flops与移动的总字节数的比率
  • 对于 DRAM Roofline…
    • 进出 DRAM 的总字节数,包括所有缓存和预取器效果
    • 可能与总加载/存储(请求的字节数)有很大不同
    • 等于持续 GFLOP/s 与持续 GB/s 的比率

使用算术强度作为 x 轴绘制roofline边界,对数刻度使画图变得容易,根据摩尔定律推断性能等……

一般的机器现在都是5-10flops每个字节,40-80flops每个double。对于以下计算:

1
2
3
#pragma omp parallel for
for (int i = 0; i < N; i ++)
z[i] = x[i] + alpha*y[i];

每次迭代2个flops,传送24个bytes(读x[i],y[i],写z[i])AI = 0.083。

每次循环7flops,每个点8次内存访问,除了一次读和1次写,cache都能搞定,AI=7/16=0.44

1
2
3
4
5
6
7
8
9
10
11
12
#pragma omp parallel for
for(k=1;k<dim+1;k++){
for(j=1;j<dim+1;j++){
for(i=1;i<dim+1;i++){
new[k][j][i] = -6.0*old[k ][j ][i ]
+ old[k ][j ][i-1]
+ old[k ][j ][i+1]
+ old[k ][j-1][i ]
+ old[k ][j+1][i ]
+ old[k-1][j ][i ]
+ old[k+1][j ][i ];
}}}

分层roofline

  • 处理器具有多级内存/缓存
    • 寄存器
    • L1、L2、L3 缓存
    • MCDRAM/HBM(KNL/GPU 设备内存)
    • DDR(主存储器)
    • NVRAM(非易失性存储器)
  • 应用程序在每个级别都有局部性
  • 独特的数据移动意味着独特的AI
  • 此外,每个级别都有独特的峰值和持续带宽

片内并行性:我们假设一个可以获得高flops的运算,也有高局部性。实际上,我们必须……

  • 向量化循环(每条指令 16 个触发器)
  • 使用特殊说明(例如 FMA)
  • 确保 FP 指令在指令组合中占主导地位
  • 使用所有内核和插槽

  • 大多数处理器利用某种形式的 SIMD 或向量。

    • KNL 使用 512b 向量 (8x64b)
    • GPU 使用 32 线程扭曲 (32x64b)
  • 实际上,应用程序是标量和向量指令的混合体。
    • 性能是 SIMD 和无 SIMD 之间的加权平均值
    • 有一个基于这个加权平均值的隐含上限

我们可以仅使用强制缓存未命中来绑定 AI

  • 但是,写分配缓存会降低 AI
  • 缓存容量未命中可能会造成巨大损失
  • 计算墙转换成内存墙

由于计算与内存交互影响,flops可能不能作为优化的依据,所以就可以把AI排序,将这些部分的性能与机器扩展性/容量进行比较。roofline附近的内核正在充分利用计算资源

  • 内核可能具有低性能 (GFLOP/s),但可以很好地利用机器
  • 内核可以具有高性能 (GFLOP/s),但不能很好地利用机器

AI(数据移动)因线程并发性和问题大小而异

  • 大问题(绿色和红色)每个线程移动更多数据,最终耗尽缓存容量
  • 由此导致的 AI 下降意味着它们迅速达到带宽上限并降级。
  • 较小的问题会减少 AI,但不要达到带宽上限

现在CPU采用多种方法来增加浮点效率。比如使用fused multiply add来使乘法和加法在同一个流水线中,使用向量指令。

三种方法来提高性能:增加片上性能(比如让编译器进行向量化),增加内存带宽(NUMA),最小化数据移动。

如何构建roofline?要创建 Roofline 模型,我们必须执行基准测试……

  • 持续的flops
    • 双精度/单精度/半精度
    • 有和没有 FMA(例如编译器标志)
    • 有和没有 SIMD(例如编译器标志)
  • 持续带宽
    • 在每个级别的内存/缓存之间进行测量
    • 迭代各种大小的工作集并识别平台
    • 识别带宽不对称(读:写比率)
  • 基准测试必须运行足够长的时间以观察

衡量应用程序 AI 和性能

  • 要使用 Roofline 来描述执行的特征,我们需要……
    • 时间
    • flops(=> flops/时间)
    • 每级内存之间的数据移动(=> FLOPs / GB’s)
  • 我们可以查看完整的应用程序……
    • 粗粒度,平均 30 分钟
    • 遗漏了很多细节和瓶颈
  • 或者我们可以查看单个循环嵌套……
    • 需要逐个循环地进行自动检测
    • 此外,我们可能应该逐核区分数据移动或flops

我们如何计算 FLOP?

  • 手动计数
    • 遍历每个循环嵌套并计算 FP 操作的数量
    • 最适合确定性循环边界
    • 或按迭代次数参数化(运行时记录)
    • 不可扩展
  • 性能计数器
    • 之前/之后读取计数器
    • 更准确
    • 低开销 (<%) == 可以运行完整的 MPI 应用程序
    • 可以检测负载不平衡
    • 需要特权访问
    • 需要手动检测(+开销)或完整的应用程序表征
    • 差的counter得到的数据=垃圾
    • 可能无法区分 FMADD 和 FADD
    • 没有深入了解特殊流水线
  • 二进制指令测试器
    • 在运行时自动检查装配
    • 最准确
    • 可以按类别/类型统计指令
    • 可以检测负载不平衡
    • 可以包括来自非 FP 指令的效果
    • 自动应用于多个循环嵌套
    • 大于10 倍的开销

我们如何衡量数据移动?

  • 手动计数
    • 遍历每个循环嵌套并估计将移动多少字节
    • 使用缓存的健壮模型
    • 最适合从 DRAM 流的简单循环
    • 对于复杂的缓存不适用
    • 不可扩展
  • 性能计数器
    • 之前/之后读取计数器
    • 适用于全层级(L2、DRAM)
    • 更准确
    • 低开销 (<%) == 可以运行完整的 MPI 应用程序
    • 可以检测负载不平衡
    • 需要特权访问
    • 需要手动检测(+开销)或完整的应用程序表征
  • 缓存模拟
    • 构建一个由内存地址驱动的全缓存模拟器
    • 适用于全层级和多核
    • 可以检测负载不平衡
    • 自动应用于多个循环嵌套
    • 忽略了预取器
    • 大于 10 倍的开销

工具:Intel Advisor

lecture 6

并行化和数据局部性都对性能很重要,因为数据迁移是很费劲的。很多对象是独立于其他对象来操作的,更多的是依赖于与之相邻的对象,其依赖关系很简单。

同时讲了一些计算域剖分的问题,减少通信的同时实现局部性。

并行图计算:如果两个进程的图相连,则需要交互,他的数据结构与一般的图算法不一样,将图剖分到各个节点上,平均分布实现均衡,同时最小化节点之间边的关系减少通信。

粒子系统模拟需要实现每个粒子受到的作用和对其他粒子的作用,这就需要通信,通常是对整个区域进行剖分,区域之间进行halo交换。第一个挑战就是在处理区域边界上的粒子碰撞,第二个挑战是如果粒子成簇状分布的话会不均衡,为了减少不均衡,对空间也采取不均衡划分,采用k-d树的形式对粒子进行划分。

远域强迫指的是每个进程都跟其他进程的粒子进行作用,采用循环的方式进行数据通信,最中每个进程得到所有其他进程的数据。如果使用树形剖分的话,将每一簇粒子看成一个整体,降低复杂度。

假定ODE是x(t) = f(x) = A * x(t),A是一个稀疏矩阵,显式方法可以转化成一个近似的稀疏矩阵乘,隐式方法可以转化成一个线性方程。另外还有直接方法,做LU分解,迭代方法(Jacobi、Successive over-relaxation、Conjugate Gradient)等。

稀疏矩阵向量乘:稀疏矩阵可以只保存非0元,CSR方法是最简单的方法,对稀疏矩阵m*n,一个指针数组有m个元素,每个元素代表着第i行所有元素的起始位置。val数组和ind数组保存着实际的元素。

1
2
3
for each row i
for k = ptr[i] to ptr[i+1]-1 do
y[i] = y[i] + val[k] * x[ind[k]];

如果要并行化,需要知道,哪个进程拥有y[i]x[i]A[i,j],和哪个进程应该计算最终的y[i] = sum(from 1 to n) A[i,j] * x[j]

首先将下标1到n进行剖分,对于进程k,它存储了每一个下标i的y[i]x[i]A[i,...],同时计算了y[i] = (row i of A) * x,这里需要通信了。

能否将矩阵重排,使所有非零元素都在对角块上?这样所有运算都可以在本地完成。重排的目标

  • 平衡负载(如何测量负载?)。
    • 大约相等数量的非零值(不一定是行)
  • 平衡存储(每个处理器存储多少?)。
    • 大约相等数量的非零值
  • 尽量减少通信
    • 最小化对角块外的非零值
    • 相关优化标准是移动对角线附近的非零值
  • 改进寄存器和缓存重用
    • 在小的垂直块中将非零值分组以便源 (x) 元素加载到缓存或寄存器中可以重用(时间局部性)
    • 在小的水平块中分组非零值,以便靠近源 (x)中的元素可以命中cache(空间局部性)
  • 其他算法出于其他原因对行/列重新排序
    • 在高斯消元后减少矩阵中的非零值
    • 提高数值稳定性

图的并行化和矩阵并行化类似。

自适应网格加密:并行化基于patches。

非结构网格的挑战:

  • 如何首先生成它们
    • 从对象的几何描述开始
    • 三角化
    • 3D 更难!
  • 如何划分它们
    • ParMetis,一个并行图分区器
  • 如何设计迭代求解器
    • PETSc,一种用于科学计算的便携式可扩展工具包
    • Prometheus,一个用于有限元问题的多重网格求解器
  • 如何设计直接求解器
    • SuperLU,并行稀疏高斯消除

lecture 7

CPU中有取指、解码、ALU、运行上下文、乱序控制逻辑、指令预测、数据预取、cache等模块。第一个优化的方法是取消让单指令流跑得更快的部分,只剩下取指、解码、ALU运行上下文模块,多个线程共享指令流。第二个方法,让多个ALU共享取指令部分,共享部分运行上下文。

如果遇到了分支,那么不是所有的ALU都运行相同的分支,会遇到暂停,通过切换来隐藏延迟。

  • 使用许多精简的内核并行运行;
  • 核心打包大量 ALU;
  • 通过交错执行来避免延迟停滞;
    • 当一组停滞时,切换到工作准备就绪的另一组

SIMD单指令流多数据流架构充分使用了数据并行,并行暴露于用户和编译器。SIMD的发展如下:
MMS(8*8 bit int) —-> SSE(4*32 bit FP) —-> SSE2(2*64 bit FP) —-> SSE3(hroizontal ops) —-> SSSE3 —-> SSE4.1 —-> SSE4.2 —-> AVX(8*32 bit FP) —-> AVX+FMA(3 operand) —-> AVX2(256 bit int ops) —-> AVX-512(512 bit)

GPU使用的是单指令流多线程(SIMT),每个线程有自己的寄存器组,且可能执行不同的流程,单指令流可以在多个地址上执行。

  • 低占用率会大大降低性能。
  • 控制流分散会大大降低性能。
  • 同步选项非常有限

CUDA是用于SIMT的模型,具有可扩展性。

block有id,每个块内共享内存,可以是1、2、3维;block内有thread,thread有自己的寄存器和私有内存,可以是1、2、3维。2/3维只是组织方式,通过一维手段同样可以达到2/3维的效果。

CUDA的线程是独立运行的,有他自己的程序计数器(PC)、变量寄存器、处理器状态位等,不会内定如何调度。线程可能会被映射到GPU上,成为物理线程;也可能在多核CPU下,1 block=1个物理线程,成为虚拟线程。

CUDA支持:

  • 线程并行
    • 每线程都是独立运行的
  • 数据并行
  • 任务并行
    • 不同block是独立的
    • 独立的核运行不同的流

线程被分到不同的block中,同一个block中的线程可以合作,不同block的线程不能合作。不同的block之间可以协调但是不能同步,容易死锁;可以进行多种交互。

  1. 在GPU上为数据分配空间
  2. 创建CPU上的数据
  3. 数据复制到GPU上
  4. 调用kernel程序来运行GPU
  5. 结果数据从GPU拷贝到CPU
  6. 释放GPU的空间
  7. 释放CPU的空间

三种不同的函数:

  • __device__ float DFunc()运行在Device上,只能从Device上调用
  • __global__ void kernel()运行在Device上,只能从host上调用,只能返回void
  • __host__ float HFunc()运行在host上,只能从host上调用

简单的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// CUDA Kernel function to add the elements
// of two arrays on the GPU
__global__
void add(float *a, float *b, float *c)
{
int i = blockId.x * blockDim.x + threadId.x;
c[i] = a[i] + b[i];
}

int main()
{
// Run N/256 blocks of 256 threads each
vecAdd<<<N/256, 256>>>(d_a, d_b, d_c);
}

数据管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define N 256*1024

int main()
{
float *h_a = malloc(sizeof(float) * N);
float *h_b = malloc(sizeof(float) * N);
float *h_c = malloc(sizeof(float) * N);

float *d_a, *d_b, *d_c;
cudaMalloc(&d_a, sizeof(float) * N);
cudaMalloc(&d_b, sizeof(float) * N);
cudaMalloc(&d_c, sizeof(float) * N);

cudaMemcpy(d_a, h_a, sizeof(float) * N);
cudaMemcpy(d_b, h_b, sizeof(float) * N);

vecAdd<<<cell(N/256), 256>>>(d_a, d_b, d_c);
cudaMemcpy(h_c, d_c, sizeof(float) * N, cudaMemcpyDeviceToHost);
}

CUDA程序一般都要求具有大量并行性,同时局部性也很重要,因为GPU没有能够隐藏延迟的硬件。

每个线程和block都有自己专属的私有内存,而各个kernel之间有共享的全局内存。

同步:

  • 一个block中的线程可能会互相同步
  • block可以通过原子内存操作来协调运行
    • 例如通过一个共享的自增队列指针
  • 互相依赖的kernel可能有隐式的barrier

访问同一个内存地址会造成冲突,变成顺序的访问。连续的32位字被分配给连续的地址。对全局内存也会有这种问题,从Fermi架构开始,全局内存地址会被hash,从而全局地址冲突不会再发生。

每一个load指令都会带来一系列对齐且连续的内存,称为页。硬件自动将从一个warp的不同线程发出的请求合并到同一个page。

以下代码启动256个线程计算数组和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <math.h>
// GPU function to add the elements of two arrays
__global__
void add(int n, float *x, float *y)
{
int index = threadIdx.x;
int stride = blockDim.x;
for (int i = index; i < n; i += stride)
y[i] = x[i] + y[i];
}
int main(void)
{
int N = 1<<20; // 1M elements
float *x, *y;
cudaMallocManaged(&x, N*sizeof(float));
cudaMallocManaged(&y, N*sizeof(float));
// initialize x and y arrays on the host
for (int i = 0; i < N; i++) {
x[i] = 1.0f;
y[i] = 2.0f;
}
// Run kernel on 1M elements on the GPU
add<<<1, 256>>>(N, x, y);
cudaDeviceSynchronize();
// … for space, remove error checking/free
return 0;
}

如果想用更多的线程的话:

1
2
3
4
5
6
7
8
__global__
void add(int n, float *x, float *y)
{
int index = blockIdx.x * blockDim.x + threadIdx.x;
int stride = blockDim.x * gridDim.x;
for (int i = index; i < n; i += stride)
y[i] = x[i] + y[i];
}

更多指的是numBlocks * blockSize

1
2
3
int blockSize = 256;
int numBlocks = (N + blockSize - 1) / blockSize;
add<<<numBlocks, blockSize>>>(N, x, y);

lecture 9

互联网络的特性:

  • 直径:给定一对节点之间最短路径的最大值(在所有节点对上)。
  • 延迟:多久能到达一个节点,即发送和接收时间之间的延迟
    • 不同体系结构的延迟往往差异很大
    • 供应商经常报告硬件延迟(连线时间)
    • 应用程序程序员关心软件延迟(用户程序到用户程序)
    • 观察结果:
      • 网络设计的延迟相差1-2个数量级
      • 源/目标成本下的软件/硬件开销占主导地位(1s-10s usecs)
      • 硬件延迟随距离变化(每跳10s-100s纳秒),但与开销相比较小
      • 延迟是包含许多小消息的程序的关键
  • 带宽:单位时间内能传输多少数据
    • 对大消息的传输很重要
  • 对分带宽:将网络分成相同两部分的最小切割上的带宽
    • 对所有进程都需要和其他进程通信的算法很重要

设计网络的参数:

  • 拓扑结构
    • crossbar、ring、2-D、3-D、超立方、树形、
    • butterfly
      • 真正的超立方体展开版本。
      • d维蝶形具有(d+1)2d“交换节点”(不要与处理器混淆,即n=2d)
      • 发明蝴蝶是因为超立方体需要随着网络变大而增加交换机基数;当时禁止
      • 直径=log n。等分带宽=n
      • 无路径多样性:对抗性流量不好
    • 参见高等计算机体系结构课程
  • 路由算法
    • all east-west then all north-south
  • 发送策略
    • circuit:对整个信息使用全部链路
    • packet:信息拆分成单独的消息发送
  • 流量控制
    • 消息暂时存储在buffer中、数据重新路由等

dragonflies:

  • 利用光互连(在机房机柜之间)和电气网络(机柜内部)之间的成本和性能差距
    • 光纤(光纤)更昂贵,但较长时带宽更高
    • 电力(铜)网络更便宜,短路时更快
  • 在层次结构中组合:
    • 使用全对全链路将多个组连接在一起,即每个组至少有一个直接连接到其他组的链路。
    • 每个组内的拓扑可以是任何拓扑。
  • 使用随机路由算法
  • 结果:程序员可以(通常)忽略拓扑,获得良好的性能
  • 在虚拟化动态环境中非常重要
  • 缺点:性能可变

在负载平衡的情况下,最小路由工作得很好,在大量的流量模式中可能会造成灾难性的后果。

随机化思想:对于路由器Rs上的每个数据包,并发送至另一组Rd中的路由器,首先将其路由到中间组。

发送消息的时间大概是:T = latency+n*cost_per_word = latency + n / bandwidth,也叫做Time = α + n * β。通常α远大于β。一个长消息比多个短消息更划算,同时需要较大的计算-通信比。

MPI:进程可以被分组,每个消息必须以一个上下文发送/接收。一个分组+上下文共同组成一个通信域。

MPI消息数据可以用一个(地址,数量,类型)三元组描述,有如下类型:

  • 预先定义的语言相关类型
  • 某类型的连续数组
  • 一个数据块
  • 一些块数据
  • 随机类型的数据

MPI消息发送时会跟上一个用户定义的tag,来协助识别消息。

只有mpi_send时等待完成,或者mpi_send后返回,recv时在某个时间段等待,才能避免创建buffer。

MPI非阻塞操作返回request

1
2
3
4
5
6
MPI_Request request;
MPI_Status status;

MPI_Isend(start, count, datatype, dest, tag, comm, &request);
MPI_Irecv(start, count, datatype, dest, tag, comm, &request);
MPI_Wait(&request, &status);

可以通过测试来等待:

1
MPI_Test(&request, &flag, &status);

在未完成通信时访问缓冲区是未定义的

可以同时等待多个:

1
2
3
MPI_Waitall(count, array_of_requests,array_of_statuses)
MPI_Waitany(count, array_of_requests, &index, &status)
MPI_Waitsome(count, array_of_requests, array_of indices, array_of_statuses)

MPI提供多种发送消息的模式:

  • 同步模式(MPI_Ssend):在匹配的接收开始之前,发送不会完成。(不安全程序死锁。)
  • 缓冲模式(MPI_Bsend):用户向系统提供一个缓冲区供其使用。用户分配足够的内存使不安全的程序安全。
  • 就绪模式(MPI_Rsend):用户保证已发布匹配的接收。
    • 允许访问快速协议
    • 如果匹配接收未发布,则未定义行为

集合操作:包括broadcast、gather/scatter,allgather、alltoall、reduce、scan。

MXX是一个MPI的库,基于MPI在交换不规则数据的时候以下的几种复杂操作:

  • 交换不规则数据时麻烦
    • 交换数量
    • 拷贝数据
    • 分配空间
    • 交换实际数据
  • 创建派生的非PDO类型
  • 将用户定义函数映射给MPI

而MXX只要如下:

1
2
3
4
5
// lets take some pairs and find the one with the max second element
std::pair<int, double> v = ...;
std::pair<int, double> min_pair = mxx::allreduce(v, [](const std::pair<int, double>& x, const std::pair<int, double>& y) {
return x.second > y.second ? x : y;
});

SUMMA:可扩展矩阵乘法

C(I, J) = C(I, J) + ∑k(A(I, k) * B(k, J)),其中I,J代表一个进程所有的行列,k是单独的一行或列,或者一块。

对于每个k(0和n-1之间),

  • 部分K行的所有者沿其进程列广播该行
  • 部分K列的所有者沿其进程行广播该列

完整算法:

1
2
3
4
5
在每个进程P(i, j):
对于k=0…n-1
在第i行中广播A(A_i)的第k列
在第j列中广播B(B_j)的第k行
C += 外积(a_i,b_j)

如果是P^(1/2)*P^(1/2)剖分:

1
2
3
4
5
6
7
8
For k=0 to n/b-1
for all i = 1 to P^(1/2)
owner of A[i,k] broadcasts it to whole processor row (using binary tree)
for all j = 1 to P^(1/2)
owner of B[k,j] broadcasts it to whole processor column (using bin. tree)
Receive A[i,k] into Acol
Receive B[k,j] into Brow
C_myproc = C_myproc + Acol * Brow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void SUMMA(double *mA, double *mB, double *mc, int p_c)
{
int row_color = rank / p_c; // p_c = sqrt(p) for simplicity
MPI_Comm row_comm;
MPI_Comm_split(MPI_COMM_WORLD, row_color, rank, &row_comm);
int col_color = rank % p_c;
MPI_Comm col_comm;
MPI_Comm_split(MPI_COMM_WORLD, col_color, rank, &col_comm);
for (int k = 0; k < p_c; ++k) {
if (col_color == k) memcpy(Atemp, mA, size);
if (row_color == k) memcpy(Btemp, mB, size);
MPI_Bcast(Atemp, size, MPI_DOUBLE, k, row_comm);
MPI_Bcast(Btemp, size, MPI_DOUBLE, k, col_comm);
SimpleDGEMM(Atemp, Btemp, mc, N/p, N/p, N/p);
}
}

int MPI_Comm_split(MPI_Comm Comm, int color, int key, MPI_Comm* newcomm)中MPI的内部算法:

  1. 使用MPI_Allgather从每个进程获取颜色和键
  2. 统计相同颜色的进程数;创建一个具有这么多进程的通信器。如果此进程将MPI_UNDEFINED为颜色,请创建一个具有单个成员的进程。
  3. 使用键对列组进行排序
  • 颜色:控制newcomm的分配
  • 键:控制newcomm内的rank分配

MPI内建的集合操作:

  • MPI_MAX:Maximum
  • MPI_MIN:Minimum
  • MPI_PROD:Product
  • MPI_SUM:Sum
  • MPI_LAND:Logical and
  • MPI_LOR:Logical or
  • MPI_LXOR:Logical exclusive or
  • MPI_BAND:Binary and
  • MPI_BOR:Binary or
  • MPI_BXOR:Binary exclusive or
  • MPI_MAXLOC:Maximum and location
  • MPI_MINLOC:Minimum and location

集合操作实现的示例:MPI_AllReduce

  1. 所有进程必须接收相同的结果向量;
  2. 必须按照规范顺序m0 + m1 + … + mp-1进行归约(如果操作不是可交换的);
  3. 对于结果向量的所有元素,不严格要求使用相同的规约顺序和括号,但应努力做到这一点。

复杂度下界:

MPI_AllGather有几种实现:

  1. 环算法:在0时刻,发送你自己的数据;在t时刻,把你在t-1时刻收到的数据发给你右边,从你左边接收新的数据;利用了带宽,但是有很高的延迟。在数据很大的时候使用,反而比下边的算法快很多,减少数据拷贝,降低通信次数,且只跟邻居通信。
  2. 递归算法:在t时刻,进程i与进程i+2^t交换现有的所有数据,形成通信树结构。
  3. bruck算法;在t时刻,进程i从i+2^t接受所有的数据,发送它自己所有的数据给i+2^t。该过程在lg(p)次后结束,在最后一次,仅收发最上边(p-2^(lg(p)))个数据。同时需要租后进行顺序调整。


SUMMA in MPI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void SUMMA(double *mA, double *mB, double *mc, int p_c)
{
int row_color = rank / p_c; // p_c = sqrt(p) for simplicity
MPI_Comm row_comm;
MPI_Comm_split(MPI_COMM_WORLD, row_color, rank, &row_comm);
int col_color = rank % p_c;
MPI_Comm col_comm;
MPI_Comm_split(MPI_COMM_WORLD, col_color, rank, &col_comm);
double *mA1, *mA2, *mB1, *mB2;
colsplit(mA, mA1, mA2); // split mA by the middle column
rowsplit(mB, mB1, mB2); // split mA by the middle row
if (col_color == 0) memcpy(Atemp1, mA1, size)
if (row_color == 0) memcpy(Btemp1, mB1, size);
MPI_Request reqs1[2];
MPI_Request reqs2[2];
MPI_Ibcast(Atemp1, size, MPI_DOUBLE, k, row_comm, &reqs1[0]);
MPI_Ibcast(Btemp1, size, MPI_DOUBLE, k, col_comm, &reqs1[1]);
for (int k = 0; k < p_c-1; ++ k) {
if (col_color == k) memcpy(Atemp2, mA2, size);
if (row_color == k) memcpy(Btemp2, mB2, size);
MPI_Ibcast(Atemp2,size,MPI_DOUBLE,k,row_comm,&reqs2[0]);
MPI_Ibcast(Btemp2,size,MPI_DOUBLE,k,col_comm,&reqs2[1]);
MPI_Waitall(reqs1, MPI_STATUS_IGNORE);
SimpleDGEMM (Atemp1, Btemp1, mC, N/p, N/p, N/p);
if (col_color == k) memcpy(Atemp1, mA1, size);
if (row_color == k) memcpy(Btemp1, mB1, size);
MPI_Ibcast(Atemp1,size,MPI_DOUBLE,k,row_comm,&reqs1[0]);
MPI_Ibcast(Btemp1,size,MPI_DOUBLE,k,col_comm,&reqs1[1]);
MPI_Waitall(reqs2, MPI_STATUS_IGNORE);
SimpleDGEMM (Atemp2, Btemp2, mC, N/p, N/p, N/p);
}

if (col_color == p-1) memcpy(Atemp2, mA2, size);
if (row_color == p-1) memcpy(Btemp2, mB2, size);
MPI_Ibcast(Atemp2,size,MPI_DOUBLE,k,row_comm,&reqs2[0]);
MPI_Ibcast(Btemp2,size,MPI_DOUBLE,k,col_comm,&reqs2[1]);
MPI_Waitall(reqs1, MPI_STATUS_IGNORE);
SimpleDGEMM (Atemp1, Btemp1, mC, N/p, N/p, N/p);
MPI_Waitall(reqs2, MPI_STATUS_IGNORE);
SimpleDGEMM (Atemp2, Btemp2, mC, N/p, N/p, N/p);
}

MPI描述进程之间的并行性(使用单独的地址空间)Thread并行性在进程内提供共享内存模型

  • OpenMP和pthread是常见的模型
  • OpenMP为循环级并行提供了方便的功能。线程由编译器根据用户指令创建和管理。
  • pthread提供了更复杂、更动态的方法。Thread由用户显式创建和管理。

在仅MPI编程中,每个MPI进程都有一个程序计数器。在MPI+线程混合编程中,可以同时执行多个线程。所有线程共享所有MPI对象(通讯器、请求)。MPI实施可能需要采取措施确保MPI堆栈的状态一致。

MPI的四个线程安全级别:MPI定义了四个线程安全级别

  • MPI_THREAD_SINGLE:应用程序中只存在一个线程
  • MPI_THREAD_FUNNELED:多线程,但只有主线程进行MPI调用(调用MPI_Init_thread的调用)
  • MPI_THREAD_SERIALIZED:多线程,但一次只能有一个线程进行MPI调用
  • MPI_THREAD_MULTIPLE:多线程,任何线程都可以在任何时候(有一些限制以避免竞争)

MPI定义了MPI_Init的替代方案:MPI_Init_thread(requested, provided)

- 应用程序给出了它所需要的级别;MPI实现提供了它所支持的级别

MPI_THREAD_SINGLE时没有线程

1
2
3
4
5
6
7
8
9
10
int main(int argc, char ** argv)
{
int buf[100];
MPI_Init(&argc, &argv);
for (i = 0; i < 100; i++)
compute(buf[i]);
/* Do MPI stuff */
MPI_Finalize();
return 0;
}

MPI_THREAD_FUNNELED时所有MPI调用都是主线程在调用,在OpenMP并行区域外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, char ** argv)
{
int buf[100], provided;
MPI_Init_thread(&argc, &argv, MPI_THREAD_FUNNELED, &provided);
if (provided < MPI_THREAD_FUNNELED)
MPI_Abort(MPI_COMM_WORLD, 1);

#pragma omp parallel for
for (i = 0; i < 100; i++)
compute(buf[i]);
/* Do MPI stuff */
MPI_Finalize();
return 0;
}

MPI_THREAD_SERIALIZED一次只能有一个线程调用MPI函数,这被critical regions保证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int argc, char ** argv)
{
int buf[100], provided;
MPI_Init_thread(&argc, &argv, MPI_THREAD_SERIALIZED, &provided);
if (provided < MPI_THREAD_SERIALIZED)
MPI_Abort(MPI_COMM_WORLD, 1);
#pragma omp parallel for
for (i = 0; i < 100; i++) {
compute(buf[i]);
#pragma omp critical
/* Do MPI stuff */
}
MPI_Finalize();
return 0;
}

MPI_THREAD_MULTIPLE任何线程都可以随时进行MPI调用(不受限制)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, char ** argv)
{
int buf[100], provided;
MPI_Init_thread(&argc, &argv, MPI_THREAD_MULTIPLE, &provided);
if (provided < MPI_THREAD_MULTIPLE)
MPI_Abort(MPI_COMM_WORLD, 1);
#pragma omp parallel for
for (i = 0; i < 100; i++) {
compute(buf[i]);
/* Do MPI stuff */
}
MPI_Finalize();
return 0;
}

实现不需要支持高于MPI_THREAD_SINGLE的级别;也就是说,实现不需要是线程安全的。

调用MPI_Init(而不是MPI_Init_thread)的程序应假定只支持MPI_THREAD_SINGLE。不调用MPI_Init_thread的线程化MPI程序是不正确的程序。

MPI_THREAD_MULTIPLE的约定

  • 排序:当多个线程同时进行MPI调用时,结果将是,调用在某些情况下按某些顺序执行
  • 在每个线程内维护顺序
  • 用户必须确保在同一个comm上进行集体操作,窗口或文件句柄在线程之间的顺序正确
    • 例如,不能在一个线程上调用广播,在另一个线程上调用reduce
  • 当线程处于同一位置时,用户有责任防止冲突MPI调用
    • 例如,从一个线程访问信息对象并将其从另一线程释放。
  • 阻塞:阻塞MPI调用将只阻塞调用线程,不会阻止其他线程运行或执行MPI

一个正确的例子:

1
2
3
              Proc 0            Proc 1
Thread1 MPI_Recv(src=1) MPI_Recv(src=0)
Thread2 MPI_Send(dst=1) MPI_Send(dst=0)

一个不正确的例子:

1
2
3
              Proc 0            Proc 1
Thread1 MPI_Bcast(comm) MPI_Bcast(comm)
Thread2 MPI_Barrier(comm) MPI_Barrier(comm)

P0和P1可以有不同的Bcast和Barrier顺序

在这里,用户必须使用某种类型的同步,以确保线程1或线程2在两个进程上都首先得到调度,否则Bcast可能与同一comm上的Barrier匹配,这在MPI中是不允许的。

单边通信模型的基本思想是将数据移动与进程同步解耦

  • 应能够在不要求远程进程同步的情况下移动数据
  • 每个进程向其他进程公开其内存的一部分
  • 其他进程可以直接读取或写入该内存

  • 创建公共内存

    • 默认情况下,进程使用的任何内存都是只有本地可访问
    • 分配内存后,用户必须进行显式MPI调用,以将内存区域声明为可远程访问
      • 远程可访问内存的MPI术语是一个“窗口”
      • 一组进程共同创建一个“窗口”
    • 一旦内存区域被声明为可远程访问,窗口中的所有进程都可以向该内存读/写数据,而无需与目标进程显式同步
  • 窗口创建存在四种模式

    • MPI_WIN_CREATE:您已经有一个分配的缓冲区,您希望远程访问该缓冲区
    • MPI_WIN_ALLOCATE:您希望创建一个缓冲区并直接使其可远程访问
    • MPI_WIN_CREATE_DYNAMIC:您还没有缓冲区,但将来会有缓冲区,且您可能希望在窗口中动态添加/删除缓冲区
    • MPI_WIN_ALLOCATE_SHARED:您希望同一节点上的多个进程共享一个缓冲区

MPI_WIN_ALLOCATE:在RMA窗口中创建一个远端可访问的内存区域

1
2
3
int MPI_Win_allocate(MPI_Aint size, int disp_unit,
MPI_Info info, MPI_Comm comm, void *baseptr,
MPI_Win *win)

1
2
3
4
5
6
7
8
9
10
11
int main(int argc, char ** argv)
{
int *a; MPI_Win win;
MPI_Init(&argc, &argv);
/* collectively create remote accessible memory in a window */
MPI_Win_allocate(1000*sizeof(int), sizeof(int), MPI_INFO_NULL, MPI_COMM_WORLD, &a, &win);
/* Array ‘a’ is now accessible from all processes in MPI_COMM_WORLD */
MPI_Win_free(&win);
MPI_Finalize();
return 0;
}

MPI提供了在远程可访问内存区域中读取、写入和原子修改数据的能力:

  • MPI_PUT
  • MPI_GET
  • MPI_ACCUMULATE
  • MPI_GET_ACCUMULATE
  • MPI_COMPARE_AND_SWAP
  • MPI_FETCH_AND_OP

RMA同步模型

  • RMA数据访问模型
    • 何时允许进程读取/写入远程可访问内存?
    • 进程X写入的数据何时可供进程Y读取?
    • RMA同步模型定义了这些语义
  • MPI提供的三种同步模型:
    • Fence(主动目标)
    • 启动后完全等待(通用活动目标)
    • 锁定/解锁(被动目标)
  • 数据访问发生在“epochs”内
    • 访问时间:包含一组由源进程发出的操作
    • 曝光时间:允许远程进程更新目标窗口
    • epochs定义了顺序和完成语义
    • 同步模型提供了建立epochs的机制

被动目标同步

  • 开始/结束被动模式
    • 目标进程不进行相应的MPI调用
    • 可以启动多个被动目标事件到不同的进程
    • 不允许同一进程的并发(影响线程)
    • 共享:其他使用共享的进程可以同时访问
    • 独占:没有其他进程可以同时访问

共享内存和消息传递各有优缺点,共享内存更容易并行,容易竞争,且更容易陷入假共享之类的;消息传递需要做更多的工作,但是不容易死锁,具有很高的扩展性。

全局地址空间中,线程可以直接读写远端数据,给通信实现提供了方便。因此需要一种方式来命名全局空间。(以下使用UPC方式)

1
2
shared int *p = upc_malloc(4);
shared int a[12];

如果需要单边通信:

1
2
a[i] = ...; *p = ...; upc_mem_put(...);
... = a[i]; ... = *p; upc_mem_get(...);

lecture 11

PGAS编程的目标

  • 应用:不规则代码的方便编程
    • 哈希表
    • 稀疏矩阵
    • 自适应(分层)网格
  • 机器:在计算机上显示最佳可用性能
    • 小消息的低延迟
    • 即使对于中等大小的消息,带宽也很高
    • 高注入速率(消息数/秒)
    • 最小化软件开销并匹配硬件

UPC++与MPI类似,也是SPMD程序,使用GASNet库通信。

1
2
3
4
5
6
7
8
9
10
#include <upcxx/upcxx.hpp>
#include <iostream>
using namespace std;
int main(int argc, char *argv[])
{
upcxx::init();
cout << "Hello from " << upcxx::rank_me() << endl;
upcxx::finalize();
return 0;
}

用UPC++计算π如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int argc, char **argv) {
upcxx::init();
int hits, trials = 0;
double pi;

if (argc != 2) trials = 1000000;
else trials = atoi(argv[1]);

generator.seed(upcxx::rank_me()*17);

for (int i=0; i < trials; i++) hits += hit();
pi = 4.0*hits/trials;
cout << "PI estimated to " << pi << endl;
upcxx::finalize();
}

一般的C++变量和对象在每个线程的私有内存空间分配。共享空间的变量需要用new_显式分配,用delete_释放,共享内存可以被远端进程访问:

1
global_ptr<int> gptr = new_<int>(rank_me());

如果需要广播:

1
2
global_ptr<int> gptr =
broadcast(new_<int>(24),0).wait();

future类型的变量有一个状态位,标志是否准备好,等待future类型就绪使用户可以实现异步操作。

1
2
3
4
5
future<T> f1 = rget(gptr1); // asynchronous op
future<T> f2 = rget(gptr2);
bool ready = f1.ready(); // non-blocking poll
if !ready … // unrelated work...
T t = f1.wait(); // waits if not ready

单边通信如下,同时支持不连续内存数据:

1
2
future<T> rget(global_ptr<T> src);
future<> rput(T val, global_ptr<T> dst);

同步操作:

  • Barrier: block until all other threads arrive
    barrier();
  • Asynchronous barriers
1
2
3
4
future<> f =
barrier_async(); // this thread is ready for barrier
// do computation unrelated to barrier
wait(f); // wait for others to be ready

UPC++有一部分集合操作,都是异步的

1
2
3
4
5
6
7
8
9
template <typename T> future <T>
broadcast (T && value , intrank_t root);

template <typename T> future <T>
broadcast (T * buffer, std::size_t count,
intrank_t sender);

template <typename T, typename BinaryOp>
future <T> reduce_all (T && value, BinaryOp &&op);

远端过程调用:

1
2
future<R> rpc(intrank_t r,
F func, Args&&... args);

在进程r执行func(args...)并返回结果,R是返回类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int hits = 0;
int main(int argc, char **argv) {
init();
int trials = atoi(argv[1]);
int my_trials = (trials+rank_me())/rank_n();
generator.seed(rank_me()*17);
for (int i=0; i < my_trials; i++) {
rpc(0, [](int hit) { hits += hit; }, hit()).wait();
}
barrier();
if (rank_me() == 0)
cout << "PI estimated to " << 4.0*hits/trials;
finalize();
}

lecture 12

mapreduce模型:

  • 每条记录是一个(key, value)
  • map:(K[in], V[in]) --> list(K[inter], V[inter])
  • reduce:(K[inter], list(V[inter])) --> list(K[out], V[out])

MapReduce着眼于更高级的数据并行,自动进行数据通信等,关注容错。

lecture 13

BLAS(1):对于向量的15个操作,对O(1)的数据做O(1)的操作。对于y = a * x + y这种需要2n的计算和3n的读写的操作,计算强度为2/3,读写更多,且不能向量化,所以出现了BLAS(2),主要针对矩阵-向量对进行25种操作,对O(2)的数据做O(2)的操作。BLAS(3),主要针对矩阵-矩阵对进行9种操作,对O(2)的数据做O(3)的操作,计算强度为(2n^3)/(4n^2)=n/2。LAPACK在BLAS是并行的时候才并行。

为什么要避免通信:

  • 在DRAM间移动数据很费时;
  • 算法运行时间由:
    • flops * time_per_flop
    • words moved / bandwidth
    • messages * latency
    • 组成,后两项是通信时间
    • latency是最久的
  • 需要将线性代数组织起来以免通信

blocked matrix multiply:C = A*B。将A、B和C切分成b*b,再分到每个进程,当b=1时退化成原始的矩阵乘。总共需要(n/b)^3 * 4b^2 = 4 * n^3 / b次读写,当(3*b^2)=cache时最小化。

对于矩阵乘的n^3算法,

  • 串行算法, 且缓存为M
    • 数据从主存中移动的下界为W (n^3 / M^(1/2) )
    • 假定使用分块或者cache敏感的算法
  • P个进程的并行算法
    • M是每个进程的内存
    • 数据从主存中移动的下界为W((n^3/p) / M^(1/2) )
    • 如果M = 3n^2/p (每个矩阵的一份拷贝之和),下界为W (n^2 /p^(1/2) )

算法的目标:

  • 尽量减少移动的数据
  • 尽量减少发送的信息
    • 需要新的数据结构
  • 多个内存层次结构中最小化用量
  • 当矩阵适合最快的内存时,运算/通信最少

多种不同的矩阵剖分方法:

  • 1D剖分
  • 1D循环剖分
  • 1D列块循环
  • 对应1D的行剖分
  • 2D剖分
  • 2D循环剖分

并行矩阵-向量乘:计算y = y + A * x,使用1D行剖分,A(i)是n/p个进程i拥有的行,x(i)和y(i)类似,也是进程i拥有的数据。

对于每个进程:广播x(i),计算y(i)=A(i)x。整个算法使用了`y(i) = y(i) + A(i) x = y(i) + ∑(j) A(i,j)*x(j)`。

如果使用列剖分,减少了x的广播,但是增加了一步规约操作。2D块剖分使用了广播和规约,但都是对一个进程子集,通信开销会小一些。

并行矩阵乘法:使用1D剖分且没有广播:

1
2
3
4
5
6
7
8
C(myproc) = C(myproc) + A(myproc)*B(myproc,myproc)
for i = 0 to p-1
for j = 0 to p-1 except i
if (myproc == i) send A(i) to processor j
if (myproc == j)
receive A(i) from processor i
C(myproc) = C(myproc) + A(i)*B(i,myproc)
barrier

Cost of inner loop:

  • computation: 2*n*(n/p)^2 = 2*n^3/p^2
  • communication: a + b*n^2 /p

Running time = (p*(p-1) + 1)*computation + p*(p-1)*communication = 2*n^3 + p^2*a + p*n^2*b

缺点是每次迭代只有一对进程是活跃的,只有i进程在计算。

改进:相邻的进程对可以同时通信:

1
2
3
4
5
6
Copy A(myproc) into Tmp
C(myproc) = C(myproc) + Tmp*B(myproc , myproc)
for j = 1 to p-1
Send Tmp to processor myproc+1 mod p
Receive Tmp from processor myproc-1 mod p
C(myproc) = C(myproc) + Tmp*B( myproc-j mod p , myproc)

  • 可能需要双倍的buffer
  • 代码中没有考虑可能的死锁
  • Time of inner loop = 2*(a + b*n^2/p) + 2*n*(n/p)^2
  • Total Time = 2*n* (n/p)^2 + (p-1) * Time of inner loop = 2*n^3/p + 2*p*a + 2*b*n^2

A(myproc)必须得发给每一个进程,最少开销(p-1)*cost of sending n*(n/p) words

并行效率 = 2*n^3 / (p * Total Time) = 1/(1 + a * p^2/(2*n^3) + b * p/(2*n) ) = 1/ (1 + O(p/n)),当n/p增加时负责度降低。

如果是2.5D矩阵乘:各个进程拥有cn^2 / P数据,总共数据组织成(P/c)^(1/2) * (P/c)^(1/2) * c网格。最开始进程P(i,j,0)拥有A(i,j)和B(i,j),每一个数组大小为n(c/P)^(1/2)*n(c/P)^(1/2)

  • 进程P(i,j,0)广播A(i,j)和B(i,j)给P(i,j,k)
  • k阶的进程执行SUMMA
  • 在k方向上对结果∑(m) A(i,m)*B(m,j)规约,所以P(i,j,0)拥有了C(i,j)。

为了求解Ax=b,进行高斯消去。

1
2
3
4
5
6
7
8
… for each column i
… zero it out below the diagonal by adding multiples of row i to later rows
for i = 1 to n-1
… for each row j below row i
A(j,i) = A(j,i) / A(i,i);
for j = i+1 to n
for k = i+1 to n
A(j,k) = A(j,k) - A(j,i) * A(i,k)

高斯消去实际上也是求了一个LU分解,A=L*U,在求解方程A*x=b

  • 使用高斯消去分解A=L*U
  • 求解L*y=b
  • 求解U*x=y
  • 因此A*x = (L*U)*x = L*(U*x) = L*y = b

当矩阵A比较小或者有0时,可能会得到错误的结果。因此需要交换把A(i,i)变成一列里最大的,GEPP(Gaussian Elimination with Partial Pivoting)。

1
2
3
4
5
6
7
8
9
10
for i = 1 to n-1
find and record k where |A(k,i)| = max{i ≤ j ≤ n} |A(j,i)|
… i.e. largest entry in rest of column i
if |A(k,i)| = 0
exit with a warning that A is singular, or nearly so
elseif k ≠ i
swap rows i and k of A
end if
A(i+1:n,i) = A(i+1:n,i) / A(i,i) … each |quotient| ≤ 1
A(i+1:n,i+1:n) = A(i+1:n , i+1:n ) - A(i+1:n , i) * A(i , i+1:n)

以上算法计算A=P*L*U,这是数值上很稳定的。

分块用于计算矩阵乘法,但是在这里由于数据依赖更多很难分块。使用“delayed updates”,将多个连续矩阵的更新保存到跟踪矩阵,之后在一个BLAS3(matmul)操作中同时应用多个更新。

首先要选择一个适当的“b”,这个b应该足够小,使包含b列的子矩阵能够满足cache的大小需要,同时应该足够大以使算法更快。

1
2
3
4
5
6
7
8
for ib = 1 to n-1 step b … Process matrix b columns at a time
end = ib + b-1 … Point to end of block of b columns
apply BLAS2 version of GEPP to get A(ib:n , ib:end) = P' * L' * U'
… let LL denote the strict lower triangular part of A(ib:end , ib:end) + I
A(ib:end , end+1:n) = LL^(-1) * A(ib:end , end+1:n) … update next b rows of U
A(end+1:n , end+1:n ) = A(end+1:n , end+1:n ) - A(end+1:n , ib:end) * A(ib:end , end+1:n)
… apply delayed updates with single matrix-multiply
… with inner dimension b

白色部分已经完成,只对中间部分处理。中间的小矩形为LL。贴上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
      SUBROUTINE SGETRF( M, N, A, LDA, IPIV, INFO )
!
! .. Scalar Arguments ..
! INTEGER INFO, LDA, M, N
! ..
! .. Array Arguments ..
! INTEGER IPIV( * )
! REAL A( LDA, * )
! ..
!
! Purpose
! =======
!
! SGETRF computes an LU factorization of a general M-by-N matrix A
! using partial pivoting with row interchanges.
!
! The factorization has the form
! A = P * L * U
! where P is a permutation matrix, L is lower triangular with unit
! diagonal elements (lower trapezoidal if m > n), and U is upper
! triangular (upper trapezoidal if m < n).
!
! This is the right-looking Level 3 BLAS version of the algorithm.
!
! Arguments
! =========
!
! M (input) INTEGER
! The number of rows of the matrix A. M >= 0.
!
! N (input) INTEGER
! The number of columns of the matrix A. N >= 0.
!
! A (input/output) REAL array, dimension (LDA,N)
! On entry, the M-by-N matrix to be factored.
! On exit, the factors L and U from the factorization
! A = P*L*U; the unit diagonal elements of L are not stored.
!
! LDA (input) INTEGER
! The leading dimension of the array A. LDA >= max(1,M).
!
! IPIV (output) INTEGER array, dimension (min(M,N))
! The pivot indices; for 1 <= i <= min(M,N), row i of the
! matrix was interchanged with row IPIV(i).
!
! INFO (output) INTEGER
! = 0: successful exit
! < 0: if INFO = -i, the i-th argument had an illegal value
! > 0: if INFO = i, U(i,i) is exactly zero. The factorization
! has been completed, but the factor U is exactly
! singular, and division by zero will occur if it is used
! to solve a system of equations.
!
! =====================================================================
!
! .. Parameters ..
! REAL ONE
! PARAMETER ( ONE = 1.0E+0 )
! ..
! .. Local Scalars ..
! INTEGER I, IINFO, J, JB, NB
! ..
! .. External Subroutines ..
! EXTERNAL SGEMM, SGETF2, SLASWP, STRSM, XERBLA
! ..
! .. External Functions ..
! INTEGER ILAENV
! EXTERNAL ILAENV
! ..
! .. Intrinsic Functions ..
! INTRINSIC MAX, MIN
! ..
! .. Executable Statements ..
!
! Test the input parameters.
!
INFO = 0
IF( M.LT.0 ) THEN
INFO = -1
ELSE IF( N.LT.0 ) THEN
INFO = -2
ELSE IF( LDA.LT.MAX( 1, M ) ) THEN
INFO = -4
END IF
IF( INFO.NE.0 ) THEN
CALL XERBLA( 'SGETRF', -INFO )
RETURN
END IF
!
! Quick return if possible
!
IF( M.EQ.0 .OR. N.EQ.0 )
$ RETURN
!
! Determine the block size for this environment.
!
NB = ILAENV( 1, 'SGETRF', ' ', M, N, -1, -1 )
IF( NB.LE.1 .OR. NB.GE.MIN( M, N ) ) THEN
!
! Use unblocked code.
!
CALL SGETF2( M, N, A, LDA, IPIV, INFO )
ELSE
!
! Use blocked code.
!
DO 20 J = 1, MIN( M, N ), NB
JB = MIN( MIN( M, N )-J+1, NB )
!
! Factor diagonal and subdiagonal blocks and test for exact
! singularity.
!
CALL SGETF2( M-J+1, JB, A( J, J ), LDA, IPIV( J ), IINFO )
!
! Adjust INFO and the pivot indices.
!
IF( INFO.EQ.0 .AND. IINFO.GT.0 )
$ INFO = IINFO + J - 1
DO 10 I = J, MIN( M, J+JB-1 )
IPIV( I ) = J - 1 + IPIV( I )
10 CONTINUE
!
! Apply interchanges to columns 1:J-1.
!
CALL SLASWP( J-1, A, LDA, J, J+JB-1, IPIV, 1 )
!
IF( J+JB.LE.N ) THEN
!
! Apply interchanges to columns J+JB:N.
!
CALL SLASWP( N-J-JB+1, A( 1, J+JB ), LDA, J, J+JB-1,
$ IPIV, 1 )
!
! Compute block row of U.
!
CALL STRSM( 'Left', 'Lower', 'No transpose', 'Unit', JB,
$ N-J-JB+1, ONE, A( J, J ), LDA, A( J, J+JB ),
$ LDA )
IF( J+JB.LE.M ) THEN
!
! Update trailing submatrix.
!
CALL SGEMM( 'No transpose', 'No transpose', M-J-JB+1,
$ N-J-JB+1, JB, -ONE, A( J+JB, J ), LDA,
$ A( J, J+JB ), LDA, ONE, A( J+JB, J+JB ),
$ LDA )
END IF
END IF
20 CONTINUE
END IF
RETURN
!
! End of SGETRF
!
END

在二维剖分中进行高斯消去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for ib = 1 to n-1 step b
end = min(ib + b -1, n)
for i = ib to end
(1) find pivot row k, column broadcast
(2) swap rows k and i in block column, broadcast row k
(3) A(i+1:n, i) = A(i+1:n, i) / A(i,i)
(4) A(i+1:n, i+1:end) -= A(i+1:n, i)*A(i,1+1:end)
end for
(5) broadcast all swap information right and left
(6) apply all rows swap to other column
(7) broadcast LL right
(8) A(ib:end, end+1:n) = LL / A(ib:end, end+1:n)
(9) broadcast A(ib:end, end+1:n) down
(10) broadcast A(end+1:n, ib:end) right
(11) eliminate A(end+1:n, end+1:n)
// matrix multiply of green = green - blue * pink

lecture 15

compressed sparse row (CSR)存储:

  • 大小为nnz=非零值个数(val)数组
  • 大小为nnz的每个非零值的列索引数组
  • 大小为n=行数的行起始指针数组

其他常用格式(加分块)

  • 压缩稀疏列(CSC)
  • 坐标(COO):每个非零元素的行+列索引(易于构建)

SpMV with CSR算法对y的重用很多,但是对x的重用不足。

1
2
3
for each row i:
for k = ptr[i] to ptr[i+1] - 1 do
y[i] = y[i] + val[k] * x[ind[k]]

可能的优化:

  • 把k循环展开,需要知道这一行有多少非零元素
  • 把y[i]挪出for循环
  • 压缩ind[i],需要知道非零元素出现的规律
  • 重用x,需要很好的非零元素出现规律
  • cache:需要知道非零元在附近的行
  • register:需要知道这些非零元存在哪里

SpMV可以利用分块,不需要使用index存储每一个非零元,而是使用1个列序号存储非零r-c块?

Optimizations for SpMV

  • Register blocking (RB): up to 4x over CSR
  • Variable block splitting: 2.1x over CSR, 1.8x over RB
  • Diagonals: 2x over CSR
  • Reordering to create dense structure + splitting: 2x over CSR
  • Symmetry: 2.8x over CSR, 2.6x over RB
  • Cache blocking: 2.8x over CSR
  • Multiple vectors (SpMM): 7x over CSR
  • And combinations…

Sparse triangular solve

  • Hybrid sparse/dense data structure: 1.8x over CSR

SpMV的行并行:对x的访问是随机的,而且没有线程之间的依赖关系,所以没有竞争/锁。剖分的话就根据非零元素的个数进行分割。与列并行相比,都是对y的随机读写,列并行需要同步操作。将行与列结合起来有更多并行性。

优化总结:

  • NUMA-非统一内存访问
    • 将子矩阵固定到分配给它们的核心附近的内存中
  • 预取-值、索引和/或向量
    • 对预取距离进行彻底搜索
  • 矩阵压缩-不仅仅是寄存器分块(BCSR)
    • 32或16位索引,子矩阵的块坐标格式
  • 缓存阻塞
    • 矩阵的2D分区,因此所需的x、y部分适合缓存

分布式SpMV的并行性:

  • y=A*x,其中A是稀疏矩阵
  • 行并行性(y和A)
    • 跨处理器复制x
    • 或者只交换必要的元素
    • 非零是否聚集在一起,例如,接近对角线?
  • 列并行性(x和A)
    • 在所有处理器上设置临时y=[0, …];
    • 更新该信息;并跨处理器添加reduce
  • p很大和非零一致时的二维并行性
    • 将处理器划分为p1 x p2(例如,方形网格)
    • 使用混合行和列并行性
    • 非零元素聚集时的负载平衡不良

lecture 17

一些机器学习算法与矩阵向量乘相关。隐式的并行是保持整体算法结构(操作序列)完整,并行化各个操作,如将BLAS操作并行化,通常可以获得完全相同的精度(例如,DNN训练中的模型并行性),如果算法的关键路径较长,则可扩展性可能会受到限制。

显式并行化:修改算法以获得更多的并行性,例如算法在单个模块上执行,这些模块的结果稍后可以合并。示例:DNNs中的CA-SVM和数据并行,可以实现显著更好的可扩展性。
训练即更新神经网络的权重。

梯度下降:为了最小化一个函数,以α速率的梯度方向朝相反方向移动,α是步长(也称为学习速率)。用作许多其他机器的优学习方法(示例:NMF)

随机梯度下降SGD,SGD因其“嘈杂”的梯度而避开尖锐的局部极小值。

并行化机会

  • 数据并行性:分发输入(图像、文本、音频等)
    • 批处理并行性:将每个完整样本分发到不同的处理器。当人们在文献中提到数据并行时,这就是他们99%的意思
  • 域并行性
    • 细分样本并将各部分分发给处理器。一种以前未经探索的并行方法。
  • 模型并行性:分配神经网络(即其权重)

参数服务器中梯度的获取和更新可以同步或异步完成。
两者都有利弊。在异步不可再现的情况下,过度同步会影响性能,并可能会影响收敛

Dean, Jeffrey, et al. “Large scale distributed deep networks.” Advances in neural information processing systems. 2012.

为了避免参数服务器带来的瓶颈,两种方法:全局allreduce参数,或者进程之间两两互相交换参数。

Peter Jin, Forrest Iandola, Kurt Keutzer, “How to scale distributed deep learning?” NIPS ML Sys 201

模型并行性的例子。图中显示了一个五层深度的神经网络,该网络具有独立性,被划分为四台机器(蓝色矩形)。只有跨越分区边界(粗线)的那些才需要它们的状态传输。即使在节点有多条边穿过分区边界的情况下,也会发送到该边界另一侧的计算机一次。

  • 解释1:将神经网络划分到处理器
  • 解释2:并行执行矩阵运算

在每个分区内,各个节点将跨所有可用的CPU核进行并行化。

在反向传播中,误差从最后一层传播到第一层,并且任何两个连续层之间都存在数据依赖性。相比之下,不同层的梯度是相互独立的。激活在前级从左到右传播; 错误在反向传播阶段从右向左传播。 梯度是使用激活和误差计算的。 每个箭头表示数据依赖性。

神经网络的SGD训练:如果看成是矩阵乘的话:权重矩阵(N✖M)乘以本层输入(M✖B)=本层输出(N✖B)。

其中权重W会被复制到每个处理器,所以是不会变的。输入和输出会由于数据并行而变得更小,例如,把输入矩阵切分到b=B/p大小

域并行(domain parallel):总体思路与用于在 HPC 中并行化模板代码的halo区域或ghost区域相同,在卷积之前,交换局部对应halo数据。

在向前和向后传递期间用于光环交换的附加通信

  • 对于激活大小较大的早期层(即卷积层),成本可以忽略不计

分布式深度学习总结

  • 大batch训练经常导致次优学习
  • 集成并行使用避免通信的算法来扩展可扩展性,而不是增大batch大小
  • 集成并行性将模型和数据(batch和domain)并行性最佳结合起来,并且通常比每个极端都表现得更好。
  • 对全连接层(大参数)使用模型并行,对卷积层(大激活)使用数据并行通常更好

支持向量机:只有支持向量上的分类约束是活动的

  • 导致巨大的二次约束优化 (QP) 问题
  • 特殊算法,例如顺序最小优化 (SMO),将这个巨大的 QP 分解为更小的(实际上是最小的)QP 子问题。

在特征空间中的计算是耗时的,因为特征空间一般都是高维的。核方法解决这个问题。核方法用任意核函数替换线性模型的点积相似度,该函数计算 x 和 y 之间的相似度:K(x, y): X ✖ X ➡ R

内核必须是对称的和半正定的。高斯核(即径向基函数)是ML中的事实上的核。K(x, x') = exp(-γ || x - x' ||^2)。我们可以预先计算核(Gram)矩阵,但这太耗时了。

下图中,2D (a) 中的圆形分类边界使用以下变换变为 3D (b) 中的线性边界:φ(x1, x2) = (x1^2, x2^2, 根下2 x1 x2)。

输入数据集是一个n✖d的矩阵,X1、X2、…、Xn都有一个d长度的特征向量。生成一个n✖n的核矩阵,K[i][j] = exp(-r||Xi - Xj||^2),r是一个正数。计算复杂度O(d*n^2),内存复杂度O(n^2),很小的输入会产生很大的核矩阵。357MB的输入(52K✖90) = 2000GB核矩阵。使用SMO(顺序最小优化)

  • 采用迭代法,避免核矩阵
  • 每次迭代使用两行核矩阵
  • 稀疏输入的关键核:稀疏矩阵乘以稀疏向量

最小可能的优化问题一次涉及“两个”拉格朗日乘子,因为仅仅改变乘数会违反等式约束。

重复直到收敛:

  1. 选择一些对 αi 和 αj 进行下一步更新(使用试探法尝试选择这两个,这将使我们能够朝着全局最大值取得最大进展)。
  2. 相对于 αi 和 αj 重新优化 W(α),同时保持所有其他 αk 的固定。

Cascade SVM:数据被分割均匀并由多个SVM处理。逐层去除非支持向量

  • 数据是前一层的支持向量(SV)
  • 将 SV 的参数 αi 传递到下一层以获得更好的启动(热启动)

Divide-and-Conquer SVM:全部数据在层与层之间传递;使用内核k-mean实现数据集的分割。

  • 定理1:子问题的支持向量集接近于整个问题的支持向量集
  • 定理2:内核 kmeans 最小化子问题的解与整个问题的解之间的差异

CP-SVM:

  • divide:K-means将数据划分为P个部分
  • conquer:欧氏距离选择最佳模型

  • ||Xi - Xj||^2很大时,exp(-r||Xi - Xj||^2)是0。
  • K-means最大化了数据集之间的欧氏距离
  • 这两个矩阵具有相似的 F 范数
  • 分析假设高斯核:对于给定的样本,只有接近它的支持向量才能对分类产生影响

communication-avoid SVM:设计了一个均衡的聚簇来取代k-means,仍然尽可能近似kmeans的距离分离特性。

非负矩阵分解 (NMF):min{W≥0, H≥0} f(W, H) = 1/2 || A - WH ||^2

m✖n矩阵A = m✖k矩阵W * k✖n矩阵H

  • 具有非负约束的降维
  • “分解”这个名字用词不当。 NMF 只是一个低秩近似,因为精确分解是 NP 难的
  • NMF 是一系列方法,而不仅仅是一种算法

聚类

  • 基于质心(k 均值、k 中值和变体)
  • 基于流(马尔可夫聚类)
  • 谱方法
  • 基于密度(DBSCAN、OPTICS)
  • 凝聚方法(单链聚类)

通常,正确的方法取决于输入特征并需要一些领域知识。我们将讨论两种并行算法:谱聚类和马尔可夫聚类 (MCL)。

谱聚类

  • 输入:数据点之间的相似性
  • 计算相似度的许多方法,有些是特定领域的:余弦、Jaccard 指数、Pearson 相关性、Spearman’s rho、Bhattacharyya 距离、LOD 分数……
  • 我们可以用图表示数据点之间的关系,通过点之间的相似性对边进行加权

图定义

  • ε-邻域图
    • 确定阈值 ε,如果两点之间的亲和力大于 ε,则加入这条边。
  • k-最近邻
    – 在节点与其 k 个最近邻居之间插入边。
    – 每个节点将连接到(至少)k 个节点。
  • 全连接
    – 在每对节点之间插入一条边。

谱聚类

  • 图形的最小割确定了数据的最佳分区。
  • 谱聚类:递归分割数据集
    • 确定最小割
    • 去除边
    • 重复直到识别出 k 个集群
  • 问题:识别最小割是NP 难的。
  • 存在使用线性代数的有效近似值,基于拉普拉斯矩阵或图拉普拉斯算子

图拉普拉斯算子

  • 非标准化图拉普拉斯算子:L = D - W
  • 标准化图拉普拉斯算子:
    • L(sym) = D^(-1/2) L D^(-1/2) = I - D^(-1/2) W D^(-1/2)
    • L(rw) = D^(-1) L = I - D^(-1) W

  • 特征值 0 的多重性给出了集群的数量(在这种理想情况下:连接的分量的数量)。
  • 假设真实情况是这种情况的近似。

如何计算那些最小的特征向量?

  • 通过 Lanczos 算法实现
    • workhorse是稀疏矩阵向量 (SpMV) 乘法
    • SpMV 没有/最小化的数据重用,受通信限制
    • 为了优化稀疏矩阵向量乘法并最小化其通信,我们绘制分区图(下一讲)
  • 替代算法是可能的
    • 幂迭代(power iteration)更廉价但数值不稳定
    • LOBPCG(局部优化块预处理共轭梯度,Locally-Optimized Block Preconditioned Conjugate Gradient)使用稀疏矩阵乘以多个向量,由于可能的数据重用,因此具有更有利的性能。
  • 最后,您可能只想调用现有的东西。

lecture 18

图分区的定义

  • 给定一个图 G = (N, E, WN, WE)
    • N = 节点(或顶点),
    • WN = 节点权重
    • E = 边
    • WE = 边权重
  • 例如:N = {tasks},WN = {task cost},E 中的边 (j,k) 表示任务 j 将 WE(j,k) 字节发送到任务 k
  • 选择一个分区 N = N1 U N2 U … U NP 使得
    • 每个 Nj 中节点权重的总和大致相同
    • 最小化连接所有不同对 Nj 和 Nk 的边的所有边权重之和
  • 例如:平衡工作负载,同时尽量减少通信
    • N = N1 U N2 的特例:图二分

稀疏矩阵向量乘法y = y +A*x分割稀疏对称矩阵

1
2
3
4
5
6
7
… declare A_local, A_remote(1:num_procs), x_local, x_remote, y_local
y_local = y_local + A_local * x_local
for all procs P that need part of x_local
send(needed part of x_local, P)
for all procs P owning needed part of x_remote
receive(x_remote, P)
y_local = y_local + A_remote(P)*x_remote

选择最优分区是 NP 完全的

  • (NP-complete = 我们可以证明它是非确定多项式时间类中其他众所周知的难题)
  • 只有已知的精确算法具有成本 = 指数(n)
  • 我们需要好的启发式方法

第一个启发式:重复图二分法

  • 将 N 分成 2^k 个部分
  • 递归地平分图 k 次
    今后主要讨论图二分法

边分隔符与顶点分隔符

  • 边分隔符:如果从 E 中删除 Es,留下 N 的两个大小相等、不相连的分量:N1 和 N2,则 Es(E 的子集)分隔 G
  • 顶点分隔符:如果移除 Ns 和所有的相关边,留下两个大小相等、不连贯的N的组成部分:N1和N2,就说Ns(N 的子集)分割G

  • 从 Es 生成 Ns:选择 Es 中每条边的一个端点
    • |Ns| ≤ |Es|
  • 从一个 Ns 生成一个 Es:选取所有在 Ns 上的边
    • |Es| ≤ d * |Ns|,其中 d 是图的最大度数
  • 我们会找到边缘或顶点分隔符,因为它们很方便

使用节点坐标(Nodal Coordinates)进行分区

  • 每个节点都有 x,y,z 坐标 ➡ 分区空间
    不使用节点坐标(Nodal Coordinates)进行分区
  • 网络文档的稀疏矩阵

节点坐标:惯性分区

  • 对于二维上的图,选择一条分割图的线
    • 在 3D 中,选择一个平面,但为了简单起见考虑 2D
  • 选择一条线 L,然后选择一条垂直于它的线 LT,两边各有一半节点
  1. 选择一条直线 L 通过这些点。L 由a*(x-xbar)+b*(y-ybar)=0给出,其中a^2+b^2=1;(a,b) 是单位向量垂直于L
  2. 将每个点投影到线上。对于每个nj = (xj,yj),计算沿 L 的坐标Sj = -b*(xj-xbar) + a*(yj-ybar)
  3. 计算中位数。让 Sbar = median(S1, …, Sn)
  4. 使用中值划分节点。让 Sj < Sbar 的节点在 N1 中,剩余的在 N2 中

节点坐标:摘要

  • 这些算法的其他变体
  • 算法高效
  • 依赖于节点(主要)连接到空间中最近邻的图
    • 算法不依赖于实际边的位置!
  • 当图来自物理模型时很常见
  • 忽略边,但可以用作后续检查边的分区的良好起始
  • 如果图连通性不是空间的,则效果不佳:

图分块的无须坐标系的方法:Kernighan/Lin

  • 取一个初始分区并迭代改进它
    • Kernighan/Lin (1970),开销 = O(|N|^3)
    • Fiduccia/Mattheyses (1982),开销 = O(|E|),更好,但更复杂
  • 给定 G = (N, E, WE) 和分区 N = A U B,其中 |A| = |B|
    • T = cost(A,B) = ∑ {W(e) 其中 e 连接 A 和 B 中的节点}
    • 使用|X| = |Y|,查找A的子集X和B的Y
    • 如果可以降低成本,请考虑交换 X 和 Y:
      • newA = (A – X) U Y 和 newB = (B – Y) U X
      • newT = cost(newA, newB) < T = cost(A,B)
  • 需要为许多可能的 X 和 Y 有效地计算 newT,选择最小(最佳)

Kernighan/Lin:初步定义

  • T = cost(A, B), newT = cost(newA, newB)
  • 需要一个有效的newT公式; 将使用
    • E(a) = A 中 a 的外部开销 = ∑ {W(a,b) for B in b}
    • I(a) = A 中 a 的内部成本 = S {W(a,a) for other a’ in A}
    • D(a) = A 中 a 的成本 = E(a) - I(a)
    • E(b)、I(b) 和 D(b) 对于 B 中的 b 定义类似
  • 考虑交换 X = {a} 和 Y = {b}
    • newA = (A - {a}) U {b}
    • newB = (B - {b}) U {a}
  • newT = T - ( D(a) + D(b) - 2*w(a,b) ) ≡ T - gain(a,b)
    • gain(a,b) 衡量通过交换 a 和 b 获得的改进
  • 更新公式
    • newD(a') = D(a') + 2*w(a',a) - 2*w(a',b)对于 A 中的 a’,a’ ≠ a
    • newD(b') = D(b') + 2*w(b',b) - 2*w(b',a)对于 B 中的 b’,b’ ≠ b

红色是第一部分,黑色是第二部分,最开始的切分是随机的,none (8);

第一部分中最大的gain的是g,把g挪到第二部分,同时计算g的邻居的gain。暂时移动的节点是空心圆。暂时移动节点的gain无关紧要。none (8); g,

第二部分的gain最大的是d,暂时移动到第一部分,重新计算d的邻居的gain,在这之后,图的切是4。none (8); g, d (4);

第一部分中没有移动过的最大的gain的是f。暂时移动到第二部分,重新计算它邻居的gain。none (8); g, d (4); f,

第二部分中没有移动过的最大的gain的是c,暂时移动到第一部分,重新计算c的邻居的gain,这次交换后,这个图的切是5。none (8); g, d (4); f, c (5);

第一部分中没有移动过的最大的gain的是b,暂时移动到第二部分,重新计算b的邻居的gain。none (8); g, d (4); f, c (5); b

第 2 部分中两个未移动节点之间的最大gain存在联系。我们选择一个(比如e)并暂时将其移至 Part1。 它没有未移动的邻居,因此不会重新计算gain。这次交换后,这个图的切是7。none (8); g, d (4); f, c (5); b, e (7);

第一部分中没有移动过的最大的gain的是a,暂时移动到第二部分,它没有未移动的邻居,因此不会重新计算gain。none (8); g, d (4); f, c (5); b, e (7); a

第二部分中没有移动过的最大的gain的是a,暂时移动到第一部分,它没有未移动的邻居,因此不会重新计算gain。最终临时交换后的切大小为 8,和任何动作之前一样。

在每个节点都被暂时移动后,我们看到交换 g 和 d 后,最小的切割是 4。 我们使该交换永久化并撤消所有后来的临时交换。这是第一个改进步骤的结束。

现在我们重新计算gain并从新的最小切4开始进行另一个改进步骤。 细节没有显示。 第二个改进步骤不改变切大小,所以算法以切为4结束。 一般来说,只要切不断变小,我们就会继续进行改进步骤。

Kernighan/Lin 算法

  • 最耗时的线以红色显示,O(n3)
  • 某些gain(k)可能为负,但如果后来的gain很大,则最终gain可能为正
    • 可以避免局部最小值,其中切换没有任何帮助
  • 我们重复多少次?
    • K/L 在非常小的图 (|N|<=360) 上测试并在 2-4 次扫描后收敛
    • 对于(具有理论意义的)随机图,一步收敛的概率似乎下降为 2^(-|N|/30)

多级分区简介

  • 如果我们想对 G(N,E) 进行划分,但它太大而无法有效地进行,我们该怎么办?
    • 1) 将 G(N,E) 替换为粗近似 Gc(Nc,Ec),并划分 Gc
    • 2) 使用Gc的分区得到G的粗略分区,然后迭代改进
  • 如果 Gc 仍然太大怎么办?
    • 递归应用相同的想法

最大匹配

  • 定义:图 G(N,E) 的匹配是 E 的子集 Em,使得 Em 中没有两条边共享端点
  • 定义:图 G(N,E) 的最大匹配是一个匹配Em,不能添加更多边并保持匹配
  • 一个简单的贪心算法计算最大匹配:
1
2
3
4
5
6
7
8
9
10
11
let Em be empty
mark all nodes in N as unmatched
for i = 1 to |N| … visit the nodes in any order
if i has not been matched
mark i as matched
if there is an edge e=(i,j) where j is also unmatched,
add e to Em
mark j as matched
endif
endif
endfor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1) 构建G(N, E)的最大匹配Em
for all edges e = (j,k) in Em 2)将匹配的节点折叠成一个节点
Put node n(e) in Nc
W(n(e)) = W(j) + W(k) … gray statements update node/edge weights
for all nodes n in N not incident on an edge in Em 3) 添加不匹配的节点
Put n in Nc … do not change W(n)
现在 N 中的每个节点 r 都在 Nc 中的唯一节点 n(r) 内

4) 如果两个节点内部的节点在 E 中连接,则在 Nc 中连接两个节点
for all edges e=(j,k) in Em
for each other edge e'=(j,r) or (k,r) in E
Put edge ee = (n(e),n(r)) in Ec
W(ee) = W(e')

如果在 Nc 中有多个边连接两个节点,将它们折叠起来,
添加边权重

通过稀疏矩阵-矩阵乘进行简化

Parallel sparse matrix-matrix multiplication and indexing:
Implementation and experiments. SIAM Journal of Scientific Computing (SISC), 2012

一些实现

  • Multilevel Kernighan/Lin
    • METIS and ParMETIS (glaros.dtc.umn.edu/gkhome/views/metis)
    • SCOTCH and PT-SCOTCH (www.labri.fr/perso/pelegrin/scotch/)
  • Matlab toolbox for geometric and spectral partitioning by Gilbert, Tang, and Li: https://github.com/YingzhouLi/meshpart
  • Multilevel Spectral Bisection
    • S. Barnard and H. Simon, - A fast multilevel implementation of recursive spectral bisection …, 1993
    • Chaco (SC’14 Test of Time Award)
  • Hybrids possible
    • Ex: Use Kernighan/Lin to improve a partition from spectral bisection
  • Recent packages with collection of techniques

超越简单的图划分:将稀疏矩阵表示为超图

edge (vi, vj) ∈ E ➡ y(i) ⬅ y(i) + A(i,j) x(j) and y(j) ⬅ y(j) + A(j,i) x(i)

P1 performs: y(4) ⬅ y(4) + A(4,7) x(7) and y(5) ⬅ y(5) + A(5,7) x(7)

x(7) only needs to be communicated once !

  • 块-行分布的列-网模型
  • 行是顶点,列是网络(超边)

两种不同的2D网格分块方法

分区质量最重要

  • 当结构在模拟过程中动态变化时,需要动态分区
    • 速度可能比质量更重要
    • 分区器必须并行快速运行
      • 这里还有一个先有鸡还是先有蛋的问题
    • 分区应该是增量的
      • 相对于之前的变化最小
    • 不得使用过多内存
  • 最近关于流分区的研究:
    • Stanton, I. and Kliot, G., “Streaming graph partitioning for large distributed graphs”. KDD, 2012

lecture 19

这一节中定义了i = sqrt(-1)

一个有m个元素的向量v的离散傅里叶变换(DFT)为F*v,其中F是一个m*m的矩阵,F(j,k)=ω^(j*k), 0 ≤ j,k ≤ m-1,其中ω=e^(2πi/m)=cos(2π/m)+i*sin(2π/m)ω是一个复数,它的m次方ω^m=1。例如m=4,则ω=i, ω^2=-1, ω^3=-i, ω^4=1m*m二维矩阵V的DFT为F*V*F

快速傅里叶变换主要用在图像处理、信号处理,求解泊松方程

分别在一维二维条件下求解泊松方程:

因为L1=F·D·FT,是特征值/特征向量分解,F与FFT很相似,F(j,k)=(2/(n+1))^(1/2) · sin(j k π /(n+1)),D是特征值的对角矩阵,D(j,j)=2(1-cos(j π/(n+1)))

二维泊松与L1 · X + X · L1 = B类似,

FFT的串行算法:计算m个元素的向量vFFT(F*v)(F*v)[j] = ∑(k=0,m-1)(F(j,k)*v(k)) = ∑(k=0,m-1)(ω^(j*k)*v(k)) = ∑(k=0,m-1)((ω^j)^k*v(k)) = V((ω^j))V是多项式V(x)=∑(k=0,m-1)(x^k * v(k))

FFT类似在m个点上评估一个m-1阶多项式V(x)

V可以用分治来计算:V(x)=∑(k=0,m-1)(x^k * v(k))=v[0] + x^2 * v[2] + x^4 * v[4] + ... + x*(v[1] + x^2 * v[3] + x^4 * v[5] + ...) = Veven(x^2) = Vodd(x^2)

V的阶为m-1,所以Veven和Vodd是m/2-1阶的多项式。

1
2
3
4
5
6
7
8
FFT(v, ω, m) … assume m is a power of 2
if m = 1 return v[0]
else
veven = FFT(v[0:2:m-2], ω^2, m/2)
vodd = FFT(v[1:2:m-1], ω^2, m/2)
ω-vec = [ω^0, ω^1, … ω^(m/2-1) ]
return [Veven + (ω-vec .* Vodd),
Veven - (ω-vec .* Vodd) ]

Cost: T(m) = 2T(m/2)+O(m) = O(m log m) operations.

FFT的分治算法画图是一个完全二叉树:

消息发送延迟的来源

一般地,重叠意味着同时计算和通信,(或与其他通信对象通信,又名流水线)

重叠的潜力是gap和开销之间的差异

  • 如果在整个消息发送过程中 CPU 被占用,则没有潜力
    • 例如,没有发送端 DMA
  • 具有 DMA 的机器的潜力随着消息大小的增长而增长(每字节成本由网络处理,即 NIC)
  • 潜力随着网络拥塞量的增加而增长
    • 因为差距随着网络变得饱和而增长

对于具有 RDMA 的机器,远程开销为 0,需要良好的软件支持才能利用这一点。

GASNet 提供 put/get 通信,是在网络硬件之上的一层。

  • 一方面:API 中不需要远程 CPU 参与(与 MPI 的主要区别)
    • 消息包含远程地址
    • 无需与接收匹配
    • 无需隐式排序

下图是GASNet和MPI在通信上的差异

lecture 20

PRAM模型:

  • 理想化的并行共享内存系统模型
  • 无限数量的同步处理器; 无同步、通信成本; 没有并行开销
  • EREW(独占读独写)、CREW(并发读独占写)
  • 衡量性能:空间和时间复杂度; 操作总数(工作)
  • 优点
    • 简单而干净的语义。
    • 大多数理论并行算法是使用 PRAM 模型设计的。
    • 独立于通信网络拓扑。
  • 缺点
    • 不现实,太强大的通信模型。
    • 通信成本被忽略。
    • 需要同步处理器。
    • 没有本地内存。
    • 大 O 符号通常具有误导性。

图的表示:压缩稀疏行:缓存高效的邻接表

分布式网格表示:

  • 每个处理器存储整个图(“完全复制”)
  • 每个处理器存储 n/p 个顶点以及这些顶点之外的所有邻接(“一维分区”)
  • 如何创建这些“p”顶点分区?
    • 图分区算法:递归优化电导(边缘切割/较小分区的大小)
    • 随机打乱顶点标识符确保边数/处理器大致相同

二维棋盘表示:

  • 考虑一个逻辑 2D 处理器网格 (pr * pc = p) 和图的矩阵表示
  • 为每个处理器分配一个子矩阵(即子矩阵内的边)

应该是把图的矩阵表示进行分割

DFS:并行化DFS的复杂度很高,难以并行

1
2
3
4
5
6
procedure DFS(vertex v)
v.visited = true
previsit(v)
for all v s.t. (v, w) ∈ E
if(!w.visited) DFS(w)
postvisit(v)

BFS的内存要求:

  • 稀疏图表示:m+n
  • 访问顶点堆栈:n
  • 距离阵列:n

广度优先搜索是其他并行图算法的一个非常重要的构建块,例如(二部)匹配、最大流、(强)连接分量等。

并行化BFS:

  • 扩展当前边界(水平同步方法,适用于小直径图)
    • 并行访问当前边界中所有顶点的邻接
  • 拼接多个并发遍历(Ullman-Yannakakis方法,适用于大直径图)

图的矩阵表示A转置为AT。最开始把开始的1点放到parents中,1点的parents是0点。

AT乘parents向量得到ATX,得到1的邻接点是2和4,修改parents向量。

X是上一步求出来的ATX,AT*X得到这一步的邻接点,加入到parent向量中。

1D Parallel BFS algorithm

  1. 查找当前边界邻接的所有者。(计算)
  2. 通过all to all交换邻接。(通讯)
  3. 更新未访问顶点的distance/parents。(计算)

2D Parallel BFS algorithm

  1. 在处理器列中收集顶点(通信)
  2. 查找当前边界邻接的所有者(计算)
  3. 交换处理器行中的邻接(通信)
  4. 更新未访问顶点的distance/parents。(计算)

并行单源最短路径(Parallel Single-source Shortest Paths,SSSP)算法

  • 著名的串行算法:
    • Bellman-Ford:标签校正-适用于任何图形
    • Dijkstra:标签设置–需要非负边权重
  • 没有已知的PRAM算法在次线性时间和O(m+nlogn)下运行
  • Ullman Yannakakis随机方法
  • meyer and Sanders,∆ - stepping算法
  • Chakaravarthy等人,巧妙地结合了∆-stepping和超级计算机规模图上的方向优化(BFS)。

U. Meyer and P.Sanders, ∆ - stepping: a parallelizable shortest path algorithm. Journal of Algorithms 49 (2003)

V. T. Chakaravarthy, F. Checconi, F. Petrini, Y. Sabharwal “Scalable Single Source Shortest Path Algorithms for Massively Parallel Systems ”, IPDPS’14

∆ - stepping算法

  • 标签校正算法:可以从未设置的顶点松弛边
  • “Dijkstra的近似实现”
  • 对于随机边权重[0,1],在L=从源到任何节点的最大距离处运行,复杂度O(n + m + D·L)
  • 顶点使用宽度∆的桶进行排序
  • 每个桶可以并行处理
  • 基本操作:Relax(e(u, v))

    • d(v)=min{d(v),d(u)+w(u,v)}
  • ∆ < min w(e):退化为Dijkstra

  • ∆ > max w(e):退化为Bellman-Ford

算法说明:

1
2
3
4
5
6
7
8
9
One parallel phase
while (bucket is non-empty)
i) Inspect light (w < ∆) edges
ii) Construct a set of “requests” (R)
iii) Clear the current bucket
iv) Remember deleted vertices (S)
v) Relax request pairs in R
Relax heavy request pairs (from S)
Go on to the next bucket

Initialization:

  • Insert s into bucket, d(s) = 0

最大独立集

  • 顶点V={1,2,…,n}的图
  • 如果S中没有两个顶点是相邻的,则S组顶点是independent的。
  • 如果无法添加另一个顶点并保持独立,则独立集S是maximal的
  • 如果没有其他独立集具有更多顶点,则独立集Smaximum
  • 难以找到最大独立集(NP难)
  • 至少在一个处理器上,找到最大独立集很容易。

红色顶点集S={4,5}是独立的,是maximal的,但不是maximum

串行的最大独立集算法:

1
2
3
4
S = empty set;
for vertex v = 1 to n
if (v has no neighbor in S)
add v to S

并行随机的最大独立集算法

1
2
3
4
5
6
7
8
9
10
S = empty set; C = V;
while C is not empty {
label each v in C with a random r(v);
for all v in C in parallel {
if r(v) < min( r(neighbors of v) ) {
move v from C to S;
remove neighbors of v from C;
}
}
}

M. Luby. “A Simple Parallel Algorithm for the Maximal Independent Set Problem”

Strongly connected components(SCC)

块三角形式的对称置换,通过深度优先搜索在线性时间内找到P。

线性方法:使用DFS,DFS似乎具有内在的顺序性。并行:分而治之和BFS(Fleischer et al.),最坏情况O(n),但实际情况良好。

  • 把给定的图分成三个邻接的图,每一个可以递归独立处理。
  • 使用并行BFS

二分图匹配:

  • 匹配:没有公共端点的边的子集M。
  • | M |=匹配M的基数

最大基数匹配的单源算法

最大基数匹配的多源算法

lecture 21

大爆炸宇宙学什么的,没用

lecture 22

云计算和大数据处理

数据编程模型:“数据”并行模型(松散耦合)

  • 限制编程接口
  • 自动处理故障、位置等。

Map-Reduce就是基于这样的模型,每个数据类型都是(key, value)键值对,输入被分到一些节点上,然后结果被reduce到一些节点上

  • Map:(Kin, Vin) ➡ list(Kinter, Vinter)
  • Reduce:(Kinter, list(Vinter)) ➡ list(Kout, Vout)

如果一个节点上的作业挂了?

  • 在其他节点上重试这个作业
  • 如果这个作业还是挂掉,结束这个作业

如果节点挂了?在其他节点上重新启动现在的任务

相比于MPI是并行模型,更加细粒度,MapReduce是更高级别的数据级并行,自动进行数据传递。

如果是多步的任务,需要更多的mapper和reducer,比如有21个MR steps的话,就要有21个mapper和21个reducer。因此提出了spark。

spark提供了更简单的API,比MR少5-10倍的代码,内存计算速度更快,也有算子之间的优化。

lecture 23

为什么会有很垃圾的扩展性?

  • 通信开销大
  • 同步开销大
  • 很多进程空闲着(负载不平衡、缺乏并行性等)

如何测试负载不平衡?

  • 很难将负载不平衡与高同步开销分开
  • 基本测量:barrier周围的计时器
    • 程序缺乏并行性是固有的负载不平衡——无法通过更好的负载平衡来解决
    • 不要性能数据的平均!需要查看所有值或画出直方图
    • 特别微妙,如果不是批量同步的话
    • 自旋锁可以使同步看起来像是有用的工作
    • 不平衡可能由硬件(缓存、动态时钟等)引起

矩阵向量乘:很简单的是稠密乘;比较难的稀疏乘,每一行都有不同的开销,如果提前知道稀疏矩阵的布局,将行按照相同的非零元数分割更好。

任务开销可变性(来自应用程序)

  • 简单:开销相等,数量固定:规则网格、密集矩阵、直接 n 体
  • 困难:任务有不同但可估计的时间,固定数量:自适应和非结构化网格、稀疏矩阵、基于树的 n 体、粒子网格方法
  • 最难:直到执行中期才知道时间或计数:搜索(UTS)、不规则边界、子网格物理、不可预测的机器

请注意,良好的负载平衡无法解决并行性不足的问题。

作业之间的依赖关系:

  • 简单:一组准备好的任务
    • 矩阵乘法,域分解(空间循环)
  • 中:任务有已知关系(任务图)
    • chain:随着时间的推移循环; 迭代方法(外循环)
    • tree:分而治之的算法
    • graph:直接求解器(密集和稀疏),即 LU、Cholesky…
  • 困难:直到运行时才知道任务结构
    • tree:搜索
    • graph:离散事件

DFS vs BFS

  • 带有显式堆栈的 DFS——几乎没有并行性
    • 将根节点放入栈
    • 当栈不为空
      • 将栈顶元素弹出
      • 如果找到了目标就返回
      • 否则将子节点放入栈中
  • 带有显式堆栈的BFS——并行性强
    • 将根放入队列(FIFO)
      • 当队列不为空时
      • 移除队列前端
      • 如果找到目标?返回成功
      • 否则将子节点排入队列末尾

分布式任务队列

  • 任务队列对分布式内存的明显扩展是:
    • 分布式任务队列(或包),即每个处理器一个
    • 空闲处理器可以拉动工作,或者忙碌的处理器推工作
  • 什么时候“分布式”队列是个好主意?
    • 分布式内存多处理器
    • 通常在共享内存上以避免同步争用
    • 任务之间的位置不是(非常)重要
    • 事先不知道任务和/或数量的开销
  • 术语旁注:
    • 队列:先进先出 (FIFO)
    • 堆栈:后进先出 (LIFO)
    • 包:任意出局

如何选择发送/接收处理器

  • 基本技术:
    • 独立循环(常见bug:都开始看p0)
      • 每个处理器k,保持一个变量targetk
      • 当处理器用完工作时,从 targetk 请求工作
      • 设置 targetk = (targetk +1) mod procs
    • 全局循环
      • Proc 0 保持单个可变target变量
      • 当处理器需要工作时,获取target,从target请求工作
      • Proc 0 设置目标 = (target + 1) mod procs
    • 随机获取
      • 当处理器需要工作时,随机选择一个处理器并向它请求工作
    • 随机推送
      • 当处理器有太多工作(至少两个任务)时,推送任务到随机处理器
  • 终止检测非常重要

随机负载均衡

  • 想要避免共享队列的瓶颈
  • 特别是在分布式内存中(但即使在共享内存中)
  • 所以自调度、Chunked SS、GSS 都不好
  • 使用分布式队列进行共享
  • 如何选择处理器?
    • 异步或全局循环
    • 随机推送:快速平衡,可能会失去空间局部性
    • 随机拉取:缓慢平衡,尽可能保留局部性

Cilk:内建负载均衡的语言

基于扩散的负载均衡

  • 在随机化方案中,机器被处理为全连接。 [Cybenko,1989]
  • 基于扩散的负载平衡将拓扑考虑在内
    • 向附近的几个处理器发送一些额外的工作
      • 与附近邻居的平均工作量
      • 与扩散的类比(Jacobi 用于求解泊松方程)
    • 局部性优于选择随机处理器
    • 负载平衡比随机化慢
    • 在创建时必须知道任务的开销
    • 任务之间没有依赖关系
  • 参见 Ghosh et al,SPAA96 了解二阶扩散负载平衡算法
    • 考虑上次发送的工作量
    • 避免一阶方案的一些振荡

DAG调度软件

  • DAGuE
    • 开发库以支持(最初)稠密线性代数
  • SMPss
    • 基于编译器; 通过编译指示表达的数据使用; 提议在 OpenMP 中; 最近添加的 GPU 支持
  • StarPU (INRIA)
    • 基于库; GPU支持; 分布式数据管理; Codelets=tasks(映射 CPU、GPU 版本)
  • OpenMP4.0 / GCC 4.9
    • 参见 openmp.org
  • 其他工具(例如,仅限 fork-join 图)
    • Cilk、英特尔线程构建块 (TBB)、Microsoft CCR、SuperGlue 和 DuctTEiP

任务通信

  • 静态:常规(或无)
    • 网格上的最近邻、密集矩阵、规则网格、FFT、直接 n 体(all-to-all)集合(难以扩展;易于调度)
  • 半静态:可以预先计算通信模式
    • 可以预先安排发送/接收对
    • 稀疏直接线性代数求解器,例如 LU、Cholesky、AMR、树结构 n 体
  • 动态:随机访问 - 模式事先未知且不会重复
    • 搜索、离散事件、稀疏更新、直方图、哈希表

解决方案的范围:一个关键问题是何时知道有关负载平衡问题的某些信息。

  • 静态调度。 所有信息都可用于调度算法,该算法在任何实际计算开始之前运行。
    • 离线算法,例如图分区、DAG 调度
    • 如果信息太多,仍可能使用动态方法
  • 半静态调度。 信息可能在程序启动时、每个时间步的开始或其他明确定义的点上是已知的。 即使问题是动态的,也可以使用离线算法。
    • 例如 Kernighan-Lin,如 Zoltan
  • 动态调度。 直到执行中期才知道信息。
    • 在线算法——今天的主要话题

动态负载均衡

负载均衡因任务属性而异

  • 任务成本
    • 所有任务的成本是否相等?
    • 如果没有,成本什么时候知道?
      • 开始前、任务创建时或仅任务结束时
  • 任务依赖
    • 所有任务能否以任何顺序运行(包括并行)?
    • 如果没有,何时知道相关性?
      • 开始前、任务创建时或仅任务结束时
      • 一项任务可能会提前结束另一项任务(例如搜索)
  • 位置(可能会与负载平衡进行权衡)
    • 将某些任务安排在同一处理器(或附近)是否重要?以降低通信成本?
    • 什么时候知道有关通信的信息?
  • 如果仅在任务结束时才知道属性
    • 统计数据是固定的、缓慢变化的还是突然变化的?

混合并行:作为另一种变体,考虑具有 2 个并行性级别的问题

  • 粗粒度的任务并行性
    • 任务多时好,任务少时差
  • 细粒度的数据并行性
    • 任务中的并行度高时好,少时差
  • 出现在:
    • 自适应网格细化
    • 离散事件模拟,例如电路模拟
    • 数据库查询处理
    • 稀疏矩阵直接求解器
  • 我们如何很好地调度这两种并行?

lecture 24

四叉树

  • 细分平面的数据结构
    • 节点可以包含框中心坐标、边长
    • 最后还有 CM、总质量等的坐标。
  • 在完整的四叉树中,每个非叶节点有 4 个孩子

使用四叉树和八叉树

  • 我们所有的算法都从构建一棵树来容纳所有粒子开始
  • 在完整的树中,大多数节点都是空的,浪费空间和时间
  • Adaptive Quad (Oct) Tree 仅细分粒子所在的空间

自适应四叉树的开销:

  • Cost ≤ N * maximum cost of Quad_Tree_Insert
    • = O( N * maximum depth of Quad_Tree)
  • 粒子分布平均
    • Depth of Quad_Tree = O( log N )
    • Cost ≤ O( N * log N )
  • 粒子分布随机
    • Depth of Quad_Tree = O( # bits in particle coords ) = O( b )
    • Cost ≤ O( b N )

Barnes-Hut Algorithm

  • N-Body 问题的 O(N log N) 近似算法

  • 使用 QuadTreeBuild 构建四叉树

    • 已经描述过,成本 = O( N log N) 或 O(b N)
  • 对于 QuadTree 中的每个节点,计算其包含的所有粒子的 CM 和总质量 (TM)
    • 四叉树的后序遍历,成本 = O(N log N) 或 O(b N)
  • 对于每个粒子,遍历四叉树来计算它所受的力,使用距离子平方的CM和TM
    • 算法核心
    • 开销取决于所需的准确度,但仍为 O(N log N) 或 O(bN)

Step 3 of BH: 计算每个粒子上的力

  • 对于每个节点,可以通过使用节点CM和TM来近似由于节点内部的粒子而对节点外部的粒子施加的力
  • 如果节点离粒子足够远,这将足够准确
  • 对于每个粒子,使用尽可能少的节点来计算力,受精度约束
  • 需要判断一个节点是否离粒子足够远的标准
    • D = side length of node
    • r = distance from particle to CM of node
    • q = user supplied error tolerance < 1
    • Use CM and TM to approximate force of node on box if D/r < q

Details of Step 3 of BH

  • 对于每个粒子,遍历四叉树以计算其上的力
  • for k = 1 to N
    • f(k) = TreeForce( k, root )
    • 计算由根内所有粒子引起的粒子 k 上的力(k 除外)
  • function f = TreeForce( k, n )
    • 对于节点 n 内的所有粒子(k 除外),计算粒子 k 上的力
    • f = 0
    • 如果 n 包含一个粒子(不是 k)……直接求值
      • f = 使用直接公式计算的力
    • 否则
      • r = 从粒子 k 到粒子在 n 中的 CM 的距离
      • D = size of n
      • if D/r < q … 可以通过 CM 和 TM 来近似
        • 使用 CM 和 TM 近似计算 f
      • else ..需要查看内部节点
        • for all children c of n
        • f = f + TreeForce ( k, c )

lecture 25

元素选择问题:在N个数据中找到第k小个元素,是一些算法的基础部分。快速选择(Hoare方法)类似快排,但是只在一个方向上递归。平均时间O(N),最坏情况O(N2)。

中位数查找 (Blum, Floyd, Pratt, Rivest, Tarjan):基于quickselect,但保证最坏情况下的线性时间。与并行算法紧密结合。

快速排序的思想,是找出一个中轴(pivot),之后进行左右递归进行排序,关于递归快速排序,C程序算法如下。

1
2
3
4
5
6
void quick_sort(int *arr,int left,int right){
if(left>right) return;
int pivot=getPivot();
quick_sort(arr,left,pivot-1);
quick_sort(arr,pivot+1,right);
}

关于划分,不同的划分决定快排的效率,下面以lomuto划分和hoare划分来进行讲述思路

lomuto划分思想:lomuto划分主要进行一重循环的遍历,如果比left侧小,则进行交换。然后继续进行寻找中轴。最后交换偏移的数和最左侧数,C程序代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**lomuto划分*/
int lomuto_partition(int *arr,int l,int r){
int p=arr[l];
int s=l;
for(int i=l+1;i<=r;i++)
if(arr[i]<p) {
s++;
int tmp=arr[i];
arr[i]=arr[s];
arr[s]=tmp;
}
int tmp=arr[l];
arr[l]=arr[s];
arr[s]=tmp;
return s;
}

hoare划分思想是先从右侧向左进行寻找,再从左向右进行寻找,如果左边比右边大,则左右进行交换。外侧还有一个嵌套循环,循环终止标志是一重遍历,这种寻找的好处就是,在一次遍历后能基本有序,减少递归的时候产生的比较次数。这也是经典快排中所使用的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**hoare划分*/
int hoare_partition(int *a,int l, int r) {
int p = a[l];
int i = l-1;
int j = r+1 ;
while (1) {
do {
j--;
}while(a[j]>p);
do {
i++;
}while(a[i] < p);
if (i < j) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}else
return j;
}
}

经典快排实际对hoare划分进行了少许改进,这个temp变量不需要每次找到左右不相等就立即交换,而是,暂时存放,先右边向左找,将左边放在右边,再左边向右找,把右边放左边,最后把初始temp变量放在左值。这样比hoare划分减少少许移动变量次数。

1
2
3
4
5
6
7
8
9
10
11
12
/**经典快排*/
int classic_quick_sort(int *arr,int left,int right){
int tmp=arr[left];
while(left<right){
while(left<right&&arr[right]>=tmp) right--;
arr[left]=arr[right];
while(left<right&&arr[left]<=tmp) left++;
arr[right]=arr[left];
}
arr[left]=tmp;
return left;
}

选择中位数的方法:

  • 将整个列表分成最少N/5个子列表,每个子列表最少5个元素;
  • 对每个子列表排序并找到中位数
  • 对这些子列表中的每个中位数,组织成一个列表M
  • 对M继续上述行为。

最好情况下,递归操作找到中位数,各个元素之间的关系如下,A’中的元素都小于中位数,C’中的元素都大于中位数。

将整个列表划分为三个区域集:A、B、C

  • A=小于mm的元素集(A’是A的子集)
  • B=等于mm的元素集
  • C=大于mm的元素集(C’是C的子集)
1
2
3
4
5
6
7
8
9
10
11
12
SELECT(S, k) // find kth smallest in S
{
M = DIVIDEANDSORT(S,5); // O(N), M: list of medians
mm = SELECT(M,|M|/2); // recurse on O(N/5)
[A,B,C] = PARTITION(S,mm); // O(N)
if (|A| < k <= |A| + |B|)
return x;
else if (k <= |A|), // recurse on O(7N/10)
return SELECT(A, k)
else if (if k > |A| + |B|) // recurse on O(7N/10)
return SELECT(C, k -|A|-|B|)
}

复杂度:T(n) <= T(n/5) + T(7n/10) + O(n),A不包括C’的成员(C不包括A’的成员)。因为T(n) <= T(9n/10)+O(n),且nlog109 <= O(n),所以T(n) = O(n)

有没有一种并行的算法实现查找中位数?

  • 加权中值的中位数

给定p个元素m1, m2 , … , mp 每个元素有正的权重w1 , w2 , … , wp,Σ1<=i<=p wi = 1,加权中值是满足以下条件的元素M,Σi,mi<M wi <= 1/2 and Σi,mi>M wi <= 1/2,就是说找到一个i,使得i前边的元素的权值加起来和i后边的元素的权值加起来都小于等于1/2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PARALLELSELECT(S, k) // find kth smallest in S
{
lm = SELECT(S,|S|/2); // find local median
LMS = MPI_Allgather(lm,0); // exchange medians
wmm = WeightedMedian(LMS); // redundant computation
[A,B,C] = PARTITION(S,wmm); // same as in serial
MPI_Allreduce(size(A), &ls, MPI_SUM); // less than
MPI_Allreduce(size(B), &eq, MPI_SUM); // equal to
if (ls < k <= ls + eq) // solution found
return wmm;
else if (k <= ls) // recurse on O(3N/4)
return PARALLELSELECT(A,k)
else if (if k > ls + eq) // recurse on O(3N/4)
return PARALLELSELECT( C, k-|A|-|B|)
}

因为Σi,mi<M wi <= 1/2Σi,mi>M wi <= 1/2,用每个处理器中的元素数替换权重:Σi,mi<M ni <= N/2Σi,mi>M ni <= N/2

在处理器i处,小于等于mi的元素至少为ni/2(根据中值定义)。这些元素中有一半也小于M。

  1. 因此,小于或等于M的总#元素(在所有处理器中)为N/4
  2. “大于或等于”的大小写是对称的

合并排序

  • Mergesort是递归排序算法的一个示例。
  • 它基于分而治之的范式
  • 它使用合并操作作为其基本操作(接收两个排序序列并生成单个排序序列)
  • mergesort的缺点:不是in-place的(使用额外的临时阵列)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
void Merge(T *C, T *A, T *B, int na, int nb) {
while (na>0 && nb>0) {
if (*A <= *B) {
*C++ = *A++; na--;
} else {
*C++ = *B++; nb--;
}
}
while (na>0) {
*C++ = *A++; na--;
}
while (nb>0) {
*C++ = *B++; nb--;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T>
void MergeSort(T *B, T *A, int n) {
if (n==1) { B[0] = A[0];}
else {
T* C = new T[n];
#pragma omp parallel {
#pragma omp single {
#pragma omp task
MergeSort(C, A, n/2);
#pragma omp task
MergeSort(C+n/2, A+n/2, n-n/2);
}
}
Merge(B, C, C+n/2, n/2, n-n/2);
delete[] C;
}
}

如果两个数组中要合并的元素总数为n=na+nb,则两个递归合并中较大的一个数组中的元素总数最多为(3/4)n。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename T>
void P_Merge(T *C, T *A, T *B, int na, int nb) {
if (na < nb) { P_Merge(C, B, A, nb, na);}
else if (na==0) { return; }
else {
int ma = na/2;
int mb = BinarySearch(A[ma], B, nb);
C[ma+mb] = A[ma];
#pragma omp parallel {
#pragma omp single {
#pragma omp task
P_Merge(C, A, B, ma, mb);
#pragma omp task
P_Merge(C+ma+mb+1,A+ma+1,B+mb,na-ma-1,nb-mb);
}
} // implicit taskwait
}
}

在桶排序中,输入数字的范围[a,b]被划分为m个大小相等的间隔,称为桶。

  • 每个元素都放置在其相应的桶中。
  • 如果数量在范围内均匀划分,则预计bucket的元素数量大致相同。
  • 桶中的元素在本地分类。
  • 该算法的运行时间为Θ(nlog(n/m))。

并行bucket排序

  • 并行桶排序相对简单。我们可以选择m=p。
  • 在这种情况下,每个处理器都有其负责的一系列值。
  • 每个处理器运行其本地array,并将其每个元素分配给相应的处理器。
  • 使用单个all-to-all个性化通信将元素发送到目标处理器。
  • 每个处理器对其接收的所有元素进行排序。
  • 负载不平衡:假设输入元件均匀分布在间隔[a,b]上是不现实的。

并行采样排序

  • 快速排序的泛化:从1支点到p支点。这是通过选择合适的拆分来实现的。
  • 拆分选择方法将n个元素划分为m个块,每个块的大小为n/m,并使用快速排序对每个块进行排序。
  • 从每个排序块中选择m-1个均匀间隔的样本。
  • 从所有区块中选择的m(m-1)元素代表用于确定铲斗的样本。
  • 该方案保证每个桶中结束的元素数量小于2n/m

并行样本排序:复杂性分析

  • n/p元素的内部排序需要时间Θ((n/p)log(n/p)),而p–1样本元素的选择需要时间Θ(p)。
  • all-to-all广播的时间为Θ(p^2),对p(p–1)样本元素进行内部排序的时间为Θ(p^2log p),选择p–1等距分离器需要时间Θ(p)。
  • 每个进程可以通过在时间Θ(plog(n/p))中执行p-1二进制搜索,将这些p-1拆分器插入其大小为n/p的本地排序块中。
  • 元素重排时间为O(n/p)