Skip to content

Commit

Permalink
perf: aminocompat: package to allow optimizations iff amino-compatible
Browse files Browse the repository at this point in the history
Implements a function "AllClear" which checks that an object is
amino-json compatible and iff all clear, can permit us to directly:
* use encoding/json.Marshal

which produces sorted JSON

instead of 3 expensive steps to produce sorted JSON:
* amino.MarshalJSON
* encoding/json.Unmarshal
* encoding/json.Marshal

and the results are stark

```shell
$ benchstat before.txt after.txt
name          old time/op    new time/op    delta
MsgDeposit-8    10.8µs ± 1%     1.3µs ± 1%  -87.55%  (p=0.000 n=10+8)

name          old alloc/op   new alloc/op   delta
MsgDeposit-8    3.75kB ± 0%    0.26kB ± 0%  -92.96%  (p=0.000 n=10+10)

name          old allocs/op  new allocs/op  delta
MsgDeposit-8      99.0 ± 0%      13.0 ± 0%  -86.87%  (p=0.000 n=10+10)
```

Updates #2350
  • Loading branch information
odeke-em committed May 10, 2023
1 parent 1b836a6 commit 6a66409
Show file tree
Hide file tree
Showing 4 changed files with 303 additions and 0 deletions.
28 changes: 28 additions & 0 deletions codec/aminocompat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## aminocompat

The purpose of this code is to help with checking the logic of compatibility with go-amino before
apply any optimizations.

### Uses
For example as per [issue #2350](https://github.com/cosmos/cosmos-sdk/issues/2350) in which it was deemed
that the cosmos-sdk invoked amino.MarshalJSON then encoding/json.Unmarshal then encoding/json.Marshal so as
to just get sorted JSON. An idea was floated to perhaps head directly to using encoding/json.Marshal

The tricky thing is that skipping amino checks would mean that previously unsupported types like floating points,
complex numbers, enums, maps would now be blindly supported, yet the module requires an explicit `.Unsafe=true` to
be set.

To solve that problem, we've implemented a pass `AllClear` which checks that a value's contents are amino-compatible
and if all clear, permits us to use encoding/json.Marshal directly with stark improvements when used with MsgDeposit:

```shell
$ benchstat before.txt after.txt
name old time/op new time/op delta
MsgDeposit-8 10.8µs ± 1% 1.3µs ± 1% -87.55% (p=0.000 n=10+8)

name old alloc/op new alloc/op delta
MsgDeposit-8 3.75kB ± 0% 0.26kB ± 0% -92.96% (p=0.000 n=10+10)

name old allocs/op new allocs/op delta
MsgDeposit-8 99.0 ± 0% 13.0 ± 0% -86.87% (p=0.000 n=10+10)
```
92 changes: 92 additions & 0 deletions codec/aminocompat/aminocompat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package aminocompat

import (
"encoding/json"
"errors"
"fmt"
"reflect"
)

var jsonMarshaler = reflect.TypeOf(new(json.Marshaler))

func AllClear(v any) error {
rv := reflect.ValueOf(v)
return allClear(rv)
}

func allClear(v reflect.Value) error {
if !v.IsValid() {
return errors.New("not valid")
}

// Derefence the pointer.
for v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil
}
v = reflect.Indirect(v)
}

// Now we can walk the value as we've dereferenced it.
// 1. If it a json.Marshaler, then skip it.
if vt := v.Type(); vt.Kind() == reflect.Interface && vt.Implements(jsonMarshaler) {
return nil
}

switch vkind := v.Kind(); vkind {
case reflect.Int, reflect.Uint, reflect.Int8, reflect.Uint8, reflect.Int16, reflect.Uint16, reflect.Int32, reflect.Uint32, reflect.Int64, reflect.Uint64:
return nil

case reflect.String:
return nil

case reflect.Map, reflect.Complex64, reflect.Complex128, reflect.Float32, reflect.Float64:
return fmt.Errorf("not supported: %v", vkind)

case reflect.Struct:
// Walk the struct fields.
typ := v.Type()
for i, n := 0, v.NumField(); i < n; i++ {
fi := v.Field(i)

for fi.Kind() == reflect.Ptr {
fi = reflect.Indirect(fi)
}
if !fi.IsValid() {
return fmt.Errorf("field #%d is invalid", i)
}

ti := typ.Field(i)
if unexportedFieldName(ti.Name) {
continue
}

// Now check this field as well.
if err := allClear(fi); err != nil {
return fmt.Errorf("field #%d: %w", i, err)
}
}
return nil

case reflect.Slice, reflect.Array:
// To ensure thoroughness, let's just traverse every single element.
// If we've got a slice of a slice or that composition we need to iterate on that.
for i, n := 0, v.Len(); i < n; i++ {
evi := v.Index(i)
if err := allClear(evi); err != nil {
return fmt.Errorf("field #%d: %w", i, err)
}
}
return nil

case reflect.Interface:
// Walk through the concrete type.
return allClear(v.Elem())
}

return fmt.Errorf("not supported: %v", v.Kind())
}

func unexportedFieldName(name string) bool {
return len(name) > 0 && name[0] >= 'a' && name[0] <= 'z'
}
133 changes: 133 additions & 0 deletions codec/aminocompat/aminocompat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package aminocompat

import (
"strings"
"testing"
)

func TestAllClear(t *testing.T) {
tests := []struct {
name string
in any
wantErr string
}{
{"nil", nil, "not valid"},
{"int", 12, ""},
{"string", "aminocompat", ""},

// Unsupported basics.
{"map", map[int]int{}, "not supported"},
{"complex64", complex64(10 + 1i), "not supported"},
{"complex128", complex128(10 + 1i), "not supported"},
{"float32", float32(10), "not supported"},
{"float64", float64(10), "not supported"},

// Supported composites
{
"struct with 8th level value",
&s{
A: &s2nd{B: &s3rd{A: &s4th{A: s5th{A: s6th{A: s7th{A: &s8th{A: "8th", B: 10}}}}}}},
},
"",
},
{
"struct with an unexported field but that's unsupported by amino-json",
&sWithunexportedunsupported{
a: map[string]int{"a": 10},
B: 10,
C: "st1",
},
"",
},

// Unsupported composites
{
"struct with map",
&sWithMap{
A: 10,
B: "ab",
C: map[string]int{"a": 10},
},
"not supported: map",
},

{
"slice of maps",
&sWithMapSlice{
A: [][]map[int]int{
{map[int]int{1: 10}},
},
},
"not supported: map",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
err := AllClear(tt.in)
if tt.wantErr != "" {
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("could not find\n\t%q\nin\n\t%q", err, tt.wantErr)
}
return
}

if err != nil {
t.Fatal(err)
}
})
}
}

type s8th struct {
A string
B int
}

type s7th struct {
A *s8th
}

type s6th struct {
A s7th
}

type s5th struct {
A s6th
}

type s4th struct {
A s5th
}
type s3rd struct {
A *s4th
B string
}
type s2nd struct {
B *s3rd
C int
}
type s struct {
A *s2nd
B []string
}

type sWithMap struct {
A int
B string
C map[string]int
}

type sWithunexportedunsupported struct {
a map[string]int
B int
C string
}

type sWithMapSlice struct {
A [][]map[int]int
}
50 changes: 50 additions & 0 deletions codec/aminocompat/bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package aminocompat

import (
"encoding/json"
"testing"

sdk "github.com/cosmos/cosmos-sdk/types"
govtypes "github.com/cosmos/cosmos-sdk/x/gov/types/v1"
)

var sink any

var coinsPos = sdk.NewCoins(sdk.NewInt64Coin(sdk.DefaultBondDenom, 1000))
var addr = sdk.AccAddress("addr1")

func BenchmarkMsgDepositGetSignBytes(b *testing.B) {
b.ReportAllocs()

for i := 0; i < b.N; i++ {
msg := govtypes.NewMsgDeposit(addr, 0, coinsPos)
sink = msg.GetSignBytes()
}

if sink == nil {
b.Fatal("Benchmark did not run")
}
sink = nil
}

func BenchmarkMsgDepositAllClearThenJSONMarshal(b *testing.B) {
b.ReportAllocs()

for i := 0; i < b.N; i++ {
msg := govtypes.NewMsgDeposit(addr, 0, coinsPos)
if err := AllClear(msg); err != nil {
b.Fatal(err)
}
// Straight away go to JSON marshalling.
blob, err := json.Marshal(msg)
if err != nil {
b.Fatal(err)
}
sink = blob
}

if sink == nil {
b.Fatal("Benchmark did not run")
}
sink = nil
}

0 comments on commit 6a66409

Please sign in to comment.