有时,即使目前预期用户数量不大,在不常见的平台上测试 R 也是有用的。当新平台出现或变得更加广泛使用时做好准备会更好,在某个平台上找到一些错误可能比在其他平台上更容易,并且此类测试可能会揭示无意中过于特定于平台的代码。
最近,我想在 64 位 ARM (Aarch64) 和 Power 上测试 R。事实证明,在 QEMU(一种指令级模拟器)中很容易,它运行在性能良好的多处理器 64 位英特尔主机上。本文给出了获取这些模拟系统的具体步骤,并描述了我在 Power 上调试的两个具体问题:一个问题是 long double 类型阻止 R 启动,另一个问题是 foreign
包无法正确读取 STATA 文件。后者附带了一些有趣的技术细节和一个相当令人惊讶的结论。
Aarch64
在这个平台上进行测试的灵感来自一些预测,即 64 位 ARM CPU 很快就能在 Apple 笔记本电脑中使用。人们可能会订购一个最新版本的 Raspberry Pi 进行测试,但安装 QEMU 仍然更快,而且该体验也适用于其他平台的测试。我基于 Ubuntu wiki 页面设置了我的设置,在 Ubuntu 19.10 上作为主机运行,在 Ubuntu 18.04 上作为访客运行。
安装 QEMU
apt-get install qemu-system-arm qemu-efi
准备用于启动的闪存
dd if=/dev/zero of=flash0.img bs=1M count=64
dd if=/usr/share/qemu-efi/QEMU_EFI.fd of=flash0.img conv=notrunc
dd if=/dev/zero of=flash1.img bs=1M count=64
获取 Ubuntu 18.04 云映像
wget http://cloud-images.ubuntu.com/releases/bionic/release/ubuntu-18.04-server-cloudimg-arm64.img
在该映像中设置 root 密码
virt-customize -a ubuntu-18.04-server-cloudimg-arm64.img --root-password password:my_root_password
调整该映像的大小,以便它可以容纳 R 和依赖项(添加 10G,也许更少就足够了)
qemu-img resize ubuntu-18.04-server-cloudimg-arm64.img +10G
运行访客系统
qemu-system-aarch64 -smp 8 -m 8192 -cpu cortex-a57 -M virt -nographic -pflash flash0.img \
-pflash flash1.img -drive if=none,file=ubuntu-18.04-server-cloudimg-arm64.img,id=hd0 \
-device virtio-blk-device,drive=hd0 \
-device e1000,netdev=net0 -netdev user,id=net0,hostfwd=tcp::5555-:22
这将运行一个具有 8 个内核和 8G 内存的系统,其中主机的 5555 端口转发到访客的 22 端口,因此可以通过以下方式登录
ssh -p 5555 username@localhost
一旦系统为此设置好。还可以使用先前设置的密码立即以 root 身份登录到控制台。
可以使用 ssh/scp 传输文件,但如果需要,也可以将映像挂载到主机系统
modprobe nbd max_part=8
qemu-nbd --connect=/dev/nbd0 ubuntu-18.04-server-cloudimg-arm64.img
mount /dev/nbd0p1 mnt/
并卸载
umount mnt
nbd-client -d /dev/nbd0
登录访客系统后,对其进行升级
apt-get update
apt-get upgrade
安装文本编辑器,在 /etc/apt/sources.list
中启用“src”存储库(取消以 deb-src 开头的行的注释)。然后安装 R 构建依赖项和一些工具
apt-get update
apt-get build-dep r-base
apt-get install subversion rsync screen libpcre2-dev
创建一个用户帐户 (adduser 用户名
) 并启用 /etc/ssh/sshd_config
中的 ssh 密码认证。通过主机的端口 5555 从主机系统通过 ssh 登录到该帐户,这样终端比通过模拟控制台工作得更好。
现在从 SVN 中签出 R 源代码,通过 rsync 获取推荐的包,从源代码构建(通过配置、make -j
,最好在树外)并像往常一样运行检查(check-all),并在 R 安装和管理 中记录。
在模拟系统上构建并不快,但并行运行会有很大帮助。最好在夜间运行测试,因为它们可能需要几个小时,尤其是对所有推荐包的“check-all”。
一个需要注意的地方是,一些测试仍然有硬编码的时间限制,而模拟系统通常无法满足这些限制。如果出现相关故障,则必须手动禁用这些检查并重新启动。这些检查应该很快就会被移除,并且在 编写 R 扩展 中不鼓励使用它们,因为它们无法真正揭示性能下降。为此,必须在隔离系统上和要测试回归的两个版本中重复运行测试,也许增加它们的运行时间。
可以像往常一样使用模拟系统,将失败的测试缩小到可重复的示例,交互式地尝试它们,通过 gdb
调试等。唯一的限制是系统较慢,但在一个合适的服务器上,它对于交互式工作来说足够快。
除了时间限制之外,在一个包中发现了关于 /proc
文件系统内容的不正确假设。一些测试的数值检查过于严格。但是,我没有在平台上发现任何重大问题(这是在 4.0.0 发布之前完成的)。
Power 9
在 4.0.0 发布后不久,我们收到报告称 R 甚至无法在 Power 上构建,所以我对 Power 也重复了这个实验。这些步骤对我来说有效
apt-get install qemu-system-ppc64
wget https://cloud-images.ubuntu.com/releases/bionic/release/ubuntu-18.04-server-cloudimg-ppc64el.img
sudo virt-customize -a ubuntu-18.04-server-cloudimg-ppc64el.img --root-password password:my_root_password
qemu-img resize ubuntu-18.04-server-cloudimg-ppc64el.img +10G
启动系统
qemu-system-ppc64le -smp 8 -m 8192 -cpu power9 -nographic \
-hda ubuntu-18.04-server-cloudimg-ppc64el.img -L pc-bios -boot c \
-device e1000,netdev=net0 -netdev user,id=net0,hostfwd=tcp::5555-:22
其余步骤与 Aarch64 相同:升级系统、启用源存储库、安装 R 构建依赖项、安装一些工具、创建用户帐户、签出 R、配置、构建、运行测试。
事实证明 R 在启动时会陷入无限循环。由于在构建 R 时 R 二进制文件本身就已经被使用,因此构建过程也无法完成。在来宾系统中运行 gdb 时,可以看到它在 machar_LD
中循环,这是检测机器特定浮点特性的长双精度版本。这些例程来自 Netlib BLAS 代码 (machar.c),基于 M. Malcolm:揭示浮点运算特性的算法 和 W. J. Cody:算法 665 MACHAR:动态确定机器参数的子例程。
这些例程无法在 Power 上的 long double 类型的非常规双双实现上运行,而该实现已知存在问题。Muller 等人:浮点运算手册:“关于将此格式视为有效 long double 类型的最大指数,仍然存在开放的规范和/或实现问题”,并且“某些需要严格浮点运算的属性(例如 Sterbenz 引理)不总是对 long double 类型成立,并且相应的浮点算法可能不再起作用。”Sterbenz 引理要求“如果 x 和 y 是浮点数,使得 x/2 <= y <= 2x,则 x-y 将被精确计算”。
针对 R 的快速修复方法是避免在 Power 上使用 long double 类型,这可以在构建 R 时通过 configure
来完成。Bryan Lewis 和 Martin Maechler 主要贡献了在 Power 上提供对 long double 的一些支持的工作,该工作仍在进行中。Dirk Eddelbuettel 将问题二分到一个特定的 R SVN 版本。
QEMU 在识别和调试此问题方面很有用。但是,一旦我让它运行,我决定在该平台上运行 check-all,同时快速修复机器检测:如果还有其他问题是由例如非常规 long double 类型实现引起的,该怎么办?
除了硬编码时序检查、过于严格的数值检查以及解析 /proc
文件系统的一个案例的预期问题之外,我发现 foreign
包的测试失败,无法正确读取某些 STATA 文件。
在 Power 上,我得到了
> foreign::read.dta("./tests/foreign.Rcheck/tests/compressed.dta")[1:10,"alkphos"]
[1] 1.718000e+03 7.394800e+03 7.394800e+03 1.836710e-40 6.121800e+03
[6] 6.121800e+03 6.710000e+02 6.710000e+02 1.175494e-38 1.175494e-38
而在 x86_64 上
> foreign::read.dta("./tests/foreign.Rcheck/tests/compressed.dta")[1:10,"alkphos"]
[1] 1718.0 7394.8 7394.8 516.0 6121.8 6121.8 671.0 671.0 944.0 944.0
因此,一些浮点数已正确读取,但并非全部,例如 516
变为 1.836710e-40
。这些是单精度浮点数(32 位),据记录,即使在 Power 上,它们也是适当的 IEEE 754,因此与长双精度类型无关。
示例中的这些数字以二进制、IEEE 754、大端形式存储在文件中。Power 是双端架构,上面显示的仿真器调用以小端形式运行它。读取值例程简单,看起来是正确的
static double InFloatBinary(FILE * fp, int naok, int swapends)
{
float i;
if (fread(&i, sizeof(float), 1, fp) != 1)
error(_("a binary read error occurred"));
if (swapends)
reverse_float(i);
return (((i == STATA_FLOAT_NA) & !naok) ? NA_REAL : (double) i);
}
此例程的小修改,即使只是添加打印语句,有时也会掩盖错误:然后正确读取所有值。打印整个值不会掩盖错误,但打印各个字节会掩盖错误。将 i
标记为 volatile 也会掩盖错误。未掩盖时,错误仅影响某些数字(例如 516),而不影响其他数字(例如 1718)。
将代码缩减到此仍保留错误
static double InFloatBinaryBugPresent(FILE * fp, int naok, int swapends)
{
float i;
if (fread(&i, sizeof(float), 1, fp) != 1)
return (double)0;
if (swapends)
reverse_float(i);
return (double)i;
}
但进一步缩减会掩盖错误
static double InFloatBinaryBugMasked(FILE * fp, int naok, int swapends)
{
float i;
fread(&i, sizeof(float), 1, fp);
if (swapends)
reverse_float(i);
return (double)i;
}
未掩盖时,错误已影响 InFloatBinary 的结果。小修改会掩盖错误,但仅适用于某些数字,因此它看起来不像内存损坏或堆栈对齐问题。此外,堆栈对齐不太可能成为 32 位值的问题。它看起来不像是不反转字节顺序或不正确反转的问题,因为正确读取了一些所有四个字节都不同的值。
同时,上述内容表明,即使存在死代码(永远不会执行的返回 0 的分支)也会影响错误的存在。例如,删除分支 if (swapends)
会掩盖错误。编译器可能错误地编译代码,或者更有可能代码可能依赖于未指定的行为。因此,下一步是查看汇编。
没有错误的 InFloatBinaryBugMasked
的注释版本
<+0>: addis r2,r12,2
<+4>: addi r2,r2,5504
<+8>: mflr r0 <== save link register to r0
<+12>: std r31,-8(r1) <== save r31 to stack
<+16>: mr r6,r3 <====== move r3 to r6 [FILE *fp]
<+20>: mr r31,r4 <====== move r4 to r31 [swapends; naok was optimized out]
<+24>: li r5,1 <======== move 1 to r5
<+28>: li r4,4 <======== move 4 to r4 (size of float)
<+32>: std r0,16(r1) <=== save r0 to stack [link register save area]
<+36>: stdu r1,-64(r1)
<+40>: addi r3,r1,36
<+44>: ld r9,-28688(r13) <=== [stack checking]
<+48>: std r9,40(r1) <=== save r9 to stack [TOC save area]
<+52>: li r9,0 <======== move 0 to r9
<+56>: bl 0x7ffff4bc4720 <0000011a.plt_call.fread@@GLIBC_2.17>
<+60>: ld r2,24(r1) <=== [compiler area]
<+64>: cmpdi cr7,r31,0 <=== check swapends
<+68>: beq cr7,0x7ffff4bd6910 <InFloatBinary+144> <==== jump if not swapping
<+72>: addi r9,r1,36
<+76>: lwbrx r9,0,r9 <==== load from address r9, reverse bytes, store to r9
<+80>: rldicr r9,r9,32,31 <= extract upper 32 bits of r9 to r9 (shift right)
<+84>: mtvsrd vs1,r9 <====== move r9 to vector register
<+88>: xscvspdpn vs1,vs1 <=== convert vs1 to double precision
<+92>: ld r9,40(r1) <== [stack checking] restore r9
<+96>: ld r10,-28688(r13)
<+100>: xor. r9,r9,r10
<+104>: li r10,0 <======= move 0 to r10
<+108>: bne 0x7ffff4bd6918 <InFloatBinary+152>
<+112>: addi r1,r1,64 <== r1 += 64
<+116>: ld r0,16(r1) <== restore r0
<+120>: ld r31,-8(r1) <== restore r31
<+124>: mtlr r0 <========== restore link register from r0
<+128>: blr <============= return
<+132>: nop
<+136>: nop
<+140>: ori r2,r2,0
<+144>: lfs f1,36(r1)
<+148>: b 0x7ffff4bd68dc <InFloatBinary+92>
<+152>: bl 0x7ffff4bc4260 <0000011a.plt_call.__stack_chk_fail@@GLIBC_2.17>
<+156>: ld r2,24(r1)
<+160>: .long 0x0
<+164>: .long 0x1000000
<+168>: .long 0x180
代码调用 fread
,反转结果中的字节顺序,将结果提升为双精度并返回它。此外,函数序言/结语中还有一些堆栈损坏检查和保存/恢复寄存器。
此示例的关键架构详细信息:寄存器 r1
指向堆栈顶部,r3
和 r4
包含函数的前两个定点参数,链接寄存器保存要返回到的地址,blr
是跳转到该地址(因此它从函数返回),cr7
是条件寄存器,在此明确用于保存比较结果,cr0
是条件寄存器,用于以点结尾的定点操作数的比较指令(例如 xor.
),浮点寄存器 f0/f1 对应于向量寄存器 vs0/vs1。
InFloatBinaryBugPresent
的反汇编具有类似的结构,但字节交换代码不同
<+128>: cmpdi cr7,r31,0 <===== check swapends
<+132>: lfs f1,36(r1) <===== load float extended to double into f1 (vs1)
<+136>: beq cr7,0x7ffff4bd68cc <InFloatBinary+76> <== jump if not swapping
<+140>: xscvdpspn vs0,vs1 <====== convert double precision vs1 to single precision vs0
<+144>: mfvsrd r9,vs0 <========= move vs0 to r9
<+148>: rldicl r9,r9,32,32 <==== shift right by 32 bits (extract upper 32 bits)
<+152>: rotlwi r10,r9,24 <====== rlwinm r10,r9,24,0,31; 32-bit rotate right 1-byte: 4321 => 1432
store "1432" to r10
<+156>: rlwimi r10,r9,8,8,15 <== rotate left 1 byte (4321 => 3214), store 2nd byte to r10
store "1232" to r10
<+160>: rlwimi r10,r9,8,24,31 <= rotate left 1 byte (4321 => 3214), store 4th byte to r10
store "1234"" to r10
<+164>: rldicr r9,r10,32,31 <=== extract r10 as high word of r9 (shift left 4 bytes)
<+168>: mtvsrd vs1,r9 <========= move r9 to vector register
<+172>: xscvspdpn vs1,vs1 <====== convert vs1 to double precision
在这两种情况下,生成的代码乍一看似乎都很好。要找到原因,下一步是指令级调试。
有用的 gdb 命令是 set disassemble-next-line on
,以便在单步执行时查看反汇编,stepi
单步执行单个指令,nexti
执行到下一条指令,i r f1 vs1
打印浮点寄存器 f1
和相应的向量单元浮点寄存器 vs1
的寄存器值。要跳转到 InFloatBinaryBugPresent
的调用,ignore 1 23
很有用(忽略断点 1 的下一个 23 个交叉点,以调试示例中值 516 的读取)。要在特定地址转储内存,例如 x/16bx 0x7fffffffb2e0
。
然后,关键观察是 fread
正确读取了字节反转的 516 的值,并将其正确存储在 36(r1)
的堆栈上。但是,<+132>
处的指令 lfs
在将其从浮点数转换为双精度数时破坏了该值。
字节反转后,516 的值是一个非规格化的浮点数。不过,即使是这种数字也应该转换为双精度数而不会丢失精度/信息。为了证明这是问题所在,很容易提取一个仅将浮点数转换为双精度数的示例:516(一个非规格化的数字)转换不正确。此操作系统地给出令人惊讶的结果。
错误的掩蔽似乎取决于代码的无关紧要的更改,这是因为编译器有时会包括将浮点值提升为双精度数并返回,但在反转字节之前。
Power 如何不能将非规格化的浮点数转换为其定义良好的双精度版本?这让我在 Minicloud 上申请了一个帐户并在那里进行测试。在他们的 Power 机器上,转换工作正常,但它是 Power8(不是 Power9),并且事实证明它也由 QEMU 仿真,而不是裸机硬件。
然后我尝试了最新版本的 QEMU,转换也同样有效。在查看了 QEMU 的变更日志后,我发现
commit c0e6616b6685ffdb4c5e091bc152e46e14703dd1
Author: Paul A. Clarke <[email protected]>
Date: Mon Aug 19 16:42:16 2019 -0500
ppc: Fix emulated single to double denormalized conversions
helper_todouble() was not properly converting any denormalized 32 bit
float to 64 bit double.
QEMU 在我的 Ubuntu 机器上很容易构建,我测试了此提交和上一个提交——无需重新安装 VM,只需重新构建 QEMU 并重新启动即可。是的,这是由 QEMU 中的一个错误引起的,该错误已通过此提交修复,这说明了在模拟平台上进行测试的可能缺点。
foreign
包中的原始 C 代码在最新 QEMU 中的 Power 上运行良好,但它仍然将任意位模式(字节反转浮点数)存储在类型化为浮点数的变量中。根据 C 标准,从浮点数转换为双精度浮点数应保留该值,以及在转换回浮点数后。
然而,并不完全清楚原始浮点数值的完全相同的位模式将始终被保留。可以通过编译器的 CPU 选项(“刷新为零”、“非规格数为零”)禁用非规格数。避免这种不必要的风险并重写代码以仅在浮点变量中存储有效的浮点数值(如果需要,在反转字节后)可能是更安全的。尽管如此,找出这种不太可能触发的问题的代价相当昂贵。在类似情况下,一个好的做法可能是尽早使用不同版本的 QEMU 进行检查。