跳至正文

Go ARM64 Base64编码优化小记

Goost库终于发布了,最开始只有base64和adler32(其实就是捡Go官方废弃的汇编加速代码来)
这次我添加了base64 arm64加速的优化,性能提升不高,也就2x,arm处理器羸弱的流水线这锅是跑不了了。
代码如下:

goost.org/encoding/base64

本文记录一下这两天的思路和总结

base64编码基础

base64很简单,数据按6bit(2^6=64)分隔并转换成可见ASCII编码内的字符就可以了,不是我们常见的8bit分隔。具体可以看Wikipedia,这里就不赘述了。

如何加速?

其实早就人研究好了Base64 encoding with SIMD instructions,只不过没有arm64版本的文章。
这里就大致翻译下上面文章的思路,一些解释和背景讲解:

  • 中括号[] 代表一个字节
  • aaaaaa代表一个base64字符

在内存里,3个字节数据是这样排布的。

[aaaaaabb][bbbbcccc][ccdddddd]

而我们的目标是:

[__aaaaaa][__bbbbbb][__cccccc][__dddddd]

接下来大致分三步:首先借助的是arm64 的ld3指令将上面的3个连续字符载入至三个不同的向量寄存器v0, v1, v2

v0: [aaaaaabb]....
v1: [bbbbcccc]....
v2: [ccdddddd]....

通过VSHL,VSHR指令,转到四个对应的向量寄存器,以bbbbbb为例:

aaaaaabb << 4 = aabb____
bbbbcccc >> 4 = ____bbbb

接下来用or,mask将bbbbbb保留

最后这步最好玩。arm64支持VTBL(向量查表操作),恰好最多支持4个128bit的表查询(正好64个字节),所以只需要把base64的码表塞进查询向量寄存器中即可
VTBL指令示意

最后st4保存四个向量寄存器到目的地址即可。

总结一下

写的时候还是碰到了一个坑,因为对VSRI和VSHR区别不熟悉,加上Go编译器只支持VSRI(vector shift right and insert),这个insert会保留原有向量寄存器的数据,导致总是在3/27/53这三个位置的数据不对。浪费了不少时间,下次有不熟悉的指令还是多读读手册。不过误打误撞倒是比原来的作者少了2个指令哈哈。

看原文的时候总是沉不下心,看不懂,下次还是要带着问题读。

解码感觉有些复杂,而且原文的实现不太方便,回头再研究一下。

Go ARM64 vDSO优化之路

背景

Go怎么获取当前时间?问一个会Go的程序员,他随手就能写这个出来给你。

import time
time.Now()

这背后是一个系统调用,X86上调用SYSCALL来完成,ARM64上是SVC。

// func walltime() (sec int64, nsec int32)
TEXT runtime·walltime(SB),NOSPLIT,$24-12
MOVW $0, R0 // CLOCK_REALTIME
MOVD RSP, R1
MOVD $SYS_clock_gettime, R8
SVC
MOVD 0(RSP), R3 // sec
MOVD 8(RSP), R5 // nsec
MOVD R3, sec+0(FP)
MOVW R5, nsec+8(FP)
RET
  • R0 分别是调用的时钟类型
  • R1 是对应的Stack Pointer
  • R8 系统调用的ID

很简单吧?但是这里有一个优化点,就是SVC涉及到了内核态和用户态的切换,
其实就是把所有的用户态的寄存器存储在内核的栈上,执行完内核函数之后,再恢复回来。
这一来一回,速度就降下来了……

而时间又是很常见的系统调用,且是只读数据,能不能不切换内核/用户态呢?
Linux内核的开发者们提出了vDSO方案
通过给每个用户态进程添加一个共享对象(virtual dynamic shared object)来提供一些常见的内核函数
这样就不用切换用户态了。

寻找vDSO

Go对于linux_amd64 vDSO已经优化得很到位了
包括ELF库的解析和使用。比较关键的代码是下面这段。
runtime/vdso_linux_amd64.go

var sym_keys = []symbol_key{
// ...
{"__vdso_clock_gettime", 0xd35ec75, 0x6e43a318, &__vdso_clock_gettime_sym},
}
// initialize with vsyscall fallbacks
var (
// ...
__vdso_clock_gettime_sym uintptr = 0
)

ARM64位也填上一样的值就可以了吧?
然而,现实是,照抄的不行,跟x86的名字和版本都不一样(摔!

man 7 vdso

aarch64 functions
The table below lists the symbols exported by the vDSO.
symbol version
──────────────────────────────────────
__kernel_rt_sigreturn LINUX_2.6.39
__kernel_gettimeofday LINUX_2.6.39
__kernel_clock_gettime LINUX_2.6.39
__kernel_clock_getres LINUX_2.6.39

好好好,我调整一下代码
注意这个后面的0x75fcb89,vDSO代码需要校验,我通过gdb跟踪才最终查到的。

vdso_linux_arm64.go

var vdsoLinuxVersion = vdsoVersionKey{"LINUX_2.6.39", 0x75fcb89}
var vdsoSymbolKeys = []vdsoSymbolKey{
{"__kernel_clock_gettime", 0xd35ec75, 0x6e43a318, &vdsoClockgettimeSym},
}
// initialize to fall back to syscall
var vdsoClockgettimeSym uintptr = 0

发起vDSO call

其实vDSO跟系统调用使用了同样的参数,都是R0 类型,R1 SP, 理论上直接BL 到vDSO函数地址即可。
但不一致的地方是,vDSO需要更大的栈空间
因此需要切换SP地址到M的第一个g上,即g0,调度器g0栈32K,一般只有2K,如果不是,就查找g0的SP地址并切换,在函数结束时切换回当前的g。


MOVD m_curg(R21), R0 // m_curg 其实是个宏,展开后是取当前m的g0
CMP g, R0
BNE noswitch
MOVD m_g0(R21), R3 // m_g0 也是个宏,展开后就是读取m的g0地址
MOVD (g_sched+gobuf_sp)(R3), R1 // Set RSP to g0 stack
noswitch:
SUB $16, R1
BIC $15, R1 // Align for C code
MOVD R1, RSP

pprof的需求

在完成这部分代码后,我发现Ian加了一个追踪vdso调用的pprof
都是照猫画虎,所以就不提了。

最后的提交

runtime: use vDSO for clock_gettime on linux/arm64

效果能快个66%

name old time/op new time/op delta
TimeNow 442ns ± 0% 163ns ± 0% -63.16% (p=0.000 n=10+10)

不过……内核得支持才行,如果内核没办法使用特定时钟源,还是会进行系统调用。
比如我这块破NanoPC,就不支持

cat /sys/devices/system/clocksource/clocksource0/current_clocksource
source timer

哎……啥时候可以买块Hikey960或者ThunderX2 🙁

2018-04-01 Updates

为什么ARM64下有些内核vDSO比原来还慢呢?
买了块Hikey960试了试,发现还是不能加速vDSO……奇怪了……

我试着在内核里找了找原因。
发现,可能是CPU的bug。

以Linux 4.15 Hikey960为例子。

/arch/arm64/kernel/vdso/gettimeofday.S
有个宏,每次调用vDSO的vsyscall时,都会检查,而有问题的芯片总是跳到fail上。

 .macro syscall_check fail
ldr w_tmp, [vdso_data, #VDSO_USE_SYSCALL]
cbnz w_tmp, \fail
.endm

这个vdso_data就是指针,#VDSO_USE_SYSCALL对应的是use_syscall

use_syscall在/arch/arm64/kernel/vdso.c)初始化时,会调用时钟驱动里的vdso_direct值。

void update_vsyscall(struct timekeeper *tk)
{
u32 use_syscall = !tk->tkr_mono.clock->archdata.vdso_direct;

而我们的Hikey960用的是arch_sys_counter

~ # cat /sys/devices/system/clocksource/clocksource0/current_clocksource
arch_sys_counter

搜了一下,发现在/drivers/clocksource/arm_arch_timer.c

static struct clocksource clocksource_counter = {
.name = "arch_sys_counter",
.rating = 400,
.read = arch_counter_read,
.mask = CLOCKSOURCE_MASK(56),
.flags = CLOCK_SOURCE_IS_CONTINUOUS,
};

精彩的部分来了!
默认情况下,vdso_default是true的,也就是不用vdso
这个时钟源驱动怎么初始化这个vdso_direct改成false的呢?

 if (wa->read_cntvct_el0) {
clocksource_counter.archdata.vdso_direct = false;
vdso_default = false;
}

继续追下去,发现是

#ifdef CONFIG_HISILICON_ERRATUM_161010101
/*
* Verify whether the value of the second read is larger than the first by
* less than 32 is the only way to confirm the value is correct, so clear the
* lower 5 bits to check whether the difference is greater than 32 or not.
* Theoretically the erratum should not occur more than twice in succession
* when reading the system counter, but it is possible that some interrupts
* may lead to more than twice read errors, triggering the warning, so setting
* the number of retries far beyond the number of iterations the loop has been
* observed to take.
*/
#define __hisi_161010101_read_reg(reg) ({ \
u64 _old, _new; \
int _retries = 50; \
\
do { \
_old = read_sysreg(reg); \
_new = read_sysreg(reg); \
_retries--; \
} while (unlikely((_new - _old) >> 5) && _retries); \
\
WARN_ON_ONCE(!_retries); \
_new; \
})

按理说,这个_new值应该就是>1的,实际上就是bool 的true。这样,vdso_direct就应该是false,即启用vdso了。
但是……我们的CPU 没有通过这个,应该就是CPU的bug了 🙁

Go ARM64 Map优化小记

背景

Go内置了map类型,而其中重要的哈希算法是一个cityhash的变种。

同时,为了避免哈希冲突攻击(collision attack)和加速哈希计算速度,Keith Randall于Go1.0中就添加了x86_64支持的有硬件加速的AESHASH算法
我搜遍了互联网,惊讶地发现,这个算法仅仅在Go里面有实现,这思路真是绝了。

这就被我这个四处搜索ARM64 Go runtime待优化点的人找到了:ARM64也支持AESHASH的硬件加速指令,但是Go并没有用上。

我嘴角又微微地一笑,满心欢喜准备加代码。可我并不知道,这看似平静的海面下不知道藏着什么妖怪……

Fishman on Danang Beach

开工

打蛇打七寸,看代码实现自然要先看头。
初始化代码在runtime/alg.go

if (GOARCH == "386" || GOARCH == "amd64") &&
GOOS != "nacl" &&
support_aes && // AESENC
support_ssse3 && // PSHUFB
support_sse41 { // PINSR{D,Q}
useAeshash = true
algarray[alg_MEM32].hash = aeshash32
algarray[alg_MEM64].hash = aeshash64
algarray[alg_STRING].hash = aeshashstr
// Initialize with random data so hash collisions will be hard to engineer.
getRandomData(aeskeysched[:])
return
}

可以看到,通过替换algarrary中的hash函数成aeshash,就完成了这个加速替换,
充分考虑了未来其他平台的跟进,赞叹同时感到这个简直就是盛情邀请我来完成接下来的工作。

先看看最简单的aeshash64的具体实现

//func aeshash64(p unsafe.Pointer, h uintptr) uintptr
TEXT runtime·aeshash64(SB),NOSPLIT,$0-24
MOVQ p+0(FP), AX // ptr to data
MOVQ h+8(FP), X0 // seed
PINSRQ $1, (AX), X0 // data
AESENC runtime·aeskeysched+0(SB), X0
AESENC runtime·aeskeysched+16(SB), X0
AESENC runtime·aeskeysched+32(SB), X0
MOVQ X0, ret+16(FP)
RET

注释也很清晰,AX载入了数据的指针,然后,把map的种子和数据载入X0中等待计算。
这里要说明一下,每个hashmap在初始化的时候都会随机分配一个种子,防止黑客找到系统的种子而发起哈希冲突攻击。
在接下来的几步中,使用程序初始化时生成的随机种子 runtime·aeskeysched 对数据进行加密,最后把结果返回。

更复杂的aeshash也只是加载各种长度进行计算而已。

到这,我只能感叹,这太简单了,便花了两个周末就写完了大体的代码,还碰到了以下问题:

  1. 平台差异
  2. Smhasher
  3. 冲突(Collision)
  4. Go编译器bug

平台差异

首先发现的问题是,ARM64并没有X86的AESENC,而是分成两个指令,AESE和AESMC。

先看了看AES的介绍
标准AES加密分成了4步:

  1. AddRoundKey
  2. SubBytes
  3. ShiftRows
  4. MixColumns

X86的AESENC指令等价于:

  1. SubBytes
  2. ShiftRows
  3. MixColumns
  4. data XOR Key

但是……ARM64的AESE指令等价于:

  1. AddRoundKey
  2. SubBytes
  3. ShiftRows

所以,要是单纯模仿X86的AESENC X0,X0写法时……数据就会被清空掉……(摔。
无奈,只好另寻出路,用系统随机种子加密数据,代码思路如下:

// 把系统种子载入 V1
// 再将种子和数据载入 V2
AESE V1.B16, V2.B16

SMhasher&Collision

解决了上个问题,开始进行测试。
Go使用的是Smhasher,一个Hash函数是否达标需要通过以下测试:

  1. Sanity,必须处理整个key
  2. AppendedZeros,填零,长度不同
  3. SmallKeys,所有小key(< 3字节)组合
  4. Cyclic,循环,例如:11211->11121
  5. Sparse,稀疏,例如:0b00001和 0b00100
  6. Permutation,组合,每个block排列组合顺序不同
  7. Avalanche,翻转每个位
  8. Windowed,例如产生的hash值是32位的,那么就20位相同,结果也需要不同
  9. Seed,种子变化会影响结果

每一次Smhasher报错,我都开始看代码哪里出了问题,一般都是放错寄存器这些低级错误……
Go还会对map中bucket分配情况进行测试,如果某个bucket放了过多的数据,一样也会出错。
不过,这部分还是比较顺利的。

Go编译器bug

搞定了这个,我又发现另一个坑。
为了减少指令,我希望直接把寄存器中的数据直接载入到ARM64 Vector Lane

但是Go的编译器没办法正确编译不同的lane index。
例如下面两条指令,最终产生的指令码是一样……

VMOV R1, V2.D[0]
VMOV R1, V2.D[1]

只好先报个bug。

cmd/asm: wrong implement vmov/vld on arm64

然后用原生字节码先顶着了,想知道如何做到可以直接拉到Tips

妖怪

终于,所有runtime的Smhasher和Hash测试都通过了,我开始试着运行src/all.bash来构建Go。

这时我拉到了海底的那只妖怪……

SpongeBob

构建日志

$ ./all.bash
Building Go cmd/dist using /usr/lib/go-1.6.
Building Go toolchain1 using /usr/lib/go-1.6.
Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1.
Building Go toolchain2 using go_bootstrap and Go toolchain1.
Building Go toolchain3 using go_bootstrap and Go toolchain2.
# runtime
duplicate type..hashfunc.struct { "".full "".lfstack; "".empty "".lfstack; "".pad0 [64]uint8; "".wbufSpans struct { "".lock "".mutex; "".free "".mSpanList; "".busy "".mSpanList }; _ uint32; "".bytesMarked uint64; "".markrootNext uint32; "".markrootJobs uint32; "".nproc uint32; "".tstart int64; "".nwait uint32; "".ndone uint32; "".alldone "".note; "".helperDrainBlock bool; "".nFlushCacheRoots int; "".nDataRoots int; "".nBSSRoots int; "".nSpanRoots int; "".nStackRoots int; "".markrootDone bool; "".startSema uint32; "".markDoneSema uint32; "".bgMarkReady "".note; "".bgMarkDone uint32; "".mode "".gcMode; "".userForced bool; "".totaltime int64; "".initialHeapLive uint64; "".assistQueue struct { "".lock "".mutex; "".head "".guintptr; "".tail "".guintptr }; "".sweepWaiters struct { "".lock "".mutex; "".head "".guintptr }; "".cycles uint32; "".stwprocs int32; "".maxprocs int32; "".tSweepTerm int64; "".tMark int64; "".tMarkTerm int64; "".tEnd int64; "".pauseNS int64; "".pauseStart int64; "".heap0 uint64; "".heap1 uint64; "".heap2 uint64; "".heapGoal uint64 }
build failed

咦……编译器构建出错??? 测试都通过了啊!?

再运行一次all.bash,发现出错的地方还不一样???

用gdb断点在我写的asm_arm64.s:aeshash,跟踪执行流程,在对长字符串(>129)哈希也没有问题。
难道是编译器的bug?

所以我开始跟踪编译器的动作,发现只有符号表(symbol)使用了map的功能
恶补了编译器的基本原理和Go实现后,我才意识到,这个符号表也仅仅用了aeshashstr(对字符做hash)的功能。
我把Smhasher中对aeshash的全部改装成了aeshashstr,发现还是能奇迹般地通过测试!
手动校验了一次,发现就算把aeshash32和aeshash64都搞得和x86实现一样,包括结果,还是报错!

于是我把这怪异的问题发邮件,发帖子,发群里问遍了所有人。还是无解。

就这样折腾了1个月业余的时间,基本看完了编译器的相关代码,
发现明明是两个不同的符号(symbol)还是会被认为是同一个。
最后还蛋疼地想用钱看看有没有人愿意帮debug一下
态度惹怒了不少人。我想我是被这bug整得脑子进水了吧……

出坑

直到最近,我才突然意识到没准Smhasher测试并没有覆盖完所有情况?
果然,仔细检查后在aeshash129plus这一段有

SUBS $1, R1, R1
BHS aesloop

这个SUBS是SUB再对比,在R1=1的时候,就退出了。
但Smhasher仅仅对128个字节做了完整的测试,所以测试能通过,但是编译不了。
而这个bug仅仅在256个字节以上才会触发(摔。

改进后是

SUB $1, R1, R1
CBNZ R1, aesloop

提速效果

最终可以提交CL

runtime: implement aeshash for arm64 platform

注意,如果使用PRFM指令,速度能加快30-40MB左右(Hash1024)。可能是下次优化的重点(对齐和Cache)

name old speed new speed delta
Hash5 97.0MB/s ± 0% 97.0MB/s ± 0% -0.03% (p=0.008 n=5+5)
Hash16 329MB/s ± 0% 329MB/s ± 0% ~ (p=0.302 n=4+5)
Hash64 858MB/s ±20% 890MB/s ±11% ~ (p=0.841 n=5+5)
Hash1024 3.50GB/s ±16% 3.57GB/s ± 7% ~ (p=0.690 n=5+5)
Hash65536 4.54GB/s ± 1% 4.57GB/s ± 0% ~ (p=0.310 n=5+5)

如何用GNU汇编语言生成原生ARM64指令字节码?

$cat vld.s
ld1 {v2.b}[14], [x0]
$as -o vld.o -al vld.lis vld.s
AARCH64 GAS vld.s page 1
1 0000 0218404D ld1 {v2.b}[14], [x0]

其中第三列就是生成的字节码,复制到go中就OK了

WORD $0x4D401802

其实还有工具asm2plan9s, 只是目前这个工具没办法编译ARM64

感谢

最后非常感谢

  • Wei Xiao
  • Fangming
  • Keith Randall

对于我细致的帮助

NanoPC3 Plus使用小结

为了更好地接近ARM64的真实情况并研究平台的优劣,我决定在真机上编译和运行Go编译器。
但是Scaleway的机器太烂,Packet.net的ThunderX是好,又太贵了(1小时0.5美元
所以我决定买一块开发板来实验,多次对比后,这个八核A53的NanoPC 3 Plus在价格和性能上比较合适, 入手了

先按照官方教程烧录sd卡。
安装的过程中有个坑,就是HDMI有时候黑屏,导致我以为是我的烧录有问题,但是重新插上就好了。
还有要注意使用的内核,得是ARM64的,我就搞错过,烧录了armv7(32位)的。
而且SD卡版本最好不要用,要不然总是io阻塞,mmcd进程频繁占用100%。

烧录完毕

先重置服务器的finger print,官方的镜像里写死了这个值,而不是初始化的时候生成

rm /etc/ssh/ssh_host_*
dpkg-reconfigure openssh-server

还得改两个默认用户的密码,因为我映射板子的22端口到了路由器22端口,通过DDNS就可以在外网访问了。

passwd root
passwd pi

这时需要换源到USTS上, Ubuntu自带的太慢了,国内很多源是没有arm64 port的(花费30分钟

deb http://mirrors.ustc.edu.cn/ubuntu-ports/ xenial main restricted universe multiverse
deb-src http://mirrors.ustc.edu.cn/ubuntu-ports/ xenial main main restricted universe multiverse
deb http://mirrors.ustc.edu.cn/ubuntu-ports/ xenial-updates main restricted universe multiverse
deb-src http://mirrors.ustc.edu.cn/ubuntu-ports/ xenial-updates main restricted universe multiverse
deb http://mirrors.ustc.edu.cn/ubuntu-ports/ xenial-backports main restricted universe multiverse
deb-src http://mirrors.ustc.edu.cn/ubuntu-ports/ xenial-backports main restricted universe multiverse
deb http://mirrors.ustc.edu.cn/ubuntu-ports/ xenial-security main restricted universe multiverse
deb-src http://mirrors.ustc.edu.cn/ubuntu-ports/ xenial-security main restricted universe multiverse

初始化完成

apt update && apt install tmux vim git build-essential zsh
wget https://dl.google.com/go/go1.10.linux-arm64.tar.gz
tar -C /usr/local -xzf go1.10.linux-arm64.tar.gz
#关闭wifi
systemctl stop wpa_supplicant && systemctl disable wpa_supplicant
#查看温度
cat /sys/class/thermal/thermal_zone0/temp
#关闭蓝牙
systemctl stop bluetooth.service
kill `ps --no-headers -C brcm_patchram_plus -o pid`

听说你想用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 [email protected]

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

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

mkfs.ext3 /dev/sda

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

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

这样就开始自动备份了~

Go零消耗debug log技巧

tL;DR, 本文末尾提供零消耗的日志代码,最高性能提升60000%。

看到题目,有人肯定会问,官方的log模块不好么?
Debug Log一般很长,在生产环境还输出的话,也很难找。
再者,log的消耗是比较大的,特别是需要打印行号时。

https://golang.org/src/log/log.go#L158

if l.flag&(Lshortfile|Llongfile) != 0 {
// Release lock while getting caller info - it's expensive.
l.mu.Unlock()
var ok bool
_, file, line, ok = runtime.Caller(calldepth)
if !ok {
file = "???"
line = 0
}
l.mu.Lock()
}

因为需要调用runtime.Caller,这样性能就有较多的损耗。
简单的benchmark,可以发现慢50%。

func BenchmarkWithLine(b *testing.B) {
logger := log.New(ioutil.Discard, "", log.Llongfile|log.LstdFlags)
tf := strings.Repeat("abcde", 1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
logger.Print(tf)
}
}
// BenchmarkWithLine-4 500000 2806 ns/op
// BenchmarkWithoutLine-4 1000000 1754 ns/op

虽然,log的性能不差,仅需要1us就能进行一次,但如果在代码中有大量的debug日志,这个损耗累积起来,那也是相当惊人的了。

那么,在生产环境,能不能不执行log语句呢?
可以的,例如

const Dev = false
func BenchmarkConst(b *testing.B) {
logger := log.New(ioutil.Discard, "", log.LstdFlags)
tf := strings.Repeat("abcde", 1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if Dev {
logger.Print(tf)
}
}
}
// BenchmarkConst-4 2000000000 0.29 ns/op

go tool objdump查看生成的二进制文件

 log_test.go:36 0x4efc32 48890424 MOVQ AX, 0(SP)
log_test.go:36 0x4efc36 e815d4fbff CALL testing.(*B).ResetTimer(SB)
log_test.go:36 0x4efc3b 488b842480000000 MOVQ 0x80(SP), AX
log_test.go:36 0x4efc43 31c9 XORL CX, CX
log_test.go:38 0x4efc45 eb03 JMP 0x4efc4a
log_test.go:38 0x4efc47 48ffc1 INCQ CX
log_test.go:38 0x4efc4a 488b90f0000000 MOVQ 0xf0(AX), DX
log_test.go:38 0x4efc51 4839d1 CMPQ DX, CX
log_test.go:38 0x4efc54 7cf1 JL 0x4efc47
log_test.go:43 0x4efc56 488b6c2470 MOVQ 0x70(SP), BP
log_test.go:43 0x4efc5b 4883c478 ADDQ $0x78, SP
log_test.go:43 0x4efc5f c3 RET

可以看出,ResetTimer之后,仅仅是跑了个空的for循环,这是因为编译器发现if语句永远不成立,所以不编译这一段了(如果Dev是var值,那么还是会对比一下,而不是没有语句生成),不过这个方法需要每次debug时都要改代码。不想改代码可以用go build -ldflags -X方法, 但这个仅仅支持字符串,特别麻烦。

所以有没有更好的解决方案呢?有的,使用build tags
下面是例子,一共三个文件:

log.go

package main
func main() {
Debug("it's expensive")
if Dev {
fmt.Println("we are in develop mode")
}
}

log_debug.go

//+build debug
package main
import (
"fmt"
)
const Dev = true
func Debug(a ...interface{}) {
fmt.Println(a...)
}

log_release.go

//+build !debug
package main
const Dev = false
func Debug(a ...interface{}) {}

debug和release最大的差别就在文件头的//+build !debug, 意思是告诉编译器,如果有debug这个tags,那么编译的时候就略过这个文件。
比如你运行go build -tags "debug" && ./main就会输出,不设定的话,就什么都不输出。

再用go tool objdump 查看生成的可执行文件,跟之前的if Dev效果相同,压根不生成语句。这样就不用每次都改代码了来debug了,是不是很赞啊?

对于Debug函数,由于Go的函数是first class,所以Call function不可避免,不过性能损失基本上为零了。

package main
import "testing"
var a = strings.Repeat("abcde", 1024)
func BenchmarkDebug(b *testing.B) {
for i := 0; i < b.N; i++ {
Debug(a)
}
}
go test -bench=.
BenchmarkDebug-4 500000000 3.27 ns/op
go test -bench=. -tags debug
BenchmarkDebug-4 10000000 146 ns/op

总结一下,如果极度要求性能,尽量使用if Dev这种判断模式,如果要求不高,可以使用Debug函数的方法。