Skip to content

Migrate to encoding/json/v2#292

Open
inteon wants to merge 2 commits into
kubernetes-sigs:masterfrom
inteon:use_json_v2
Open

Migrate to encoding/json/v2#292
inteon wants to merge 2 commits into
kubernetes-sigs:masterfrom
inteon:use_json_v2

Conversation

@inteon

@inteon inteon commented Jun 16, 2025

Copy link
Copy Markdown
Member

Replaces the github.com/json-iterator/go dependency with encoding/json/v2.

# based on 2a9ee08f99c36a53079be4e9be570f3bac18418b
$ go test -benchmem -run=^$ -bench ^BenchmarkFieldSet/serialize.*$ sigs.k8s.io/structured-merge-diff/v6/fieldpath -count=6 > pr.txt
$ go test -benchmem -run=^$ -bench ^BenchmarkFieldSet/serialize.*$ sigs.k8s.io/structured-merge-diff/v6/fieldpath -count=6 > master.txt
$ benchstat master.txt pr.txt

goos: linux
goarch: amd64
pkg: sigs.k8s.io/structured-merge-diff/v6/fieldpath
cpu: Intel(R) Core(TM) Ultra 7 165H
                            │  master.txt  │                pr.txt                │
                            │    sec/op    │    sec/op      vs base               │
FieldSet/serialize-20-8       7.974µ ± 13%   11.061µ ± 12%  +38.72% (p=0.002 n=6)
FieldSet/deserialize-20-8     21.13µ ±  5%    24.60µ ± 22%  +16.43% (p=0.002 n=6)
FieldSet/serialize-50-8       22.37µ ± 17%    33.22µ ±  4%  +48.48% (p=0.002 n=6)
FieldSet/deserialize-50-8     48.55µ ± 15%    63.67µ ±  7%  +31.14% (p=0.002 n=6)
FieldSet/serialize-100-8      64.62µ ±  7%   113.10µ ± 11%  +75.02% (p=0.002 n=6)
FieldSet/deserialize-100-8    117.6µ ±  5%    202.1µ ±  4%  +71.77% (p=0.002 n=6)
FieldSet/serialize-500-8      335.7µ ±  6%    591.3µ ± 28%  +76.16% (p=0.002 n=6)
FieldSet/deserialize-500-8    586.9µ ±  5%   1013.9µ ±  4%  +72.77% (p=0.002 n=6)
FieldSet/serialize-1000-8     740.6µ ±  7%   1288.0µ ± 26%  +73.92% (p=0.002 n=6)
FieldSet/deserialize-1000-8   1.293m ±  9%    2.567m ± 41%  +98.64% (p=0.002 n=6)
geomean                       110.1µ          174.4µ        +58.40%

                            │  master.txt   │               pr.txt                │
                            │     B/op      │     B/op      vs base               │
FieldSet/serialize-20-8         2318.0 ± 0%     847.0 ± 0%  -63.46% (p=0.002 n=6)
FieldSet/deserialize-20-8     10.494Ki ± 0%   7.130Ki ± 0%  -32.05% (p=0.002 n=6)
FieldSet/serialize-50-8        6.685Ki ± 0%   2.379Ki ± 0%  -64.40% (p=0.002 n=6)
FieldSet/deserialize-50-8      22.12Ki ± 0%   19.92Ki ± 0%   -9.91% (p=0.002 n=6)
FieldSet/serialize-100-8      20.272Ki ± 0%   7.625Ki ± 0%  -62.39% (p=0.002 n=6)
FieldSet/deserialize-100-8     66.85Ki ± 0%   68.85Ki ± 0%   +2.98% (p=0.002 n=6)
FieldSet/serialize-500-8      112.15Ki ± 0%   42.70Ki ± 0%  -61.92% (p=0.002 n=6)
FieldSet/deserialize-500-8     323.8Ki ± 0%   351.7Ki ± 0%   +8.62% (p=0.002 n=6)
FieldSet/serialize-1000-8     225.21Ki ± 0%   86.01Ki ± 0%  -61.81% (p=0.002 n=6)
FieldSet/deserialize-1000-8    706.0Ki ± 0%   776.3Ki ± 0%   +9.96% (p=0.002 n=6)
geomean                        44.05Ki        26.11Ki       -40.72%

                            │ master.txt  │               pr.txt               │
                            │  allocs/op  │  allocs/op   vs base               │
FieldSet/serialize-20-8        9.000 ± 0%    3.000 ± 0%  -66.67% (p=0.002 n=6)
FieldSet/deserialize-20-8      241.0 ± 0%    247.0 ± 0%   +2.49% (p=0.002 n=6)
FieldSet/serialize-50-8       14.000 ± 0%    8.000 ± 0%  -42.86% (p=0.002 n=6)
FieldSet/deserialize-50-8      707.0 ± 0%    720.5 ± 0%   +1.91% (p=0.002 n=6)
FieldSet/serialize-100-8       31.00 ± 0%    25.00 ± 0%  -19.35% (p=0.002 n=6)
FieldSet/deserialize-100-8    2.369k ± 0%   2.417k ± 0%   +2.00% (p=0.002 n=6)
FieldSet/serialize-500-8       138.0 ± 0%    126.0 ± 1%   -8.70% (p=0.002 n=6)
FieldSet/deserialize-500-8    12.16k ± 0%   12.42k ± 0%   +2.10% (p=0.002 n=6)
FieldSet/serialize-1000-8      295.0 ± 0%    277.0 ± 0%   -6.10% (p=0.002 n=6)
FieldSet/deserialize-1000-8   26.86k ± 0%   27.49k ± 0%   +2.32% (p=0.002 n=6)
geomean                        340.5         281.0       -17.47%

closes #202

@k8s-ci-robot k8s-ci-robot added the cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. label Jun 16, 2025
@k8s-ci-robot k8s-ci-robot added the size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. label Jun 16, 2025
@dims

dims commented Jun 17, 2025

Copy link
Copy Markdown
Member

xref: kubernetes/kubernetes#132312

@dims

dims commented Jun 18, 2025

Copy link
Copy Markdown
Member

For the pull-structured-merge-diff-test failure, please add this to fix the error @inteon

diff --git a/internal/cli/main_test.go b/internal/cli/main_test.go
index 3e409ede0673..8beed5c6cf55 100644
--- a/internal/cli/main_test.go
+++ b/internal/cli/main_test.go
@@ -21,6 +21,7 @@ import (
        "encoding/json"
        "io/ioutil"
        "path/filepath"
+       "strings"
        "testing"
 )

@@ -135,7 +136,7 @@ func (tt *testCase) checkOutput(t *testing.T, got []byte) {
                t.Fatalf("couldn't read expected output %q: %v", tt.expectedOutputPath, err)
        }

-       if a, e := string(got), string(want); a != e {
+       if a, e := strings.TrimSpace(string(got)), strings.TrimSpace(string(want)); a != e {
                t.Errorf("output didn't match expected output: got:\n%v\nwanted:\n%v\n", a, e)
        }
 }

@inteon
inteon force-pushed the use_json_v2 branch 2 times, most recently from a4b6871 to bdce391 Compare June 18, 2025 10:14
@inteon

inteon commented Jun 18, 2025

Copy link
Copy Markdown
Member Author

For the pull-structured-merge-diff-test failure, please add this to fix the error @inteon
...

I fixed the test failure.

@dims

dims commented Jun 18, 2025

Copy link
Copy Markdown
Member

/assign @BenTheElder @liggitt

@liggitt

liggitt commented Jun 19, 2025

Copy link
Copy Markdown
Contributor

/assign @jpbetz
who is the primary apimachinery approver on this bit and was deeply involved in the initial performance-driven use of json-iterator in these bits

@liggitt

liggitt commented Jun 19, 2025

Copy link
Copy Markdown
Contributor

For the pull-structured-merge-diff-test failure, please add this to fix the error @inteon

I suspect using a json marshal function (like MarshalWrite) that doesn't append a newline would be a more efficient way to accomplish that

Comment thread fieldpath/serialize-pe.go Outdated
return nil, fmt.Errorf("parsing JSON: %v", err)
}

k := rawKey.String()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is rawKey.String() the same as decoding to a string, in terms of interpreting escape sequences, etc?

Comment thread fieldpath/serialize-pe.go Outdated
Comment thread fieldpath/serialize-pe.go Outdated
Comment on lines -330 to -339
{
JSON: `1.0`,
IntoType: reflect.TypeOf(json.Number("")),
Want: json.Number("1.0"),
},
{
JSON: `1`,
IntoType: reflect.TypeOf(json.Number("")),
Want: json.Number("1"),
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious if it's ok to drop these... were they added to try to catch a specific issue?

@k8s-ci-robot

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: inteon
Once this PR has been reviewed and has the lgtm label, please ask for approval from jpbetz. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@liggitt

liggitt commented Jun 23, 2025

Copy link
Copy Markdown
Contributor

The .../deserialize... benchmarks actually don't look terrible now... I'd be willing to accept that performance drop in pursuit of correctness / safety.

The serialize benchmarks still look pretty rough. Need to see what we can improve there.

@liggitt

liggitt commented Jun 24, 2025

Copy link
Copy Markdown
Contributor

did you run the full set of benchmarks to see how we looked across all of them?

@k8s-ci-robot k8s-ci-robot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Jun 28, 2025
@k8s-ci-robot k8s-ci-robot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Jun 30, 2025
@liggitt

liggitt commented Jul 1, 2025

Copy link
Copy Markdown
Contributor

Thanks for the updates, how are the overall benchmarks looking (not just the subset in the description)?

As you were adjusting the implementation, were there any unit tests it would make sense to add to catch edges the previous implementations handled we want to ensure the new one does as well? I'm thinking specifically of things like:

  • handling of extra data in the input bytes/buffer when decoding/deserializing (e.g. "somevalue"extrastuff or {"key":"value"}extrastuff)
  • handling of special characters in strings that need escaping where the raw bytes would not be the same as the decoded or encoded/escaped bytes
  • handling of ignorable whitespace when decoding

@jpbetz

jpbetz commented Jul 1, 2025

Copy link
Copy Markdown
Contributor

First off- it's amazing to see this happening and the benchmarks are VERY promising. Thanks @inteon!

To get this to the finish line, and merge, what should our criteria be?

I chatted offline with @liggitt briefly and some of the criteria we discussed was:

  • Golang releases a stable json/v2 (The alternative would be to add an internal copy of json-experiment to this repo like kube-openapi has, but I don't know if it's worth it given how close json/v2 is to stable)
  • github.com/kubernetes/kubernetes CI test stability is not negatively impacted
  • This passes a scale test (SIG instrumentation)
  • We are confident on the correctness (triple check the implementation, shore up with additional functional tests)

Intuitively, it seems like the deserialization is already sufficiently fast. I suspect we need to optimize serialization a bit further since we serialize managed fields on all updates (not just patches). That said, I'm willing to be data driven here. If we can show downstream scale and performance is acceptable, I'm willing to accept a higher serialization perf regression in order to migrate to json/v2.

Thoughts, concerns?

@inteon

inteon commented Aug 30, 2025

Copy link
Copy Markdown
Member Author

Update check the new numbers in my PR description, upgrading encoding/json/v2 did improve performance!

@inteon

inteon commented Aug 30, 2025

Copy link
Copy Markdown
Member Author

Did some further tuning and got the # allocations lower than on master.

@liggitt liggitt moved this from Dependencies to replace/remove to Dependency review complete in [sig-architecture] Dependency management Oct 1, 2025
Signed-off-by: Tim Ramlot <42113979+inteon@users.noreply.github.com>
Signed-off-by: Tim Ramlot <42113979+inteon@users.noreply.github.com>
@dims

dims commented Nov 29, 2025

Copy link
Copy Markdown
Member

@inteon do we want to pick this up when k8s 1.36 cycle reopens

@inteon

inteon commented Dec 2, 2025

Copy link
Copy Markdown
Member Author

@inteon do we want to pick this up when k8s 1.36 cycle reopens

Yes. I updated the code, fixed some bugs and updated the benchmark results.

@liggitt

liggitt commented Dec 2, 2025

Copy link
Copy Markdown
Contributor

Looks like we can drop the "Performance is not yet great" comment from the description now... this looks ~neutral or an improvement for memory use at this point 🎉

@liggitt

liggitt commented Dec 2, 2025

Copy link
Copy Markdown
Contributor

@inteon do we want to pick this up when k8s 1.36 cycle reopens

We want to pick it up as soon as possible, but we need to wait until json/v2 lands in stdlib in a non-experimental way. I'm not sure if that's happening in Go 1.26 or not (the status of golang/go#76406 and json/v2 working group (view) makes me think not).

api-machinery folks have a detailed code review of this PR scheduled for later this week. Once we're happy with the shape of it, and do a final round of benchmarks, we'll submit this as positive community feedback on the current shape of json/v2, and then wait patiently for json/v2 to land in stdlib 🤞

Comment thread fieldpath/serialize-pe.go
}

// DeserializePathElement parses a serialized path element
func DeserializePathElement(s string) (PathElement, error) {

@liggitt liggitt Dec 4, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jpbetz @lalitc375: let's augment the unit tests that exercise successful DeserializePathElement calls (TestPathElementRoundTrip?) to also assert the in-memory state returned, not just the round-trip

Comment thread fieldpath/serialize-pe.go
fields.Sort()
return PathElement{Key: &fields}, iter.Error
var fields value.FieldList
if err := json.UnmarshalRead(strings.NewReader(s[2:]), &fields); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jpbetz @lalitc375: can we add test-cases to exercise

  • zero-length
  • EOF
  • leading / trailing whitespace in value
  • multi-token should fail identically

Comment thread fieldpath/serialize-pe.go
v, err := readJSONIter(iter)
if err != nil {
var v any
if err := json.UnmarshalRead(strings.NewReader(s[2:]), &v); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jpbetz @lalitc375: can we add test-cases to ensure behave identically between readJSONIter / json.UnmarshalRead

  • zero-length - json-iter masks, json/v2 returns EOF error? https://go.dev/play/p/D80jALZ7ZRq
  • non-zero length with EOF (not sure this is possible)
  • leading / trailing whitespace in value
  • multi-token should fail identically

Comment thread value/fields.go
return fmt.Errorf("parsing JSON: %v", err)
}

fields = append(fields, Field{Name: k, Value: NewValueInterface(v)})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks it would accept duplicate key entries. Did the original ReadObjectCB prevent duplicates or accept them? Is there a test case to make sure these behave identically to json-iterator?

cc @jpbetz @lalitc375

Comment thread value/fields.go
} else if err != nil {
return fmt.Errorf("parsing JSON: %v", err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible to encounter a token other than EndObject or String here? will ReadToken() error in that case?

@jpbetz @lalitc375: can we make sure there's a test that some other token type instead of a string key errors correctly here?

@liggitt liggitt Feb 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect ReadToken to error in this case but I want us to verify we can rely on that

We can try adding a test that exercises this in master and see if it exercises this code path in this PR. If it doesn't, we may need to also add a specific test in this PR.

Comment thread value/fields.go
break
}

k := rawKey.String()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jpbetz @lalitc375: can we ensure we have test cases that hit this point to compare behavior between json-iterator and json/v2 and we end up with the same in-memory values:

  • escaped/encoded keys ("\u... \n ...")
  • multi-byte keys ("Iñtërnâtiônàlizætiøn,💝🐹🌇⛔")
  • keys containing bytes that are invalid unicode characters

Comment thread fieldpath/serialize.go
return children, isMember, nil
}

// FromJSON clears s and reads a JSON formatted set structure.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: move this back up to where FromJSON was previously to minimize the diff

Comment thread fieldpath/serialize.go
func readIterV1(iter *jsoniter.Iterator) (children *Set, isMember bool) {
iter.ReadMapCB(func(iter *jsoniter.Iterator, key string) bool {
if key == "." {
func (s *setContentsV1) readIterV1(parser *jsontext.Decoder) (children *Set, isMember bool, err error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the receiver isn't used, change this back to a function:

Suggested change
func (s *setContentsV1) readIterV1(parser *jsontext.Decoder) (children *Set, isMember bool, err error) {
func readIterV1(parser *jsontext.Decoder) (children *Set, isMember bool, err error) {

Comment thread fieldpath/serialize.go

// Append the member to the members list, we will sort it later
m := &children.Members.members
// Since we expect that most of the time these will have been

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's leave the insert-in-sorted-order code as it was here and below if there's not a reason it has to be connected to this PR

@jpbetz, let's separately consider whether to switch this to a post-accumulation sort

Comment thread fieldpath/serialize.go
if children == nil {
children = &Set{}
}
// Since we expect that most of the time these will have been

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revert this bit as well

Comment thread fieldpath/serialize.go
})
}

// Sort the members and children

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revert the post-accumulation sort

@liggitt

liggitt commented Dec 4, 2025

Copy link
Copy Markdown
Contributor

@inteon, thanks so much for this PR, we spent several hours today reviewing the details deeply now that the performance has converged to something we could actually merge.

There were four main categories of feedback:

  • splitting unrelated changes out of this PR to keep this limited just to the json/v2 switch
  • test coverage to ensure equivalent behavior in lots of edges (which we'll want to make pass against this PR and against master, and merge to master ahead of this PR)
  • small tweaks (error checking, relocating to minimize diff, helper function around writing an empty object, etc)
  • comments with some possibilities for simplification and correctness improvements – some were questions we need to actually run through benchmarks to know whether we can simplify without impacting performance

Don't feel like you have to be the one to resolve all the comments, @jpbetz and @lalitc375 will be jumping in to help as well.

We can gradually work on this and continue getting this ready for when json/v2 lands in stdlib (which looks like it will now be Go 1.27 at the earliest)

@k8s-triage-robot

Copy link
Copy Markdown

The Kubernetes project currently lacks enough contributors to adequately respond to all PRs.

This bot triages PRs according to the following rules:

  • After 90d of inactivity, lifecycle/stale is applied
  • After 30d of inactivity since lifecycle/stale was applied, lifecycle/rotten is applied
  • After 30d of inactivity since lifecycle/rotten was applied, the PR is closed

You can:

  • Mark this PR as fresh with /remove-lifecycle stale
  • Close this PR with /close
  • Offer to help out with Issue Triage

Please send feedback to sig-contributor-experience at kubernetes/community.

/lifecycle stale

@k8s-ci-robot k8s-ci-robot added lifecycle/stale Denotes an issue or PR has remained open with no activity and has become stale. needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. labels May 20, 2026
@k8s-ci-robot

Copy link
Copy Markdown
Contributor

PR needs rebase.

Details

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.

@k8s-triage-robot

Copy link
Copy Markdown

The Kubernetes project currently lacks enough active contributors to adequately respond to all PRs.

This bot triages PRs according to the following rules:

  • After 90d of inactivity, lifecycle/stale is applied
  • After 30d of inactivity since lifecycle/stale was applied, lifecycle/rotten is applied
  • After 30d of inactivity since lifecycle/rotten was applied, the PR is closed

You can:

  • Mark this PR as fresh with /remove-lifecycle rotten
  • Close this PR with /close
  • Offer to help out with Issue Triage

Please send feedback to sig-contributor-experience at kubernetes/community.

/lifecycle rotten

@k8s-ci-robot k8s-ci-robot added lifecycle/rotten Denotes an issue or PR that has aged beyond stale and will be auto-closed. and removed lifecycle/stale Denotes an issue or PR has remained open with no activity and has become stale. labels Jun 19, 2026
@jpbetz

jpbetz commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

/remove-lifecycle rotten

@k8s-ci-robot k8s-ci-robot removed the lifecycle/rotten Denotes an issue or PR that has aged beyond stale and will be auto-closed. label Jun 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. size/XL Denotes a PR that changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

remove use of json-iterator / reflect2

9 participants