听说你想用Go汇编写memmove?

最近逛Cloudflare的博客,发现已经有ARM64的服务器《Arm takes wing》
文章里还吐槽了Go对于Arm64的优化不够好,特别是memmove这类的函数,实现方式没有优化过,而Go的内置copy函数,
slice的扩容时,背后都是runtime/memmove函数,因此说memmove的性能提升可以带来整个runtime的性能提升也不为过。

啥是memmove?

memmove是memory move的简写,说白了就是把内存中一个区域的数据,搬到另一个地方去。

如图所示。

但是memmove需要应对另一种情况,那就是overlapping(内存重叠)即源地址+要移动的数量跟目标地址的起点有重叠。
如下图

复制完A之后,图中位置1的数据就被改写成了A了,而不是我们预想的B
这样复制的结果就是AAA,明显是错误的结果。

那么怎么才能解决这个问题呢,那就是backward copy(后向复制)

首先从内存位置2开始复制C到目标地址,依次向后(减少)复制数据,从而解决重叠导致的数据错误。

听起来很简单吧?
那我们就来动手吧!!!

实战方法一:简单字节移动

  1. 判断是否有重叠
  2. 按方向移动

核心代码如下:

 ADD count, src, srcend
loop:
MOVB.P (src), tmp
MOVB.P tmp, (dst)
CMP src, srcend
BNE loop

这就是最简单的memmove了。
其中:

  • ADD 加法操作, 两个寄存器内容相加,放到第三个寄存器中
  • MOVB (move byte) 移动一个字节
  • MOVB.P (move byte post-index) 移动完成_后_,对应寄存器 +1
  • BNE (Branch Not Equal)两值不相等时,移动到对应标签上

还有很多指令,都可以在Arm的手册中查询到,Go自己实现了编译器,因此指令可能对不上,如果接下来有对不上的指令,我会指出来的。

代码写完后就是跑测试,测试不通时

使用gdb debug Go

$GODEV/bin/go test -c
gdb <文件名>

即可进入gdb,其中有以下常见的命令

b 文件名:行号 // break 设置断点
n // next 下一指令
layout asm // 设置输出汇编模式
c // continue 继续到下个断点

实战方法二: 多重移动

通过之前的例子,相信你也会开始想,一次只搬一个字节太慢了,能不能一次性搬多个?
这也是现在标准库的实现方式。
因为Arm64是64位平台,每次都可以操作8个字节,如果使用了MOVD指令(move double word)。一次性能移动8个字节了!

核心代码如下:

forwardlargeloop:
MOVD.P 8(R4), R8 // R8 is just a scratch register
MOVD.P R8, 8(R3)
CMP R3, R9
BNE forwardlargeloop

能不能给力点

有!LDP/STP(Load/Store Data by Pair),这样可以一次移动16个字节。

LDP (R1), (R4, R5) //R1 为地址指针,移动数据至R4, R5
STP (R4, R5) ,(R0) // 移动R4, R5数据至 R0 为地址指针的区域

能不能给力点

真有!VLD1/VST1(Load/Store Data to Vector),可以将内存载入向量寄存器V0-V3,每个寄存器有128bit。即一条指令可以移动多达64个字节!

VLD1.P 64(R1), [V0.B16, V1.B16, V2.B16, V3.B16]
VST1.P [V0.B16, V1.B16, V2.B16, V3.B16], 64(R0)

但是,测试性能的话,你会发现,还不如LDP快。

坑1: 并不是那么快的SIMD (slow simd on Arm)

测试代码 https://gist.github.com/mengzhuo/bb3769d42097eec6f3fce12895e441b9

主要是因为,ARM架构中,ASIMD作为协处理器,虽然在软件优化手册中,延迟只有5个微指令,但是每次写都会阻塞流水线(需要等待数据写入)。很多,所以性能要求高的实现(glibc、Linux kernel、Chrome)都没有选择用ASIMD作为Memmove/Memcpy的指令基础。

实战方法三: 指令展开(unroll instructions)

既然有这么库实现了ARM64的memmove,我们自然会参看一下
例如Linux的memmove

.Lcpy_over64:
subs count, count, #128
b.ge .Lcpy_body_large
/*
* Less than 128 bytes to copy, so handle 64 here and then jump
* to the tail.
*/
ldp1 A_l, A_h, src, #16
stp1 A_l, A_h, dst, #16
ldp1 B_l, B_h, src, #16
ldp1 C_l, C_h, src, #16
stp1 B_l, B_h, dst, #16
stp1 C_l, C_h, dst, #16
ldp1 D_l, D_h, src, #16
stp1 D_l, D_h, dst, #16
tst count, #0x3f
b.ne .Ltail63
b .Lexitfunc

你会发现,都是整16,32,64字节的移动,很少像我们上个例子中移动16字节后,减去位移数并判断一下是否需要返回。
这种优化称做指令展开(unroll instructions),主要为了减少条件跳转语句的执行,毕竟每个指令的执行都会消耗CPU时间。

坑2: 分支预测失效 (branch misprediction)

就算不考虑跳转指令导致的CPU消耗,还有分支预测失效(branch misprediction)的风险,一旦预测失效,CPU会消耗13微指令的时间来清空流水线,并重新执行(手册5.1z章)

With Program flow prediction enabled, all mispredicted branches incur a 13-cycle penalty

相比之下,一个LDP指令只需要4个微指令时间,意味着一次预测失败就要少搬48个字节。

实战方法四: 优雅的覆盖

通过上面的例子,你会发现其实对于小数据移动,我们可能压根不需要循环遍历,只需要找足够的寄存器,把源地址的所有数据全部载入到寄存器中,然后再依次放入目标地址中就好了。

例如上图,移动长度是6,源地址为0-5,目标地址是1-6。
移动指令只能移动1,2,4,8字节,没有6的怎么办?那么我们就用移动4个字节的MOVH( move half word )。
如下图所示,先用R4保存0-3的数据,再用R5保存2-5的数据

这样的话R4,R5都保存了2、3上的数据,虽然2-3重叠的部分会被覆盖掉,但是数据是完全一致的。完美地解决了重叠的问题。
移动后数据如下图所示。

代码如下

ADD count, src, srcend
ADD count, dst, dstend
MOVH (src), R4
MOVH -4(srcend), R5
MOVH R4, (dst)
MOVH R5, -4(dstend)

这就是glibc的memcpy.S的原理,同时也促成了Go CL83175: runtime: improve arm64 memmove

经过这多么优化,我们来测试一下性能增长的情况,下图是字节数移动的性能报告,数值越大性能增幅就越大。

咦……为啥移动9-11个字节时的性能不升反降?!

坑3: 未对齐数据访问性能下降(unaligned data access penalty)

什么是未对齐?
计算机由于性能的考虑,通常在取内存时是以特定数量一次性地取走的,例如一次取4个字节。
当你只取3个字节,或者任意不是4为倍数的地址的时候,就叫做未对齐数据访问。这时CPU都要先找到对应的4字节位置并取出来,然后再取到对应的3个字节并会造成此类性能下降。
Linux Kernel中也有详细的解释

但是根据ARM的优化手册( 4.6 P39)

Load/Store Alignment
The ARMv8-A architecture allows many types of load and store accesses to be arbitrarily aligned.
The Cortex- A57 processor handles most unaligned accesses without performance penalties.
However, there are cases which reduce bandwidth or incur additional latency, as described below.

• Load operations that cross a cache-line (64-byte) boundary

• Store operations that cross a 16-byte boundary

手册上说是几乎不会下降的……蛤?

最后通过测试发现,原来是CPU的区别,Makam的测试报告显示(如下图),还在技术评审的AmberWing CPU在测试中并没有下降。
而因为我测试用的2010年的Marvell Armada XP,比较老,所以可能并没有对未对齐优化。

小结

总结一下,这次的优化最高可以给Go Arm64平台的memmove带来100%的性能提升,平均也有34%。

Go如何测试标准库

部分的Go的标准库由于使用了internal这个包,所以,没有办法进行测试。
而之前在写adler32时,我用了个投机的方法,先测试完,再进行移植,其实不算完整的冒烟,毕竟可能会有什么移植的代码搞错了。
每次都跑./all.bash太费时间。所以到底怎么单独测试标准库呢?

直到我发现了Go: Testing Standard Library Changes

# Path that the Go repo was cloned to.
$ GODEV=$HOME/godev
# Move into the src directory.
$ cd $GODEV/src
# Set the bootstrap Go install to the current installation.
$ GOROOT_BOOTSTRAP=$(go env GOROOT) ./make.bash
[output omitted]
# Use the newly built toolchain to run the test.
$ $GODEV/bin/go test -v go/types
[output truncated]
=== RUN ExampleInfo
--- PASS: ExampleInfo (0.00s)
PASS
ok go/types 7.129s

其实就是Go怎么判断自己能不能用internal的标准就是前缀路径是不是一致的(太暴力了)。
这样编译的话,你开发的环境路径就和Go工具链的路径一致了~

使用Go制作USB温控风扇

项目地址

本文将涉及

  • 基础电路
  • Go交叉编译(cross compile)
  • USB原理和驱动
  • Wireshark抓USB包
  • 一些吐槽

前因

最近发现路由器的温度在负载高时(Time Machine备份时),常常能飙升到80C,于是我从X宝上买了一对5V USB电压驱动的风扇。
发现降温效果不错,能降到55C,但是负载低时,路由器的温度才65C左右,又不需要风扇了。再到X宝上搜索USB可程控的开关或者风扇,发现压根没有,于是萌生了自己做一个Go版本的温控风扇的想法,当然最好是不用拆机,直接用USB供电.

说干就干!原理其实很简单,就是USB控制一个开关。电路图如下:

蛋疼的现实

上X宝,随便买了一个USB 免驱 继电器

USB免驱继电器

电路部分不难~

结果货到的时候发现,对方没有开源技术资料,所谓免驱,只是这个继电器用的是USB HID(就是驱动鼠标键盘)的接口。而真正的控制驱动代码是Windows版的……还是闭源的DLL……
页面上还有一个明显是复制粘贴来的公告……

我们提供应用程序和C++二次开发库代码,没有VB代码,但是都是dll文件,所以能否在VB上操作,需要熟悉VB的朋友自己尝试。由于工程师出深造,没法给您提供技术支持,所以拍前请先看资料
里面是产品所有的资料,已经很详细了,看是否有您需要的东西再考虑购买,给您带来不便请多包涵。

坑爹呢!

经过一番搜索,发现被坑的不止我一个,国外的哥们都被Ebay上的这个所谓外贸产品(共同点是USB ID 16c0:05df)坑惨了,还专门写了一个项目USB Relay HID,来驱动这些免驱的USB继电器。

这下好了,我看了一下实现,都是通过libusb这个库来控制的,虽然驱动代码有了,但是是C写的,我希望用Go实现,(毕竟C苦手……

又搜索了一圈,各种Go的binding都是用了cgo,这是我一个Go爱好者无法容忍的。

要就要上纯Go!

幸好,来自乌克兰的叫Serge Zaitsev的小哥写了不太完整的纯Go的HID驱动,说只支持x86 Linux,不过没差,大不了我移植一下嘛~

更蛋疼的实现过程

我试着直接用我的Mac交叉编译一次HID驱动里的例子代码

env GOARM=5 GOARCH=arm GOOS=linux go build example/main.go

丢在路由器上,能跑!

注意一下,RT-AC68U虽然是ARMv7的,但是丫竟然没有FPU,所以直接使用GOARM=7的话,会报错

Illegal instruction

解决完编译的问题,现在来看看驱动。USB Relay HID项目好在代码量不大。最核心的代码是这段开关控制代码

if ( relaynum < 0 && (-relaynum) <= 8 ) {
mask = 0xFF;
cmd2 = 0;
if (is_on) {
cmd1 = 0xFE;
maskval = (unsigned char)( (1U << (-relaynum)) - 1 );
} else {
cmd1 = 0xFC;
maskval = 0;
}
} else {
if ( relaynum <= 0 || relaynum > 8 ) {
printerr("Relay number must be 1-8\n");
return 1;
}
mask = (unsigned char)(1U << (relaynum-1));
cmd2 = (unsigned char)relaynum;
if (is_on) {
cmd1 = 0xFF;
maskval = mask;
} else {
cmd1 = 0xFD;
maskval = 0;
}
}

啊哈!原来只需要设置一下控制位,并发送set_feature就可以开关继电器,例如打开1号继电器,只需要传递

[]byte{0, 0xff, 0x1}

我迅速地开始抄袭(误 对应的Go代码,并不能工作……
到底哪里出错了,我开始一一核对代码,还是不能理解这USB驱动代码,于是我开始找USB相关资料,看看到底这是个什么玩意。
结果在知乎上一个解释让我豁然开朗,原帖是纯文字,我觉得转化成图例应该更形象,左边是服务端开发常用的术语,右边是USB对应的名词

物理设备 -> Device
应用程序 -> Interface
应用端口 -> Endpoint

USB in nutshell 第6章中,我查询到了对应的控制数据包定义。

对应起来就是

  • bmRequestType指的是请求类型+请求对象+数据方向的一个bit-map,相当于一个UDP/IP包
  • bmRequest,相当于调用什么RPC接口
  • wValue,wIndex, wLength这些就相当于数据包的内容。

修改好代码后,还是没办法控制,于是我想到了Wireshark抓USB包的方法,来对比我的驱动代码和USB HID RELAY之间的区别。
编译好usb hid relay后
tcpdump -i usbmon -w cap.pcap
对比两个发出的数据,发现原来没有前面的0(满头黑线中= =|||

注意看最后一行,有个Data Fragment,是驱动的关键,仿造这个数据包,就驱动开关了。

但是还有一个问题,就是获取开关的状态。我试着发送GetReport指令,但是没有任何返回。再次抓包对比,发现原来这个USB驱动用的不是标准接口……

不标准的USB请求

而Go的驱动不支持不标准的请求,也就是我说不完整的原因,碰上不标准的厂商,只能抓瞎。于是我开始改造,发现很简单,只需要把原来私有的方法export出来就可以了。

+func (hid *usbDevice) Ctrl(rtype, req, val, index int, data []byte, t int) (int, error) {
+ return hid.ctrl(rtype, req, val, index, data, t)
+}

这样就可以达到自定义消息的目的了。

var dev *Relay
dev.Ctrl(0xa0, 0x01, 3<<8, 0x0, buf, 1000)

最后测试一下,完美!

Golang Control USB Relay

Build a thermal control fan by Go

Github

Why

I just bought a Asus router that sometimes it is overheat like 80C. For cooling, I bought a pair of USB powered fan and it works well, however, it’s waste of energy while CPU is idle. So I think it’s a good idea to use USB control a relay that switch on fan while CPU is hot and turn it off while it’s idle.

Here is the circle diagram, pretty simple.

bad USB driver

I bought a usb hid relay from Taobao( Chinese version of Ebay) for only $2.5

USB HID relay

Putting them together.

But when I try to coding, the vendor didn’t has any kind of opensource code nor protocol to drive this relay 🙁

WTF

After painstacking search on Google, I found a project named USB Relay HID, which is same device and implment by … well … C

I love Golang, so why not try it in Golang?

Lucky Serge Zaitsev has already written a pure USB HID driver, which only support Linux and it’s fine to me.

First, I use my Mac to do the cross compile the example code from the usb hid driver project, and scp to my router and it works!

env GOARM=5 GOARCH=arm GOOS=linux go build example/main.go

Some note, although RT-AC68U is based on ARMv7 but it don’t have FPU, so don’t try to use GOARM=7 or you will get this error:

Illegal instruction

Now we had done with compiling, let’s take a look at the driver code. Fortunately the code is simple and easy to understand, here is the core control code

if ( relaynum < 0 && (-relaynum) <= 8 ) {
mask = 0xFF;
cmd2 = 0;
if (is_on) {
cmd1 = 0xFE;
maskval = (unsigned char)( (1U << (-relaynum)) - 1 );
} else {
cmd1 = 0xFC;
maskval = 0;
}
} else {
if ( relaynum <= 0 || relaynum > 8 ) {
printerr("Relay number must be 1-8\n");
return 1;
}
mask = (unsigned char)(1U << (relaynum-1));
cmd2 = (unsigned char)relaynum;
if (is_on) {
cmd1 = 0xFF;
maskval = mask;
} else {
cmd1 = 0xFD;
maskval = 0;
}
}

After I copied the code, it just not working, what could possibly gone wrong?

I found some definition of USB requests at USB in nutshell Chapter 6 , for server engineer like me, I think this is a good analogy below.

Node in the networking -> Device
Application -> Interface
protocol port -> Endpoint

I tried to capture the USB package by Wireshark to see the diffrenece between USB HID RELAY and I own driver.
tcpdump -i usbmon -w cap.pcap

Turns out there is one byte less in the good control request. You can see that at the Data Fragment.

One more thing. Status from relay, I tried to send GetReport request, nothing came back.
I used tcpdump and Wireshark, again.

Non Standrad USB request

Well, this USB relay vendor using a non stadrad request for the reporting feature. So I have to construct a non standrad request too.

var dev *Relay
dev.Ctrl(0xa0, 0x01, 3<<8, 0x0, buf, 1000)

Compile, execute it, and it works!

Golang Control USB Relay

3分钟AC68U打造成Time Machine

最近为了100M光纤,一咬牙买了台好点的华硕AC68U路由器。
结果发现这货竟然可以当作Time Machine用,原来想过用树莓派做的,结果发现传输速度太慢,只有仅仅1Mb/s,希望这次的AC68U能给力点。

因为常见的fat32不支持4G以上的文件,NTFS系统苹果又没有自带的,Linux支持也不好,所以我就准备用AC68U格式化磁盘。

首先是登入路由器,“系统管理 -> 系统设置”

把ssh选项打开,把自己的公钥,注意是公钥!文件名以.pub结尾的!把里面的内容粘贴到上图的黑框中,然后应用设置。

接着登入路由器

ssh admin@192.168.1.1

注意是用户名是admin,不是root

把移动硬盘接上。格式化成ext3,为啥是sda,因为我只接上了一个磁盘。

mkfs.ext3 /dev/sda

等格式化完成后,就可以愉快地使用咯,点击“USB 相关应用 -> Time Machine“,开启并选定我们刚才格式化好的磁盘。

打开Mac里的Time Machine,注意这里链接时需要的账号密码是路由器的管理员账号密码 ( = =)

这样就开始自动备份了~