-
Notifications
You must be signed in to change notification settings - Fork 18k
reflect: map iteration does unnecessary excessive allocation for non-pointer types #32424
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
Comments
See attached github_golang_issue_32424_test.go.txt Please rename the file to .go, and run as below:
Got results:
For an operation that shouldn't have any allocations, we have a lot.
The allocation is mostly from copyVal (which I traced as being called for each time we convert a map key or value into a reflect.Value, where we call unsafe_New because interfaceIndir(t) returns true. For Reflect using MapIndex, the call to MapKeys allocates a slice with same length as the map, which is a reason for why MapIter is better. But that (MapIter) still has allocation because of the copyVal function call. As you see above, with normal range, there is no allocation. And the allocation is completely because the copyVal code does a seemingly unnecessary allocation for composing a reflect.Value from the map key or value. |
From my understanding, the reason for this is that reflection works off interface{}, and so we MUST create a pointer for each non-pointer that is wrapped in an interface. I am hoping there is a way to create an API that works around this. For example, we could pass in a reflect.Value to each call to MapIter.Key() or Value(), and that would be used to pass on |
Correct.
That's a possibility. That would be a pretty ugly API though. In general there are lots of places (not just map iteration) where reflect is slow because we're putting non-pointers in interfaces. We've historically come down on the side of cleaner API even though there might be faster ways with an uglier API. |
For single byte things, we could use runtime.staticbytes. For non-large zero-valued things we could use runtime.zeroval. I don’t know how common those cases are. |
It's just that I can make a workaround for all other types because I control their location in memory i.e. for a (u)int(|8|16|32|64), float(32|64), bool, string, I can put these in a struct, slice or array or standalone variable, and make an addressable reference. For slices/array (pointers), all elements are already addressable (in general). However, for maps, because the "slot" can be reused and reclaimed under the hood, we do not expose the allocated space and MUST always re-allocate to expose the entries as an interface{}. It is thus the case that only map iteration has no workaround. This is why the option of passing a reference is the only workaround specifically for maps. I am currently looking to mimic such an interface, leveraging unsafe and cull'ing from the reflection codebase, for API below: //- look up key, and set val (expecting settable)
//- if return false, then no entry for that key
mapIndex(mapp reflect.Value, key reflect.Value, val reflect.Value) bool
//- creates an iterator, using key and val (expecting both settable) to set the next entries on each iteration
// - if any of key or val is invalid, treat as if a _ was passed to "for ... range"
//
//- if Next() returns true, then key and val have been set to the next iteration key/value pair
//- if Next() returns false, no more entries
mapIter(key, val reflect.Value) *mapIter
(*mapIter).Next() IMO, it is not an inelegant API. If retrofited to the current reflection API, it would look like: // Look up key, and set its mapping into val, returning true if mapping exists
Value.MapIndexUsing(key, val reflect.Value) bool
// Create an iterator, using key and val for each key/value mapping
// Treat as _ if invalid (zero value) allowing us simulate for k, _ = range; _, v = range; k, v = range
Value.MapRangeUsing(key, val reflect.Value) *MapIter Again, I personally do not see this as an inelegant or unweildly API. My usecase is the go-codec encoding library: https://github.com/ugorji/go. This is the one place where code-generation is far and away more performant than reflection-based. Others "encoders" in the space e.g. json-iterator went the way of completely re-writing the reflection package, which I think is extremely fraught with peril: see https://github.com/json-iterator/go https://github.com/modern-go/reflect2 . But they have gotten a lot of mileage doing this, gaining performance equivalent to static codebases. |
We could add methods to
(and the same for values) I think that would provide an allocation-free path. It seems strange to me that we'd provide this optimized path for map iteration, and not for other things that construct a Value. Just scanning value.go, you could make a case for |
Nice - the methods on *reflect.MapIter work nicely! I would love to have for MapIndex also, as that is a common use-case. That is the Specifically regarding Regarding The remaining gaps will be the |
We do this by introducing safe and unsafe 1.12+ variants for map iteration and map indexing. This is necessary due to golang/go#32424 - Map Iteration using reflection always causes allocation because every key and value might have to be converted to an interface{} first, meaning scalars may be allocated on the heap. - Map Indexing also causes allocation We workaround both by implementing a copy-free allocation where a holder value is passed into the indexing or iterating operation, and we "copy" the value into that pointer. Also, improve heuristics of decInferLen Also, fix lint, staticcheck and other issues raised via static analysis. Clean out xdebugf comments and remove much old commented code Finally, make more changes to assist the bounds check elimination part of compiler.
I personally would love this, map iteration is a major allocation pain-point where it is being used, and is significantly slower compared to either type-specific cases, or code-gen. Personally I am seeing a ~3x increase in encoding removing special cases for common maps(map[string]interface{}, map[interface{}]interface{} and map[string]string). I was benchmarking those types (map[interface{}]interface{} specifically) but those are the most common map types. I don't thinks a generic send/recv for channels is very common, so I personally wouldn't see the value if its any difficulty implementing. But if those are trivial to do then I would do so for the sake of keeping the same API. |
I agree that the channel ones are probably not worth worrying about, performance-wise.
But |
I came by the If we can reuse the existing pointed to SliceHeader in the reflect.Value and just set the underlying len to the new length we may be able to avoid an allocation per Append call. Seems like this would need a new API e.g. |
@randall77 Sorry, I missed this earlier.
As @Martish alluded to, a new API like AppendInPlace(s Value, x ...Value) will be needed. In the same vein, AppendSlice() can elide the allocation caused by the call to s.Slice, by doing something akin to: Copy(s.Slice(i0, i1), t) to p := (*unsafeheader.Slice)(s.ptr)
p0 := *p
p.Data = arrayAt(p0.Data, i0, typ.elem.Size(), "i < cap")
p.Len = i1 - i0
Copy(s, t)
*p = p0 |
These augment the existing MapIter.Key and MapIter.Value methods. The existing methods return new Values. Constructing these new Values often requires allocating. These methods allow the caller to bring their own storage. The naming is somewhat unfortunate, in that the spec uses the word "element" instead of "value", as do the reflect.Type methods. In a vacuum, MapIter.SetElem would be preferable. However, matching the existing methods is more important. Fixes golang#32424 Fixes golang#46131 Change-Id: I19c4d95c432f63dfe52cde96d2125abd021f24fa
Change https://golang.org/cl/320929 mentions this issue: |
These augment the existing MapIter.Key and MapIter.Value methods. The existing methods return new Values. Constructing these new Values often requires allocating. These methods allow the caller to bring their own storage. The naming is somewhat unfortunate, in that the spec uses the word "element" instead of "value", as do the reflect.Type methods. In a vacuum, MapIter.SetElem would be preferable. However, matching the existing methods is more important. Fixes golang#32424 Fixes golang#46131 Change-Id: I19c4d95c432f63dfe52cde96d2125abd021f24fa
These augment the existing MapIter.Key and MapIter.Value methods. The existing methods return new Values. Constructing these new Values often requires allocating. These methods allow the caller to bring their own storage. The naming is somewhat unfortunate, in that the spec uses the word "element" instead of "value", as do the reflect.Type methods. In a vacuum, MapIter.SetElem would be preferable. However, matching the existing methods is more important. Fixes golang#32424 Fixes golang#46131 Change-Id: I19c4d95c432f63dfe52cde96d2125abd021f24fa
Given a map[string]*T or map[uint16]T or many other types, iterating via reflection is expensive, because we will allocate a new value on the heap each time for very many types.
In a call to reflect.MapKeys, MapIndex, MapIter.Key, MapIter.Value, the implementation includes the following code:
The ifaceIndir call returns true for very many types, including string, intXXX, uintXXX, bool, []uint8 ([]byte), etc.
This causes an allocation to be done when retrieving each map key or value.
Iterating through a large map[int][]byte for example, will cause an allocation for each key or value retrieved from the map.
Without reflection, this doesn't happen. And it need not happen.
It has the effect of causing an explosion in memory use of my benchmarks. Here, I have a
map[string]*codec.stringUint64T
with about 8192 entries. And I have other maps in here also.Sample benchmark result:
The first result was manual using range operator, while the second result was via reflection. The number of allocation jumped from 4 to 32879 per op.
To illustrate, I ran my benchmark which includes a
map[string]*codec.stringUint64T
with about 8192 entries.I then instrumented the
copyVal
function to validate it.I then instrumented copyVal again to track the number of times that the allocation happened.
It was as expected (>32000 times).
What version of Go are you using (
go version
)?Does this issue reproduce with the latest release?
Yes
What operating system and processor architecture are you using (
go env
)?go env
OutputWhat did you do?
What did you expect to see?
What did you see instead?
The text was updated successfully, but these errors were encountered: