A small Go code generator that emits fast, allocation-light
json.Unmarshaler implementations from your struct definitions.
Instead of decoding JSON with reflection at run time (like encoding/json),
lightning reads a struct definition at build time and writes a hand-written
style UnmarshalJSON method plus the recursive decoders it needs. The decoders
share a single set of scanning primitives in pkg/unstable, so the
generated files stay small.
Those same primitives are also exposed directly as a small toolkit in
pkg/json, for working with JSON without generating or decoding into
a struct at all: pull a few fields out of a document
(Get/GetMany), edit values in place
(Set/SetMany), prune default members
(StripDefaults), decode into a generic value
(DecodeAny), and escape, unescape, or parse a JSON number
on its own — each an allocation-light, single-pass operation over the raw bytes.
See Layout for the full list.
Run it straight from the module path (no clone needed):
go run github.com/JohanLindvall/lightning@latest path/to/data.go@latest can be any version, branch, or commit (@v1.0.0, @main, @<sha>).
Or install the binary once:
go install github.com/JohanLindvall/lightning@latest
lightning path/to/data.goThe generated code imports github.com/JohanLindvall/lightning/pkg/unstable, so
the module you generate into must depend on lightning:
go get github.com/JohanLindvall/lightningpkg/unstable is the generator's runtime: it is exported only because the
generated *_unmarshal.go files (which live in your module) have to call into it.
As its name says, it is not a stable API — don't import it directly; use
pkg/json for the public toolkit.
A go:generate directive in the file that holds your structs works well:
//go:generate go run github.com/JohanLindvall/lightning@latest $GOFILEPoint the generator at a Go file containing one or more struct types. From inside this repo:
go run . path/to/data.go(go run . only works in this repo; elsewhere use the module path shown above.)
For each input file FOO.go it writes FOO_unmarshal.go next to it, containing
an UnmarshalJSON method for every top-level type (a struct, or a named slice or
map root — see Root types). The generated code imports
github.com/JohanLindvall/lightning/pkg/unstable for the shared scanner.
Given:
package cloudflare
type Log struct {
RayID string `json:"RayID"`
EdgeResponseStatus int64 `json:"EdgeResponseStatus"`
// ...
}you get a func (v *Log) UnmarshalJSON(data []byte) error that parses the JSON
with an index-based scanner, no reflection, and no allocation on the common
paths (unescaped strings, integers, object keys).
string, bool, every sized int/uint kind, float32/float64,
json.RawMessage (and RawValue), time.Time (RFC 3339, like encoding/json;
the lax option also accepts a space separator and Unix
timestamps), nested named and anonymous structs, slices, fixed-size arrays
([N]T), maps with string keys, pointers, and interface{}/any (decoded into
the usual Go representation of an arbitrary JSON value). Unknown object keys are
skipped.
A fixed-size array follows encoding/json: the leading elements are filled, a
shorter JSON array leaves the remaining elements zero, and a longer one's extras
are discarded.
Embedded struct fields are promoted like encoding/json: an embedded struct's
exported fields decode as if they were the outer struct's own (an embedded
pointer is allocated on demand), a name present on both the outer struct and an
embed is resolved by Go's shallower-wins rule, an equal-depth clash is dropped
unless a single field is tagged, and an embedded field with its own JSON tag name
is a plain named field rather than promoted. Embedding a type from another
package, whose fields aren't visible to the generator, is the one gap — it is
decoded as a single named field instead of being flattened.
A top-level type need not be a struct: a named slice or a string-keyed map at the
root gets its own UnmarshalJSON too, so a document that is a bare array or a
data map needs no wrapper struct.
type Records []Record // a JSON array at the root
type ByID map[string]Record // a JSON object used as a data maptype Records []Record decodes a top-level […] with the slice element rules;
type ByID map[string]Record decodes a top-level {…} as a map, its keys the
object's member names. Either element/value type, and any nested types and field
options, behave exactly as the same type used for a struct field would. Several
root types (struct, slice, map, in any mix) can live in one input file. For a
root that is a bare any/interface{} — whose schema you don't know at all —
there is no method to generate (Go forbids methods on interface types); decode it
dynamically with json.DecodeAny instead.
By default, string and json.RawMessage fields copy their bytes out of the
input, matching encoding/json semantics. Add nocopy to the json tag to make
a field alias the input buffer instead — zero-copy, but the caller must keep the
input []byte unchanged while the result is in use:
type Log struct {
RayID string `json:"RayID,nocopy"` // aliases the input
Body json.RawMessage `json:"Body,nocopy"` // aliases the input
}nocopy propagates through slices, maps, and pointers, but stops at struct
boundaries (each struct's own field tags govern). Strings containing escape
sequences still allocate, since they can't be a slice of the raw input.
The one allocation nocopy can't avoid is unescaping: a string like "a\/b" has
to be decoded somewhere, and it can't alias the raw input. The
//lightning:destructive directive removes even that allocation by unescaping
into the input buffer — overwriting the escaped bytes with the decoded ones (the
decoded form is always shorter) and aliasing the result. Put it on the type, above
its declaration:
//lightning:destructive
type Log struct {
RayID string `json:"RayID,nocopy"`
Message string `json:"Message,nocopy"`
}The name says it plainly: this mutates — and effectively destroys — the input
document. The bytes of every escaped string are overwritten in place, so the
[]byte you pass in is no longer valid JSON afterward, and you must not read it
again or hold any other alias into it. In return, escaped nocopy strings cost zero
allocations. Use it when you own the input buffer and discard it after decoding (a
request body, a buffer you'll reuse). It upgrades the type's nocopy string fields;
escape-free input decodes byte-identically to plain nocopy. On escape-heavy
documents the savings are large — e.g. −41% time and −86% bytes on a
string-heavy corpus.
A json tag may list several pipe-separated names. Any of them appearing in the input fills the field, which is handy when an upstream source renamed a key and you want to accept both spellings:
type Log struct {
EdgeResponseStatus int64 `json:"EdgeResponseStatus|AnotherField"`
}Comma-separated options still follow the name as usual, so names and nocopy
combine freely — json:"Name|Title,nocopy" accepts both Name and Title,
zero-copy.
By default a value of the wrong type fails the whole decode: a string where a
number is expected returns an error. Add lax to the json tag to make such a
mismatch a no-op instead — the offending value is skipped and the field left at
its zero value, while the rest of the object decodes normally:
type Log struct {
Status int64 `json:"Status,lax"` // a non-number Status is ignored, leaving 0
}Only type mismatches are tolerated; genuinely malformed JSON (a syntax error in
the value) still fails, since a well-formed value of the wrong type can be
skipped but a broken one cannot. lax works for every field type, including
nested structs, slices, and maps, where a decode error anywhere in the value
leaves the whole field unset. It combines with the other options and with
alternate names — json:"Name|Title,nocopy,lax".
On a time.Time field, lax additionally widens what counts as a valid
timestamp. Besides strict RFC 3339, it accepts a space in place of the T
date/time separator and a Unix timestamp given as a JSON number or numeric
string, inferring seconds, milliseconds, or microseconds from the magnitude; the
result is normalized to UTC. An unrecognized timestamp is skipped and the field
left unset, like any other lax mismatch. As with nocopy, the lenient parser
propagates through slices, maps, and pointers (e.g. []time.Time) but stops at
struct boundaries.
Some payloads carry a nested document as a string — JSON embedded in JSON,
sometimes base64-encoded. Add unwrap to a field's json tag to decode through
that wrapper: the field's value is read as a JSON string, its body unescaped,
and the result decoded as JSON into the field.
type Envelope struct {
Name string `json:"name"`
Payload Message `json:"payload,unwrap"` // value is a string holding JSON
}Both forms are accepted automatically. If the unescaped string is itself JSON
(its first non-whitespace byte starts a JSON value) it is decoded directly;
otherwise it is base64-decoded first (standard alphabet, with or without
padding) and the decoded bytes are the JSON. So a "payload" of
"{\"id\":7}" and of "eyJpZCI6N30=" both fill Payload. A null or empty
string leaves the field at its zero value.
The field decodes with its normal rules, so unwrap composes with the field's
type (struct, slice, map, scalar…) and with nocopy — a nocopy string inside
the embedded document aliases the decoded buffer, which is retained for as long
as the result is in use. The embedded document is parsed as a fresh input, so
its own whitespace, escaping, and structure are independent of the outer JSON.
Some behavior is selected with a //lightning:<name> comment on the struct type
(or its declaration), separate from the per-field json tags above.
By default a decoder calls SkipWS around every token so it accepts JSON with
any whitespace. Mark a type //lightning:compact to assert the input has no
whitespace between tokens — the form encoding/json's Marshal and most wire
protocols emit — and the generator drops those inter-token SkipWS calls,
decoding tokens back-to-back:
//lightning:compact
type Log struct {
RayID string `json:"RayID"`
Status int64 `json:"Status"`
}This runs a few percent faster on object-heavy payloads (the cloudflare-compact
benchmark beats cloudflare-nocopy, its non-compact equivalent, by ~4%).
Whitespace surrounding the whole document is still tolerated — a trailing newline
is fine — so only inter-token whitespace is assumed absent.
The directive is an assertion you make about the input: a compact decoder fed input that does contain inter-token whitespace (for example the same document pretty-printed) returns an error instead of parsing it. Use it only for sources that are guaranteed compact. The directive applies to the whole type graph it roots, including nested structs, slices, and maps.
The nocopy tag option lives on a field, but a named
slice or map root type has no field to tag — its element strings and map keys
would always be copied. Mark such a root //lightning:nocopy to alias them out of
the input instead, the same zero-copy trade as the tag (the caller must keep the
input []byte unchanged while the result is in use):
//lightning:nocopy
type Index map[string]Record // the map keys alias the input, not copiedIt applies to what the root itself owns — a map's keys, a slice's string elements
— while each value/element type keeps its own field tags. On map[string]struct
documents with many keys this is a real saving: tagging the GeoSciences gsoc_2018
corpus's root map cut its allocations ~21%. (Only slice and map roots take the
directive; a struct root uses per-field nocopy tags.)
The UnmarshalJSON methods keep their exact name (the json.Unmarshaler
interface requires it). The unexported decoder helpers they call are named
lightning<ImportPath><Type>decode… — a prefix derived from the package's import
path and the top-level type — so generating decoders for several types into one
package never produces colliding helper names. No annotation is needed; the
prefix is automatic.
When you only need a few values out of a document and don't want to generate (or
decode into) a struct, the pkg/json package exposes the scanner's
key-lookup primitives. They walk the input with the same allocation-free
Skip/ReadKey machinery the generated decoders use — non-target objects and
arrays are jumped with the SIMD whole-container skip, so
reaching a key behind many siblings in a large document stays fast — and every
value they return aliases the input []byte (keep it unchanged while the result
is in use). A returned value follows the same conventions throughout: a string keeps
its surrounding quotes with escapes intact, an object or array spans the whole
{…} or […], and a scalar is the literal token.
Get(data []byte, keys ...string) ([]byte, int, error)— walks the object-key pathkeysone level per key and returns the value's raw bytes (and the offset it starts at), without reporting a value type. With no keys it returns the whole root value; a missing key returnsErrKeyNotFound.Lookup(data []byte, keys ...string) ([]byte, error)—Getwithout the offset return, for the common read-only case where the value's position indataisn't needed (useGetwhen it is, e.g. to splice the value back in).GetMany(data []byte, keys []string, out [][]byte) ([][]byte, error)— looks up several top-level keys in a single pass over the object, where N separateGetcalls would rescan it N times. Results are written intoout[:0](pass a slice to reuse across calls, allocation-free; anilreuses nothing) and returned withlen == len(keys):out[n]is the value forkeys[n], ornilif that key is absent. A missing key is reported by thenilslot, not an error (a present key whose value is JSONnullyields the bytes"null", distinct from absent); a non-object root or malformed JSON returns an error.GetPaths(data []byte, paths [][]string, out [][]byte) ([][]byte, error)— the multi-path form ofGet(asGetManyis its multi-key form): eachpaths[n]is a nested key path andout[n]receives the value there. The document is walked once and paths that share a prefix share that descent, so pulling several nested fields — especially under a common parent — costs one traversal rather than oneGetper field (≈40–50% faster on a record with a handful of nested paths). Same result conventions asGetMany; anil/empty path selects the root.ObjectEach(data []byte, fn func(key string, value []byte) error, keys ...string) error— callsfnfor every member of the object reached by the pathkeys(the root object with no keys). Iffnreturns an error, iteration stops and returns it.
// Pull a few fields out of a log record in one pass, reusing a scratch slice.
keys := []string{"ClientIP", "EdgeResponseStatus", "RayID"}
vals, err := json.GetMany(data, keys, scratch[:0])
// vals[0] == []byte(`"203.0.113.23"`), vals[1] == []byte("599"), …Each function has a compact counterpart — GetCompact, LookupCompact,
GetManyCompact, GetPathsCompact, ObjectEachCompact — with the identical
signature and result. Like the
//lightning:compact directive, they assume the input has
no whitespace between tokens (the form encoding/json's Marshal and most
wire protocols emit) and skip the inter-token SkipWS scans, running about 10%
faster; whitespace surrounding the whole document is still tolerated. Feed one
input that does contain inter-token whitespace and it may return an error, so use
them only for sources guaranteed compact.
When a document's shape isn't known ahead of time — too variable to model, or
genuinely arbitrary — DecodeAny reads the whole thing into the generic
representation encoding/json produces for interface{}: nil, bool,
float64, string, []any, and map[string]any. It is the dynamic
counterpart to a generated unmarshaler, using the same scanner but with no target
type.
DecodeAny(data []byte) (any, error)— decodes the single JSON value indata. Whitespace around the whole document is tolerated; trailing non-whitespace content after the value is an error. Unlike the key-lookup helpers it builds Go values (so strings are unescaped and copied, numbers parsed), allocating the maps and slices the result needs.DecodeAnyCompact(data []byte) (any, error)— the same for compact input, skipping the inter-tokenSkipWSscans (asGetCompactdoes), faster on minified input; may error if the input does contain inter-token whitespace.
To check well-formedness without a target type, Valid(data []byte) bool reports
whether data is one well-formed JSON value (optionally whitespace-surrounded, no
trailing content). It matches encoding/json.Valid's strictness — rejecting
trailing commas, malformed numbers, and trailing bytes — by parsing with the same
scanner as DecodeAny and discarding the result (so it allocates the intermediate
values for a document containing objects or arrays; it deliberately does not use
the lenient bracket-balancing skip path, which would accept a trailing comma).
Errors returned by these helpers are the package's exported sentinels —
ErrKeyNotFound, ErrInvalidJSON, ErrTruncated, ErrExpectObject,
ErrExpectArray, ErrExpectColon — so callers can match them with errors.Is
without importing the internal pkg/unstable package.
The pkg/json package exposes the scanner's string codec on its
own, for when you have a JSON string body (the bytes between the quotes) and
just want to decode or encode it.
Unescaping (escaped body → decoded value):
UnescapeString(in []byte) (string, error)— decodes the escapes inin. Ifincontains no escapes the result aliasesinwith no copy; otherwise a new string is allocated.inis left unchanged.UnescapeStringInto(in, out []byte) (string, error)— same, but writes the decoded bytes intooutinstead of allocating. With no escapes the result aliasesin; otherwise it aliasesoutand allocates nothing whencap(out) >= len(in), since unescaping never lengthens a string. Passout == in(e.g.in[:0]) to decode truly in place, overwritingin.
Both return a string that aliases a buffer, so keep that buffer unchanged while the result is in use.
Escaping (raw value → escaped body, escaping ", \, and control bytes;
/ is left as-is and \b/\f are written in \u00XX form):
EscapeString(s []byte, out *strings.Builder)— writes the escaped form ofstoout. The common no-escape case is detected with a vectorized scan and written straight to the builder, with no scratch buffer or copy.EscapeStringInto(s, out []byte) []byte— appends the escaped form ofstooutand returns the extended slice, allocating nothing whenouthas room (escaping can grow a string up to 6× for control bytes). Passout[:0]to reuse a buffer across calls.
Clean runs are skipped eight bytes at a time (SWAR), so strings that need little
or no escaping cost roughly one memcpy.
The pkg/json package also exposes the scanner's float parser:
ParseFloat(b []byte) (float64, error)— parses the JSON number inbas afloat64. It takes the scanner's Clinger fast path first — when the mantissa is exact and the decimal exponent is small, the result is a single multiply or divide by a power of ten. Numbers that miss it (a mantissa ≥ 2^53 or a larger exponent, e.g. high-precision coordinates) take an Eisel-Lemire pass that converts the extracted mantissa and exponent with a 128-bit multiply, the same fast pathstrconv.ParseFloatuses internally but without re-scanning the digits; only the rare ambiguous or >19-digit cases fall back tostrconv.ParseFloat. The Eisel-Lemire result is bit-for-bit identical tostrconv(verified by a differential fuzz test).bmust be exactly one number with no surrounding whitespace; trailing bytes or an empty input return an error. Nothing is retained or copied.
The pkg/json package can also prune a JSON document in a single
pass, dropping object members whose value is a "default" — useful for shrinking
verbose, mostly-default records (Cloudflare HTTP logs, say) before storing or
forwarding them:
StripDefaults(input, output []byte, defaults, keep [][]byte, ws WhitespaceMode) []byte— copiesinputtooutput, dropping every object member whose value is a default and then dropping any object or array that this leaves empty. A value is a default when it is byte-equal to one ofdefaults, compared against the bare token — the unquoted contents for a string, the literal token for a number or keyword (e.g.[]byte("none"),[]byte("false")). Empty values are not special-cased: to drop empty strings (and other empty tokens) include an empty entry[]byte("")indefaults. A member is kept despite a default value when its unquoted key is byte-equal to one ofkeep(e.g.[]byte("WallTimeMs")). String values keep their surrounding quotes and escapes; scalars keep their literal token. (Empty{}/[]are always dropped, independent ofdefaults.)
output is filled from the front and the populated prefix is returned; input
is never modified. StripDefaults never lengthens the document, so output is grown
(allocated) only when cap(output) < len(input) — pass input[:0] to strip in
place, or a reused buffer to run allocation-free. It is best effort and copies
malformed input through unchanged.
ws chooses how inter-token whitespace is handled:
RemoveWhitespace(the zero value) — tolerate any whitespace; output is compact.AssumeCompact— assert the input has no inter-token whitespace and skip theSkipWSscans (faster, asGetCompactdoes); misreads spaced input.PreserveWhitespace— keep the input's whitespace around surviving content, so a pretty-printed document stays pretty-printed; only dropped members are removed.
// "" opts empty strings in; "0"/"none"/"false"/"unknown" are the non-empty defaults.
defaults := [][]byte{[]byte(""), []byte("0"), []byte("none"), []byte("false"), []byte("unknown")}
keep := [][]byte{[]byte("WallTimeMs")} // retained even when their value is a default
var scratch []byte
scratch = json.StripDefaults(record, scratch[:0], defaults, keep, json.AssumeCompact)
// {"a":0,"b":"x","e":"","WallTimeMs":0} -> {"b":"x","WallTimeMs":0}Matching is length-pre-filtered so a value or key longer than any candidate
skips the scan, and a kept member is moved with a single copy when its
"key":value span is contiguous in the input.
The pkg/json package can also splice values into a document in a
single pass, without a full unmarshal/edit/marshal round-trip:
Set(in, out, rawVal []byte, keys []string) []byte— returnsinwith the value at the object-key pathkeysreplaced by the raw JSONrawVal, written intoout. A path that doesn't exist is created: a missing member is inserted into its parent, and a missing intermediate object (or a non-object found where the path must still descend) is built up as nested objects. With no keys the whole document becomesrawVal.SetMany(in, out []byte, rawVal [][]byte, keys []string) []byte— sets several of the root object's own keys at once:keys[i]'s value becomesrawVal[i], replacing it where the key exists and appending a member where it doesn't.SetManyis toSetwhatGetManyis toGet— one walk over the object instead of rescanning and rewriting it once per key. A non-object root is replaced by a fresh object of all the members; arawValshorter thankeysignores the surplus keys.SetPaths(in, out []byte, rawVal [][]byte, paths [][]string) []byte— the multi-path form ofSet(the write counterpart ofGetPaths): eachpaths[i]is a nested key path set torawVal[i], replaced if present or created (with any missing intermediate objects) if absent. Paths that share a prefix are edited and created together, so the document is rewritten once rather than once per path — a large win over sequentialSet, which re-reads and re-writes the whole document each call (≈2–3× faster, far fewer allocations). Anil/empty path replaces the whole document; when one path is a prefix of another the shorter wins.
// {"a":{"b":1}} -> {"a":{"b":1},"c":[true]}
out = json.Set(doc, out[:0], []byte("[true]"), []string{"c"})
// {"a":1,"b":2} -> {"a":1,"b":9,"c":3} (replace b, add c, one pass)
out = json.SetMany(doc, out[:0], [][]byte{[]byte("9"), []byte("3")}, []string{"b", "c"})
// {"a":{"b":1}} -> {"a":{"b":9,"c":8}} (replace a.b, create a.c, one pass)
out = json.SetPaths(doc, out[:0], [][]byte{[]byte("9"), []byte("8")},
[][]string{{"a", "b"}, {"a", "c"}})Each rawVal is inserted verbatim and must be one well-formed JSON value; any
keys created along the way are written as plain JSON strings (no escaping, so
avoid keys needing it). out is filled from out[:0] and returned — pass a
reusable buffer to avoid allocation; out must not alias in, which is never
modified. Inter-token whitespace in in is preserved outside the edited spans.
Several hot scan loops use a single vectorized pass instead of byte-at-a-time
work, with kernels in pkg/unstable/simd_{amd64,arm64}.s and
pkg/unstable/skipfast_{amd64,arm64}.s (arm64 uses NEON/ASIMD, amd64 SSE2/AVX2):
- next
"or\in a string — replaces twobytes.IndexBytescans; speeds up string-heavy payloads. On amd64 it uses SSE2 (16-byte vectors, two compares per 32-byte step), which avoids theVZEROUPPERan AVX2 routine must run on every call — pure overhead for the short keys and values typical of JSON. - next structural byte (
{,},[,],") — AVX2 on amd64, 32 bytes/pass, letsskipObject/skipArrayjump over inert content (numbers, keys, whitespace) when skipping unknown values. Skipping a large ignored array/object is dramatically faster (theskip-heavybenchmark decodes at50 GB/s, ~230×
encoding/json). - whole-container skip — when an object or array must be skipped (a non-target
value during a
Get, an unknown struct field, a slice element counted for presizing),SkipValuereads 32 bytes at a time, builds an inside-string bitmask so{/}/[/]/"within string values are ignored, and balances brackets to the matching close — absorbing string contents into the bulk scan instead of a separate scan per string. This makesGeton a document where the wanted key sits behind many nested-object siblings about 2× faster. Objects and arrays of objects/strings take this path; a flat scalar array ([1,2,…]) keeps the structural-byte scan above, which already reaches the close in one pass.
The string scanner is used unconditionally on amd64 and arm64, relying only on each architecture's baseline, mandatory vector ISA — no runtime gate:
- amd64 requires SSE2. SSE2 is part of the AMD64 ISA, so every 64-bit x86 CPU has it (Go itself requires it); the string scanner uses it directly.
- arm64 requires NEON / Advanced SIMD (ASIMD). ASIMD is mandatory in the ARMv8-A baseline that Go targets, so every arm64 CPU has it; the string scanner uses it directly. (This unconditional call is also what lets the dispatch inline into its callers.)
The only optional feature is AVX2 (amd64), used for the structural-byte
scanner and the whole-container skip, and runtime-detected via
golang.org/x/sys/cpu; without it the structural scan falls back to scalar code
and SkipValue stays on the structural-byte skip. On arm64 both use NEON
directly. Inputs shorter than the vector width, and platforms other than
amd64/arm64, also take the scalar (bytes.IndexByte-based) path. Behavior is
identical across paths — verified by fuzzing each primitive against a reference
and by SkipValue/decode round-trips vs encoding/json, on amd64 and on arm64
under qemu (the arm64 container-skip kernel is correctness-verified there, but its
speed on real hardware is unmeasured).
The bench/ directory is a separate module (so its benchmark-only
dependencies on easyjson and
sonic stay out of the main module). It
benchmarks the same payload decoded four ways — lightning, encoding/json,
easyjson, and bytedance/sonic — across each bench/<case>/ folder.
See the per-architecture results for the full, current numbers:
bench/results_amd64.md and
bench/results_arm64.md.
Run them yourself with:
./bench/run_bench.shwhich (re)generates each case's deserializers and writes bench/results.txt
and an architecture-specific bench/results_<goarch>.md (so runs on different
CPUs do not overwrite each other's committed results).
Representative numbers for a 1.8 KB Cloudflare log (Go 1.26, amd64):
| Decoder | ns/op | B/op | allocs/op | vs stdlib |
|---|---|---|---|---|
lightning (nocopy) |
~660 | 0 | 0 | ~13× |
| lightning (default) | ~800 | 144 | 10 | ~10× |
| easyjson | ~1600–1770 | 24–144 | 1–10 | ~5× |
| sonic | ~4600 | 3380 | 40 | ~1.9× |
encoding/json |
~8250 | 920 | 17 | 1.0× |
| Path | Description |
|---|---|
main.go |
the generator (package main) |
pkg/unstable |
the (unstable, do-not-import) runtime the generated decoders call into |
pkg/json |
small public API over the scanner (Get/Lookup/GetMany/GetPaths/ObjectEach, Valid, DecodeAny, UnescapeString, ParseFloat, StripDefaults, Set/SetMany/SetPaths) |
bench/ |
benchmark module: hand-written data.go + input.json per case, plus the generated decoders, harness, and results |
Generated files (*_unmarshal.go, bench/*/bench_test.go, bench/*/ej/, and
the bench/results.* outputs) are reproducible and excluded from version
control via .gitignore.
Several of the hot-path techniques are borrowed from prior art:
- simdjson (Geoff Langdale and Daniel
Lemire) and its Go port minio/simdjson-go —
the SWAR "parse four digits at once" bit trick used in the float and integer
scanners, the two-
VPSHUFBnibble-table classification thatindexStructuralAVX2uses to find structural bytes, and the branchless escaped-quote detection + quote-mask prefix-XOR that builds the inside-string bitmask for the whole-container skip. - sonic-rs (ByteDance/CloudWeGo) and
the JSONSki paper — the SIMD
container-skip used by
SkipValue/Get: balance a container's brackets over the inside-string bitmask, so string contents are absorbed into the bulk scan rather than re-scanned per string. (The arm64maskBlockmovemask uses the well-known byte-LSB gather,(x & 0x01…01) * 0x0102…80 >> 56.) - The float parser in
scanFloatlayers two published algorithms: William Clinger's fast path for exactly-representable values, then the Eisel–Lemire algorithm (Michael Eisel and Daniel Lemire) for the rest — bit-for-bit identical tostrconv.ParseFloaton the values it accepts. - The benchmark corpus draws on minio/simdjson-go and go-json-experiment/jsonbench test data, and the comparison suite measures against encoding/json, mailru/easyjson, bytedance/sonic, goccy/go-json, and go-json-experiment/json (json/v2).