Logging, interfaces and allocations in Go
- Transfer

Hi Habr. I published my last post relatively recently, so it is unlikely that you had time to forget that my name is Marco. Today I am publishing a translation of a small note, which concerns several very tasty optimizations from Go 1.9 that has not yet been released. These optimizations allow you to generate less junk in most Go programs. Less garbage - less delay and the cost of collecting this garbage.
This article is about new compiler optimizations that are being prepared for the release of Go 1.9, but I would like to start the conversation with logging.
A couple of weeks ago Peter Burgon started a thread on golang-dev with a proposal to standardize logging. It is used everywhere, so the issue of performance is quite acute. The go-kit package uses structural logging, which is based on the following interface:
type Logger interface {
Log(keyvals ...interface{}) error
}Call example:
logger.Log("transport", "HTTP", "addr", addr, "msg", "listening")Note: everything that is passed to the Log call is converted to an interface. This means that there will be many memory allocation operations.
Compare with another zap structural logging package . Calling logging in it is much less convenient, but this is done in order to avoid interfaces and, accordingly, allocations:
logger.Info("Failed to fetch URL.",
zap.String("url", url),
zap.Int("attempt", tryNum),
zap.Duration("backoff", sleepFor),
)Arguments to logger.Infohave type logger.Field. logger.Field- this structure a la union, which contains a type field and for each of string, int, and interface{}. And it turns out that interfaces are not needed to convey the basic types of values.
But enough about logging. Let's see why converting a value to an interface sometimes requires memory allocation.
Interfaces are represented in two words: a pointer to a type and a pointer to a value. Russ Cox has written an excellent article about this, and I will not even try to repeat it here. Just go and read it.
But still, his data is a little outdated. The author points out an obvious optimization: when the size of the value is equal to or less than the size of the pointer, we can simply put the value instead of the pointer in the second word of the interface. However, with the advent of a competitive garbage collector, this optimization has been removed from the compiler , and now the second word is always just a pointer.
Suppose we have a code like this:
fmt.Println(1)Prior to Go 1.4, it did not lead to memory allocation, since the value 1 could be put directly into the second word of the interface.
That is, the compiler did something like this:
fmt.Println({int, 1}),Where {typ, val}represents two words of the interface.
After Go 1.4, this code began to lead to memory allocation, since 1 is not a pointer, and the second word of the interface should now be a pointer. And it turns out that the compiler and runtime did something like this:
i := new(int) // allocates!
*i = 1
fmt.Println({int, i})This was unpleasant, and many copies were broken in verbal battles after this change.
The first significant optimization to get rid of allocations was made a bit later. It worked when the resulting interface did not run away ( translator's note: term from escape analysis ). In this case, the temporary value can be allocated on the stack instead of the heap. Using the example above:
i := new(int) // now doesn't allocate, as long as e doesn't escape
*i = 1
var e interface{} = {int, i}
// do things with e that don't make it escapeUnfortunately, many interfaces run away, including those in fmt.Printlnand in my logging examples above.
Fortunately, several more optimizations will appear in Go 1.9, the implementation of which was inspired by the very conversation about logging (unless, of course, they are rolled back at the last moment, which is always possible).
The first optimization is to not allocate memory when we convert a constant into an interface . So it fmt.Println(1)will no longer result in memory allocation. The compiler puts a value of 1 in the global read-only memory region. Like that:
var i int = 1 // at the top level, marked as readonly
fmt.Println({int, &i})This is possible because the constants are unchanged (immutable) and will remain so anyway.
This optimization was inspired by the discussion of logging. In structural logging, a large number of arguments are constants (exactly all keys and, probably, some of the values). Recall an example from go-kit:
logger.Log("transport", "HTTP", "addr", addr, "msg", "listening")This code after Go 1.9 will lead to only one memory allocation operation instead of six, since five of the six arguments are constant strings.
The second new optimization is to not allocate memory when converting Boolean values and bytes to interfaces . This optimization is implemented by adding a global [256]bytearray called staticbytesin all resulting binaries. For this array, it is true that staticbytes [b] = b for all b. When the compiler wants to put a boolean value, or uint8, or some other single-byte value in the interface, instead of allocating it, it puts a pointer to an element of this array there. For example, like this:
var staticbytes [256]byte = {0, 1, 2, 3, 4, 5, ...}
i := uint8(1)
fmt.Println({uint8, &staticbytes[i]})And the third optimization, which is still in the review stage, is not to allocate memory when converting standard zero values to an interface . This applies to null values for integers, floating point numbers, strings, and slices. Rantime checks the value for equality to a zero value, and if so, it uses a pointer to an existing large chunk of zeros instead of allocating memory.
If everything goes according to plan, Go 1.9 will remove a large number of allocations during conversion to interfaces. But he will not remove all such allocations, and this means that the issue of performance will remain relevant when discussing standardization of logging.
Quite interesting is the interaction between the API and some solutions in the implementation.
Choosing and creating an API requires thinking about performance issues. It is no coincidence that the io.Reader interface allows the calling code to use its own buffer.
Performance is an important aspect of various solutions. As we saw above, interface implementation details affect where and when memory allocation operations will occur. And at the same time, these very decisions depend on what code people write. The authors of the compiler and runtime seek to optimize real, frequently used code. So, the decision to save two words for interfaces in Go 1.4 instead of adding a third one, which would cause an unnecessary operation of allocating memory in fmt.Println(1), was based on considering the code that real people write.
And since the kind of code people write often depends on which API they use, we get such feedback that on the one hand is amazing, and on the other it is sometimes difficult to manage.
Perhaps this is not a very deep observation, but still: if you are designing an API and are worried about performance, keep in mind not only what the compiler and runtime do, but also what they could do. Write code for the present, but design an API for the future.
And if you are not sure, ask. This worked (a bit) for logging.