Skip to content

Commit

Permalink
perf(libs/json): Lower heap overhead of JSON encoding (backport #2846) (
Browse files Browse the repository at this point in the history
#2876)

---

Many RPC methods require JSON marshalled responses. We saw this taking a
notable amount of heap allocation in query serving full nodes. This PR
removes some extra heap allocations that were being done. We avoided
using the more efficient encoder.Encode before, because it added a
newline. This PR changes the function signature for these private
methods to be using *bytes.Buffer, and then uses the in-buffer methods
(rather than a second copy). We then just truncate the final byte after
each such call, which does not waste any allocations.

I added a benchmark for the most complex test case. 

OLD:
```
BenchmarkJsonMarshalStruct-12              78992             15542 ns/op            4487 B/op        191 allocs/op
```
New:
```
BenchmarkJsonMarshalStruct-12              93346             11132 ns/op            3245 B/op         58 allocs/op
```

Roughly a 3-4x reduction in the number of allocations, and 20% speedup.

#### PR checklist

- [x] Tests written/updated - Existing tests cover this
- [x] Changelog entry added in `.changelog` (we use
[unclog](https://github.com/informalsystems/unclog) to manage our
changelog)
- [x] Updated relevant documentation (`docs/` or `spec/`) and code
comments
- [x] Title follows the [Conventional
Commits](https://www.conventionalcommits.org/en/v1.0.0/) spec
<hr>This is an automatic backport of pull request #2846 done by
[Mergify](https://mergify.com).

---------

Co-authored-by: Dev Ojha <ValarDragon@users.noreply.github.com>
Co-authored-by: Andy Nogueira <me@andynogueira.dev>
  • Loading branch information
3 people committed Apr 23, 2024
1 parent b7846d2 commit 15bb562
Show file tree
Hide file tree
Showing 3 changed files with 33 additions and 11 deletions.
2 changes: 2 additions & 0 deletions .changelog/v0.37.5/improvements/2846-speedup-json-encoding.md
@@ -0,0 +1,2 @@
- `[libs/json]` Lower the memory overhead of JSON encoding by using JSON encoders internally
([\#2846](https://github.com/cometbft/cometbft/pull/2846)).
25 changes: 14 additions & 11 deletions libs/json/encoder.go
Expand Up @@ -42,7 +42,7 @@ func MarshalIndent(v interface{}, prefix, indent string) ([]byte, error) {
return buf.Bytes(), nil
}

func encode(w io.Writer, v interface{}) error {
func encode(w *bytes.Buffer, v any) error {
// Bare nil values can't be reflected, so we must handle them here.
if v == nil {
return writeStr(w, "null")
Expand All @@ -60,7 +60,7 @@ func encode(w io.Writer, v interface{}) error {
return encodeReflect(w, rv)
}

func encodeReflect(w io.Writer, rv reflect.Value) error {
func encodeReflect(w *bytes.Buffer, rv reflect.Value) error {
if !rv.IsValid() {
return errors.New("invalid reflect value")
}
Expand Down Expand Up @@ -115,7 +115,7 @@ func encodeReflect(w io.Writer, rv reflect.Value) error {
}
}

func encodeReflectList(w io.Writer, rv reflect.Value) error {
func encodeReflectList(w *bytes.Buffer, rv reflect.Value) error {
// Emit nil slices as null.
if rv.Kind() == reflect.Slice && rv.IsNil() {
return writeStr(w, "null")
Expand Down Expand Up @@ -150,7 +150,7 @@ func encodeReflectList(w io.Writer, rv reflect.Value) error {
return writeStr(w, "]")
}

func encodeReflectMap(w io.Writer, rv reflect.Value) error {
func encodeReflectMap(w *bytes.Buffer, rv reflect.Value) error {
if rv.Type().Key().Kind() != reflect.String {
return errors.New("map key must be string")
}
Expand Down Expand Up @@ -181,7 +181,7 @@ func encodeReflectMap(w io.Writer, rv reflect.Value) error {
return writeStr(w, "}")
}

func encodeReflectStruct(w io.Writer, rv reflect.Value) error {
func encodeReflectStruct(w *bytes.Buffer, rv reflect.Value) error {
sInfo := makeStructInfo(rv.Type())
if err := writeStr(w, "{"); err != nil {
return err
Expand Down Expand Up @@ -212,7 +212,7 @@ func encodeReflectStruct(w io.Writer, rv reflect.Value) error {
return writeStr(w, "}")
}

func encodeReflectInterface(w io.Writer, rv reflect.Value) error {
func encodeReflectInterface(w *bytes.Buffer, rv reflect.Value) error {
// Get concrete value and dereference pointers.
for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
if rv.IsNil() {
Expand All @@ -237,14 +237,17 @@ func encodeReflectInterface(w io.Writer, rv reflect.Value) error {
return writeStr(w, "}")
}

func encodeStdlib(w io.Writer, v interface{}) error {
// Doesn't stream the output because that adds a newline, as per:
// https://golang.org/pkg/encoding/json/#Encoder.Encode
blob, err := json.Marshal(v)
func encodeStdlib(w *bytes.Buffer, v any) error {
// Stream the output of the JSON marshaling directly into the buffer.
// The stdlib encoder will write a newline, so we must truncate it,
// which is why we pass in a bytes.Buffer throughout, not io.Writer.
enc := json.NewEncoder(w)
err := enc.Encode(v)
if err != nil {
return err
}
_, err = w.Write(blob)
// Remove the last byte from the buffer
w.Truncate(w.Len() - 1)
return err
}

Expand Down
17 changes: 17 additions & 0 deletions libs/json/encoder_test.go
Expand Up @@ -102,3 +102,20 @@ func TestMarshal(t *testing.T) {
})
}
}

func BenchmarkJsonMarshalStruct(b *testing.B) {
s := "string"
sPtr := &s
i64 := int64(64)
ti := time.Date(2020, 6, 2, 18, 5, 13, 4346374, time.FixedZone("UTC+2", 2*60*60))
car := &Car{Wheels: 4}
boat := Boat{Sail: true}
for i := 0; i < b.N; i++ {
_, _ = json.Marshal(Struct{
Bool: true, Float64: 3.14, Int32: 32, Int64: 64, Int64Ptr: &i64,
String: "foo", StringPtrPtr: &sPtr, Bytes: []byte{1, 2, 3},
Time: ti, Car: car, Boat: boat, Vehicles: []Vehicle{car, boat},
Child: &Struct{Bool: false, String: "child"}, private: "private",
})
}
}

0 comments on commit 15bb562

Please sign in to comment.