Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: arena: new package providing memory arenas #51317

Open
danscales opened this issue Feb 22, 2022 · 269 comments
Open

proposal: arena: new package providing memory arenas #51317

danscales opened this issue Feb 22, 2022 · 269 comments

Comments

@danscales
Copy link
Contributor

@danscales danscales commented Feb 22, 2022

Proposal: arena: new package providing memory arenas

Author(s): Dan Scales (with input from many others)

Last updated: 2022-2-22

Discussion at https://golang.org/issue/51317

Abstract

We propose implementing memory arenas for Go. An arena is a way to allocate a set of memory objects all from a contiguous region of memory, with the advantage that the allocation of the objects from the arena is typically more efficient than general memory allocation, and more importantly, the objects in the arena can all be freed at once with minimal memory management or garbage collection overhead. Arenas are not typically implemented for garbage-collected languages, because their operation for explicitly freeing the memory of the arena is not safe and so does not fit with the garbage collection semantics. However, our proposed implementation uses dynamic checks to ensure that an arena free operation is safe. The implementation guarantees that, if an arena free operation is unsafe, the program will be terminated before any incorrect behavior happens. We have implemented arenas at Google, and have shown savings of up to 15% in CPU and memory usage for a number of large applications, mainly due to reduction in garbage collection CPU time and heap memory usage.

Background

Go is a garbage-collected language. Application code does not ever explicitly free allocated objects. The Go runtime automatically runs a garbage-collection algorithm that frees allocated objects some time after they become unreachable by the application code. The automatic memory management simplifies the writing of Go applications and ensures memory safety.

However, large Go applications spend a significant amount of CPU time doing garbage collection. In addition, the average heap size is often significantly larger than necessary, in order to reduce the frequency at which the garbage collector needs to run.

Non-garbage-collected languages also have significant memory allocation and de-allocation overhead. In order to deal with complex applications where objects have widely varying lifetimes, non-garbage-collected languages must have a general-purpose heap allocator. Because of the differing sizes and lifetimes of the objects being allocated, such an allocator must have fairly complex code for finding memory for a new object and dealing with memory fragmentation.

One approach to reducing the allocation overhead for non-garbage-collected languages is region-based memory management, also known as arenas. The idea is that applications sometimes follow a pattern where a code segment allocates a large number of objects, manipulates those objects for a while, but then is completely done with those objects, and so frees all (or almost all) of the objects at roughly the same time. The code segment may be allocating all the objects to compute a result or provide a service, but has no need for any of the objects (except possibly a few result objects) when the computation is done.

In such cases, region-based memory allocation using an arena is useful. The idea is to allocate a large region of memory called an arena at the beginning of the code segment. The arena is typically a contiguous region, but may be extensible in large chunk sizes. Then all the objects can be allocated very efficiently from the arena. Typically, the objects are just allocated consecutively in the arena. Then at the end of the code segment, all of the allocated objects can be freed with very low overhead by just freeing the arena. Any result object that is intended to be longer-lived and last past the end of the code segment should not be allocated from the arena or should be fully copied before the arena is freed.

Arenas have been found to be useful for a number of common programming patterns, and when applicable, can reduce memory management overhead in non-garbage collected languages. For instance, for a server serving memory-heavy requests, each request is likely independent, so most or all of the objects allocated while serving a particular request can be freed when the request has been fulfilled. Therefore, all the objects allocated during the request can be allocated in an arena, and then freed all at once at the completion of the request.

In a related vein, arenas have been useful for protocol buffer processing, especially when unmarshalling the wire format into the in-memory protocol message object. Unmarshalling a message's wire format to memory can create many large objects, strings, arrays, etc., because of the complexity of messages and the frequent nesting of sub-messages inside other messages. A program may often unmarshal one or more messages, make use of the in-memory objects for a period of time, and then be done with those objects. In this case, all of the objects created while unmarshalling the message(s) can be allocated from an arena and freed all at once. The C++ protocol buffer documentation provides an example of using arenas. Arenas may similarly be useful for other kinds of protocol processing, such as decoding JSON.

We would like to get some of the benefits of arenas in the Go language. In the next section, we propose a design of arenas that fits with the Go language and allows for significant performance benefits, while still ensuring memory safety.

Note that there are many applications where arenas will not be useful, including applications that don't do allocation of large amounts of data, and applications whose allocated objects have widely varying lifetimes that don't fit the arena allocation pattern. Arenas are intended as a targeted optimization for situations where object lifetimes are very clear.

Proposal

We propose the addition of a new arena package to the Go standard library. The arena package will allow the allocation of any number of arenas. Objects of arbitrary type can be allocated from the memory of the arena, and an arena automatically grows in size as needed. When all objects in an arena are no longer in use, the arena can be explicitly freed to reclaim its memory efficiently without general garbage collection. We require that the implementation provide safety checks, such that, if an arena free operation is unsafe, the program will be terminated before any incorrect behavior happens.

For maximum flexibility, we would like the API to be able to allocate objects and slices of any type, including types that can be generated at run-time via reflection.

We propose the following API:

package arena

type Arena struct {
	// contains filtered or unexported fields
}

// New allocates a new arena.
func New() *Arena

// Free frees the arena (and all objects allocated from the arena) so that
// memory backing the arena can be reused fairly quickly without garbage
// collection overhead.  Applications must not call any method on this
// arena after it has been freed.
func (a *Arena) Free()

// New allocates an object from arena a.  If the concrete type of objPtr is
// a pointer to a pointer to type T (**T), New allocates an object of type
// T and stores a pointer to the object in *objPtr.  The object must not
// be accessed after arena a is freed.
func (a *Arena) New(objPtr interface{})

// NewSlice allocates a slice from arena a.  If the concrete type of slicePtr
// is *[]T, NewSlice creates a slice of element type T with the specified
// capacity whose backing store is from the arena a and stores it in
// *slicePtr. The length of the slice is set to the capacity.  The slice must
// not be accessed after arena a is freed.
func (a *Arena) NewSlice(slicePtr interface{}, cap int)

The application can create an arbitrary number of arenas using arena.New, each with a different lifetime. An object with a specified type can be allocated in a particular arena using a.New, where a is an arena. Similarly, a slice with a specified element type and capacity can be allocated from an arena using a.NewSlice. Because the object and slice pointers are passed via an empty interface, any type can be allocated. This includes types that are generated at run-time via the reflect library, since a reflect.Value can be converted easily to an empty interface.

The application explicitly frees an arena and all the objects allocated from the arena using a.Free. After this call, the application should not access the arena again or dereference a pointer to any object allocated from this arena. The implementation is required to cause a run-time erro and terminate the Go program if the application accesses any object whose memory has already been freed. The associated error message should indicate that the termination is due to access to an object in a freed arena. In addition, the implementation must cause a panic or terminate the Go program if a.New or a.NewSlice is called after a.Free is called. a.New and a.NewSlice should also cause a panic if they are called with an argument which is not the correct form (**T for a.New and *[]T for a.NewSlice).

Here is some sample code as an example of arena usage:

import (
	“arena”
	…
)

type T struct {
	val int
}

func main() {
	a := arena.New()
	var ptrT *T
	a.New(&ptrT)
	ptrT.val = 1

	var sliceT []T
	a.NewSlice(&sliceT, 100)
	sliceT[99] .val = 4

	a.Free()
}

There may be an implementation-defined limit, such that if the object or slice requested by calls to a.New or a.NewSlice is too large, the object cannot be allocated from the arena. In this case, the object or slice is allocated from the heap. If there is such an implementation-defined limit, we may want to have a way to expose the limit. We’ve listed it as one of the possible metrics mentioned in the “Open Issues” section. An alternate API would be to not allocate the object or slice if it is too large and instead leave the pointer arguments unchanged. This alternate API seems like it would be more likely to lead to programming mistakes, where the pointer arguments are not properly checked before being accessed or copied elsewhere.

For optimization purposes, the implementation is allowed to delay actually freeing an arena or its contents. If this optimization is used, the application is allowed to proceed normally if an object is accessed after the arena containing it is freed, as long as the memory of the object is still available and correct (i.e. there is no chance for incorrect behavior). In this case, the improper usage of arena.Free will not be detected, but the application will run correctly, and the improper usage may be detected during a different run.

The above four functions are the basic API, and may be sufficient for most cases. There are two other API calls related to strings that are fairly useful. Strings in Go are special, because they are similar to slices, but are read-only and must be initialized with their content as they are created. Therefore, the NewSlice call cannot be used for creating strings. NewString below allocates a string in the arena, initializes it with the contents of a byte slice, and returns the string header.

// NewString allocates a new string in arena a which is a copy of b, and
// returns the new string.
func (a *Arena) NewString(b []byte) string

In addition, a common mistake with using arenas in Go is to use a string that was allocated from an arena in some global data structure, such as a cache, which that can lead to a run-time exception when the string is accessed after its arena is freed. This mistake is understandable, because strings are immutable and so often considered separate from memory allocation. To deal with the situation of a string whose allocation method is unknown, HeapString makes a copy of a string using heap memory only if the passed-in string (more correctly, its backing array of bytes) is allocated from an arena. If the string is already allocated from the heap, then it is returned unchanged. Therefore, the returned string is always usable for data structures that might outlast the current arenas.

// HeapString returns a copy of the input string, and the returned copy
// is allocated from the heap, not from any arena. If s is already allocated
// from the heap, then the implementation may return exactly s.  This function
// is useful in some situations where the application code is unsure if s
// is allocated from an arena.
func HeapString(s string) string

Of course, this issue of mistakenly using an object from an arena in a global data structure may happen for other types besides strings, but strings are a very common case for being shared across data structures.

We describe an efficient implementation of this API (with safety checks) in the "Implementation" section. Note that the above arena API may be implemented without actually implementing arenas, but instead just using the standard Go memory allocation primitives. We may implement the API this way for compatibility on some architectures for which a true arena implementation (including safety checks) cannot be implemented efficiently.

Rationale

There are a number of possible alternatives to the above API. We discuss a few alternatives, partly as a way to justify our above choice of API.

Removing Arena Free

One simple adjustment to the above API would be to eliminate the arena Free operation. In this case, an arena would be freed automatically only by the garbage collector, once there were no longer any pointers to the arena itself or to any objects contained inside the arena. The big problem with not having a Free operation is that arenas derive most of their performance benefit from more prompt reuse of memory. Though the allocation of objects in the arena would be slightly faster, memory usage would likely greatly increase, because these large arena objects could not be collected until the next garbage collection after they were no longer in use. This would be especially problematic, since the arenas are large chunks of memory that are often only partially full, hence increasing fragmentation. We did prototype this approach where arenas are not explicitly freed, and were not able to get a noticeable performance benefit for real applications. An explicit Free operation allows the memory of an arena to be reused almost immediately. In addition, if an application is able to use arenas for almost all of its allocations, then garbage collection may be mostly unneeded and therefore may be delayed for quite a long time.

APIs that directly return the allocated objects/slices

An alternate API with similar functionality, but different feel, would replace (*Arena).New and (*Arena).NewSlice with the following:

// New allocates an object of the given type from the arena and returns a
// pointer to that object.
func (a *Arena) New(typ reflect.Type) interface{}

// NewSlice allocates a slice of the given element type and capacity from the
// arena and returns the slice as an interface. The length of the slice is
// set to the capacity.
func (a *Arena) NewSlice(typ reflect.Type, cap int) interface{}

An example of usage would be:

a := arena.New()
floatPtr := a.New(reflect.TypeOf(float64(0))).(*float64)
byteSlice := a.NewSlice(reflect.TypeOf(byte(0)), 100).([]byte)

This API potentially seems simpler, since it returns the allocated object or slice directly, rather than requiring that a pointer be passed in to indicate where the result should be stored. This allows convenient use of Go’s idiomatic short variable declaration, but does require type assertions to convert the return value to the correct type. This alternate API specifies the types to be allocated using reflect.Type, rather than by passing in an interface value that contains a pointer to the required allocation type. For applications and libraries that already work on many different types and use reflection, specifying the type using reflect.Type may be convenient. However, for many applications, it may seem more convenient to just pass in a pointer to the type that is required.

There is an efficiency distinction in the NewSlice call with the two choices. In the NewSlice API described in the "Proposal" section, the slice header object is already allocated in the caller, and only the backing element array of the slice needs to be allocated. This may be all that is needed in many cases, and hence more efficient. In the new API in this section, the Slice call must allocate the slice object as well in order to return it in the interface, which causes extra heap or arena allocation when they are often not needed.

Another alternative for a.New is to pass in a pointer to type T and return a pointer to
type T (both as empty interfaces):

// New, given that the concrete type of objPtr is a pointer to type T,
// allocates an object of type T from the arena a, and returns a pointer to the
// object.
func (a *Arena) New(objPtr interface{}) interface{}

An example use of this API call would be: intPtr := a.New((*int)(nil)).(*int). Although this also allows the use of short variable declarations and doesn’t require the use of reflection, the rest of the usage is fairly clunky.

Simple API using type parameterization (generics)

We could have an optional addition to the API that uses type parameterization to express the type to be allocated in a concise and direct way. For example, we could have generic NewOf and NewSliceOf functions:

// NewOf returns a pointer to an object of type T that is allocated from
// arena a.
func arena.NewOf[T any](a *Arena) *T
// NewSliceOf returns a slice with element type T and capacity cap
// allocated from arena a
func arena.NewSliceOf[T any](a *Arena, cap int) []T

Then we could allocate objects from the arena via code such as:

intPtr := arena.NewOf[int](a)

We don’t think these generic variants of the API can completely replace the suggested methods above, for two reasons. First, the NewOf function can only allocate objects whose type is specified at compile-time. So, it cannot satisfy our goal to support allocation of objects whose type is computed at run-time (typically via the reflect library). Second, generics in Go are just arriving in Go 1.18, so we don’t want to force users to make use of generics before they are ready.

Compatibility

Since this API is new, there is no issue with Go compatibility.

Implementation

In order to fit with the Go language, we require that the semantics of arenas in Go be fully safe. However, our proposed API has an explicit arena free operation, which could be used incorrectly. The application may free an arena A while pointers to objects allocated from A are still available, and then sometime later attempt to access an object allocated from A.

Therefore, we require that any implementation of arenas must prevent improper accesses without causing any incorrect behavior or data corruption. Our current implementation of the API gives a memory fault (and terminates the Go program) if an object is ever accessed that has already been freed because of an arena free operation.

Our current implementation performs well and provides memory allocation and GC overhead savings on the Linux amd64 64-bit architecture for a number of large applications. It is not clear if a similar approach can work for 32-bit architectures, where the address space is much more limited.

The basic ideas for the implementation are as follows:

  • Each arena A uses a distinct range in the 64-bit virtual address space
  • A.Free unmaps the virtual address range for arena A
  • The physical pages for the arena can then be reused by the operating system for other arenas.
  • If a pointer to an object in arena A still exists and is dereferenced, it will get a memory access fault, which will cause the Go program to terminate. Because the implementation knows the address ranges of arenas, it can give an arena-specific error message during the termination.

So, we are ensuring safety by always using a new range of addresses for each arena, in order that we can always detect an improper access to an object that was allocated in a now-freed arena.

The actual implementation is slightly different from the ideas above, because arenas grow dynamically if needed. In our implementation, each arena starts as a large-size "chunk", and grows incrementally as needed by the addition of another chunk of the same size. The size of all chunks is chosen specifically to be 64 MB (megabytes) for the current Go runtime on 64-bit architectures, in order to make it possible to recycle heap meta-data efficiently with no memory leaks and to avoid fragmentation.

The address range of these chunks do not need to be contiguous. Therefore, when we said above that each arena A uses a distinct range of addresses, we really meant that each chunk uses a distinct range of addresses.

Each chunk and all the objects that it contains fully participate in GC mark/sweep until the chunk is freed. In particular, as long as a chunk is part of an arena that has not been freed, it is reachable, and the garbage collector will follow all pointers for each object contained in the chunk. Pointers that refer to other objects contained in the chunk will be handled very efficiently, while pointers to objects outside the chunk will be followed and marked normally.

The implementation calls SetFinalizer(A, f) on each arena A as it is allocated, where f calls A.Free. This ensures that an arena and the objects allocated from it will eventually be freed if there are no remaining references to the arena. The intent though is that every arena should be explicitly freed before its pointer is dropped.

Because unmapping memory is relatively expensive, the implementation may continue to use a chunk for consecutively allocated/freed arenas until it is nearly full. When an arena is freed, all of its chunks that are filled up are immediately freed and unmapped. However, the remaining part of the current unfilled chunk may be used for the next arena that is allocated. This batching improves performance significantly.

Because of the large 64-bit address space, our prototype implementation has not required reusing the virtual addresses for any arena chunks, even for quite large and long-running applications. However, the virtual addresses of most chunks can eventually be reused, since there will almost always be no more reachable pointers to anywhere in the chunk. Since the garbage collector sees all reachable pointers, it can determine when an address range can be reused.

The implementation described above demonstrates that it is possible to implement the Arena API for 64-bit architectures with full safety, while still providing performance benefits. Many other implementations are possible, and some may be tuned for other types of usage. In particular, because of the 64 MB chunk size, the above implementation may not be useful for applications that need to create a large number of arenas that are live at the same time (possibly because of many concurrent threads). It is probably most appropriate that there should only be a few to 10's of arenas in use at any one time. Also, it is not intended that arenas be shared across goroutines. Each arena has a lock to protect against simultaneous allocations by multiple goroutines, but it would be very inefficient to actually use the same arena for multiple goroutines. Of course, that would rarely make sense anyway, since the lifetimes of objects allocated in different goroutines are likely to be quite different.

Open issues

Another possibility in the design space is to implement the API described in the "Proposal" section, but without the safety checks, or with an option to disable the safety checks. The idea here is that the performance savings from the use of arenas can be increased by doing an implementation that doesn't have safety guarantees. As compared to the implementation described above, we can avoid the mapping and unmapping overhead, and reuse the memory of an arena much more quickly (and without OS involvement) when it is freed. We have done a prototype of such an implementation, which we call "unsafe arenas". We have seen an additional 5-10% improvement in performance in some cases when using unsafe arenas rather than our safe arena implementation. However, we feel very strongly that arenas in Go need to be safe. We do not want the use of arenas to lead to memory bugs that may be very hard to detect and debug, and may silently lead to data corruption. We think that it is better to continue to optimize the implementation of safe arenas, rather than trying to support unsafe arenas.

It would be useful to have some run-time metrics associated with arenas. The desired metrics will depend somewhat on the final API, so we have not yet tried to decide the exact metrics that will cover the application needs. However, here are some metrics which might be useful:

  • the number of arena created and freed
  • the number of current arenas and the maximum number of arenas that have been active at one time
  • the total number of bytes allocated via arenas, and the average number of bytes allocated per arena
  • the (constant) limit on the largest-size object or slice that can be allocated from an arena

Another open issue is whether arenas can be used for allocating the elements of a map. This is
possible, but it is not clear what a good API would be. Also, there might be unusual cases if the arena used for the main map object is different from the arena used to allocate new elements of the map. With generics arriving in Go 1.18, generic maps (or hash tables) can now be implemented in user libraries. So, there could be a user-defined generic map implementation that allows optionally specifying an arena for use in allocating new elements. This might be the best solution, since that would allow for greater flexibility than adjusting the semantics of the built-in map type.

Protobuf unmarshalling overheads

As noted above, arenas are often quite useful for reducing the allocation and GC overhead associated with the objects that are created as a protobuf message is being unmarshaled. We have prototyped changes to the protobuf package which allow for providing an arena as the allocation option for objects created during unmarshalling. This arrangement makes it quite easy to use arenas to reduce the allocation and GC overhead in applications that make heavy use of protobufs (especially unmarshalling of large protobufs). If the arena proposal is accepted and implemented in Go, then it would make sense to extend the protobuf package to provide such an arena allocation option.

@gopherbot gopherbot added this to the Proposal milestone Feb 22, 2022
@ianlancetaylor ianlancetaylor added this to Incoming in Proposals Feb 22, 2022
@hherman1
Copy link

@hherman1 hherman1 commented Feb 22, 2022

What is the reason to add this to the standard library as opposed to building a third party package?

@clausecker
Copy link

@clausecker clausecker commented Feb 22, 2022

Your specification says:

The application explicitly frees an arena and all the objects allocated from the arena using a.Free. After this call, the application should not access the arena again or dereference a pointer to any object allocated from this arena. The implementation is required to cause a run-time erro and terminate the Go program if the application accesses any object whose memory has already been freed.

If I read it correctly, this means that it is permitted to keep pointers into a released arena (i.e. stray pointers) as long as you do not explicitly dereference them. However, this means that the compiler must now be careful not to dereference any pointer it knows not to be nil unless user code explicitly does so, lest it be a pointer into a released arena. This seems like it would significantly reduce the potential for optimisation as otherwise, the compiler seems to be allowed to perform such accesses, knowing that each reachable object can also be dereferenced safely.

If the rules were tightened to say that you have to erase all pointers into an arena before calling Free, not only would these optimisations be enabled, but there would also be a way to reclaim the address space occupied by the arena: the garbage collector could be programmed to check if any pointers into the arena address space remain and if there aren't any, it could allow the address space to be reclaimed. If it finds a stray pointer, it could likewise abort the program in much the same manner as when you dereference a stray pointer.

Another benefit is that it's less likely to have tricky edge cases where stray pointers could remain in some data structures (e.g. as the result of some string manipulation or append operations which may only some times return a pointer to one of their arguments), causing them to be dereferenced later only under specific, hard to reconstruct circumstances. By prohibiting the presence of any stray pointers after a call to Free, this kind of error would be much easier to find.

With this issue addressed, I'm very interested in this proposal. It will be very useful for complex temporary data structures that need to be built step by step and deallocated all at once.

@quenbyako
Copy link

@quenbyako quenbyako commented Feb 22, 2022

@hherman1 as far as i understood this proposal, there are a lot of troubles for example in appending to slices, e.g. the code could be more readable if you just use append, without calling arena-specific methods.

Even though, the idea to manualy handle memory freeing sounds great for me, cause there are a lot of specific cases, when you don't want to hope on the garbage collector

@tarndt
Copy link

@tarndt tarndt commented Feb 22, 2022

I'd be curious to understand the problems that an arena solves that can't be solved by either using sync.Pool, allocating a slice of a given type (for example a binary tree allocating a slice of nodes), or a combination of both techniques. It seems to me that Go provides idiomatic ways of addressing at least the specific protobuf use-case mentioned here.

In addition to the above, doesn't the Go allocator already have sizes classes? If the proposal does proceed, I think we need to see a prototype outperform the runtimes allocator and is enough gain to justify the ecosystem complexity.

@komuw
Copy link
Contributor

@komuw komuw commented Feb 22, 2022

We don’t think these generic variants of the API can completely replace ....

First, the NewOf function can only allocate objects whose type is specified at compile-time. So, it cannot satisfy our goal to support allocation of objects whose type is computed at run-time (typically via the reflect library).

What's the main usecase for wanting to allocate types created via reflect in arenas? Marshalling & unmarshall?
Are those usecases compelling enough? Honest question.

Second, generics in Go are just arriving in Go 1.18, so we don’t want to force users to make use of generics before they are ready.

Is there a hurry in adding arenas? It can always wait one or two release cycles before been added so that people are ready with generics.
In other words, if hypothetically speaking, the arena-generics design was the better API; we shouldn't shelve it just because generics aren't ready yet.

@frioux
Copy link

@frioux frioux commented Feb 22, 2022

This proposal sounds really good to me from a user's perspective, but one detail causes concern: wouldn't something like all code eventually end up needing arena support? For example, imagine I have a web service where I want to allocate an arena for each web request. This sounds like a great use case for this, but I'd need encoding/json to have an Arena mode, and maybe text/template/html/template to have Arena modes so their working sets can use the Arena. Probably same for various clients (SQL, http, etc.) Is that what you see as the path forward or am I missing something?

@ianlancetaylor
Copy link
Contributor

@ianlancetaylor ianlancetaylor commented Feb 22, 2022

@hherman1

What is the reason to add this to the standard library as opposed to building a third party package?

It's hard to do this safely in a third party package. Consider a struct allocated in the arena that contains pointers to memory allocated outside the arena. The GC must be aware of those pointers, or it may incorrectly free ordinary-memory objects that are still referenced by arena objects. A third party package would have to somehow make the garbage collector aware of those pointers, including as the pointer values change, which is either hard or inefficient.

@ianlancetaylor
Copy link
Contributor

@ianlancetaylor ianlancetaylor commented Feb 22, 2022

@clausecker

However, this means that the compiler must now be careful not to dereference any pointer it knows not to be nil unless user code explicitly does so

That is already true. Go code can use pointers that point to memory that was allocated by C, or that was allocated by syscall.Mmap. The compiler already can't casually dereference a pointer.

the garbage collector could be programmed to check if any pointers into the arena address space remain and if there aren't any, it could allow the address space to be reclaimed

I believe that we can already do that. If the garbage collector sees a pointer to a freed arena, it can crash the program. If we have two complete GC cycles after an arena is freed, we can know for sure that there are no remaining pointers into that address space, and we can reclaim the addresses. However, I don't know if the current patches implement that.

@ianlancetaylor
Copy link
Contributor

@ianlancetaylor ianlancetaylor commented Feb 22, 2022

@tarndt

I'd be curious to understand the problems that an arena solves that can't be solved by either using sync.Pool, allocating a slice of a given type (for example a binary tree allocating a slice of nodes), or a combination of both techniques. It seems to me that Go provides idiomatic ways of addressing at least the specific protobuf use-case mentioned here.

As you note, sync.Pool only permits allocating a specific type. It's reasonable to use if all allocations are the same type. An arena is when most allocations are different types. That is the case for protobuf allocations: each protobuf is implemented as a different Go struct. It's also the case for many uses of, for example, JSON.

In addition to the above, doesn't the Go allocator already have sizes classes? If the proposal does proceed, I think we need to see a prototype outperform the runtimes allocator and is enough gain to justify the ecosystem complexity.

I'm not sure how size classes are related.

As @danscales mentions, there is already an implementation, which is in use internally at Google. It does overall outperform the runtime allocator for cases like RPC servers that transmit data as protobufs.

@Merovius
Copy link

@Merovius Merovius commented Feb 22, 2022

First, I feel this should live in the runtime package, or at least in runtime/arena, as it seems fairly runtime specific. Which is my main concern with this, that other implementations might not support it and then packages using it would not be usable on those implementations.

Also, a question for clarification:

It is probably most appropriate that there should only be a few to 10's of arenas in use at any one time.

One of the main usecases mentioned is protobuf decoding. ISTM that, if every call to proto.Unmarshal creates an arena, used until that message is done with, we would very quickly outpace 10's of arenas by orders of magnitude on a loaded gRPC server.

@quenbyako

as far as i understood this proposal, there are a lot of troubles for example in appending to slices, e.g. the code could be more readable if you just use append, without calling arena-specific methods.

AIUI the proposal would not interoperate with append, i.e. you'd indeed have to use arena-specific methods.

@ianlancetaylor
Copy link
Contributor

@ianlancetaylor ianlancetaylor commented Feb 22, 2022

@komuw

What's the main usecase for wanting to allocate types created via reflect in arenas? Marshalling & unmarshall?
Are those usecases compelling enough? Honest question.

Yes, marshaling and unmarshaling. These cases are compelling for, for example, network RPC servers that must serialize and unserialize data for every request.

@adonovan
Copy link
Member

@adonovan adonovan commented Feb 22, 2022

One implementation caveat: a 64-bit address space isn't quite the inexhaustible vastness it first seems because many environments impose tighter restrictions. (I recently used a 4GB mmap as an optimization but found OpenBSD's default ulimit is 1.5GB and some users seem to impose their own limits.)

@Merovius
Copy link

@Merovius Merovius commented Feb 22, 2022

Which reminds me:

It is not clear if a similar approach can work for 32-bit architectures, where the address space is much more limited.

Does this mean this package would use build-tags to limit itself to 64-bit architectures? And would programs using this then not run on 32-bit platforms, unless they specifically code around that? Also, this seems all the more reason to put this into runtime, to make clear that it's platform dependent.

@thepudds
Copy link

@thepudds thepudds commented Feb 22, 2022

Hi @Merovius, my interpretation was that the API could be implemented everywhere, but it would not increase efficiency everywhere:

Note that the above arena API may be implemented without actually implementing arenas, but instead just using the standard Go memory allocation primitives. We may implement the API this way for compatibility on some architectures for which a true arena implementation (including safety checks) cannot be implemented efficiently.

@Merovius
Copy link

@Merovius Merovius commented Feb 23, 2022

@thepudds ah thank you, I missed that, alleviates my concerns around portability. Though I'd find it a bit confusing to provide the API without actual arenas. But, fair enough.

@thepudds
Copy link

@thepudds thepudds commented Feb 23, 2022

@Merovius raises an interesting question, though, around what the overhead of the non-arena implementation of the arena api would be…

@tarndt
Copy link

@tarndt tarndt commented Feb 23, 2022

@ianlancetaylor

As you note, sync.Pool only permits allocating a specific type. It's reasonable to use if all allocations are the same type. An arena is when most allocations are different types. That is the case for protobuf allocations: each protobuf is implemented as a different Go struct. It's also the case for many uses of, for example, JSON.

Assuming the problem with protobufs is the actual objects and not some internal buffer, here is how I would naively use sync.Pool.

Today you might see code like this:

book := &pb.AddressBook{}

Rather I would suggest the generated AddressBook code includes a constructor function:

book := NewAddressBook()

The protoc generated code in its package would look something like this:

var addressBookPool sync.Pool = sync.Pool{
	New: func() interface{} {
		return new(AddressBook)
	},
}

func NewAddressBook() *AddressBook {
    return addressBookPool.Get().(*AddressBook)
}

func (ab *AddressBook) Close() error { 
    *ab = AddressBook{}
     addressBookPool.Put(ab)
     return nil
}

I assume if arena existed similar changes to have a constructor that uses it would need to happen as well? If not that burden would fall to the object creator which is in fact how I use sync.Pool with protobufs today (when needed)

Now maybe more is needed say:

func (ab *AddressBook) Close() error {
    ab.closeOnce.Do(func() {
        *ab = AddressBook{}
         addressBookPool.Put(ab)
    })
    return nil
}

Or maybe we need to also generate and use pools/constructors of *structs referenced in AddressBook (or do what some pkgs like gogoproto optionally do and not use pointers for objects nested in proto structs). Lots of nuance that is besides the point of this discussion-

But I would like to understand why the arena approach is better. I'd also be curious to bench an arena prototype against sync.Pool and an approach like the above.

@danscales
Copy link
Contributor Author

@danscales danscales commented Feb 23, 2022

If the rules were tightened to say that you have to erase all pointers into an arena before calling Free, not only would these optimisations be enabled, but there would also be a way to reclaim the address space occupied by the arena: the garbage collector could be programmed to check if any pointers into the arena address space remain and if there aren't any, it could allow the address space to be reclaimed. If it finds a stray pointer, it could likewise abort the program in much the same manner as when you dereference a stray pointer.

In addition to what Ian already said about the compiler not dereferencing stray pointers:

One reason that we would like to able to deal with pointers to objects in the arena staying around after it freed (as long as they are not used) is because it may be quite tricky and disruptive to have to remove them from the stack. A pointer to an object in the arena could have been passed around as an arg and is still on the stack as an argument or local variable, but will not be used again. It would be good to ensure that the programmer does not have to nil out these pointers on the stack just to satisfy arenas, when they will naturally go away later.

@danscales
Copy link
Contributor Author

@danscales danscales commented Feb 23, 2022

We don’t think these generic variants of the API can completely replace ....

Second, generics in Go are just arriving in Go 1.18, so we don’t want to force users to make use of generics before they are ready.

Is there a hurry in adding arenas? It can always wait one or two release cycles before been added so that people are ready with generics. In other words, if hypothetically speaking, the arena-generics design was the better API; we shouldn't shelve it just because generics aren't ready yet.

Yes, I agree. There's no rush to figure out and add arenas, so it may be worth waiting to use the generics API if there's consensus on it being a better API. The first point does remain - which is that we would prefer to have some API that allows for allocating a type that is created/calculated at runtime. However, the generics API could become the main API, and the one that allows for dynamic types could be the secondary API.

@danscales
Copy link
Contributor Author

@danscales danscales commented Feb 23, 2022

This proposal sounds really good to me from a user's perspective, but one detail causes concern: wouldn't something like all code eventually end up needing arena support? For example, imagine I have a web service where I want to allocate an arena for each web request. This sounds like a great use case for this, but I'd need encoding/json to have an Arena mode, and maybe text/template/html/template to have Arena modes so their working sets can use the Arena. Probably same for various clients (SQL, http, etc.) Is that what you see as the path forward or am I missing something?

Yes, that's a good point. If we have a package that is allocating lots of memory (often by doing decoding of wire format) and we want to use arenas with it, then we will have to plumb in a way to send down an optional allocator function that it should use if enabled (in our case, the allocator function would use an arena). That's what we have prototyped for the protobuf unmarshaling library calls (see last section). I don't see any easy way to make packages use arenas, but open to suggestions.

@thepudds
Copy link

@thepudds thepudds commented Feb 23, 2022

Hi @danscales

If we have a package that is allocating lots of memory (often by doing decoding of wire format) and we want to use arenas with it, then we will have to plumb in a way to send down an optional allocator function that it should use if enabled (in our case, the allocator function would use an arena).

Is there a rough estimate of how many new APIs this might translate to in the standard library, for example?

@danscales
Copy link
Contributor Author

@danscales danscales commented Feb 23, 2022

Also, a question for clarification:

It is probably most appropriate that there should only be a few to 10's of arenas in use at any one time.

One of the main usecases mentioned is protobuf decoding. ISTM that, if every call to proto.Unmarshal creates an arena, used until that message is done with, we would very quickly outpace 10's of arenas by orders of magnitude on a loaded gRPC server.

Yes, that is true. It is possible there is another implementation that could deal with that many arenas simultaneously. But I will note that if you have 100s of requests in flight at one time, then they may not be allocating that much new data while serving each individual request, so arenas may not really be required in that case. Arenas are really more useful/appropriate for unmarshalling large data objects for which you may do a lot of processing. sync.Pool may be more appropriate if the allocated data per request is small (since there may also be only a limited number of known object types).

@CannibalVox
Copy link

@CannibalVox CannibalVox commented Feb 23, 2022

Yes, I agree. There's no rush to figure out and add arenas, so it may be worth waiting to use the generics API if there's consensus on it being a better API. The first point does remain - which is that we would prefer to have some API that allows for allocating a type that is created/calculated at runtime. However, the generics API could become the main API, and the one that allows for dynamic types could be the secondary API.

Is there any idea of (A) what is the net cost of doing this with reflection and (B) will it be possible to not do it with reflection when using the generics API?

Yes, that is true. It is possible there is another implementation that could deal with that many arenas simultaneously. But I will note that if you have 100s of requests in flight at one time, then they may not be allocating that much new data while serving each individual request, so arenas may not really be required in that case. Arenas are really more useful/appropriate for unmarshalling large data objects for which you may do a lot of processing. sync.Pool may be more appropriate if the allocated data per request is small (since there may also be only a limited number of known object types).

What message sizes/throughputs was the prototype tested at? Many small requests/responses is certainly the norm in my background, but I know google was looking hard (I believe?) a Vitess blog post that mainly dealt with very large data replication messages. Certainly if proto performance gets worse than status quo at high throughput levels, many people wouldn't be thrilled with that.

@CannibalVox
Copy link

@CannibalVox CannibalVox commented Feb 23, 2022

This proposal sounds really good to me from a user's perspective, but one detail causes concern: wouldn't something like all code eventually end up needing arena support? For example, imagine I have a web service where I want to allocate an arena for each web request. This sounds like a great use case for this, but I'd need encoding/json to have an Arena mode, and maybe text/template/html/template to have Arena modes so their working sets can use the Arena. Probably same for various clients (SQL, http, etc.) Is that what you see as the path forward or am I missing something?

Yes, that's a good point. If we have a package that is allocating lots of memory (often by doing decoding of wire format) and we want to use arenas with it, then we will have to plumb in a way to send down an optional allocator function that it should use if enabled (in our case, the allocator function would use an arena). That's what we have prototyped for the protobuf unmarshaling library calls (see last section). I don't see any easy way to make packages use arenas, but open to suggestions.

Add memory allocator to context? It seems really silly but doing a second round of "context-aware" updates to add context in more places seems better to me than doing a second round of "context-aware" updates that adds an entirely new type of object that ends up needing to be sent everywhere.

@evanphx
Copy link
Contributor

@evanphx evanphx commented Feb 23, 2022

Hi all,

One quick thought: could you utilize the write barrier that is currently used by the compiler/GC to detect writes to arena allocated memory outside of the arena. Then on .Free, copy the values that are referenced out to the main heap?

This would let you effectively use an arena as a large scratch area, with the GC assisting in figuring out which values are not just scratch.

@schmichael
Copy link
Contributor

@schmichael schmichael commented Feb 23, 2022

Is the code for the experimental protobuf implementation published? The scope of code that needs changing in order to take advantage of per-request arenas seems huge, but I'm eager to see a real world example in hopes I'm wrong!

@ianlancetaylor
Copy link
Contributor

@ianlancetaylor ianlancetaylor commented Feb 23, 2022

@tarndt A protobuf message can include many different messages (as they are called) arranged in a complex memory layout. This is all relatively seamless for the program but it means that unmarshaling a protobuf isn't just a matter of allocating a single object type, it can involve dozens of different types. So you would need dozens of different sync.Pool structures. And in order to use the pools effectively you would need a complicated piece of code to release all the data back to the appropriate pools, which is much more complicated, and slower, than releasing an arena.

Frankly an arena sounds a lot simpler. And let's not forget that an arena lets you notify the runtime exactly when the memory is no longer needed. A pool will stick around in a much less predictable fashion.

@Merovius
Copy link

@Merovius Merovius commented Mar 18, 2022

@rasky

Then, continuing on the same example packages, what if I want to load a viper configuration in an arena so that I can quickly dispose it later? Should I call a new function viper.LoadConfigArena()?

ISTM that this is a decision the viper-authors need have to make. That is, if the community doesn't want arenas to spread, there is a simple way to not have them spread - don't expose APIs for them.

Personally, I find the idea of a configuration parser using arenas sufficiently outlandish to not be worried.

Languages that allow to use custom allocators like slabs provide a different allocation function, but the memory allocated by them can then be used with existing code without that code to ever know that the custom allocator was used.

Arena-allocated memory can be used without knowing about arenas with this proposal just as well¹. It is just that if code wants to allocate into an arena itself, it needs to know about it.

From what I know, the most common approach to custom allocators seems to be to add an optional type-parameter for the allocator to use, FWIW. That seems largely identical to this proposal in both the virality of API and practical usage.

@beoran

Yes there are already similar ways in which Go can be memory unsafe when making mistakes. Similar does not mean equal.

The argument is that the bug causing the misbehavior is exactly the same. The class of bug this triggers is different. But if you retain a pointer past its lifetime, that's exactly the same programming mistake - whether that pointer was allocated on the heap (triggering a race condition) or allocated in an arena (triggering a panic). That is, the bug is not "a racing write" or "a panic". The bug is "retaining a pointer after its lifetime".

The argument is that thus, the likelihood of making that mistake is exactly the same.


[1] As long as we don't incorporate the IMO impractical idea of a custom pointer type.

@beoran
Copy link

@beoran beoran commented Mar 19, 2022

@CannibalVox yes, a Free should instruct the GC to check and fix up and if pisdible free the area as soon as possible. If we had a runtime.Free function, this could be used similarly even for GC memory...

@Merovius Even if the kind of bug to be careful of is the same, areas as per this proposal increase thr likeliness of making this bug because the list of things to keep in mind becomes longer. It was the same whrn sync.Pool was introduced. In Go 1.1 the list was: be careful with unsafe.Pointer and race conditions. When pools were introdiced it took a while before we realized but they are now on the list as well. I would rather not add yet anotger item to that list.

@kylelemons
Copy link
Contributor

@kylelemons kylelemons commented Mar 19, 2022

As I've been following along with this proposal, it seems like the largest beneficiaries would be wire protocol libraries / encoding schemes. Apologies if I'm missing another large, motivating use-case here. I have been thinking about ways to kick the can down the road a bit by finding a way to fill the gap between these wire protocols' optionality and the Go type system--the gap that is currently filled by using e.g. *int to mean "an optional int".

The question I have is this: if we had a new common way to specify an "optional" field that didn't also carry with it the burden of a pointer to a separately-allocated value (along with language / builtin / stdlib constructs to make it about as heavy as today's nil checks / allocation calls in associated code), how much would that buy us? Maybe we wouldn't get the 15% improvement, since the objects themselves (rather than the objects and their fields) are still subject to normal GC, but it might be interesting if it could get something like 10% back.

@danscales Do you have any insight into how much of the reduction in GC overhead of protobuf decoding is a result of optional primitive types (strings, integers, etc) vs the sheer number of message values? Or, put another way, does proto2 (whose generated code has many more pointers to primitives) benefit substantially more than proto3? If so, something like this would potentially allow proto2 to allocate closer to once per message than once per field.

@beoran
Copy link

@beoran beoran commented Mar 19, 2022

@kylelemons Manual memory management is useful for other use cases as well. For example, in a game or game engine like ebiten, it would be convenient to be able to have a memory area for storing image or sound data which can be passed to the OS or to C libraries as is, and which could be immediately freed after a quick GC safety scan when not needed anymore. After reading the prototype PR, am not sure if the proposed arenas can really be used for that, though.

Your remark on optional types is very interesting. Now in Go we have to use either a pointer, or like sql does, a struct with a bool and the actual value. But a pointer creates more work for the gc while a struct with a bool wastes space. If he had a built in way, perhaps a built in generic optional[T], then the GC could be taught to not scan these optional values in the most common cases and improve performance like that.

@zephyrtronium
Copy link
Contributor

@zephyrtronium zephyrtronium commented Mar 19, 2022

At this point, I am convinced that @Merovius's argument is correct, specifically in the sense that arenas do not introduce any error conditions that sync.Pool does not. I disagree that they are memory safe in the same sense as properly typed Go, but I think the difference isn't important.

There has been a lot of speculation about how different details about arenas would affect or "pollute" APIs. By now, I think it's worthwhile to try it and see. In particular, I'm curious about the impacts of arenas as proposed on decoders and encoders for wire transmission, New(args) *T constructor functions, and situations like managing resources for games like @beoran mentions (which was also my first thought on seeing this proposal!). I'd also like to see the impact of making the nil Arena always allocate from the heap, so that instead of NewWithArena we might have SetArena methods – although I'm not sure whether that's better or worse. I think it isn't particularly viable particularly where pointers exist inside allocated objects, but I'm also curious about an arena.Pointer[T] type that does not expose the allocated address.

@beoran
Copy link

@beoran beoran commented Mar 19, 2022

@zephyrtronium I think i said it before, while it is true that arenas as proposed here are equally risky as sync.Pool is, that is not what we should be aiming at. I think we can do significantly better than sync.Pool on the level of safety, use in games, selecting the size of the allocated area, etc.

But I actually agree that it would be good if we could try it out for at least one year in x/exp/arenas as to be not bound by the Go compatibility promise.

@cuishuang
Copy link
Contributor

@cuishuang cuishuang commented Mar 23, 2022

It sounds strange to a GC language also have a backdoor to manage memory manually. Worried about being abused. It is recommended to have a description or measure like the unsafe package

@beoran
Copy link

@beoran beoran commented Mar 23, 2022

@cuishuang. Go would not be the first language to do this. The D language is normally garbage collected but it has a whole panoply of features for manual memory management as well. https://news.digitalmars.com/d/2.0/memory.html

@golang golang deleted a comment from cocotyty Mar 23, 2022
@fzhedu
Copy link

@fzhedu fzhedu commented Mar 25, 2022

func A() {
    a := arena.New()
    B(a)
    a.Free()
}

do you propose a smart point way to prevent from forgetting deleting arena, like the unique/share_ptr in C++?

func A() {
   smartPoint a := arena.New()
    B(a)
    /// a.Free()
}

@Merovius
Copy link

@Merovius Merovius commented Mar 25, 2022

@fzhedu I think the recommendation will be

func A() {
    a := arena.New()
    defer a.Free()
    B(a)
}

Go doesn't have a way to do something when a value falls out of scope, i.e. it doesn't have destructors. So, the best approximation of what you suggest are finalizers. The proposal text talks about those.

@fzhedu
Copy link

@fzhedu fzhedu commented Mar 25, 2022

@Merovius
that would also be dangerous, some programmers, especially new guys often forget writing the defer xxx. Does Go have a similar way to destruct objects like try-with-resources of Java?

@Merovius
Copy link

@Merovius Merovius commented Mar 25, 2022

@fzhedu Please consult the proposal text:

The implementation calls SetFinalizer(A, f) on each arena A as it is allocated, where f calls A.Free. This ensures that an arena and the objects allocated from it will eventually be freed if there are no remaining references to the arena. The intent though is that every arena should be explicitly freed before its pointer is dropped.

And the section titled "Removing Arena Free", for making finalizers the primary mechanism.

Does Go have a similar way to destruct objects like try-with-resources of Java?

The closest it has is writing something like

func WithResource(f func(*Resource)) {
    r := acquireResource()
    defer r.Close() // Free, whatever
    f(r)
}

which is the same as the thing we are talking about.

It would certainly be possible to make the API func WithArena(func(*Arena)) in this manner, instead of NewArena() *Arena. There is a bit of discussion about this above as well.

@rsc
Copy link
Contributor

@rsc rsc commented Mar 30, 2022

This discussion seems not to be converging. I think the Go runtime team would like to be able to experiment with this code in the main tree to learn more. I suggest that we add the code behind GOEXPERIMENT=arena and then put this proposal on hold until we have more experience. Having it as a GOEXPERIMENT should make it easier for others to try too, but it will avoid any of the "API invasiveness" concerns of directly adopting the proposal.

@zephyrtronium
Copy link
Contributor

@zephyrtronium zephyrtronium commented Mar 30, 2022

A use case that isn't unmarshaling: I expect that arenas could substantially improve the performance of certain uses of math/big. For example, this implementation of pi to arbitrary precision requires several temporary variables with predictable lifetimes. If I translate that code to use an arena as proposed, and assuming that *big.Float gets a new SetArena(*arena.Arena) *big.Float method, I get:

func pi(arena *Arena, prec uint) *big.Float {
	var half, two, a, b, t, x, y, lim *big.Float
	arena.New(&half)
	arena.New(&two)
	arena.New(&a)
	arena.New(&b)
	arena.New(&t)
	arena.New(&x)
	arena.New(&y)
	arena.New(&lim)
	half.SetArena(arena).SetFloat64(0.5)
	two.SetArena(arena).SetFloat64(2).SetPrec(prec + 64)
	a.SetArena(arena).SetFloat64(1).SetPrec(prec + 64)
	b.SetArena(arena)
	b.Mul(b.Sqrt(b), half)
	t.SetArena(arena).SetFloat64(0.25).SetPrec(prec + 64)
	x.SetArena(arena).SetFloat64(1).SetPrec(prec + 64)
	y.SetArena(arena)
	lim.SetArena(arena).SetMantExp(x, -int(prec+1))
	for y.Sub(a, b).Cmp(lim) != -1 {
		y.Copy(a)
		a.Add(a, b).Mul(a, half)
		b.Sqrt(b.Mul(b, y))
		y.Sub(a, y)
		y.Mul(y, y).Mul(y, x)
		t.Sub(t, y)
		x.Mul(x, two)
	}
	a.Mul(a, a).Quo(a, t)
	return a.SetPrec(prec)
}

func Pi(prec uint) *big.Float {
	arena := arena.New()
	defer arena.Free()
	return new(big.Float).Copy(pi(arena, prec))
}

Using the arena accounts for about half of the code here. If the arena API were Arena.New(...any) instead of Arena.New(any), half of the arena code would disappear. If Arena.New would check whether the arguments have a SetArena(*arena.Arena) method and automatically call it, then the other half could disappear. If the function taking the arena is exposed such that the arena argument could be nil, and a nil Arena.New does not implicitly allocate from the heap, then the sequence of calls to New must be doubled with half = new(big.Float) &c. in an if arena == nil branch.

If @ALTree wants to support using a single arena for pi and other calculations, then he must choose between exposing an arena argument on all functions, having arena and non-arena variants (possibly in separate packages to avoid API pollution), using an arena global, or making all operations methods of some wrapper type with its own SetArena (which also lends itself to non-arena package-level variants, reminiscent of flag's and math/rand's functions versus methods).

It is not straightforward to use the Arena.Run(func()) variant that people have mentioned for this problem, because the way to have a *big.Float definitely copy a result to heap memory is non-obvious: one must manipulate the precision outside the Run function to ensure that Copy/Set doesn't allocate. All types which own memory would have to provide such mechanisms, so it seems like it wouldn't solve the API pollution problem. It would instead introduce APIs to support arenas but which don't mention arenas in their signatures.

@CannibalVox
Copy link

@CannibalVox CannibalVox commented Mar 30, 2022

@zephyrtronium This is a great use case, and definitely the sort of thing that makes me interested in the proposal. Although it would be replaceable with object pooling, I think. I think unmarshalling would be a really bad use case.

@gopherbot
Copy link

@gopherbot gopherbot commented Mar 31, 2022

Change https://go.dev/cl/397034 mentions this issue: arena: have nil arenas allocate from the heap

@gopherbot
Copy link

@gopherbot gopherbot commented Mar 31, 2022

Change https://go.dev/cl/397035 mentions this issue: math/big: use arenas for Float

@zephyrtronium
Copy link
Contributor

@zephyrtronium zephyrtronium commented Mar 31, 2022

CL 397035 is an experiment to test the API and performance changes associated with having operations on big.Float use arenas. A quick summary is that having nil arenas handle allocation from the heap appears to cause up to a 200% slowdown (although I don't have time to profile and verify that that is indeed the source of the problem), and actually trying to use an arena in a benchmark to compute pi as I demonstrated in my previous comment causes the benchmark to hang at 53 bits of precision on my machine. I don't know the cause of that issue.

@zephyrtronium
Copy link
Contributor

@zephyrtronium zephyrtronium commented Apr 2, 2022

I've had some time to try more changes with arena in math/big and find more findings and feedback.

  • Even undoing the change to allow nil arenas to handle heap allocations, the geometric mean for execution time of the big.Float benchmarks with internal changes for arenas is +51%, with a peak of +166%. runtime.mallocgc appears substantially higher in profiles now, and I note that the benchmarks now perform dramatically more heap allocations, including going from 0 to 2 per op for BenchmarkFloat{Add,Sub}. I think this is because of the way I wrote some parts of code to use arenas, but it may be the case that the interface-based arena API is inhibiting escape analysis.
  • I thought before that my arena-using benchmark would hang at 53 bits of precision, but it actually hangs on whatever is the second benchmark function to use arenas. This is to say, it hangs on the second iteration of the first such benchmark if I pass -count=2. I have been able to find nothing which would indicate the cause of this, and the only information that I have is that stack traces seem to suggest that the benchmark goroutine is spending its time in a system stack.
  • I find that I do wish we had generic variants of Arena.New and Arena.Slice, for no reason other than to serve as documentation of exactly the right thing to provide to those functions.
  • In cases like formatting big.Float to a decimal string where it is difficult to predict the size of the result, it is awkward to use an arena to allocate a slice for appending. It would be nice to have a dedicated arena method to replace append. An alternative would be an API to suggest a capacity to allocate when growing a slice, so that I could follow size classes. A worse alternative would be to naïvely double the length of the slice when it needs to grow.

@thepudds
Copy link

@thepudds thepudds commented Apr 2, 2022

Hi @zephyrtronium. FWIW, I also initially saw heavy interface{} related allocations from arenas, and for what I was testing, adding arenas initially made it ~4x slower.

The first thing I did was modify the arena implementation to add a generics API:

thepudds/go119@3c3094d#diff-2018f445bcf0c48965fd7accc508a0fa2a8ce02b0ed0db7cf2d6928f42fd4795R46-R60

It reuses most of the existing machinery, but it avoided any interface{} related allocs for what I was testing. That change eliminated about 40% of the wall clock overhead of arenas.

(Another reason I added the generics API was I was more interested in getting the feel of it given there's a decent chance that would become the primary API if adopted).

Arenas were still about ~2.5x slower than no arenas, so I poked at it a bit more and tweaked the runtime a bit to get some better performance (at least for my case), which ultimately ended up with arenas being ~2x faster than the original and also used ~40% less RAM. I put a longer writeup into #51667 with details (with some additional comments above on potential API implications). YMMV.

I could send a CL with the generics API, but it is also something anyone can patch in themselves. (Also, I think a lower-level implementation of the proposed generics API would likely squeeze out a bit more performance, including there were hints that a different generics API might be faster, but my simple first cut seemed to help enough that I moved on to other performance areas).

Finally, thanks for the detailed analysis & experiments -- very interesting!

@rsc
Copy link
Contributor

@rsc rsc commented Apr 6, 2022

I'm definitely concerned about the follow-on API effects, such as the proposal to add Arenas to math/big.

Given the positive responses to #51317 (comment), let's put this proposal on hold, land the code behind GOEXPERIMENT for now, and see how things look down the road.

@rsc
Copy link
Contributor

@rsc rsc commented Apr 13, 2022

Given the reactions to my last comment, let's put this on hold. Runtime team, it's OK to land this behind GOEXPERIMENT=arena if you like.

@rsc rsc moved this from Active to Hold in Proposals Apr 13, 2022
@rsc
Copy link
Contributor

@rsc rsc commented Apr 13, 2022

Placed on hold.
— rsc for the proposal review group

@fzhedu
Copy link

@fzhedu fzhedu commented Apr 22, 2022

we look forward to the arena useful to mitigate OOM in TiDB in terms of the following three cases:

  1. Unmarshaling json or grpc protobuf packets uses a lot of memory, and the memory useage cannot be estimated in advance. From some cases, unmarshaling uses more than 10GB memory, causing OOM. This case is worse when many goroutines unmarshal packets at the same time;

  2. Heap size is often significantly larger than necessary for some structures, like table stats, hash table in hash agg or join. we cannot exactly count the memory usage of these general structures, causing OOM;

  3. The used memory cannot be released in time. we cannot exactly know the free memory at runtime, causing OOM.

@burdiyan
Copy link

@burdiyan burdiyan commented Apr 22, 2022

I'm very excited about this proposal. Strongly believe that having manual memory management "hooks" in Go could expand its usefulness for so many different areas where a more granular control of the memory is required.

People from Dgraph wrote a blog post about manual memory management using jemalloc. Their allocator approach sounds similar to the use case of this proposal.

Zig also has an interesting approach of explicitly passing Allocator object to places where memory needs to be managed manually. Go code that needs to manage memory manually could similarly take Allocator as an argument, which clearly conveys the idea of manual memory management, in a similar way that a function taking a Context conveys that it could block and get canceled.

@traetox
Copy link

@traetox traetox commented Apr 29, 2022

I admittedly haven't dug too deep into the Golang slab allocator, but could it be possible to hand in a custom slab allocator to the arena.New() function and still get GC sweeps on the allocations? That would allow some very interesting use cases with file backed allocations and custom "swap." It would also allow for users to manually define arenas that can be offloaded to disk in very high resource contention.

It is an admitted rabbit hole in terms of additional fault potential, but if the internal slab allocator for the arena is accessible there are some very cool custom application potentials.

@hauntedness
Copy link

@hauntedness hauntedness commented May 14, 2022

I would suggest to have a Reset() or Clone() to deal with some infinite loops.

arena := arena.New()
for {
    doSomething(arena)
    arena.Reset()
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Todo
Development

No branches or pull requests