Day 21: Writing Math in Go Assembly
☁️ Mood: Monday.
🎵 Soundtrack: No Lyrics - Folk
📚 Reading Github Issues
Started this week by reading 38 new github issues.
- One was about a possible issue in Go 1.14, which is no longer supported. The author wrote that “this is in production” and they can’t tell the app team to upgrade. Which, yikes. But also, I know how much work it is to keep go up-to-date.
- One was about how the garbage collector data is stored as PCDATA and FUNCDATA by the compiler and possible optimizations. I didn’t understand everything, but I understood a lot more than I would have a week ago. Which is the whole reason I started diving into the compiler, so yay!
- Lots of issues created to track a gomote epic.
- Two new go fuzz (no-hypen) issues: one about crashes while minimizing and one about more documentation.
🧶 Go Fuzz Update
My go fuzz fix was merged today!
internal/fuzz: print size of interesting cache
This change updates the log lines to clarify that the printed interesting count is only for newly discovered cache entries, and prints the total cache size. It only prints information about interesting entries when coverageEnabled is true.
🎥 Go Contributor AMA
Given that it is Monday I didn’t feel like diving straight into go assembly again. Instead I watched GopherCon 2020: Ask Me Anything with the Go Team. It was okay. I learned some interesting tidbits though:
- Go pls does not support goland. (I assume goland does its own integrations?)
- Go pls tests their new versions against go v1.12+ since many users don’t keep their golang up-to-date. I feel like this is the theme today.
- There are “many dozens” of people (not just eng) on the go team.
- They are looking at ways to better alert on how to know if you code is vulnerable to CVEs. Not just if you are using a package with a known CVE, but if you are actually using the code path that triggers the CVE.
- When asked for areas of improvement, one person added they “need to onboard people better”. So it’s nice to know everywhere has the same problems.
- They really avoided the question of why go doesn’t have a foundation.
🧩 Reviewing Assembly From Before
I started by going back to the assembly I was looking at last week. I ended up learning a few new things:
MOVL $2, AX
: is literally just putting the number 2 into the register AX. This means that the compiler (or something) already summed1 + 1
since it was hardcoded and stored it as2
. I checked this by changing the hardcoded values and looking at the assembly output.- I also learned that “inlining” is not what I thought it was. Rather than try to explain the nonsense that I thought it was, I am going to post what it is here:
To inline a function call is to bring the body of the function over to where it’s being called — thus, reducing the (small but unavoidable) overhead of a function call. Tweaks like this can get our code from fast to super fast. - An Introduction to the Go Compiler
- When I turned off inlining, the function only required 48 bytes (instead of
64). My guess is that it inlined the
fmt.Println
function, which would explain why I did not see that function called.
💀 I think I have beat that example to death and I am ready to move onto something new. Why not… write my own assembly?
🧩 Writing My Own Assembly
I started by following this blog post to write my own add method is assembly.
The code looked like this…
math.go:
package main
import (
"fmt"
"os"
"strconv"
)
func add(x, y int) int
func main() {
argsWithoutProg := os.Args[1:]
function := argsWithoutProg[0]
a, err := strconv.Atoi(argsWithoutProg[1])
if err != nil {
panic("a")
}
b, err := strconv.Atoi(argsWithoutProg[2])
if err != nil {
panic("b")
}
var result int
if function == "add" {
result = add(a, b)
}
fmt.Println(result)
}
math.s:
// func add(x, y int) int
// make function add
TEXT ·add(SB),$0
// load variable x at memory address 0(FP) (I guess we have to know it is already there) into BX
MOVQ x+0(FP), BX
// load var y which is at 8(FP) into register BP
MOVQ y+8(FP), BP
// add BP and BX. set the value in BX.
ADDQ BP, BX
// move BX into the return value 16FQ
MOVQ BX, ret+16(FP)
// return
RET
And it worked! Wooo!
Next I really stretched my assembly knowledge, changed six characters, and wrote a subtract function:
TEXT ·sub(SB),$0
MOVQ x+0(FP), BX
MOVQ y+8(FP), BP
SUBQ BP, BX
MOVQ BX, ret+16(FP)
RET
Then I tried to make a multiply function and…. got stuck. It took me FOREVER to figure out what the name of the instruction was for multiply and what arguments it takes.
I ended up finding this github issue that was written by one of the Go maintainers who I see often about how he was learning how Go uses x86 assembly and how difficult he found it to play with. Relateable! One of the things he mentioned in the issue is that when he got stuck he would write the function in Go, look at the assembly that the compiler spits out, and then use that as inspiration.
So I wrote a multiply method in Go, compiled it, and looked at the assembly, and
was able to determine that the instruction was looking for was IMULQ
and I
wrote this beautiful function:
TEXT ·mult(SB),$0
MOVQ x+0(FP), BX
MOVQ y+8(FP), BP
IMULQ BX, BP
MOVQ BP, ret+16(FP)
RET
Next was division. Based on some googling I knew that DIVQ is
bad and you shouldn’t use it (you
can’t stop me). So I wasn’t surprised when I didn’t see any DIV
-like
instructions when I wrote a div function in Go and then looked at the assembly
for it. But when I turned off optimizations it did use the instruction
IDIVQ
, but only with one argument.
Based on my 2.5 days worth of compiler/assembly knowledge I assumed that the instruction only had one argument because the divisor is implicit and you just have to know to store it in some register before you call divide. But which register? I tried a few randomly (didn’t work). I tried to google (didn’t work). I searched the source code (worked! But it actually didn’t.) and I found this:
case ssa.OpAMD64DIVQ, ssa.OpAMD64DIVL, ssa.OpAMD64DIVW:
// Arg[0] (the dividend) is in AX.
// Arg[1] (the divisor) can be in any other register.
// Result[0] (the quotient) is in AX.
// Result[1] (the remainder) is in DX.
Great, the secret registrar is AX! So I tried…
TEXT ·div(SB),$0
MOVQ x+0(FP), AX
MOVQ y+8(FP), BP
IDIVQ BP
MOVQ AX, ret+16(FP)
RET
But… nope. It fails:
$ go build . && ./math div 4 2
panic: runtime error: integer divide by zero
[signal SIGFPE: floating-point exception code=0x7 addr=0x108b56d pc=0x108b56d]
goroutine 1 [running]:
main.div(0x4, 0x2)
/Users/adowns/workspace/amelia/assembly-day-21/math_amd64.s:30 +0xd
main.main()
/Users/adowns/workspace/amelia/assembly-day-21/math.go:31 +0x19a
I printed out the registrars to make sure they were set correctly (they were).
But I’m not really sure what went wrong. My ideas for tomorrow are: (1) do a
brute force attempt with setting all the registers and see which ones are being
used and if that doesn’t work, (2) think harder about the sizes of things and
see if I am doing byte math wrong somewhere, (3) move onto loops and figure out how to do
division without using IDIVQ
.