The Go compiler is at the heart of Go’s build process, taking code and generating executables from that code. Go’s compiler is available for those interested to tinker with it using the
go tool compiler command.
Assembly and the Assembler
Assembly is programming language meant to be understood by humans, and is often characterized as a low-level programming language. In some languages, the compiler generates assembly language. In most cases Assembly is the penultimate step in the hierarchy of programming abstractions before you know how to speak to the machine.
Assembly cannot be directly executed as machine code or by the host machine, and it needs an assembler (which is just another program) to be able to convert it into machine code. Assembly differs from machine code in that, assembly does not contain binaries, it cannot be directly executed by machine code and assembly is meant to be “human readable”. Typically as you ascend the hierarchy of programming abstractions. e.g. from high-level programming languages, to low-level ones, more emphasis is placed on interfacing with the actual characteristics of the architecture you’re running on. This arguably makes assembler highly relevant when pursuing performance or accessing low-level hardware functionality such as in embedded systems.
Low Level Golang Primitives
Assembler gives access to the runtime which allows for functionality such as context switching and better access to the stack, that allows for efficient communication of data in primitives such as channels. One interesting use case is the math/big package in the Go standard library.
How Assembly is used in Go
In a talk given by Rob Pike, he walked through some of the compilers architectural changes from early versions of the Go language, to modern day implementation. The figure shown below (Figure 1), showcases a variety steps compilers can take to transform code to linked programs.
The top row, is the canonical way programs that use assembly compile code. Code is compiled into assembly, and that assembly code is then linked. gcc is an example of a compiler that does this. The red dotted line describes binary representation of pseudo instructions that are generated at that point. The subsequent rows represent how
Plan9 architectures go about creating executable binaries from code.
As of Go 1.3, in a bid to rid the Go standard library (STL) of C code, the Go language designers opted for a trade off that would sacrifice compile speeds, for faster builds.
In the newer architecture, the bottom two rows (Go’s compilation process), the compiler encompasses what the traditional compiler would be doing (shown in the first row), as well as the role of the assembler. This is important, as now the compilation phase not only handles both the high-level code (golang) and the generation of assembly, it generates real instructions in the form of an intermediate representation known as
obj generates real instructions for the linker and is also agnostic to the type of assembler.
Where does Golang fit in all this?
Assembly is included in a small set of go packages, some of these are
crypto and reflect
1. math package
math/big package are one of the few packages in the standard library that actually has assembly code that your program calls into in order to achieve better performance with regards to the calculation of highly computational constructs such as big numbers i.e. numbers greater than int64 i.e 9,223,372,036,854,775,807 or less than -9,223,372,036,854,775,807.
In the words of Rob Pike, sometimes using assembler allows you to come up with something better than what the compiler could come up with on its own.
math/big also has assembly functions for arithmetic operations on vectors that are more efficient to compute in assembly than in Go or C. Some trigonometrical polynomial coefficients and certain constants are referenced and computed in assembly such as pi, the computation of
arccos, arcsin, arctan as well as other hyperbolic trignometric functions e.g.
sinh, cosh, tanh etc.
2. crypto package
As hardware evolves and becomes more capable of computing cryptographic keys very quickly, the software needs to catch up to this. The designers of Go thought it would be better to include Assembly code for some of the
crypto functionality (as it can be hardware dependent, especially when trying to achieve fast cryptography), and not have to include it as actual Go code in the STL. Here are some examples listed below:
crypto/aespackage contains assembly code for various CPU architectures to optimize the encryption of certain blocks.
crypto/ellipticpackage contains assembly code for the computation of fast prime field elliptic curves that contain 256-bit primes.
crypto/md5package has assembly for different architectures for the computation of various md5 hashes.
crypto/sha256 & crypto/sha512package uses assembly for the optimization of its SHA256 hashes.
3. reflect package
The reflect package is Go’s go to for functionality that involves the use of reflection. Reflection is best defined by this answer from StackOverflow
The ability to inspect the code in the system and see object types is not reflection, but rather Type Introspection. Reflection is then the ability to make modifications at runtime by making use of introspection. The distinction is necessary here as some languages support introspection, but do not support reflection. One such example is C++
Reflection can have the tendency to be slow, or computationally expensive. Some low-level implementations done in assembly can speed it up.
4. runtime/cgo package
The cgo tool that enables the creation of C code from Go. Some of the cross calling that happens between both languages can be optimized by the use of assembly. One technique is to efficiently reuse registers between callee and caller programs saving computation and memory usage. Some assembly optimizations also can be done for the gcc compiler. In other cases Go’s take on assembly is simply used to standardize the calling between code on all different CPU platforms.
5. runtime/atomic and sync/atomic package
Some low-level synchronization primitives are handled by Assembler. This is shown both the
6. syscall package
It would make sense that calls to the kernel are optimized through the use of Assembly. In Linux based GOOS, the actual
Syscall command itself is performed in Assembly. Even retrieving core OS information such as time of day, is done in assembly. Perhaps there is value in optimizing that function as a lot of tools require access to GOOS time regularly and often.
Generating Assembly in Go
With all this talk of Assembly in Go, let’s actually generate some Assembly from Go code, using the go tools. So we’ll write an extremely simple Hello World application. I’m keeping it simple because the output tends to be very verbose.
package main func main()
// Generates obj file as main.o
go tool compile main.go
Generates an obj file (that we speak about earlier). It is simply binary, but you can inspect it if you are curious.
// Generates assembly, and sends it to a new main.asm
go tool compile -S main.go > main.asm
This will send the output generated by the
-S flag into a
main.asm file so you can inspect it. Note how verbose the output is, as well as all the underlying instructions.
To see the full contents of the generated files, check out this GitHub repo.
Assembly is important, it has been around for a long time, however as the need for performance and higher access to low-level hardware arises, it’s value is inexolerable.
If you have never seen Assembly language, here’s a code snippet from one of my 2nd Year Computer Science assignments. This code is written for MIPS architecture and checks to see if a given text is a palindrome (i.e is the same when read forward and in reverse). Note that Assembly varies based on the CPU architecture