perf(endpoint): optimize ProviderSpecific to use map for O(1) access#5814
perf(endpoint): optimize ProviderSpecific to use map for O(1) access#5814u-kai wants to merge 18 commits intokubernetes-sigs:masterfrom
Conversation
|
Hi @u-kai. Thanks for your PR. I'm waiting for a kubernetes-sigs member to verify that this patch is reasonable to test. If it is, they should reply with Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
apis/v1alpha1/dnsendpoint.go
Outdated
| // DNSEndpointSpec defines the desired state of DNSEndpoint | ||
| type DNSEndpointSpec struct { | ||
| Endpoints []*endpoint.Endpoint `json:"endpoints,omitempty"` | ||
| Endpoints []*Endpoint `json:"endpoints"` |
There was a problem hiding this comment.
As you can see, the path is apis/v1alpha1. Moving Endpoints under this folder, means we are doing versioning for Endpoint object. Not against, but not sure if this is the right approach. As now we most likely going to have v1alpha.Endpoint....
I have no solution, not an easy one though
There was a problem hiding this comment.
Thanks for raising this — you’re right that moving Endpoint under apis/v1alpha1 makes it part of the public API surface and therefore versioned.
A few clarifications on intent and impact:
Intentional separation:
We’re explicitly decoupling the CRD type (apis/v1alpha1.Endpoint) from the internal type (endpoint.Endpoint).
This lets us keep the CRD schema stable while giving us freedom to optimize the internal representation (e.g., map-based ProviderSpecific) without leaking those changes into the API.
No user-facing change:
The CRD schema shape for Endpoints remains the same from a user point of view; we only replaced the reference to the internal type with the versioned API type.
On future versions (v1, etc.):
If v1’s Endpoint is identical to v1alpha1, we can avoid extra helpers by using a type alias or a shared converter.
If it diverges, we’ll add a thin conversion layer and still keep the internals map cleanly.
Typically the controller consumes one served version; the apiserver handles storage-version normalization.
Maintenance cost:
We’re aware this assigns versioning responsibility to Endpoint, but it also prevents tight coupling to internals and reduces the risk for future internal refactors — or at least, we believe it can reduce such risks.
There was a problem hiding this comment.
Have you generated CRDs, are they still the same or there is diff?
There was a problem hiding this comment.
Sorry, I didn't handle this properly initially.
I found that the Endpoints field should have the omitempty tag.
After adding omitempty to the endpoints field and regenerating the CRDs, there are no diffs.
Everything is now in the proper state.
|
/ok-to-test |
a2c62d3 to
a2c0d29
Compare
a2c0d29 to
921fb70
Compare
endpoint/endpoint.go
Outdated
| func (ps ProviderSpecific) String() string { | ||
| if len(ps) == 0 { | ||
| return "[]" | ||
| } | ||
| // Collect and sort keys for stable output. | ||
| keys := make([]string, 0, len(ps)) | ||
| for k := range ps { | ||
| keys = append(keys, k) | ||
| } | ||
| sort.Strings(keys) | ||
| // Build entries like "{key value}" preserving stable order. | ||
| b := strings.Builder{} | ||
| b.WriteByte('[') | ||
| for i, k := range keys { | ||
| if i > 0 { | ||
| b.WriteByte(' ') | ||
| } | ||
| b.WriteByte('{') | ||
| b.WriteString(k) | ||
| b.WriteByte(' ') | ||
| b.WriteString(ps[k]) | ||
| b.WriteByte('}') | ||
| } | ||
| b.WriteByte(']') | ||
| return b.String() | ||
| } |
There was a problem hiding this comment.
I'm not sure if we want to maintain code that mimic the default output from fmt?
I would prefer to keep the ProviderSpecificProperty struct (but package-private), build a slice of it from ProviderSpecific and use fmt.Sprintf().
Performance wise your code is faster though.
@ivankatliarchuk wdyt ?
edit:
Something like that:
func (ps ProviderSpecific) String() string {
data := make([]providerSpecificProperty, 0, len(ps))
for k, v := range ps {
data = append(data, providerSpecificProperty{Name: k, Value: v})
}
slices.SortFunc(data, func(a, b providerSpecificProperty) int {
return strings.Compare(a.Name, b.Name)
})
return fmt.Sprint(data)
}There was a problem hiding this comment.
Not too shure what this method is for.
There was a problem hiding this comment.
It's to keep the log output identical. But that's mostly for debug logs.
external-dns/endpoint/endpoint.go
Line 383 in 7792e78
But there is some info logs too:
external-dns/registry/dynamodb.go
Line 305 in abdf8bb
There was a problem hiding this comment.
@ivankatliarchuk
As @vflaux mentioned, this logic is to maintain compatibility of the log output.
There was a problem hiding this comment.
it's a map, so the output should be consistent. I think I'm questioning the actual need for this method. Is formatting with %q or %v is not enough?
There was a problem hiding this comment.
Hi @mloiseleur. Wdyt, do we want to keep same output in logging or Map : ProviderSpecific map[key1:value1 key2:value2 key3:value3] is just enough?
There was a problem hiding this comment.
🤔 Changing logs output is not the goal of this PR.
Nonetheless, it changes the struct, so it can be somehow expected that it impacts log output.
The default output provided by Go on map is fine and well known in go software.
My recommendation is to use it as a start, for this PR.
We'll see with time if it's not enough and if we need to update it with specific code and/or struct.
@vflaux @ivankatliarchuk @u-kai Wdyt ? Does it make sense to you ?
There was a problem hiding this comment.
Thanks for the feedback.
My original motivation for adding the custom String() was to keep backward compatibility in log output — since the struct changed, I wanted to avoid surprising changes in log messages.
That said, I’m also fine with simplifying the code and just returning the default map output. It makes the code cleaner, though it will slightly change the log format. If we go this route, I think it’s worth mentioning in release notes so users are aware of the change.
If you prefer that approach, I can drop the custom String() method and just let it print the map directly.
There was a problem hiding this comment.
Yes, I prefer the simpler approach and drop String() method.
No problem to add a line on this in the next release notes.
There was a problem hiding this comment.
I've removed the String() method and related tests as suggested.
@mloiseleur @ivankatliarchuk
Please review when you have a chance 🙇
| newEp.Labels[k] = v | ||
| } | ||
| newEp.ProviderSpecific = append(endpoint.ProviderSpecific(nil), ep.ProviderSpecific...) | ||
| if ep.ProviderSpecific != nil { |
There was a problem hiding this comment.
Maybe add a test case for this as this is not covered?
There was a problem hiding this comment.
I'll address the test coverage for the if ep.ProviderSpecific !=nil branch in a separate PR.
The current tests use makeZone helper function which doesn't set ProviderSpecific, and modifying it would require updating all existing tests that depend on it.
A dedicated PR focused on test infrastructure improvements would be more appropriate to maintain consistency across the codebase.
ivankatliarchuk
left a comment
There was a problem hiding this comment.
Overall lgtm,. few questions left to resolve
| v4EP := ep.DeepCopy() | ||
| v4EP.Targets = v4Targets | ||
| v4EP.RecordType = endpoint.RecordTypeA | ||
| v4EP := &endpoint.Endpoint{ |
There was a problem hiding this comment.
Is deepcopy no longer works here? Not sure if we should be using endpoint.Endpoint in refactorings, dedicated methods more preferred, so we could incapsulate things in the future.
Why this code is required, with null checks and map copy?
if ep.Labels != nil {
v4EP.Labels = make(endpoint.Labels, len(ep.Labels))
maps.Copy(v4EP.Labels, ep.Labels)
}
if ep.ProviderSpecific != nil {
v4EP.ProviderSpecific = make(endpoint.ProviderSpecific, len(ep.ProviderSpecific))
maps.Copy(v4EP.ProviderSpecific, ep.ProviderSpecific)
}My understanding v4EP created without labels or provider specific, is this not just?
v4ep.Labels = ep.LabelsThere was a problem hiding this comment.
On DeepCopy:
This code now uses an internal Endpoint type instead of the CRD code-generated one, so we don’t have generated DeepCopy methods anymore.
I don’t think it’s worth re-implementing DeepCopy just for this use case—being explicit in the adapter keeps the conversion type-safe and makes future encapsulation easier.
On the nil checks and map copy:
A direct assignment would share the same map (Go maps are reference types), so changes to the converted value could leak back to the source.
Copying avoids aliasing and preserves nil vs empty.
| if providerSpecific.Name == key { | ||
| return providerSpecific.Value, true | ||
| } | ||
| if e.ProviderSpecific == nil { |
| } | ||
|
|
||
| // DeleteProviderSpecificProperty deletes any ProviderSpecificProperty of the specified name. | ||
| func (e *Endpoint) DeleteProviderSpecificProperty(key string) { |
pkg/adapter/endpoint.go
Outdated
| "sigs.k8s.io/external-dns/endpoint" | ||
| ) | ||
|
|
||
| func ToInternalEndpoint(crdEp *apiv1alpha1.Endpoint) *endpoint.Endpoint { |
There was a problem hiding this comment.
name crdEp to specific and method description is missing for exported method
pkg/adapter/endpoint.go
Outdated
| @@ -0,0 +1,86 @@ | |||
| /* | |||
| Copyright 2017 The Kubernetes Authors. | |||
There was a problem hiding this comment.
| Copyright 2017 The Kubernetes Authors. | |
| Copyright 2025 The Kubernetes Authors. |
pkg/adapter/endpoint.go
Outdated
| return ep | ||
| } | ||
|
|
||
| func ToInternalEndpoints(crdEps []*apiv1alpha1.Endpoint) []*endpoint.Endpoint { |
| newEp.ProviderSpecific = append(endpoint.ProviderSpecific(nil), ep.ProviderSpecific...) | ||
| if ep.ProviderSpecific != nil { | ||
| newEp.ProviderSpecific = make(endpoint.ProviderSpecific) | ||
| maps.Copy(newEp.ProviderSpecific, ep.ProviderSpecific) |
There was a problem hiding this comment.
is the map copy even required, why not a direct assignment?
There was a problem hiding this comment.
On the map copy: direct assignment would just alias the underlying map, since maps in Go are reference types.
That would mean v4EP and ep share the same backing map, and any mutation on one would affect the other.
To avoid this aliasing we explicitly copy into a new map, so the converted object is fully independent.
This struct is used in a variety of ways in tests, and we wanted to minimize any chance of shared state leaking between instances.
Making explicit copies ensures data isolation and makes test behavior more predictable.
| w.WriteHeader(http.StatusOK) | ||
| if err := json.NewEncoder(w).Encode(records); err != nil { | ||
| apiRecords := adapter.ToAPIEndpoints(records) | ||
| if err := json.NewEncoder(w).Encode(apiRecords); err != nil { |
There was a problem hiding this comment.
Since we are only encoding valid types into an in-memory buffer, this call cannot realistically fail.
Because the failure path is not reproducible in this context, we are intentionally not covering it with tests.
| log.Errorf("Failed to call adjust endpoints: %v", err) | ||
| w.WriteHeader(http.StatusInternalServerError) | ||
| } | ||
| pve = adapter.ToAPIEndpoints(endpoints) |
pkg/adapter/endpoint.go
Outdated
| "sigs.k8s.io/external-dns/endpoint" | ||
| ) | ||
|
|
||
| func ToInternalEndpoint(crdEp *apiv1alpha1.Endpoint) *endpoint.Endpoint { |
There was a problem hiding this comment.
The name ToInternalEndpoint not necessary is clear engough. external-dns Endpoint is not internal, as is exported. I do get the meaning probably slighly different here.
Maybe ToAPI and FromApi or ToVersionedApi and FromVersionedApi
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com>
Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com>
|
@ivankatliarchuk |
|
Overall lgtm. Need to find some time to execute few smoke tests on my side. |
|
@ivankatliarchuk |
Pull Request Test Coverage Report for Build 18125384109Details
💛 - Coveralls |
|
Yeap. Sry incorrect branch |
szuecs
left a comment
There was a problem hiding this comment.
This PR tries to speedup things, but there was nothing shown that:
- it's an actual problem at all
- how much faster or slower we will be by applying this gigantic risky change.
|
Let me clarify the motivation:
|
|
/close Please no ai for cv driven work. |
|
@szuecs: Closed this PR. DetailsIn response to this:
Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
|
@szuecs @ivankatliarchuk @mloiseleur @vflaux |
|
I'll work on benchmark as well. May take few month |
|
Thanks @u-kai for your work! I think you could create a pr with the benchmark and then we can use that for testing optimizations that someone will come up with. |






What does it do ?
This PR optimizes the
ProviderSpecificfield in theEndpointstruct by changing it from a slice-based implementation ([]ProviderSpecificProperty) to a map-based implementation (map[string]string).This change improves property access performance from O(n) linear search to O(1) constant-time lookups while maintaining full backward compatibility for CRD users.
Motivation
The current slice-based
ProviderSpecificimplementation creates significant performance bottlenecks in large-scale environments.This optimization was identified as a prerequisite for addressing the performance concerns raised in PR #5799.
Before implementing provider-specific property warnings, the underlying data structure needed to be optimized to prevent the warnings themselves from becoming a performance bottleneck.
More