其实写这个的原因是,这周真的在很多工程上的问题耗费了太多太多的时间,基本就是一个坑接着一个坑踩下去的节奏,而且还要经常麻烦同事帮忙看,自己心里又愧疚又不爽。所以就写一个类似GitHub Gist的东西,记录自己踩的坑,也希望你们以后不踩。

故事说来话长,其实是自己最近开发的R package,由于循环的性能问题,把将近一半的代码改成了C,并且还要在C里面执行并行,所以选择了OpenMP这个我认为还算坑比较少的库来实现。

如果想看懂这个东西的话,你应该需要了解一个R package的基本结构,以及R’s C/C++Interface,至少要像我一样算是个starter/beginner。我用最通俗的语言解释一下吧,就是我们都知道R里面的循环效率很低,此时我们要用编译语言去把这些R里面的循环重新写一遍,然后通过API去调用这些用C或者Cpp写的函数。所以,编译型语言,首先需要将你写好的代码通过gcc(只讲Unix-like machine下)进行编译,生成一个.o的目标文件,再把这些.o文件合成执行文件去执行。这个时候,你需要一个configure script,如果理解的形象一点呢,就是你已经写好C source code了,但你的目标是把它包裹到R里,让它在R里面能做事情,如何从起点到达目的地呢?就要写一些配置脚本,这个配置脚本呢,要告诉机器,我要达成这个目的,去哪找能使得目的达成的一些源文件,通过什么样的规则去达成这个目的等等。学过C和Cpp的同学应该都知道,这个是个叫makefile的东东。

可以就着自己的project来说说这个事情。首先,下图是一个R package的标准structure。

2016-09-24 2 38 30

那么我们需要编译的C代码就存放在src/目录下,src是source的简写,放源代码文件的。打开瞅瞅,发现长这样。一系列的.c file是吧。咦,还放了一个Makevars。里面有一个叫hello.c的,是我用来做测试的小玩意儿,就在这里头讲吧。

2016-09-24 2 50 48

// calculate sum of first 100 natural number

// include the header files
# include <R.h> 
# include <Rinternals.h> 
# include <Rmath.h>

SEXP hello(SEXP vec) {
    int n = length(vec);
    double *vec1, *a;
    vec1 = REAL(vec);
    SEXP out = PROTECT(allocVector(REALSXP, 1));
    a = REAL(out);
    for(int i = 0; i < n; i++) {
        a[0] += vec1[i];
    }
    UNPROTECT(1);
    return(out);
}

下面就来解释一下这段最简单的R和C的混写代码:

声明一下,我的C代码都是通过.Call来和R连接的(.Call,.External和.C还是略有区别的,.Call允许R对象和C对象互相操作,是比较灵活的;如果你要操作的都是向量,那么.C也是极好的,毕竟C里面没有矩阵操作(除非你用了某些矩阵操作的库或者自己写了函数),不然所有的操作都针对向量)。

扫一眼你就知道,这个函数是准备做自然数相加。SEXP是S expression的简写,函数名称前面有一个SEXP代表我们的input和output都是R对象。函数的input也需要是一个R对象,所以在这里是可以输入矩阵的,如果你要用.C,那么就一定要输入一个C对象,也就是向量。在这里我的input是一个数值型的向量。然后我们声明一个整数n,等于向量的长度。你看到length了吧?这根本就是C和R在混写!这就是.Call的灵活之处。但目前vec还是一个R对象,我们要把它转换成C可以处理的对象,可以做这个事情的函数就是REAL,具体我就不讲太多,可以去看文档,具体R对象如何转换C对象,C对象再如何转换成R对象最终输出结果。转换之后我们就要存放到一个C的对象里,所以在前面声明了一个一维数组的指针 *vec1。由于我们的输出结果必须也是R对象,所以我们需要用SEXP定义一个R对象叫out,并且要用一个PROTECT函数把它保护起来避免它被R的垃圾回收站回收释放。这是比较麻烦的一个东西,总觉得写这个很不爽。只要你创建一个R对象,紧接着你就必须要把它保护起来。allocVector就是你要为这个对象预留一个空间,第一个参数是对象的类型,第二个参数是这个向量的长度。这里我就创建了一个数值类型长度为1的向量,其实就是一个数。然后你又要把你的R对象转换成C可以处理的对象,并且在前面声明一个一维数组的指针 *a。至此,无聊的事情终于做完了,开始干正事了,要求和了。C里面的求和都是通过循环完成的(是不是觉得写的很彻底?很爽?),要提醒大家的是,C里面向量的下角标都是从0开始的,结束于向量的长度减1,这个不同的语言不一样,一定要记得,python下标也是0开始,本媛媛在努力学习,每天两小时py!所以这里要写a[0]。嗯,终于算完了吧,我们要把我们保护的对象释放了,UNPROTECT(1)就是指你要释放1个R对象。最后将R对象return。至此,这个事情就终于完成了。

我们看看结果如何:

2016-09-24 10 36 53

嗯,结果是正确的。这个事情在R里面只有1句话:sum(1:100),现在老子写了10行,真是心累。但我告诉你,当你写复杂的算法的时候,it pays

等等,细心的你会发现,我怎么只展示了源码和结果,中间编译过程呢!不要骗我们啊~

嗯,你要在scr/下新建一个叫Makevars的文件,用来写各种配置脚本来组织你的代码编译过程。简单从一个初学者的角度说说我对makefile的理解吧(貌似对学统计的你还有点用,但会被学计算机的拍死😒)。

我们都知道,C和Cpp是编译型语言,源码需要经过编译变成二进制代码以可执行文件存在,以后再调用呢,就不用翻译一遍了,所以效率高啊。那么我们熟悉的R呢?是个解释型语言。解释型语言不是在执行之前翻译,而是在执行的时候用一个解释器进行翻译,所以每每执行的时候,它都要耗时间去翻译成机器可识别的代码,所以啊,解释型语言效率低啊。那么,如何将源代码进行翻译呢?这就需要敲shell命令了。为了讲这个,我把hello.c复制到一个新建文件夹下了。

2016-09-25 10 26 52

这段程序是要在R里面用的,所以我们要用R CMD SHLIB编译源码以便能够在R里面装载。我们看看发生了什么!文件夹下多出了两个二进制代码文件。按套路来,我们把.so装载到R里并调用,嗯,可以了。哎?我说,怎么也没看你写什么Makevars啊!这不就编译结束了嘛!下面跟我研究一下你敲完R CMD SHLIB命令之后出现的两条命令吧。clang是什么?clang是苹果研发的一款编译器前端,比gcc报告错误信息更友好,且编译速度更快,不过最近老板教本媛一招儿,那就是用Intel研发的编译器,效果会更好!所以这个地方就是指定一款你要编译器,当然啦,编译器就是可以把代码翻译成机器可识别的低级语言的工具。后面一系列的-I -c -o之类的统统叫编译标记。编译标记是很有用的东西,有好多优化选项,选与不选,代码运行速度也是有很大差异的。具体可以给个链接,感觉有成百上千个选项,这个没法讲,要用的时候要查文档的!Option Summary

2016-09-25 10 46 10

2016-09-25 10 57 09

我先把命令简化一下,我们这次不用R CMD SHLIB编译了,因为它把一切都给你做好了!这不适合聪明好学刨根问底的你~其实,为什么R CMD SHLIB能把一切都做好呢?因为R里面有一些makefile告诉R CMD SHLIB该如何去编译源码。这个makefile已经把你需要的东西的路径给你指定好了,各种option也写好了,这就是makefile的好处。那如果勤快的我就想敲命令呢?那我们把.o和.so删了再重来一遍。

2016-09-25 3 10 57

这样简洁的命令最适合我们这样的初学者了!clang是我们指定的编译器,没什么好解释的。你还记得我们的C代码里有两个头文件吧?首先怎么理解header files?头文件都是以.h结尾,里面呢,放了一些函数的声明和一些宏的定义。至于头文件中声明的函数的代码,还是要放到同名的.c文件里的。这样主要是为了代码好管理。就好似写个R包,写个python库,写个head.html,当你在外部实现一些自己的任务的时候可以去调用。所以这句命令的意思是,我要把.c的源代码文件编译成目标文件.o,那么这中间我需要什么呢?我需要找到其依赖的头文件的位置。头文件在哪呢?就在-I后面的路径里。打开瞅瞅,嗯,没骗你,三个头文件都躺的好好的呢~-g的选项就是显示debug信息吧。-c后面就是你要编译的文件(源文件)的名字。-o的意思是你的目标是什么,后面就是你要生成的目标文件的名字,代表你这次的目标是将源码编译成一个二进制的目标文件。

2016-09-25 2 48 37

故事还没有结束,只是生成了目标文件,还没有生成可执行文件。如何生成可执行文件呢?要用刚刚生成的目标文件与系统库文件进行连接才行。

2016-09-25 5 16 40

不过呢,这个各种选项不准备唠叨了,比如这个时候生成的是一个动态库.so,比如-L是要搜寻系统库啥的。非计算机专业的我还是不乱卖萌了,说错了又要挨拍,这种东西要是讲透估计能讲上1个月。

好了,说了这么多,你可能还是没有明白Makevars的好处。上面我提到了,R CMD SHLIB其实就调用了R内部的makefile,下面产生的两条SHELL命令其实就是当你没有makefile这种东西的时候,你要在terminal里面输入的东西,是不是觉得很繁琐啊!!!你想想,公司开发的可都是大中型项目,上千个源码待编译,一句一句写SHELL命令岂不是要写到天昏地暗啊!!所以写一个Makevars就结了。下面就是一个非常非常简单的Makevars,也是我的package里的几句话。我们可以在Makevars里定义一些常量,比如CC = clang-omp,就是我每次编译的时候选择的编译器是哪个。我还是暂时不讲OpenMP的坑了,说多了都是泪。下面的PKG_CFLAGS呢,就是一些编译C代码的选项,比如去哪里找你的头文件。-f选项是一些优化选项,比如lto就是Link Time Optimization的缩写,我要做一些链接优化。PKG_LIBS就是你要去找到你做这件事情依赖的库,这里就是-l选项。这就是R package里面Makevars大概要做的事情。下面的这三行,可以说能完成个中小型项目吧,比如我的项目就是开发包。据说大型项目会写上千行,写出花。

CC = clang-omp
PKG_CFLAGS = $(SHLIB_OPENMP_CFLAGS) -I../inst/include -I/usr/local/Cellar/libiomp/20150701/include/libiomp -flto
PKG_LIBS = $(SHLIB_OPENMP_CFLAGS) -liomp5

其实第一次见到makefile的时候就在想,这tm是啥,老子不要学,我就准备抄抄别人的。后来随着自己装了外部库,似乎真的没法再在不懂原理的情况下抄别人的了,根本不知道要抄哪句话,根本不知道哪里环境变量配错了,哪里需要链接起来。所以感谢同事带我入门了这个东西,并且自己周末有耐心来看文档学习。一个写不好makefile的人基本上是不具备独立开展大中型项目的能力的人,因为你写好的代码你不知道要怎么组织它去编译,你就达不到你的目的了。所以有些东西还是要钻研一下的。

感悟:如果不是写C,如果不是写makefile,我是不会有机会认真了解Unix like的操作系统是什么原理的。曾经的我并不懂/usr/bin里面是什么,也不知道brew安装完的东西会到/usr/local/bin里面,还要改环境变量或者设个优先级什么的。所以说这些就是想告诉大家,一切都不容易啊,尤其我们不是计算机科班出身,但你就准备这么放弃了吗!而且我还是妹子,最讨厌研究电脑,研究操作系统,我对这些一点兴趣都没有。但重要的事情说三遍,这不是学校,这不是学校,这不是学校。这是职场,你的项目不能看心情,你要考虑周到,老板问的问题你要提前想好答案,你不能说不知道,你不能再像玩儿一样的做项目,你的代码是要上生产环境的,你的目标是优化产品,而不是学校里写逗你玩儿的simulation。

职场不易,媛媛更不易,我们一起加油~

有人要我推荐书,还是甩链接吧,谁让我人这么好呢~~

Writing R Extensions里面啥都有,够大家看的。

Advanced R Hadley同学的书

R Packages Hadley同学的书

多读别人的代码吧,尤其是那些你看不懂的代码,还是要硬着头皮看的。