📜 ⬆️ ⬇️

Logging, interfaces and allocations in Go


Hi Habr. I published my last post relatively recently, so it is unlikely that you managed to forget that my name is Marco. Today I am publishing a translation of a small note that concerns several very tasty optimizations from the not yet released Go 1.9. These optimizations allow you to generate less garbage in most Go programs. Less garbage - less delay and the cost of collecting this garbage.


This article is about new compiler optimizations that are preparing for the release of Go 1.9, but I would like to start a 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 allocations.


Compare to another zap structured 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), ) 

The arguments for logger.Info are of type logger.Field. logger.Field logger.Field. logger.Field is a union union structure that contains a type and a field for each of the string , int , and interface{} . And it turns out that interfaces are not needed to transfer 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 by two words: a pointer to the type and a pointer to the value. Russ Cox wrote an excellent article about this, and I will not even try to repeat it here. Just go and read it.


But his data is still a bit out of date. The author points to an obvious optimization: when the size of the value is equal to or smaller than the pointer, we can simply put the value instead of the pointer in the second word of the interface. However, with the advent of the competitive garbage collector, this optimization has been removed from the compiler , and now the second word is always just a pointer.


Suppose we have this code:


 fmt.Println(1) 

Before Go 1.4, it did not result in 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 interface words.


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 is now required to 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}) 

It was unpleasant, and many copies were broken in the verbal battles after this change.


The first significant optimization to get rid of allocations was made a little later. It worked when the resulting interface did not run away ( translator's note: term from escape analysis ). In this case, a temporary value can be allocated on the stack instead of a 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 escape 

Unfortunately, many interfaces run away, including those in fmt.Println and in my examples on logging above.


Fortunately, in Go 1.9 there will be some more optimizations for the implementation of which the developers were inspired by the same conversation about logging (unless, of course, they are not rolled back at the last moment, which is always possible).


The first optimization is to not allocate memory when we convert a constant to an interface . So fmt.Println(1) will no longer result in memory allocation. The compiler puts the value 1 in a global read-only memory area. Like that:


 var i int = 1 // at the top level, marked as readonly fmt.Println({int, &i}) 

This is possible, since the constants are unchanged (immutable) and will remain so anyway.


This optimization of the developers was inspired by the discussion of logging. In structural logging, a large number of arguments are constants (exactly all the keys, and probably some of the values). Remember the go-kit example:


 logger.Log("transport", "HTTP", "addr", addr, "msg", "listening") 

This code after Go 1.9 will result in 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 into interfaces . This optimization is implemented by adding a global [256]byte array called staticbytes to all resulting binaries. For a given 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 into the interface, instead of allocating it, it puts a pointer to an element of this array. For example:


 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 being reviewed, is to not allocate memory when converting standard zero values ​​to an interface . This refers to zero values ​​for integers, floating-point numbers, strings, and slices. Rantaim checks the value for equality to zero, and, if so, it uses a pointer to an existing large piece of zeros instead of allocating memory.


If everything goes according to plan, Go 1.9 will remove a large number of allocations during the conversion to interfaces. But it will not remove all such allocations, and this means that the issue of performance will remain relevant when discussing the standardization of logging.


The interaction between the API and some solutions in the implementation is quite interesting.


Selecting and creating an API requires thinking about performance issues. It’s not by chance that the io.Reader interface allows the calling code to use its buffer.


Productivity is an important aspect of certain solutions. As we saw above, the implementation details of the interfaces affect where and when memory allocations will occur. And at the same time, these very solutions depend on what code people write. Compiler and runtime authors seek to optimize real, frequently used code. So, the decision to save in Go 1.4 two words for interfaces instead of adding the third, which would cause an extra memory allocation operation in fmt.Println(1) , was based on considering the code that real people write.


And since what code people write often depends on which APIs they use, we get such feedback, which on the one hand delights, and on the other, 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 your head not only what the compiler and runtime do, but what they could do. Write code for the present, but design an API for the future.


And if you are not sure, ask. It worked (slightly) for logging.


')

Source: https://habr.com/ru/post/328722/


All Articles