-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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] zero-allocation Set #4142
Conversation
This is a failed experiment that tried to hold set data in its own registry and use a uint64 reference number to act as a fast map key and underlie a set. It failed because a Set is returned from all the NewSet* functions and since it is not allocated to the heap there is no way to correctly set a finalizer for the object.
This is still a failed solution. It converts a pointer to a uintptr via unsafe.Pointer, but it also then converts it back. This is going to cause issues because the original pointer address is not guaranteed to remain the same and casting back could end up pointing at invalid memory.
An additional branch from this that would show the SDK changes needed to support this would be helpful. |
This comment was marked as outdated.
This comment was marked as outdated.
In my opinion, this is acceptable. This is not an ABI breaking change. Moreover, we always have the option to revert the change. |
Using a weak reference for the pointer held by the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I only had a chance to do a cursory look, so mostly superficial comments.
var ( | ||
// keyValueType is used in computeDistinctReflect. | ||
keyValueType = reflect.TypeOf(KeyValue{}) | ||
var slicePool = sync.Pool{New: func() any { return new([]KeyValue) }} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am curious how much this pool helps with performance. How much worse it is without this pool and with just allocating slices as needed?
func getSlice(length, capacity int) *[]KeyValue { | ||
v := slicePool.Get().(*[]KeyValue) | ||
if cap(*v) < capacity { | ||
*v = make([]KeyValue, length, capacity) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
*v = make([]KeyValue, length, capacity) | |
return make([]KeyValue, length, capacity) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also can return v to the pool since we didn't use it.
return Set{id: id} | ||
} | ||
|
||
var idPool = sync.Pool{New: func() any { return new(uint64) }} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you clarify why we need a pool of ids? Can we always allocate instead?
attribute.Float64Slice("[]float64", []float64{10.23, 941.1, 184e9, -2.3}), | ||
attribute.StringSlice("[]string", []string{"", "one", "two"}), | ||
} | ||
// Pre-sort to remove from first iteration results. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doesn't this defeat the purpose of the benchmark? Aren't we interested in finding out the performance of a typical case where the attributes are not necessarily sorted? Or is being sorted more typical?
var sets = newSetRegistry(-1) | ||
|
||
func newSet(data *[]KeyValue) Set { | ||
id := getID() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not quite sure but is it necessary to allocate (or get from a pool) a pointer to an id? The id is already stored in the setData.key
. Can Set.id point to setData.key
instead?
// | ||
// A pointer is used so the finalizer can handle reference-counting for | ||
// the sets registry while still being optimized as a map key. | ||
id *uint64 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be possible to make this work by using a pointer to setData
instead of a pointer to a separate uint64
?
The use of finalizers here does not seem like a production ready solution. Closing. |
This proposes to separate the data a
Set
represents from theSet
itself. Doing so allows for ...Set
s (i.e. the metric pipeline)It also means that while
Set
s remain comparable, their equivalents can no longer be tested with==
. The existingDistinct
type or theEquals
method need to be used to test equivalence.Design
Set
data.Set
s are constructed by sorting and de-duplicating their data then passing that data to the registry for a unique ID.Set
to hold pointer to a unique ID of the data (note: this is not the same reference as other sets for the same data)Set
s are constructed their IDs have a finalizer set. When they become unreachable the reference they hold to the data will be removed from the registry.Destinct
to hold the unique ID of theSet
data theDestinct
was made fromSet
dataZero Allocations
By owning all of the data and controlling its life-cycle, we are able to effectively use pools for any data that needs to be allocated to the head. This means that all allocations can be amortized and effectively creating
Set
s will require zero allocations to the heap.Reduced size of
Set
Defining a
Set
with a single*uint64
field means the size of theSet
is now 8 bytes. Contrast this to the prior implementation (aninterface{}
(2uintptr
) referencing an array ofN
KeyValue
) which was(2 * sizeOf(uintptr)) + (N * sizeOf(KeyValue))
(on a 32-bit system with no data this would be 8 bytes).This reduction in
Set
data size means that whenever a set is passed as an argument, the sized copied on the stack will be much smaller, but also consistent. This means theSet
will always be able to be in a small size stack frame.Trade Offs
Equivalence testing
The
Set
is still comparable. However, comparison ofSet
s created with the sameKeyValue
s will evaluate tofalse
when compared in amap
or with==
. To test equivalence theEquals
method needs to be used, and amap
should be defined over theDistinct
type instead.This incompatibility does not break the API, but it does alter released behavior. Even though the
Set
is defined with anEquals
method and theDistinct
type is explicitly declared to be used as a map key instead, this will likely break user (and our metric pipeline) code. Careful consideration of if this is acceptable needs to be made.It might be possible to resolve this, but I have not found a way. More investigation might be beneficial.
Testing
BenchmarkNewSet
benchmark is added to show the performance improvement of the changes.