你左手是内存,右手是显存,内存可以打死显存,显存也可以打死内存。

                             —— 请协调好你的主存

从硬件说起

物理之觞

大部分Caffe源码解读都喜欢跳过这部分,我不知道他们是什么心态,因为这恰恰是最重要的一部分。

内存的管理不擅,不仅会导致程序的立即崩溃,还会导致内存的泄露,当然,这只针对传统CPU程序而言。

由于GPU的引入,我们需要同时操纵俩种不同的存储体:

一个受北桥控制,与CPU之间架起地址总线、控制总线、数据总线。

一个受南桥控制,与CPU之间仅仅是一条可怜的PCI总线。

一个传统的C++程序,在操作系统中,会被装载至内存空间上。

一个有趣的问题,你觉得CPU能够访问显存空间嘛?你觉得你的默认C++代码能访问显存空间嘛?

结果显然是否定的,问题就在于CPU和GPU之间只存在一条数据总线。

没有地址总线和控制总线,你除了让CPU发送数据拷贝指令外,别无其它用处。

这不是NVIDIA解决不了,AMD就能解决的问题。除非计算机体系结构再一次迎来变革,

AMD和NVIDIA的工程师联名要求在CPU和GPU之间追加复杂的通信总线用于异构程序设计。

当然,你基本是想多了。

环境之艰

可怜的数据总线,加大了异构程序设计的难度。

于是我们看到,GPU的很大一部分时钟周期,用在了和CPU互相交换数据。

也就是所谓的“内存与显存之间友好♂关系”。

你不得不接受一个事实:

GPU最慢的存储体,也就是片外显存,得益于镁光的GDDR技术,目前家用游戏显卡的访存速度也有150GB/S。

而我们可怜的内存呢,你以为配上Skylake后,DDR4已经很了不起了,实际上它只有可怜的48GB/S。

那么问题来了,内存如何去弥补与显存的之间带宽的差距?

答案很简单:分时、异步、多线程。

换言之,如果GPU需要在接下来1秒内,获得CPU的150GB数据,那么CPU显然不能提前一秒去复制。

它需要提前3秒、甚至4秒。如果它当前还有其它串行任务,你就不得不设个线程去完成它。

这就是新版Caffe增加的新功能之一:多重预缓冲。

设置于DataLayer的分支线程,在GPU计算,CPU空闲期间,为显存预先缓冲3~4个Batch的数据量,

来解决内存显存带宽不一致,导致的GPU时钟周期浪费问题,也增加了CPU的利用率。

最终,你还是需要牢记一点:

不要尝试以默认的C++代码去访问显存空间,除非你把它们复制回内存空间上。

否则,就是一个毫无提示的程序崩溃问题(准确来说,是被CPU硬件中断了【微机原理或是计算机组成原理说法】)

编程之繁

在传统的CUDA程序设计里,我们往往经历这样一个步骤:

->计算前

cudaMalloc(....)          【分配显存空间】

cudaMemset(....)   【显存空间置0】

cudaMemcpy(....)    【将数据从内存复制到显存】

->计算后

cudaMemcpy(....)       【将数据从显存复制回内存】

这些步骤相当得繁琐,你不仅需要反复敲打,而且如果忘记其中一步,就是毁灭性的灾难。

这还仅仅是GPU程序设计,如果考虑到CPU/GPU异构设计,那么就更麻烦了。

于是,聪明的人类就发明了主存管理自动机,按照按照一定逻辑设计状态转移代码。

这是Caffe非常重要的部分,称之为SyncedMemory(同步存储体)。

主存模型

状态转移自动机

自动机共有四种状态,以枚举类型定义于类SyncedMemory中:

enum SyncedHead { UNINITIALIZED, HEAD_AT_CPU, HEAD_AT_GPU, SYNCED };

这四种状态基本会被四个应用函数触发:cpu_data()、gpu_data()、mutable_cpu_data()、mutable_gpu_data()

在它们之上,有四个状态转移函数:to_cpu()、to_gpu()、mutable_cpu()、mutable_gpu()

前两个状态转移函数用于未进入Synced状态之前的状态机维护,后两个用于从Synced状态中打破出来。

具体细节见后文,因为Synced状态会忽略to_cpu和to_gpu的行为,打破Synced状态只能靠人工赋值,切换状态头head。

后两个mutable函数会被整合在应用函数里,因为它们只需要简单地为head赋个值,没必要大费周章写个函数封装。

★UNINITIALIZED:

UNINITIALIZED状态很有趣,它的生命周期是所有状态里最短的,将随着CPU或GPU其中的任一个申请内存而终结。

在整个内存周期里,我们并非一定要遵循着,数据一定要先申请内存,然后在申请显存,最后拷贝过去。

实际上,在GPU工作的情况下,大部分主存储体都是直接申请显存的,如除去DataLayer的前向/反向传播阶段。

所以,UNINITIALIZED允许直接由to_gpu()申请显存。

由此状态转移时,除了需要申请内存之外,通常还需要将内存置0。

★HEAD_AT_CPU:

该状态表明最近一次数据的修改,是由CPU触发的。

注意,它只表明最近一次是由谁修改,而不是谁访问。

在GPU工作时,该状态将成为所有状态里生命周期第二短的,通常自动机都处于SYNCED和HEAD_AT_GPU状态,

因为大部分数据的修改工作都是GPU触发的。

该状态只有三个来源:

I、由UNINITIALIZED转移到:说白了,就是钦定你作为第一次内存的载体。

II、由mutable_cpu_data()强制修改得到:都要准备改数据了,显然需要重置状态。

cpu_data()及其子函数to_cpu(),只要不符合I条件,都不可能转移到改状态(因为访问不会引起数据的修改)

★HEAD_AT_GPU:

该状态表明最近一次数据的修改,是由GPU触发的。

几乎是与HEAD_AT_CPU对称的

★SYNCED:

最重要的状态,也是唯一一个非必要的状态。

单独设立同步状态的原因,是为了标记内存显存的数据一致情况。

由于类SyncedMemory将同时管理两种主存的指针,

如果遇到HEAD_AT_CPU,却要访问显存。或是HEAD_AT_GPU,却要访问内存,那么理论上,得先进行主存复制。

这个复制操作是可以被优化的,因为如果内存和显存的数据是一致的,就没必要来回复制。

所以,使用SYNCED来标记数据一致的情况。

SYNCED只有两种转移来源:

I、由HEAD_AT_CPU+to_gpu()转移到:

含义就是,CPU的数据比GPU新,且需要使用GPU,此时就必须同步主存。

II、由HEAD_AT_GPU+to_cpu()转移到:

含义就是,GPU的数据比CPU新,且需要使用CPU,此时就必须同步主存。

在转移至SYNCED期间,还需要做两件准备工作:

I、检查当前CPU/GPU态的指针是否分配主存,如果没有,就重新分配。

II、复制主存至对应态。

处于SYNCED状态后,to_cpu()和to_gpu()将会得到优化,跳过内部全部代码。

自动机将不再运转,因为,此时仅需要返回需要的主存指针就行了,不需要特别维护。

这种安宁期会被mutable前缀的函数打破,因为它们会强制修改至HEAD_AT_XXX,再次启动自动机

代码实战

主存操作函数

建立synced_memory.hpp,在操作主存之前,你需要封装一些基础函数。

CPU端的函数是C/C++标准的通用函数:

inline void dragonMalloc(void **ptr, size_t size){
*ptr = malloc(size);
CHECK(*ptr) << "host allocation of size " << size << " failed";
}
inline void dragonFree(void *ptr){
free(ptr);
}
inline void dragonMemset(void *ptr,size_t size){
memset(ptr, , size);
}
inline void dragonMemcpy(void* dest, void* src,size_t size){
memcpy(dest, src, size);
}

内存操纵函数

CHECK宏由GLOG提供,条件为假时,会触发assert,终结程序。

GPU端的函数由CUDA提供:

#ifndef CPU_ONLY
#include "cuda.h"
inline void cudaSetDevice(){
int device;
cudaGetDevice(&device);
if (device != -) return;
CUDA_CHECK(cudaSetDevice());
}
inline void dragonGpuMalloc(void **ptr, size_t size){
cudaSetDevice();
CUDA_CHECK(cudaMalloc(ptr, size));
}
inline void dragonGpuFree(void *ptr){
cudaSetDevice();
CUDA_CHECK(cudaFree(ptr));
}
inline void dragonGpuMemset(void *ptr, size_t size){
cudaSetDevice();
CUDA_CHECK(cudaMemset(ptr, , size));
}
inline void dragonGpuMemcpy(void *dest, void* src, size_t size){
cudaSetDevice();
CUDA_CHECK(cudaMemcpy(dest, src, size, cudaMemcpyDefault));
}
#endif

显存操纵函数

#ifndef CPU ONLY  ..... #endif 确保本段代码不会被非CUDA模式所编译

cudaSetDevice()是一个通用函数,在后期,你应该移至common.hpp中。

该函数不是必要的,目的只是对当前执行GPU的一个惯性检查,检查失败则终结程序。

需要注意的是cudaMemcpy的最后一个参数,Flag:cudaMemcpyDefault,在CUDA 6.0之后才被使用。

在6.0版本之前,cudaMemcpy需要指明dest和src的来源,是host向device,还是device向host,还是device向device?

所以,早期的CUDA代码可能需要三个if来指明Flag的值,而cudaMemcpyDefault会自动检测,相当智能。

数据结构

class SyncedMemory
{
public:
SyncedMemory():cpu_ptr(NULL), gpu_ptr(NULL), size_(), head_(UNINITIALIZED) {}
SyncedMemory(size_t size) :cpu_ptr(NULL), gpu_ptr(NULL), size_(size), head_(UNINITIALIZED) {}
void to_gpu();
void to_cpu();
const void* cpu_data();
const void* gpu_data();
void set_cpu_data(void *data);
void set_gpu_data(void *data);
void* mutable_cpu_data();
void* mutable_gpu_data();
#ifndef CPU_ONLY
void async_gpu_data(const cudaStream_t& stream);
#endif
enum SyncedHead { UNINITIALIZED, HEAD_AT_CPU, HEAD_AT_GPU, SYNCED };
void *cpu_ptr, *gpu_ptr;
size_t size_;
bool own_cpu_data, own_gpu_data;
SyncedHead head_;
SyncedHead head() { return head_; }
size_t size() { return size_; }
~SyncedMemory();
};

SynecMemory的声明

成员变量包括:

★两个主存指针:cpu_ptr、gpu_ptr

★主存大小size以及状态标记head

★共享标记:own_cpu_data、 own_gpu_data

成员函数包括:

★状态转移函数:void to_gpu()、void to_cpu()

★常访问函数:const void* cpu_data()、 const void* gpu_data()

★修改函数:void* mutable_cpu_data()、void* mutable_gpu_data()

★共享函数:void set_cpu_data(void *data)、void set_gpu_data(void *data)

★封装访问函数:SyncedHead head()、size_t size()

★异步流同步函数与析构函数:void async_gpu_data(const cudaStream_t& stream)、~SyncedMemory()

值得注意的是,两个共享函数以及共享标记不属于自动机范围。

共享函数的唯一用处是用于局部主存的共享,只用于DataLayer的Transformer中。

在Blob级别的共享中,存在两种共享:

I、共享另一个Blob的全部数据:只需要让SyncedMemory指针重新指向另一个Blob的SyncedMemory指针

II、共享另一个Blob的部分数据:

这利用了C/C++内存指针的一个Trick,内存首指针可以做代数加减运算,做一定偏移。

set_xxx_data(void *data)提供了最底层的指针修改,可以直接指向偏移之后的内存,而共享部分数据。

实现

建立synced_memory.cpp。

void SyncedMemory::to_cpu()
{
switch (head_){
case UNINITIALIZED:
dragonMalloc(&cpu_ptr, size_);
dragonMemset(cpu_ptr, size_);
head_ = HEAD_AT_CPU;
own_cpu_data = true;
break;
case HEAD_AT_GPU:
#ifndef CPU_ONLY
if (cpu_ptr == NULL){
dragonMalloc(&cpu_ptr, size_);
own_cpu_data = true;
}
dragonGpuMemcpy(cpu_ptr,gpu_ptr,size_);
head_ = SYNCED;
#endif
break;
case HEAD_AT_CPU:
case SYNCED:
break;
}
}

★void SyncedMemory::to_cpu()

需要注意的是共享标记own_xxx_data,只要申请了内存,就必须做标记。

共享标记在析构时是必要的,因为你不能将宿主数据一并释放掉。

void SyncedMemory::to_gpu()
{
#ifndef CPU_ONLY
switch (head_){
case UNINITIALIZED:
dragonGpuMalloc(&gpu_ptr,size_);
dragonGpuMemset(gpu_ptr, size_);
head_ = HEAD_AT_GPU;
own_gpu_data = true;
break;
case HEAD_AT_CPU:
if (gpu_ptr == NULL){
dragonGpuMalloc(&gpu_ptr,size_);
own_gpu_data = true;
}
dragonGpuMemcpy(gpu_ptr, cpu_ptr, size_);
head_ = SYNCED;
break;
case HEAD_AT_GPU:
case SYNCED:
break;
}
#endif
}

★void SyncedMemory::to_gpu()

GPU的转移函数是与CPU版本对称的。

const void* SyncedMemory::cpu_data(){
to_cpu();
return (const void*)cpu_ptr;
} const void* SyncedMemory::gpu_data(){
to_gpu();
return (const void*)gpu_ptr;
}

★const void* SyncedMemory::xxx_data()

常访问函数,注意const指针的强制转换,访问之前需要运行一次自动机。

void* SyncedMemory::mutable_cpu_data(){
to_cpu();
head_ = HEAD_AT_CPU;
return cpu_ptr;
} void* SyncedMemory::mutable_gpu_data(){
#ifndef CPU_ONLY
to_gpu();
head_ = HEAD_AT_GPU;
return gpu_ptr;
#endif
}

★void* SyncedMemory::mutable_xxx_data()

修改函数,运行自动机、强制修改自动机状态,最后返回指针,用于从外部修改。

void SyncedMemory::set_cpu_data(void *data){
if (own_cpu_data) dragonFree(cpu_ptr);
cpu_ptr = data;
head_ = HEAD_AT_CPU;
own_cpu_data = false;
} void SyncedMemory::set_gpu_data(void *data){
#ifndef CPU_ONLY
if (own_gpu_data) dragonGpuFree(gpu_ptr);
gpu_ptr = data;
head_ = HEAD_AT_GPU;
own_gpu_data = false;
#endif
}

★void SyncedMemory::set_xxx_data(void *data)

共享函数,共享之前,先释放旧主存,修改共享标记,强制修改自动机状态。

SyncedMemory::~SyncedMemory(){
if (cpu_ptr && own_cpu_data) dragonFree(cpu_ptr);
#ifndef CPU_ONLY
if (gpu_ptr && own_gpu_data) dragonGpuFree(gpu_ptr);
#endif
}

★SyncedMemory::~SyncedMemory()

析构函数,注意检查共享标记,不能释放宿主内存。

异步流同步

异步流概念,是CUDA 5.0中引入的。

与Intel CPU的流水线架构一样,NVIDIA的GPU也采用了I/O和计算分离的流水线做法。

cudaMemcpy使用的是默认流cudaStreamDefault,编号为0。

异步流编程API开放之后,允许程序员在CPU端多线程编程中,向GPU提交异步的同步复制流,

以此增加GPU端的I/O利用率。

简单来说,默认流只允许主进程与显存复制数据,而我们实际上不可能这么干,原因有二:

I、效率低,主进程就是单线程啊。

II、很多情况下,数据复制完之前,需要阻塞。阻塞主进程不是一个好主意。

Caffe中只有一处是这么做的,那就是DataLayer正向传播一个Batch的时候,这个阻塞是必然的。

但是,在构成Batch之前,只要采取多线程设计,那么异步流复制只会阻塞旁支线程,而不会影响主进程。

这是为什么NVIDIA开放异步流API的原因,它鼓励了CPU用于多线程I/O,让GPU计算如虎添翼。

#ifndef CPU_ONLY
void SyncedMemory::async_gpu_data(const cudaStream_t& stream){
CHECK(head_ == HEAD_AT_CPU);
// first allocating memory
if (gpu_ptr == NULL){
dragonGpuMalloc(&gpu_ptr, size_);
own_gpu_data = true;
}
const cudaMemcpyKind kind = cudaMemcpyHostToDevice;
CUDA_CHECK(cudaMemcpyAsync(gpu_ptr, cpu_ptr, size_, kind, stream));
head_ = SYNCED;
}
#endif

★void SyncedMemory::async_gpu_data()

异步流的底层代码接受一个异步流作为参数,使用cudaMemcpyAsync()向GPU提交复制任务。

它等效于HEAD_AT_CPU+to_gpu(),所以需要更新同步标记。

完整代码将在DataLayer中完成,该函数将由多线程调用。

完整代码

synced_mem.hpp:

https://github.com/neopenx/Dragon/blob/master/Dragon/include/synced_mem.hpp

synced_mem.cpp:

https://github.com/neopenx/Dragon/blob/master/Dragon/src/synced_mem.cpp

从零开始山寨Caffe·贰:主存模型的更多相关文章

  1. 从零开始山寨Caffe·拾贰:IO系统(四)

    消费者 回忆:生产者提供产品的接口 在第捌章,IO系统(二)中,生产者DataReader提供了外部消费接口: class DataReader { public: ......... Blockin ...

  2. 从零开始山寨Caffe·陆:IO系统(一)

    你说你学过操作系统这门课?写个无Bug的生产者和消费者模型试试! ——你真的学好了操作系统这门课嘛? 在第壹章,展示过这样图: 其中,左半部分构成了新版Caffe最恼人.最庞大的IO系统. 也是历来最 ...

  3. 从零开始山寨Caffe·零:必先利其器

    工作环境 巧妇有了米炊 众所周知,Caffe是在Linux下写的,所以长久以来,大家都认为跑Caffe,先装Linux. niuzhiheng大神发起了caffe-windows项目(解决了一些编译. ...

  4. 从零开始山寨Caffe·壹:仰望星空与脚踏实地

    请以“仰望星空与脚踏实地”作为题目,写一篇不少于800字的文章.除诗歌外,文体不限. ——2010·北京卷 仰望星空 规范性 Caffe诞生于12年末,如果偏要形容一下这个框架,可以用"须敬 ...

  5. 从零开始山寨Caffe·捌:IO系统(二)

    生产者 双缓冲组与信号量机制 在第陆章中提到了,如何模拟,以及取代根本不存的Q.full()函数. 其本质是:除了为生产者提供一个成品缓冲队列,还提供一个零件缓冲队列. 当我们从外部给定了固定容量的零 ...

  6. 从零开始山寨Caffe·肆:线程系统

    不精通多线程优化的程序员,不是好程序员,连码农都不是. ——并行计算时代掌握多线程的重要性 线程与操作系统 用户线程与内核线程 广义上线程分为用户线程和内核线程. 前者已经绝迹,它一般只存在于早期不支 ...

  7. 从零开始山寨Caffe·拾:IO系统(三)

    数据变形 IO(二)中,我们已经将原始数据缓冲至Datum,Datum又存入了生产者缓冲区,不过,这离消费,还早得很呢. 在消费(使用)之前,最重要的一步,就是数据变形. ImageNet Image ...

  8. 从零开始山寨Caffe·玖:BlobFlow

    听说Google出了TensorFlow,那么Caffe应该叫什么? ——BlobFlow 神经网络时代的传播数据结构 我的代码 我最早手写神经网络的时候,Flow结构是这样的: struct Dat ...

  9. 从零开始山寨Caffe·柒:KV数据库

    你说你会关系数据库?你说你会Hadoop? 忘掉它们吧,我们既不需要网络支持,也不需要复杂关系模式,只要读写够快就行.    ——论数据存储的本质 浅析数据库技术 内存数据库——STL的map容器 关 ...

随机推荐

  1. 高斯混合模型(GMM)

    复习: 1.概率密度函数,密度函数,概率分布函数和累计分布函数 概率密度函数一般以大写“PDF”(Probability Density Function),也称概率分布函数,有的时候又简称概率分布函 ...

  2. 数据存储_ SQLite (1)

    一.SQL语句 如果要在程序运行过程中操作数据库中的数据,那得先学会使用SQL语句 1.什么是SQL SQL(structured query language):结构化查询语言 SQL 是一种对关系 ...

  3. 《大型网站系统与Java中间件实践》读书笔记——CAP理论

    分布式事务希望在多机环境下可以像单机系统那样做到强一致,这需要付出比较大的代价.而在有些场景下,接收状态并不用时刻保持一致,只要最终一致就行. CAP理论是Eric Brewer在2000年7月份的P ...

  4. Shell case esac语句

    case ... esac 与其他语言中的 switch ... case 语句类似,是一种多分枝选择结构. case 语句匹配一个值或一个模式,如果匹配成功,执行相匹配的命令.case语句格式如下: ...

  5. JavaScript闭包(Closure)学习笔记

    闭包(closure)是JavaScript语言的一个难点,也是它的特色,很多高级应用都要依靠闭包实现. 下面就是我的学习笔记,对于JavaScript初学者应该是很有用的. 一.变量的作用域 要理解 ...

  6. C和指针 第五章 警告总结

    1.有符号的值得右移位操作是不可移植的 2.移位操作的位数是个负数,是未定义的 3.连续赋值的各个变量的长度 不一,导致变量值截断. #include <stdio.h> int main ...

  7. no screens found! ubuntu进不了图形界面了

    no screens found! ubuntu进不了图形界面了 结果是没装显卡 startx error. reinstall xorg, x server doesn't work. driver ...

  8. poj 1695

    用动态规划,dp[a][b][c]表示从位置最大的车在a(注意不是第一辆车),第二的车在b,第三的车在c开始最少需要的时间. 方程:dp[a][b][c]=max{dp[a+1][b][c],     ...

  9. Java 计算数学表达式(字符串解析求值工具)

    Java字符串转换成算术表达式计算并输出结果,通过这个工具可以直接对字符串形式的算术表达式进行运算,并且使用非常简单. 这个工具中包含两个类 Calculator 和 ArithHelper Calc ...

  10. 安装配置LDAP遇到的问题

    问题1:安装完启动ldap服务报错: ldap: unrecognized service? 原因在于新版的openldap将服务名改为了slapd,使用service slapd start即可启动 ...