Skip to content

Commit

Permalink
perf: various performance improvements for all options
Browse files Browse the repository at this point in the history
Replace calls to reflect.DeepEqual with a custom comparison
function that only operates on the types used by encoding/json.

Replace calls to json.Marshal, used to calculate the length of
patch operations when using the Rationalize option, with a custom
Operation method that lookup the size of the value from the
original target bytes.

name                                old time/op    new time/op    delta
Compare/differ_diff/default-8         6.96µs ± 0%    5.21µs ± 1%  -25.11%
Compare/differ_diff/invertible-8      7.80µs ± 0%    6.02µs ± 0%  -22.87%
Compare/differ_diff/factorize-8       11.4µs ± 1%     7.5µs ± 0%  -33.73%
Compare/differ_diff/rationalize-8     59.8µs ± 0%    30.6µs ± 1%  -48.77%
Compare/differ_diff/factor+ratio-8    63.8µs ± 1%    29.8µs ± 0%  -53.19%
Compare/differ_diff/all-options-8     89.3µs ± 0%    37.4µs ± 1%  -58.16%

name                                old alloc/op   new alloc/op   delta
Compare/differ_diff/default-8         3.25kB ± 0%    3.28kB ± 0%   +0.99%
Compare/differ_diff/invertible-8      5.94kB ± 0%    5.97kB ± 0%   +0.54%
Compare/differ_diff/factorize-8       3.80kB ± 0%    3.85kB ± 0%   +1.26%
Compare/differ_diff/rationalize-8     16.3kB ± 0%     5.9kB ± 0%  -63.98%
Compare/differ_diff/factor+ratio-8    16.8kB ± 0%     6.3kB ± 0%  -62.57%
Compare/differ_diff/all-options-8     24.7kB ± 0%     7.8kB ± 0%  -68.68%

name                                old allocs/op  new allocs/op  delta
Compare/differ_diff/default-8           32.0 ± 0%      32.0 ± 0%     ~
Compare/differ_diff/invertible-8        33.0 ± 0%      33.0 ± 0%     ~
Compare/differ_diff/factorize-8         53.0 ± 0%      47.0 ± 0%  -11.32%
Compare/differ_diff/rationalize-8        232 ± 0%        91 ± 0%  -60.78%
Compare/differ_diff/factor+ratio-8       253 ± 0%       102 ± 0%  -59.68%
Compare/differ_diff/all-options-8        348 ± 0%       108 ± 0%  -68.97%
  • Loading branch information
wI2L committed Mar 27, 2022
1 parent 5aa8829 commit 7ba8329
Show file tree
Hide file tree
Showing 18 changed files with 387 additions and 88 deletions.
46 changes: 46 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
run:
timeout: 10m
linters:
disable-all: true
enable:
- asciicheck
- bodyclose
- deadcode
- depguard
- dogsled
- dupl
- errcheck
- exportloopref
- funlen
- gocognit
- goconst
- gocritic
- gocyclo
- gofmt
- goimports
- goprintffuncname
- gosec
- gosimple
- govet
- ineffassign
- misspell
- nakedret
- nolintlint
- revive
- rowserrcheck
- staticcheck
- structcheck
- stylecheck
- typecheck
- unconvert
- unparam
- unused
- varcheck
- whitespace
linters-settings:
gofmt:
simplify: true
dupl:
threshold: 400
funlen:
lines: 120
4 changes: 2 additions & 2 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2020 William Poussier <[email protected]>
Copyright (c) 2020-2022 William Poussier <[email protected]>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand All @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<p align="center"><strong>jsondiff</strong> is a Go package for computing the <i>diff</i> between two JSON documents as a series of <a href="https://tools.ietf.org/html/rfc6902">RFC6902</a> (JSON Patch) operations, which is particularly suitable to create the patch response of a Kubernetes Mutating Webhook for example.</p>
<p align="center">
<a href="https://pkg.go.dev/github.com/wI2L/jsondiff"><img src="https://img.shields.io/static/v1?label=godev&message=reference&color=00add8&logo=go"></a>
<a href="https://goreportcard.com/report/wI2L/jsondiff"><img src="https://goreportcard.com/badge/github.com/wI2L/fizz"></a>
<a href="https://goreportcard.com/report/wI2L/jsondiff"><img src="https://goreportcard.com/badge/github.com/wI2L/jsondiff"></a>
<a href="https://github.com/wI2L/jsondiff/actions"><img src="https://github.com/wI2L/jsondiff/workflows/CI/badge.svg"></a>
<a href="https://codecov.io/gh/wI2L/jsondiff"><img src="https://codecov.io/gh/wI2L/jsondiff/branch/master/graph/badge.svg"/></a>
<a href="https://github.com/wI2L/jsondiff/releases"><img src="https://img.shields.io/github/v/tag/wI2L/jsondiff?color=blueviolet&label=version&sort=semver"></a>
Expand Down Expand Up @@ -314,7 +314,7 @@ CompareJSONOpts/all-options-8 106µs ± 1%

## Credits

This package has been inspired by existing implementations of JSON Patch for various languages:
This package has been inspired by existing implementations of JSON Patch in various languages:

- [cujojs/jiff](https://github.com/cujojs/jiff)
- [Starcounter-Jack/JSON-Patch](https://github.com/Starcounter-Jack/JSON-Patch)
Expand Down
4 changes: 3 additions & 1 deletion bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ func BenchmarkCompare(b *testing.B) {
})
b.Run("differ_diff/"+bb.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
d := differ{}
d := differ{
targetBytes: afterBytes,
}
for _, opt := range bb.opts {
opt(&d)
}
Expand Down
22 changes: 12 additions & 10 deletions compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ type Option func(*differ)
// given values and returns the differences relative
// to the former as a list of JSON Patch operations.
func Compare(source, target interface{}) (Patch, error) {
d := differ{}
var d differ
return compare(&d, source, target)
}

// CompareOpts is similar to Compare, but also accepts
// a list of options to configure the diff behavior.
func CompareOpts(source, target interface{}, opts ...Option) (Patch, error) {
d := differ{}
var d differ
d.applyOpts(opts...)

return compare(&d, source, target)
Expand All @@ -27,14 +27,14 @@ func CompareOpts(source, target interface{}, opts ...Option) (Patch, error) {
// returns the differences relative to the former as
// a list of JSON Patch operations.
func CompareJSON(source, target []byte) (Patch, error) {
d := differ{}
var d differ
return compareJSON(&d, source, target)
}

// CompareJSONOpts is similar to CompareJSON, but also
// accepts a list of options to configure the diff behavior.
func CompareJSONOpts(source, target []byte, opts ...Option) (Patch, error) {
d := differ{}
var d differ
d.applyOpts(opts...)

return compareJSON(&d, source, target)
Expand Down Expand Up @@ -62,14 +62,15 @@ func Invertible() Option {
}

func compare(d *differ, src, tgt interface{}) (Patch, error) {
si, err := marshalUnmarshal(src)
si, _, err := marshalUnmarshal(src)
if err != nil {
return nil, err
}
ti, err := marshalUnmarshal(tgt)
ti, tb, err := marshalUnmarshal(tgt)
if err != nil {
return nil, err
}
d.targetBytes = tb
d.diff(si, ti)

return d.patch, nil
Expand All @@ -83,21 +84,22 @@ func compareJSON(d *differ, src, tgt []byte) (Patch, error) {
if err := json.Unmarshal(tgt, &ti); err != nil {
return nil, err
}
d.targetBytes = tgt
d.diff(si, ti)

return d.patch, nil
}

// marshalUnmarshal returns the result of unmarshaling
// the JSON representation of the given value.
func marshalUnmarshal(i interface{}) (interface{}, error) {
func marshalUnmarshal(i interface{}) (interface{}, []byte, error) {
b, err := json.Marshal(i)
if err != nil {
return nil, err
return nil, nil, err
}
var val interface{}
if err := json.Unmarshal(b, &val); err != nil {
return nil, err
return nil, nil, err
}
return val, nil
return val, b, nil
}
68 changes: 35 additions & 33 deletions differ.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
package jsondiff

import (
"encoding/json"
"reflect"
"sort"
"strings"
)

type differ struct {
patch Patch
hasher hasher
hashmap map[uint64]*jsonNode
hashmap map[uint64]jsonNode
factorize bool
rationalize bool
invertible bool
targetBytes []byte
}

func (d *differ) diff(src, tgt interface{}) {
Expand All @@ -24,7 +23,7 @@ func (d *differ) diff(src, tgt interface{}) {
}

func (d *differ) compare(ptr pointer, src, tgt interface{}) {
if reflect.DeepEqual(src, tgt) {
if src == nil && tgt == nil {
return
}
if !areComparable(src, tgt) {
Expand All @@ -40,6 +39,9 @@ func (d *differ) compare(ptr pointer, src, tgt interface{}) {
}
return
}
if deepValueEqual(src, tgt, typeSwitchKind(src)) {
return
}
size := len(d.patch)

// Values are comparable, but are not
Expand All @@ -52,8 +54,10 @@ func (d *differ) compare(ptr pointer, src, tgt interface{}) {
default:
// Generate a replace operation for
// scalar types.
d.replace(ptr, src, tgt)
return
if !deepValueEqual(src, tgt, typeSwitchKind(src)) {
d.replace(ptr, src, tgt)
return
}
}
// Rationalize any new operations.
if d.rationalize && len(d.patch) > size {
Expand All @@ -67,29 +71,28 @@ func (d *differ) prepare(ptr pointer, src, tgt interface{}) {
}
// When both values are deeply equals, save
// the location indexed by the value hash.
if reflect.DeepEqual(src, tgt) {
if !areComparable(src, tgt) {
return
} else if deepValueEqual(src, tgt, typeSwitchKind(src)) {
k := d.hasher.digest(tgt)
if d.hashmap == nil {
d.hashmap = make(map[uint64]*jsonNode)
d.hashmap = make(map[uint64]jsonNode)
}
d.hashmap[k] = &jsonNode{ptr: ptr, val: tgt}
return
}
if !areComparable(src, tgt) {
d.hashmap[k] = jsonNode{ptr: ptr, val: tgt}
return
}
// At this point, the source and target values
// are non-nil and have comparable types.
switch src.(type) {
switch vsrc := src.(type) {
case []interface{}:
oarr := src.([]interface{})
oarr := vsrc
narr := tgt.([]interface{})

for i := 0; i < min(len(oarr), len(narr)); i++ {
d.prepare(ptr.appendIndex(i), oarr[i], narr[i])
}
case map[string]interface{}:
oobj := src.(map[string]interface{})
oobj := vsrc
nobj := tgt.(map[string]interface{})

for k, v1 := range oobj {
Expand All @@ -103,10 +106,10 @@ func (d *differ) prepare(ptr pointer, src, tgt interface{}) {
}

func (d *differ) rationalizeLastOps(ptr pointer, src, tgt interface{}, lastOpIdx int) {
ops := make(Patch, 0, 2)
newOps := make(Patch, 0, 2)

if d.invertible {
ops = ops.append(OperationTest, emptyPtr, ptr, nil, src)
newOps = newOps.append(OperationTest, emptyPtr, ptr, nil, src)
}
// replaceOp represents a single operation that
// replace the source document with the target.
Expand All @@ -115,17 +118,18 @@ func (d *differ) rationalizeLastOps(ptr pointer, src, tgt interface{}, lastOpIdx
Path: ptr,
Value: tgt,
}
ops = append(ops, replaceOp)
newOps = append(newOps, replaceOp)
curOps := d.patch[lastOpIdx:]

b2, _ := json.Marshal(replaceOp)
b1, _ := json.Marshal(d.patch[lastOpIdx:])
newLen := replaceOp.jsonLength(d.targetBytes)
curLen := curOps.jsonLength(d.targetBytes)

// If one operation is cheapest than many small
// If one operation is cheaper than many small
// operations that represents the changes between
// the two objects, replace the last operations.
if len(b1) > len(b2)+2 {
if curLen > newLen {
d.patch = d.patch[:lastOpIdx]
d.patch = append(d.patch, ops...)
d.patch = append(d.patch, newOps...)
}
}

Expand All @@ -135,10 +139,10 @@ func (d *differ) compareObjects(ptr pointer, src, tgt map[string]interface{}) {
cmpSet := make(map[string]uint8)

for k := range src {
cmpSet[k] |= (1 << 0)
cmpSet[k] |= 1 << 0
}
for k := range tgt {
cmpSet[k] |= (1 << 1)
cmpSet[k] |= 1 << 1
}
for _, k := range sortedObjectKeys(cmpSet) {
v := cmpSet[k]
Expand Down Expand Up @@ -189,32 +193,30 @@ func (d *differ) add(ptr pointer, v interface{}) {
idx := d.findRemoved(v)
if idx != -1 {
op := d.patch[idx]

// https://tools.ietf.org/html/rfc6902#section-4.4
// The "from" location MUST NOT be a proper prefix
// of the "path" location; i.e., a location cannot
// be moved into one of its children.
if !strings.HasPrefix(ptr.String(), op.Path.String()) {
if !strings.HasPrefix(string(ptr), string(op.Path)) {
d.patch = d.patch.remove(idx)
d.patch = d.patch.append(OperationMove, op.Path, ptr, v, v)
}
return
}
uptr := d.findUnchanged(v)
if uptr != emptyPtr && !d.invertible {
if !uptr.isRoot() && !d.invertible {
d.patch = d.patch.append(OperationCopy, uptr, ptr, nil, v)
} else {
d.patch = d.patch.append(OperationAdd, emptyPtr, ptr, nil, v)
}
}

// areComparable returns whether the interfaces values
// areComparable returns whether the interface values
// i1 and i2 can be compared. The values are comparable
// only if they are both non-nil and share the same kind.
func areComparable(i1, i2 interface{}) bool {
typ1 := reflect.TypeOf(i1)
typ2 := reflect.TypeOf(i2)

return typ1 != nil && typ2 != nil && typ1.Kind() == typ2.Kind()
return typeSwitchKind(i1) == typeSwitchKind(i2)
}

func (d *differ) replace(ptr pointer, src, tgt interface{}) {
Expand Down Expand Up @@ -245,7 +247,7 @@ func (d *differ) findUnchanged(v interface{}) pointer {
func (d *differ) findRemoved(v interface{}) int {
for i := 0; i < len(d.patch); i++ {
op := d.patch[i]
if op.Type == OperationRemove && reflect.DeepEqual(op.OldValue, v) {
if op.Type == OperationRemove && deepEqual(op.OldValue, v) {
return i
}
}
Expand Down
8 changes: 7 additions & 1 deletion differ_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ func runTestCases(t *testing.T, cases []testcase, opts ...Option) {
name := testNameReplacer.Replace(tc.Name)

t.Run(name, func(t *testing.T) {
d := differ{}
beforeBytes, err := json.Marshal(tc.Before)
if err != nil {
t.Error(err)
}
d := differ{
targetBytes: beforeBytes,
}
d.applyOpts(opts...)
d.diff(tc.Before, tc.After)

Expand Down
Loading

0 comments on commit 7ba8329

Please sign in to comment.