Skip to content
mazreanPublic

About

JIT-class speed for Go's own JSON — one go:generate line on, one rm off.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Repository files navigation

odjson

English | 日本語

CI Go Reference License: MIT

odjson (overdrive JSON) is a CLI code generator that takes encoding/json/v2 past bytedance/sonic without editing a line of your code. JSON encoding and decoding that already goes through the standard encoding/json/v2 gets 2.6×–4.6× faster by adding the comment below and running go generate.

//go:generate go tool odjson -type User

Deleting the generated file (odjson_gen.go) puts everything back to plain encoding/json/v2.

Time per operation, lower is better. Marshal large: encoding/json/v2 386 µs, with odjson 98 µs, sonic 115 µs, go-json 191 µs, json-iterator 425 µs, segmentio/encoding 223 µs, jettison 321 µs, easyjson 430 µs, gojay 397 µs. Marshal medium: 11855 ns, with odjson 2596 ns, sonic 3434 ns, go-json 3491 ns, json-iterator 9156 ns, segmentio/encoding 4947 ns, jettison 8518 ns, easyjson 9698 ns, gojay 17408 ns. Marshal small: 1014 ns, with odjson 276 ns, sonic 294 ns, go-json 378 ns, json-iterator 582 ns, segmentio/encoding 395 ns, jettison 477 ns, easyjson 687 ns, gojay 647 ns. Unmarshal large: 1102 µs, with odjson 378 µs, sonic 524 µs, go-json 405 µs, json-iterator 1096 µs, segmentio/encoding 879 µs, simdjson-go 611 µs, easyjson 1113 µs, gojay 2608 µs. Unmarshal medium: 22048 ns, with odjson 8550 ns, sonic 14946 ns, go-json 8903 ns, json-iterator 22574 ns, segmentio/encoding 17640 ns, simdjson-go 30004 ns, easyjson 18594 ns, gojay 25832 ns. Unmarshal small: 1891 ns, with odjson 530 ns, sonic 1021 ns, go-json 550 ns, json-iterator 1110 ns, segmentio/encoding 1156 ns, simdjson-go 1618 ns, easyjson 1169 ns, gojay 1070 ns.

The benchmarks measure 2.6×–4.6× over encoding/json/v2 on encode and decode alike, across the same three payload sizes sonic's benchmarks use. They also put odjson ahead of bytedance/sonic — which uses assembly for JIT compilation, SIMD and more — on all six. Against goccy/go-json v0.11, whose decoder now finds a document's structure with SIMD, odjson is 1.34×–1.95× faster on the three encodes and 1.04×–1.07× on the three decodes — level, a few percent ahead on each, and 8–12% ahead when both run in one process.

odjson is also ahead of json-iterator/go, segmentio/encoding, jettison, simdjson-go, easyjson and gojay on all six benchmarks.

Benchmark environment and how to reproduce it

The benchmarks live in bench/, a Go module of its own. It is kept separate so that sonic, goccy/go-json and the other comparison libraries never reach github.com/mazrean/odjson's go.mod.

Commands

The chart's figures come from the command below, with benchstat taking the median.

cd bench
go test -run xxx -bench 'Benchmark(Marshal|Unmarshal)/(json-v2|go-json|sonic|json-iterator|segmentio|jettison|simdjson-go)/' -benchmem -count 10 ./plain/
go test -run xxx -bench 'Benchmark(Marshal|Unmarshal)/json-v2/' -benchmem -count 10 ./gen/
go test -run xxx -bench 'Benchmark(Marshal|Unmarshal)/(easyjson|gojay)/' -benchmem -count 10 ./easyjson/ ./gojay/

Environment

Item Value
CPU AMD Ryzen 9 7950X
OS Linux (WSL2)
Go 1.27.1
Method bench/plain, bench/gen, bench/easyjson and bench/gojay each run 10 times; the median is quoted
Variance measured with benchstat

What is measured

Ten subjects: encoding/json v1, encoding/json/v2, sonic, goccy/go-json, json-iterator/go (as ConfigCompatibleWithStandardLibrary), segmentio/encoding, jettison (encode only), simdjson-go (decode only), easyjson and gojay. The benchmark names are BenchmarkMarshal/<library>/<input> and BenchmarkUnmarshal/<library>/<input>; the large input is the benchmarks' twitter. All three inputs are the ones bytedance/sonic's own benchmarks use.

Every library but odjson is measured as it ships, with two qualifications. easyjson and gojay are code generators like odjson, so their rows carry their own generated code — easyjson's from its generator, gojay's written by hand, because its generator rejects the interface{} fields these types have. simdjson-go parses into a tape and has no struct decoder, so its row is that parse plus a hand-written walk of the tape into the struct, the same job as the other decode rows rather than the parse alone. Each of those three has a parity test against encoding/json in bench/.

Input Size Go type Content
large ~616 KiB TwitterStruct twitter.json
medium ~13 KiB TwitterStruct medium.json, the same shape at four statuses (sonic's "Medium")
small ~340 B Book a small JSON document

Conditions

  • Unmarshal reads into a fresh zero value on every iteration.
  • Marshal uses a value decoded beforehand.
  • Every case is warmed up once before the timer starts.
  • The chart's figures come from separate processes. To compare generated code against reflection inside one process, use bench/ab.

The method and the assumptions behind it are written up in bench/README.md and docs/internals.md.

Important

odjson depends heavily on the internals of encoding/json/v2 and encoding/jsontext. On any Go minor version other than the one it has been verified against (Go 1.27), the acceleration disables itself and the speed drops. Deleting the generated file also stops all of it immediately.

Requirements

Go 1.27 or newer. Generated files import encoding/json/jsontext, so they do not compile on 1.26 and older.

encoding/json benefits too, since 1.27 implements it on top of encoding/json/v2 internally, but odjson is tuned to get the most out of encoding/json/v2, which is the recommended way to use it.

Quick Start

Install odjson:

go get -tool github.com/mazrean/odjson@latest

Add a //go:generate comment:

package model

//go:generate go tool odjson -type User,Post

type User struct {
	ID    int64  `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

Running go generate then writes an odjson_gen.go file:

go generate ./...

After that, encoding/json/v2 encodes and decodes far faster on its own:

func handler(w http.ResponseWriter, r *http.Request) {
	var u model.User
	if err := json.Unmarshal(body, &u); err != nil {
		// ...
	}

	b, err := json.Marshal(&u)
	// ...
}

Install

go get -tool is the recommended way in. It puts github.com/mazrean/odjson/odjsonrt, which the generated code uses, under the same go.mod version management.

go get -tool github.com/mazrean/odjson@latest
go tool odjson -h

Other package managers work as well.

Standalone binary, Homebrew, Linux packages

Binary

go install github.com/mazrean/odjson@latest

Homebrew (macOS)

brew install --cask mazrean/tap/odjson

Linux packages (Debian / Ubuntu / RHEL / Fedora / openSUSE / Alpine)

# Debian / Ubuntu
curl -LO https://github.com/mazrean/odjson/releases/latest/download/odjson_<version>_linux_amd64.deb
sudo dpkg -i odjson_<version>_linux_amd64.deb

# RHEL / Fedora / openSUSE
sudo rpm -i odjson_<version>_linux_amd64.rpm

# Alpine
apk add --allow-untrusted odjson_<version>_linux_amd64.apk

Flags

odjson [flags] [packages]

With no package argument odjson generates for the package in the current directory, which is what //go:generate needs.

Flag Default Description
-type all Comma-separated struct type names to generate for. The default is every struct declared in the package, unexported ones included.
-output odjson_gen.go File name written into each matched package directory. A name, not a path.
-recursive true Also generate codecs for struct types reachable from the selected ones, so nested values skip reflection too.
-escape-html true Escape <, > and & in strings, matching encoding/json's default.
-case-insensitive false In UnmarshalJSON, fall back to a case-insensitive member match the way encoding/json v1 does. Off by default, matching encoding/json/v2.
-direct false Also generate package level MarshalT / UnmarshalT per struct type T, reaching the generated codec without going through encoding/json. Off by default: an unchanged json.Marshal is the way odjson is meant to be used, and a call site written against MarshalT stops compiling once the generated file is deleted. Measured in docs/internals.md.
-version Print the version and exit.

How it works

Generating dedicated Marshal/Unmarshal code per type

A Go struct already carries everything about its JSON shape at compile time. encoding/json/v2 and every other JSON library extract that at run time and encode and decode from it. odjson reads the struct definitions ahead of time instead and writes out code dedicated to each type, which is where the speed comes from. Concretely, the generated code encodes and decodes without reflection and without an intermediate map[string]any: this performs JIT-grade optimisation at compile time and cuts the run-time overhead sharply.

  • Marshal: appends struct fields straight to a byte slice
  • Unmarshal: streams the input straight into the struct's fields
    • no reflection, no intermediate map[string]any, no run-time field name lookup
    • member names are matched against the document's raw bytes, quotes included
    • bools, integers and simple floats are decoded inline

Bolting onto the standard library

On top of that, making the dedicated code satisfy the standard library's (T).MarshalJSONTo / (*T).UnmarshalJSONFrom interfaces is what lets code that uses the standard library benefit from odjson without being changed. Only encoding/json/v2, by offering streaming interfaces with that little overhead, made such an implementation possible in the first place. The result keeps the standard library's reliability while passing github.com/bytedance/sonic on all six measurements.

The methods added to each type are these:

Symbol Interface
(T).MarshalJSONTo / (*T).UnmarshalJSONFrom encoding/json/v2's streaming interfaces. What a json.Marshal reaches on Go 1.27, and where the speed is.
(T).MarshalJSON / (*T).UnmarshalJSON encoding/json v1's interfaces, following v1's rules.

Types that already implement json.Marshaler, json.Unmarshaler, encoding.TextMarshaler or encoding.TextUnmarshaler are left alone. Nothing about their behaviour changes, but they get nothing from odjson either.

One more thing on the encode side: writing to (encoding/jsontext).Encoder through the ordinary route costs too much in validation of what was written to reach the speed above. odjson writes straight into the encoder's internal buffer, working from (*encoding/jsontext).Encoder's memory layout. That is where a large part of the speed comes from, and it depends on the standard library's internal layout. So the path is only enabled on Go minor versions it has been verified against; on any other it disables itself at start-up and falls back to the implementation that goes through the public API. That still compiles and still produces the same JSON — only the speed drops. See docs/internals.md for the details.

Other small optimisations

A number of smaller optimisations are what close the remaining gap to github.com/bytedance/sonic:

  • buffer reuse through sync.Pool
  • a dedicated float formatter: a short decimal is proven by one division, anything else by a shortest-digit search that writes whole words instead of going through strconv
  • word-at-a-time scanning
  • fusing UTF-8 validation into the string scan, removing the separate validation pass
  • writing a string's bytes as the scan judges them, so that no string pays a separate copy
  • writing integer digits straight into the output buffer instead of formatting them into a scratch array first

What adopting it changes

Adopting odjson does not normally change behaviour, but a few cases can shift as described below.

Changes to encoding/json's behaviour

Since Go 1.27 encoding/json is implemented on top of encoding/json/v2, and json/v2 prefers MarshalJSONTo when a type offers both. So an unchanged v1 call site reaches the encoder and decoder odjson generated for encoding/json/v2, and some behaviour shifts with it.

The concrete changes are these:

Case Without odjson With odjson
nil slice / map null [] / {}
nil []byte null ""
omitempty on 0 or false omitted kept
null into a scalar, struct, time.Time or ,string field (decode) left untouched zeroed
array length mismatch (decode) padded or truncated rejected
member name in the wrong case (decode) matched not matched
map member order sorted by name sorted by name (unchanged)
<, >, &, U+2028/9 escaped escaped (unchanged)

Changes to embedding behaviour

When a struct is embedded as below, the (T).MarshalJSONTo / (*T).UnmarshalJSONFrom methods added to A, the type odjson accelerated, are inherited by B as well. B then encodes and decodes through A's JSON handling, and FieldB stops being recognised as a JSON member.

// A is accelerated by odjson
type A struct {
	FieldA string `json:"field_a"`
}

// B embeds A
type B struct {
	A
	FieldB string `json:"field_b"`
}

For now, B has to be accelerated by odjson too.

How reliable the encoding and decoding is

odjson's MarshalJSON / UnmarshalJSON behave exactly like encoding/json, and its MarshalJSONTo / UnmarshalJSONFrom exactly like encoding/json/v2. The test suites and fixtures below are what confirm it.

  • the parity fixtures in internal/testfixture/
    • covering every field shape the generator can handle
    • every numeric width, []byte and [N]byte, pointers, slices, arrays, maps, any, json.RawMessage, json.Number, time.Time, embedding by value and by pointer, omitempty, omitzero, ,string, -, unexported fields, self-referential and cross-package types, generics and other fallbacks
    • compared against the standard library's encode and decode results, which must match exactly
  • the JSON Test Suite (318 cases)
    • run against the runtime scanner and the generated decoders
    • each case must produce the same value as the standard library
  • encoding/json's field promotion rules
    • reimplemented on go/types
    • checked against encoding/json on a struct built to hit all three cases

Parity is measured against the toolchain named in go.mod. Since Go 1.27 encoding/json is implemented on top of encoding/json/v2 in v1 compatibility mode, and a few escape forms differ from the classic v1 encoder. For the exact list, see the package documentation of odjsonrt.

License

MIT

About

JIT-class speed for Go's own JSON — one go:generate line on, one rm off.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages