Bug story

主要记录自己在项目中比较记忆犹新的各种bug

degbug的方法论

虽说debug更多的时候是工程问题,但是很好的debug技巧和工具能加速问题排查的过程。

gdb and print

gdb总是第一个想到的工具,gdb --args <commands> 如果对于具体的命令使用不熟练就多print一些信息,memory的问题使用valgrind等等。

test case

对于比较大规模一点的项目,test case 至关重要,每次一个小模块更新完成就想办法使用test case 测试相关的代码,所有的功能混杂到一个大的模块中是很危险的。这里要特别留意,单独的unit testing可能比较容易想到,但是集成测试常常容易忽略,这个时候使用mock server 是很重要的,比如淡化network,着重看结果。总而言之,将比较大的功能划分成不同的小功能进行测试是非常重要的。

single node testing

很多时候bug要在high parallel的环境下使用,需要用到大量的资源,这种时候一下申请到达的资源很难办,就需要抽象出核心的功能,模拟场景,去掉冗余的依赖,让test能够快速迭代。

come back to successful point

有好多次发现加入了一个很大的模块或者很多的功能,然后代码出现了bug,然后很长时间都找不出问题。这个时候的方式就是找到上次成功的地方,然后一点点的通过增量的方式增加代码。想想核心的功能是什么,把新增的修改分离出去单独进行测试。去掉那些比较稳定的模块,然后一点一点把增加的部分隔离出来。或者是找到一个正常运行的版本,然后一点一点地进行比对,定位问题。

IP 配置

描述
127.0.0.1与eth1的ip弄错。在stage环境上,没有做网络的限制,通过127.0.0.1可以访问到swarm的服务,在线上环境,有网络的限制,还能通过eth1的ip访问到swarm的服务,在数据插入的时候,没有留意,开始以为一直是swarm的问题,后来发现,原来是数据库ip配制的问题。

表面原因,ip配制错误

深层原因,连接信息应该通过环境变量传入进来,比如像数据库连接信息,swarm client 以及 etcd client 这种信息,这个就是代买编写的规范性方面所遇到的问题,连接信息一定要通过环境变量加载进来,或者配置文件,写成配置文件的话,就相当于是启动的时候传入了几个参数。

缺乏容器里外的概念,应用都是在容器里面启动的,127.0.0.1怎么可能访问到容器外面的网络呢,除非都是用net=host的模式。

在stage环境上,竟然加上了net=host参数,但是在线上环境,可能没有加上,还是因为线上环境和stage环境的配置不一致所导致的。

环境变量配置

描述

给原来的老项目中添加一个报警功能,通过docker compose文件的方式来启动,因为要在里面添加一些新的环境变量,修改的时候不够小心,把一个关键的环境变量(原来是true)设置成了truei导致程序启动的时候,每次api返回一个错误。

表面原因,做工作不够细致。深层次原因,程序对于环境变量的检测不到位,没有进行input参数的检测,比如输入的参数如果不是false或者true会怎样,自己输出的日志信息也不够详细,导致出了问题难定位。即使给api返回了错误的信息,也应该在本程序的日志这里留一个对应的记录才行。

之前的程序在给api返回的信息中,指明了环境表变量的问题,但是本身没有log输出,这样就导致了在出现问题之后,很难进行排查。虽然给api也返回了信息,但是显然环境变量这边的信息不是api传过来的,是程序启动时候设置进去的,不管api怎么样,这个参数都是要设置的,这样在程序启动这边,就应该打印有对应的log信息。

json解析的问题

在使用Golang进行json解析的时候,想当然地把结构体中的字段的首字母定义成了小写的形式。老员工仔细看一眼就能看出来,一提醒我我也能知道,但是自己在主动些的时候就是有异常然后json 解析不成功,由于在一个大的已有项目中进行更改很难去把问题限制在一个比较小的范围因此出了问题常常不确定是哪部分导致的,这种字段大小写的问题实在是不应该犯的。

listkeys的问题

在我们的项目中有一个接口是通过输入的volumename查找对应的volumeid,具体实现的逻辑如下,函数的输入参数是searchName,函数逻辑是,先列出当前所有的volume信息,之后对这些volume信息遍历,如果遍历的时候发现volumename==searchName,count+1,如果最后count值==0或者>1就会报错。这样的逻辑其实默认假设了list时候返回的信息是没有重复的,或者说返回的是一个set或是map。然而实际情况是list所有的volume信息的时候,返回的数据是一个list,这个list中出现了好多重复的元素比如[volumeida:volumenamea,volumeida:volumenamea]这样如果searchName是volumeName的话,计数值就为2,然而实际是仅有一个volume的。这就是考虑输入参数不周全的问题。因为涉及多层的调用,所以在调用之前最好是假设对方的接口是不牢靠的,处理之前一定要考虑到各种情况,即使没有办法规避也要留有debug的日志信息。比如这个问题是重新添加了list的打印,然后build代码,放在线上运行才发现原来接口返回的信息本身就有问题(如果接口定义的时候返回一个map或者set也就没有这种问题了),整个过程浪费了好多时间和心力。

==写成了=的问题

这种问题虽然是很基本,但是常常跌倒在这里,有时候感叹,我靠,怎么会是这样,这不科学啊,回过头来再仔细一看原来是==写成了=。对于这种问题,还是老老实实地通过单元测试来解决比较好,不要心存侥幸,觉得自己的程序就没有问题,一定要用测试说话,求是求是,用测试来证明自己的函数没问题,并不是自己拍胸脯随便说说。

c中的segmentation fault的问题

一次是malloc()后面的数字写错了,本来应该是写成 malloc(rindex-lindex)结果写成了malloc(sizeof(rindex-lindex))
一次是空间定义的小了

一个tips就是看上下文,也就是看输入和输出

实在不行就是用笨办法,大致定位到出错的函数,然后打点往下走,看在哪一步的时候程序有报错,这个方法虽然慢一些,但最后往往都能找到出错的地方。

主要是代码常常写的不完整,没有检测函数的返回值,导致即使malloc没有成功也不知道是由于什么原因导致的,直接在malloc的过程中就给报错了。

对malloc这种裸的内存操作一定要对输入参数考虑充分,边界条件考虑清楚。一个是习惯问题,一个是经验问题。

使用多了golang的编程模型就觉得其他的语言各种不严谨与随意或者说是灵活,因为golang就是在代码里强制地返回error然后程序必须处理这个error,虽然有时候看起来繁琐,但是可以让所有的可能的问题最准确的定位出来,代码中bug出现的概率首先就小了很多,即使是有错误也能很快定位了。

general的方法就是compile的时候使用DEBUG参数,然后使用 gdb --args 参数,再加上bt,在加上print stack information。

安装nvidia driver

之前帮同学安装nvidia driver 开始是双系统,每次装好都说有个脚本不能运行然后安装失败,smi无法于nvidia通信, 后来把windows卸载了,只装ubuntu14.04之后又参考了这个教程 https://gist.github.com/wangruohui/df039f0dc434d6486f5d4d098aa52d07 才弄好,主要是在bios设置中有一个secure boot option没有修改, 导致有些内核模块没法写入到系统, 总之driver的问题不仅仅是软件的问题,应该把软件和硬件看成一个整体然后全方位的考虑,暂时先写在这里,万一以后自己也要装driver就省去好多事了,从这个角度看双系统还是有风险的,也可能是driver相互冲突,还是单系统加虚拟机的方式比较好用。

同学想替换server上的cuda 9 到 cuda 10 但是按照官方的教程 每次在apt-get update之后 都还是原先 cuda 9 的安装包 后来仔细查抄 /etc/source 也就是 apt-get 的源文件时候才发现 当前的机器有一个默认的source配置 那个配置中有一个默认的cuda的列表 猜想可能是那个列表中的默认的信息把new added的source给覆盖了 结果吧default的list注释掉之后 再apt-get update之后就能识别出新的cuda10了 这种配置的问题常常让人很费解 会因为一个很小的地方就花费掉很多的时间 这里mark一下

upper case and lower case

回想起来很久的时候,帮亲戚家弄宽带,各种接线配置,到了最后一步,当时还是拨号上网那种,输入密码的时候总是有错误,链接不上种种。折腾好久,各个部分检查,最后才发现是因为输入密码的时候大小写出现了问题。好多时候都是因为一个小的condiguration反反复复折腾好久,it’s life。

polling frequency and the scheduler strategy

这两个问题其实还蛮有启发,就是说要care到整个software stack 的各个环节。首先是scheduler strategy的问题,使用的是srun on super computer。之前都是采用默认参数,也没有留意。后来发现,同时run several task的时候,后面的task就没有被调度上,虽然还有额外的资源。后来各种debug之后才发现,默认的case下,task会占用node上所有的memory资源,如果正好6个node,正好6个process,这个时候6个node的资源都会被占用。虽然可能每个process仅仅占用一个core。使用了–mem-per-cpu=1000 进行限制之后就没有这个问题了。有的时候不使用某些参数并不是说这些参数就不存在了或者it will be fine if I doesn’t care it,要注意默认值。

还有一个很坑的地方,就是srun中的--ntasks=<task number>的问题。有几次直接启动单process的程序,直接用了srun但是后面没有加--ntasks=1。有的时候这个参数就按照#SBATCH --ntasks-per-node=<task limits on node>来了,这个参数是想指定每个node中启动的task的上限,两个参数是不同的。有几次就单节点的program在每个node上都启动了一个,很trick。比较好的策略就是不论默认参数是多少,这些重要的参数都通过显示的方式进行声明。

还有一个默认值的问题是使用一个I/O的libraray。比如要是自己实现的话,肯定有一个polling的机制,就是每隔一段时间pull一下,看看是否ok。自己之前忽略了这一点。但后来仔细查看,发现libraray中有相关的设置,并且可以通过外部的参数来控制这个变量。这个启发就是,在使用library之前也要对它的关键行为或者关键参数有一定的了解,这样在集成到自己的程序中的时候,对于那些感到莫名其妙的问题才不至于手忙脚乱,无从下手。

关于.h文件的引用顺序

在面向对象的programming中,经常需要考虑的一个问题就是哪个类要具备哪些功能,有些错误也是发证在这里。有时候会发现一些类要互相引用彼此,就这时候就要考虑考虑模块或者定义的变量是不是合理。什么样就算是合理呢,比如拓扑排序之后发现没有环存在。

如果环装的引用实在无法避免的话,就需要采用 forward declaration 提前声明相关变量,注意两个互相引用的变量都需要进行 forward declaration。

关于gcc版本的问题

HPC上常常配有不同版本的gcc,不同software对gcc的版本要求也不同,有的时候cmake的时候如果不加上-DCMAKE_CXX_COMPILER=g++ -DCMAKE_C_COMPILER=gcc的参数,很有可能cmake就会按照default的gcc版本来安装,导致是旧版本。有几次build的dep的时候使用的是旧版本的gcc但是在build新的项目的时候使用的是新版本的gcc,这导致出现了许多奇怪的错误,特别难以debug。所以tips就是不论在用cmake build什么software的时候,都要通过explicit的方式来指定gcc/g++的版本。

关于serilize的问题。之前有一个distributed timer的libraray, 使用的是grpc的libraray,但是grpc最好是使用bazel来进行build,使用cmake进行build,在有些HPC上还有些问题。有些build时候的问题也不清楚如果解决。于是打算把grpc换成一直使用的thallium,这个时候发现了一个问题,以前一直认为string是会被自动进行serilize的,就把string当做参数直接填在rpc的函数上。但实际情况是,需要额外地调用thallium的serilize函数才能进行正常的解析。这里要特别注意要include thallium的相关serialize的函数或者是进行customize的data structure,之前一直忽略了这个地方,导致有一些奇怪的关于serialize的问题,反反复复折腾了好多次才正常解决。

几个南辕北辙的问题

使用c++的时候很容易会出现一些奇怪的一连串的编译器报错,但是实际上引起这些错误的原因却总是很小,但是通过报错信息却不容易看出来。
比如有一次include了一个.h文件,这个.h文件中有一些执行的函数还是用了namespace,但是在修改这个.h文件的时候,不小心把namespace的一个括号删去了,没有发现,之后在编译的时候就引出了一连串的报错,甚至是这个.h文件后面跟随着的其他的.h文件中的错误,但实际上后面的.h文件中没有什么问题。最后花了一些时间反复对比,才找到问题。这个lesson有两个,一个是test驱动,单个的模块必须要test ok之后再集成到整个的平台性的server中,第二个是build的顺序,最好把test放在整个server之前进行build,这样的好处是隔离错误。因为像是前面提到的小错,如果是build test case的话很容易就能发现,因为只引用了一个.h文件,但自己习惯在最后build test case (这不是一个好的习惯) 这导致了问题没有及时暴露出来。

最近还遇到一个问题是error: invalid use of non-static member function 这个再网上查找可以发现是一个常见的错误,但是我的调用的地方并没有使用函数指针,后来花了好多时间修改 class 的static函数和static 成员变量,还是没搞定。然后仔细一看才发现原来是一个函数没有加()这导致了编译器把这个参数理解成了函数指针。但是巧的是这个位置要传入的是一个指向raw data 的void*, 编译器也发现不了是类型不对称的问题。这说明了两个问题,就是使用void *作为函数的参数还是很危险的,要谨慎使用,或者最好是在外面包装一层自定义的类型,这样有语义的类型就不容易出错。

degbug 三重境界

基本的一些bug可以通过unit test来解决,比如输入输出是否符合预期等等。在一层就是 thread safety的问题,比如一个server来说,有一些thread safety的问题只有在压力测试的规模达到一定的程度之后才能展现出来。这一步就比较难模拟了,理想的情况就是可以有尽量真实的环境来经行测试,比较巧妙的方法是采用mock server或者抽象出其中比较关键的数据流来进行测试。这个阶段比较容易忽略的就是lock的问题,尽量通过改写语句把不适合使用lock的地方修改一下,比如在if condition中access一个map,如果元素不存在则返回,这个时候可以使用conunt函数,在lock的范围内计算map中指定key的count,之后再比较count的时候就不用lock了。第三个境界大概就是memory leak的问题了,特别是对于c/c++ 以及指针操作比较多的语言,需要手动管理memory的时候,这种memory leak就很明显了,比如你的server是否能比较robust运行较长的时间。运行几个月都不down,如如果是c/c++ 这样的语言,valgrind是比较常用的检测方式,可以提早把问题消灭在萌芽阶段,当然实践常常是最好的检验标准了。

wait one and wait any

有一个project是基于rpc服务提供一个block send 以及 block recv 的操作,主要是有两种情况,case1, 先 send req 到达 然后 recv req 到达, case2 先 recv req 到达 然后 send req 到达。 对于 case 1, send req 会被放在一个buffer中,当recv req 到达的时候会从这个buffer中寻找对应的req然后pull数据完成后续的操作。对于case2,会使用搞一个condition vaeaible的wait操作,具体wait的condition就是对应的send req是否存在于这个buffer中。于是配合使用起来,在send req的最后要使用wait one或者 wait any 的操作来wake up一个在sleep状态的thread。开始的时候一直使用的是wait one,小规模的时候程序没有什么问题,但是到了大规模的时候,程序总是hang在中间的某个位置上,后来经提醒才发现了如下情况的存在,正确的操作是使用wait any而不是wait one:

imagine you have 2 processes, each will post 1 irecv operation. This operation will block until the message appears in the list of pending requests. When it does, on_p2p_request will call notify_one and wake up the thread that is blocking on the condition variable in irecv.
Now imagine you have more than 2 processes (say 4 processes), when a message arrive, for instance from rank 3, notify_one will wake up one irecv operation. If it wakes up the irecv for rank 3, that’s great, but if it wakes up the irecv for another rank, then bad luck, it can’t do anything, and the correct irecv operation is still blocked. Using notify_all ensures that ALL the threads are waken up, giving all of them a chance to check whether their operation has completed.

Relocation truncated to fit

this issue looks a very low level one, this is how it is occured in general (https://www.technovelty.org/c/relocation-truncated-to-fit-wtf.html), but in my case, I just use the spack to load a particular software, then the error occures, more details can be found here (https://discourse.paraview.org/t/issues-of-compiling-v5-8-0-on-nersc-cori-by-cc-and-cc/5278/3). I doubt some things are introduced after I execute the spack load mesa. According to how the problem is happened, it seems that it is related with the linker, so I checked the linker used before and after I execute the load operation, and there are some interesting results:

1
2
3
4
5
6
7
8
zw241@cori11:~> which ld
/global/common/cori/software/altd/2.0/bin/ld
zw241@cori11:~>
zw241@cori11:~> module load spack
zw241@cori11:~>
zw241@cori11:~> spack load mesa/qozjngg
zw241@cori11:~> which ld
/global/common/sw/cray/sles15/x86_64/binutils/2.32/gcc/8.2.0/sl7nxhi/bin/ld

Since the mesa also depends on binutils, it looks that the cc find the wrong linker associated with the binutils. This problem can be fixed to modify the PATH and let the /global/common/cori/software/altd/2.0/bin/ld to come back to the first position in PATH by this way: PATH="/global/common/cori/software/altd/2.0/bin:$PATH". It looks hard to solve, but actually the reason behind this is quite simple.

Besides, if the python is linked to the .so file but the python libraray is not compiled with the fpic label, it can also generates this error. such like this:

1
/usr/bin/ld: /projects/community/python/3.8.5/gc563/lib/libpython3.8.a(abstract.o): relocation R_X86_64_32S against symbol `_Py_NotImplementedStruct' can not be used when making a shared object; recompile with -fPIC

check more details here in this case

about the auto key word in c++

In my example, there is a vector, then I use auto x : vectorInstance to get the particular value and then excute the update operation of that class to update the data value. But I found the data was not updated after that. For the plain auto, it will execute the copy constructor of the original class, which means it is just the copy of the original class instance instead of the original instance. Therefore, the data in the original instance is not updated.

it is a little bit tricky (easy to be ignored) here, check this answer for more detailes. If you want to use reference instead of the copy of the object, remember to use the auto & x

Here is recap:

Choose auto x when you want to work with copies.
Choose auto &x when you want to work with original items and may modify them.
Choose auto const &x when you want to work with original items and will not modify them.

More details are recorded here (https://discourse.paraview.org/t/undefined-symbol-pyexc-valueerror/5494/4), firstly I found that some function related with python is not linked, but I do not know the reason. Actually, this is caused by the static version of the python. At this time, I may try to use python2, it will solve the probelm. The python 2 on that cluater allows the dynamic link and is compiled with the .so file. It is importatnt to consider the root reason or ask other people to get more insights instead of just worrying about the issue. It looks that the good practice is to install an anaconda and avoid those issues.

关于c的规范编程

high level的语言写的多了,很容易忽略一些编程规范, 简单的例子比如c的array,申请了一段连续的空间之后是否初始化了合适的值,还有使用pointer的之后是否检查了null,space使用完成之后是否很好地释放掉。说起来都是老生常谈的话题了,最近又因为这样的错误浪费了不少时间,说起来都是一些小错误,但有时候由于各种error propatation却又是花费了很多时间来反复调整。在这里再强调一下,总而言之以遇到相关的代码的时候就在心里给自己提个醒。至于错误的定位,如果实在没有什么好的线索,把关键函数的输入的变量打印出来,多rank的时候把rank信息也打印出来,说起来似乎很简单,感觉是一个比较万能的方法,通过这样的方法,之前确实解决了不少问题,在和别人沟通的时候,也比较convincing。

about the length of the int

these is differences between the int and the int32 https://www.quora.com/Is-using-int32_t-preferred-to-using-int-in-modern-C-C++-programming
currently, the size of the int is 4B for the 64bit system and 2B for the 32bit system. I mistakenly use the int64 to parse the int, and casued the error propagate between the program. It takes some time to find this issue. Be careful about the data length anyway. There is extra padding if misuse the int32 and int64, that will cause the unexpected behaviours. One reason that it is hard to debug is that the unexpected errors might be caused by these mistakes and it is hard to trace the actual error. The one workable way in general is to come back to the last point that you could make tings sucess, maybe the original impoementation, for example, if you tries to provide a new communicator, and you may need to come back to original communicator and to see if it works first. When you have sth that can works well, you could start to add new things or tries to replace particular subfunction, and try to add your modification/updates step by step to narrow down the problem.

some other discussions about how integer is read out from the memory address, alignment issue, how the alignment can avoid multiple memory access with the cost of the data padding.
(https://softwareengineering.stackexchange.com/questions/363370/how-does-a-cpu-load-multiple-bytes-at-once-if-memory-is-byte-addressed)

virtual for the destructor

I forgot to add the virtual key word at the destructor of the class defination when there is inheritance. In this case, the destructor of the child class is not called. Refer to this for more details

https://www.quantstart.com/articles/C-Virtual-Destructors-How-to-Avoid-Memory-Leaks/

This might cause the potential memory leak issue.

correct compiler and linker

很多意想不到的问题是由compiler和linker引起的,总的来说就是没有明确地指明compiler或者是cpp的版本,比如不同gcc版本编译过的代码被link到了一起等等。或者是使用spack这样的package管理软件,没有明确地指定使用什么样的gcc进行安装。有的时候这种问题比较讨厌,因为常常不知道从哪里入手,这个时候想想当前运行的环境,是否有compiler或者linker相关的问题,比如因为env的改变而使用了unexpected的compiler或者linker这个比较重要。

variable initilization

Refer to this about the vairable initiliation (https://www.learncpp.com/cpp-tutorial/uninitialized-variables-and-undefined-behavior/). The lesson is that: do not assume that the compiler helps you to initilize a particular variable, start with the assumption that the variable is uninitilized in default may help you to avoid some potential issues and generate more cautious code. One recent bug is that, in one program, we use the eof label to indicate the end of the file, this variable is not initilized (but we assume it is initilized as 0), therefore, the file can not be parsed correctly when we read data from it (since we use this label to indicate if it is the end of the file). This kind of error may more common in c related project, since we need to provide a lot of manual implementations for the file I/O.

the evidence to show sth

The imperfect part of the human is that we are easy to make all kinds of mistakes. With this assumption, you may have more flexible strategy to validate the decisions and results. I made several small mistakes here and there because the lack of checking things. Let see some times we need to register the class before a particular date and it might cause lot of trouble if we miss that date. I could remember many things like this, and I even missed an important exam since I mix the p[osition of the exam (two locations have similar names in spelling). So that means the double check is important, and when you check it, you ‘d better make sure the method of checking is solid. This is a kind of habit that helps you to avoid the small mistakes. Make sure the evidence or the results fully support your decision. Try to consider yourself as a judger, and you need the direact evidence, not the oral expression from the others (double confirm the expression by more direact evidence such as the code or executing results) or the ideas start with the “maybe” in your mind.

the gdb can not be used direactly in some cases

For one particular case, we can not use gdb in traditional way (open a terminal and wait here), this might be the long running server which crash by segfault somehow. In this case, if we want a stack trace, do ulimit -c unlimited then run the server and do other things. You should end up with a file named core if it segfaults. Open gdb (gdb mbserver for instance if it’s the mbserver that crashed) then do core core and that will load the state of the core when the program crashed. You can then do backtrace and things like that.

wrong static parameter

in a for loop, we put the static parameter at the varaible declaration accidentally. then the code does not work as expected. since the varable is declared as static, when it jumps to the second iteration, the value still not change. In our case, we try to create a file name that uses iteration number as the suffix, but the code does not work as expected since we add a static key word accidentally.

推荐文章