Skip to content

Commit fa98249

Browse files
vishrclaude
andcommitted
feat(sonic): add opt-in sonic JSON serializer as a separate module
Drop-in echo.JSONSerializer backed by bytedance/sonic, shipped as its own Go module so the Echo core stays dependency-free. Big decode win on all arches (c.Bind -44%); encode is architecture-dependent (see sonic/README.md). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c3bbcb4 commit fa98249

6 files changed

Lines changed: 394 additions & 0 deletions

File tree

sonic/README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Echo sonic JSON serializer
2+
3+
An opt-in, drop-in [`echo.JSONSerializer`](https://pkg.go.dev/github.com/labstack/echo/v5#JSONSerializer)
4+
backed by [bytedance/sonic](https://github.com/bytedance/sonic) — a JIT + SIMD accelerated JSON library.
5+
6+
Echo's core deliberately stays on the standard library (`encoding/json`) with **no third-party
7+
dependencies**. This adapter lives in **its own Go module**, so only applications that import it pull
8+
sonic and its (assembler) dependencies. The Echo core build is unaffected.
9+
10+
## Install
11+
12+
```bash
13+
go get github.com/labstack/echo/v5/sonic
14+
```
15+
16+
## Usage
17+
18+
```go
19+
import (
20+
"github.com/labstack/echo/v5"
21+
"github.com/labstack/echo/v5/sonic"
22+
)
23+
24+
func main() {
25+
e := echo.New()
26+
e.JSONSerializer = sonic.New() // all c.JSON(...) / c.Bind(...) now use sonic
27+
28+
// ...
29+
}
30+
```
31+
32+
Custom sonic configuration (e.g. the fastest, less-strict config, or the encoding/json-compatible one).
33+
Note the adapter package and bytedance's package are both named `sonic`, so alias one of them:
34+
35+
```go
36+
import (
37+
bsonic "github.com/bytedance/sonic"
38+
"github.com/labstack/echo/v5/sonic"
39+
)
40+
41+
e.JSONSerializer = sonic.NewWithAPI(bsonic.ConfigFastest) // max throughput
42+
e.JSONSerializer = sonic.NewWithAPI(bsonic.ConfigStd) // encoding/json compatible
43+
```
44+
45+
## When should I use it?
46+
47+
sonic is a **targeted** optimization, not a universal "make JSON faster" switch. Benchmark your own
48+
workload, but as a rule of thumb:
49+
50+
| Workload | Recommendation |
51+
|---|---|
52+
| Decode-heavy (large request bodies, `c.Bind`) | ✅ Win on **all** architectures |
53+
| Encode-heavy (`c.JSON` responses) on **amd64** | ✅ Generally a win |
54+
| Encode-heavy on **arm64** (Apple Silicon, Graviton) | ⚠️ Often **slower** than `encoding/json` for small/medium payloads |
55+
56+
### Benchmarks
57+
58+
End-to-end through `ServeHTTP`, Apple M3 Max (arm64), Go 1.26, representative payload
59+
(struct with strings, ints, bool, slices, floats and a map):
60+
61+
| Operation | `encoding/json` | sonic | Δ time | Δ allocs |
62+
|---|--:|--:|--:|--:|
63+
| `c.Bind` (decode) | 3554 ns / 42 allocs | **1982 ns / 24 allocs** | **−44%** | **−43%** |
64+
| `c.JSON` (encode) | 737 ns / 9 allocs | 1053 ns / 7 allocs | +43% | −22% |
65+
66+
> The encode result is **architecture dependent**: sonic's JIT/SIMD is primarily tuned for amd64. On
67+
> arm64 its encode path typically loses to `encoding/json` for small/medium payloads, while decode wins
68+
> everywhere. This is why sonic is shipped opt-in rather than as the default serializer.
69+
70+
## Notes
71+
72+
- On platforms without sonic JIT support (anything other than amd64/arm64), sonic transparently falls
73+
back to a compatibility implementation, so this serializer is safe to use everywhere.
74+
- `Serialize` uses sonic's `Marshal` fast path, which buffers the encoded body before writing.
75+
- sonic's default configuration is not byte-for-byte identical to `encoding/json` (HTML escaping,
76+
number formatting, key ordering). Use `sonic.ConfigStd` via `NewWithAPI` if you need strict
77+
`encoding/json` compatibility.

sonic/go.mod

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
module github.com/labstack/echo/v5/sonic
2+
3+
go 1.25.0
4+
5+
require (
6+
github.com/bytedance/sonic v1.15.2
7+
github.com/labstack/echo/v5 v5.0.0
8+
)
9+
10+
require (
11+
github.com/bytedance/gopkg v0.1.3 // indirect
12+
github.com/bytedance/sonic/loader v0.5.1 // indirect
13+
github.com/cloudwego/base64x v0.1.6 // indirect
14+
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
15+
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
16+
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
17+
golang.org/x/sys v0.22.0 // indirect
18+
)
19+
20+
replace github.com/labstack/echo/v5 => ../

sonic/go.sum

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
2+
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
3+
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
4+
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
5+
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
6+
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
7+
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
8+
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
9+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
11+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12+
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
13+
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
14+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
15+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
16+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
17+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
18+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
19+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
20+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
21+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
22+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
23+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
24+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
25+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
26+
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
27+
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
28+
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
29+
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
30+
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
31+
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
32+
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
33+
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
34+
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
35+
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
36+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
37+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
38+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
39+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

sonic/serve_bench_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package sonic
5+
6+
import (
7+
"bytes"
8+
"encoding/json"
9+
"io"
10+
"net/http"
11+
"net/http/httptest"
12+
"testing"
13+
14+
"github.com/labstack/echo/v5"
15+
)
16+
17+
// End-to-end benchmarks through the full Echo request path (ServeHTTP), comparing the default
18+
// encoding/json serializer with the sonic serializer for both JSON responses and request binding.
19+
20+
func serveJSON(b *testing.B, s echo.JSONSerializer) {
21+
e := echo.New()
22+
if s != nil {
23+
e.JSONSerializer = s
24+
}
25+
p := sample()
26+
e.GET("/", func(c *echo.Context) error { return c.JSON(http.StatusOK, p) })
27+
req := httptest.NewRequest(http.MethodGet, "/", nil)
28+
w := &nopResponseWriter{}
29+
b.ReportAllocs()
30+
b.ResetTimer()
31+
for i := 0; i < b.N; i++ {
32+
w.h = nil
33+
e.ServeHTTP(w, req)
34+
}
35+
}
36+
37+
func serveBind(b *testing.B, s echo.JSONSerializer) {
38+
e := echo.New()
39+
if s != nil {
40+
e.JSONSerializer = s
41+
}
42+
e.POST("/", func(c *echo.Context) error {
43+
var p payload
44+
return c.Bind(&p)
45+
})
46+
data, _ := json.Marshal(sample())
47+
w := &nopResponseWriter{}
48+
b.ReportAllocs()
49+
b.ResetTimer()
50+
for i := 0; i < b.N; i++ {
51+
w.h = nil
52+
req := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(bytes.NewReader(data)))
53+
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
54+
e.ServeHTTP(w, req)
55+
}
56+
}
57+
58+
func BenchmarkServeJSON_Default(b *testing.B) { serveJSON(b, nil) }
59+
func BenchmarkServeJSON_Sonic(b *testing.B) { serveJSON(b, New()) }
60+
func BenchmarkServeBind_Default(b *testing.B) { serveBind(b, nil) }
61+
func BenchmarkServeBind_Sonic(b *testing.B) { serveBind(b, New()) }

sonic/sonic.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
// Package sonic provides a high-performance JSON serializer for Echo backed by
5+
// github.com/bytedance/sonic (a JIT + SIMD accelerated JSON library).
6+
//
7+
// It is an opt-in, drop-in replacement for the standard-library based
8+
// echo.DefaultJSONSerializer. The Echo core intentionally keeps its default
9+
// serializer on encoding/json (no third-party dependencies); enable sonic
10+
// explicitly when you want the extra throughput:
11+
//
12+
// e := echo.New()
13+
// e.JSONSerializer = sonic.New()
14+
//
15+
// On platforms not supported by sonic's JIT (anything other than amd64/arm64),
16+
// sonic transparently falls back to a compatibility implementation built on
17+
// encoding/json, so this serializer is safe to use everywhere.
18+
//
19+
// This adapter lives in its own Go module so that the Echo core stays dependency-free: only
20+
// applications that import this package pull sonic and its transitive (assembler) dependencies.
21+
package sonic
22+
23+
import (
24+
"github.com/bytedance/sonic"
25+
"github.com/labstack/echo/v5"
26+
)
27+
28+
// Serializer implements echo.JSONSerializer using the sonic library.
29+
type Serializer struct {
30+
// API is the frozen sonic configuration used for encoding/decoding.
31+
// Defaults to sonic.ConfigDefault when created via New.
32+
API sonic.API
33+
}
34+
35+
// New returns a Serializer using sonic's default configuration.
36+
func New() *Serializer {
37+
return &Serializer{API: sonic.ConfigDefault}
38+
}
39+
40+
// NewWithAPI returns a Serializer using the provided sonic configuration, e.g.
41+
// sonic.ConfigStd (encoding/json compatible) or sonic.ConfigFastest.
42+
func NewWithAPI(api sonic.API) *Serializer {
43+
return &Serializer{API: api}
44+
}
45+
46+
// Serialize converts target into JSON and writes it to the response.
47+
// The optional indent parameter produces pretty-printed JSON.
48+
//
49+
// It uses sonic's Marshal fast path (which buffers the encoded output) rather than the streaming
50+
// encoder: for typical response sizes Marshal is significantly faster than both the streaming encoder
51+
// and encoding/json. The trade-off is that the full encoded body is held in memory before writing.
52+
func (s *Serializer) Serialize(c *echo.Context, target any, indent string) error {
53+
var (
54+
buf []byte
55+
err error
56+
)
57+
if indent != "" {
58+
buf, err = s.API.MarshalIndent(target, "", indent)
59+
} else {
60+
buf, err = s.API.Marshal(target)
61+
}
62+
if err != nil {
63+
return err
64+
}
65+
_, err = c.Response().Write(buf)
66+
return err
67+
}
68+
69+
// Deserialize reads JSON from the request body and stores it in target.
70+
func (s *Serializer) Deserialize(c *echo.Context, target any) error {
71+
if err := s.API.NewDecoder(c.Request().Body).Decode(target); err != nil {
72+
return echo.ErrBadRequest.Wrap(err)
73+
}
74+
return nil
75+
}

sonic/sonic_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package sonic
5+
6+
import (
7+
"bytes"
8+
"io"
9+
"net/http"
10+
"net/http/httptest"
11+
"reflect"
12+
"testing"
13+
14+
"github.com/labstack/echo/v5"
15+
)
16+
17+
type payload struct {
18+
ID int `json:"id"`
19+
Name string `json:"name"`
20+
Email string `json:"email"`
21+
Active bool `json:"active"`
22+
Tags []string `json:"tags"`
23+
Scores []float64 `json:"scores"`
24+
Meta map[string]string `json:"meta"`
25+
}
26+
27+
func sample() payload {
28+
return payload{
29+
ID: 42,
30+
Name: "Jon Snow",
31+
Email: "jon@winterfell.north",
32+
Active: true,
33+
Tags: []string{"king", "stark", "watch"},
34+
Scores: []float64{1.5, 2.25, 3.125},
35+
Meta: map[string]string{"house": "stark", "region": "north"},
36+
}
37+
}
38+
39+
// nopResponseWriter discards everything; used to isolate serializer cost.
40+
type nopResponseWriter struct{ h http.Header }
41+
42+
func (w *nopResponseWriter) Header() http.Header {
43+
if w.h == nil {
44+
w.h = make(http.Header)
45+
}
46+
return w.h
47+
}
48+
func (w *nopResponseWriter) Write(b []byte) (int, error) { return len(b), nil }
49+
func (w *nopResponseWriter) WriteHeader(int) {}
50+
51+
// TestSerializerRoundTrip verifies sonic encodes/decodes equivalently to the
52+
// standard library serializer (semantic equality, ignoring trailing newline).
53+
func TestSerializerRoundTrip(t *testing.T) {
54+
e := echo.New()
55+
in := sample()
56+
57+
// Encode with sonic.
58+
rec := httptest.NewRecorder()
59+
c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), rec)
60+
if err := New().Serialize(c, in, ""); err != nil {
61+
t.Fatalf("sonic Serialize: %v", err)
62+
}
63+
64+
// Decode it back with sonic.
65+
var out payload
66+
dc := e.NewContext(httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(rec.Body.Bytes())), httptest.NewRecorder())
67+
if err := New().Deserialize(dc, &out); err != nil {
68+
t.Fatalf("sonic Deserialize: %v", err)
69+
}
70+
if !reflect.DeepEqual(in, out) {
71+
t.Fatalf("round trip mismatch:\n in=%#v\nout=%#v", in, out)
72+
}
73+
74+
// Cross-check: the standard serializer must decode sonic's output to the same value.
75+
var std payload
76+
sc := e.NewContext(httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(rec.Body.Bytes())), httptest.NewRecorder())
77+
if err := (echo.DefaultJSONSerializer{}).Deserialize(sc, &std); err != nil {
78+
t.Fatalf("default Deserialize of sonic output: %v", err)
79+
}
80+
if !reflect.DeepEqual(in, std) {
81+
t.Fatalf("sonic output not std-compatible:\n in=%#v\nstd=%#v", in, std)
82+
}
83+
}
84+
85+
func benchSerialize(b *testing.B, s echo.JSONSerializer) {
86+
e := echo.New()
87+
in := sample()
88+
c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), &nopResponseWriter{})
89+
b.ReportAllocs()
90+
b.ResetTimer()
91+
for i := 0; i < b.N; i++ {
92+
if err := s.Serialize(c, in, ""); err != nil {
93+
b.Fatal(err)
94+
}
95+
}
96+
}
97+
98+
func benchDeserialize(b *testing.B, s echo.JSONSerializer) {
99+
e := echo.New()
100+
data, _ := func() ([]byte, error) {
101+
rec := httptest.NewRecorder()
102+
c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), rec)
103+
err := New().Serialize(c, sample(), "")
104+
return rec.Body.Bytes(), err
105+
}()
106+
req := httptest.NewRequest(http.MethodPost, "/", nil)
107+
c := e.NewContext(req, &nopResponseWriter{})
108+
b.ReportAllocs()
109+
b.ResetTimer()
110+
for i := 0; i < b.N; i++ {
111+
req.Body = io.NopCloser(bytes.NewReader(data))
112+
var out payload
113+
if err := s.Deserialize(c, &out); err != nil {
114+
b.Fatal(err)
115+
}
116+
}
117+
}
118+
119+
func BenchmarkSerialize_Default(b *testing.B) { benchSerialize(b, echo.DefaultJSONSerializer{}) }
120+
func BenchmarkSerialize_Sonic(b *testing.B) { benchSerialize(b, New()) }
121+
func BenchmarkDeserialize_Default(b *testing.B) { benchDeserialize(b, echo.DefaultJSONSerializer{}) }
122+
func BenchmarkDeserialize_Sonic(b *testing.B) { benchDeserialize(b, New()) }

0 commit comments

Comments
 (0)