perf(decode): resolve interfaces through cached type flags

This commit is contained in:
2026-09-20 22:15:23 +02:00
parent b4d564c682
commit 3a4bd74bf0
2 changed files with 118 additions and 12 deletions
+114 -12
View File
@@ -10,6 +10,7 @@ import (
"slices"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -22,6 +23,97 @@ func newDecoder() *decoder { return &decoder{} }
var timeType = reflect.TypeFor[time.Time]()
var (
unmarshalerType = reflect.TypeFor[Unmarshaler]()
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
)
// The per-type flags record which interface lookups a decode into that type
// can succeed at, so the hot path consults the cache instead of boxing every
// value into an interface to ask. The bits name the receiver the method is
// found on: the value itself, or its address.
const (
flagUnmarshaler uint8 = 1 << iota
flagAddrUnmarshaler
flagTextUnmarshaler
flagAddrTextUnmarshaler
)
// typeFlagCache holds one flag entry per destination type. A set is immutable
// once published, the same trade-off structSchemaCache makes; the cache grows
// with the number of distinct types decoded, never per document. The hint
// below re-points at these published entries, so a hot lookup allocates
// nothing.
var typeFlagCache sync.Map // reflect.Type -> *flagHintEntry
// flagHintEntry pairs a type with its cached flags for the monomorphic hint
// below. Both caches share the entry shape.
type flagHintEntry struct {
typ reflect.Type
flags uint8
}
// typeFlagHint remembers the entry resolved last, because a decode walks one
// type across consecutive fields and elements. A lost race loses only the
// hint: every value it can hold came from the cache.
var typeFlagHint atomic.Pointer[flagHintEntry]
func typeFlags(t reflect.Type) uint8 {
if e := typeFlagHint.Load(); e != nil && e.typ == t {
return e.flags
}
if v, ok := typeFlagCache.Load(t); ok {
entry := v.(*flagHintEntry)
typeFlagHint.Store(entry)
return entry.flags
}
var f uint8
if t.Implements(unmarshalerType) {
f |= flagUnmarshaler
}
pt := reflect.PointerTo(t)
if pt.Implements(unmarshalerType) {
f |= flagAddrUnmarshaler
}
// The date-time types are excluded from the text path: they carry
// time.Time's UnmarshalText through an embedded field while their only
// accepted form is a bare timestamp.
if !isDateTimeType(t) {
if t.Implements(textUnmarshalerType) {
f |= flagTextUnmarshaler
}
if pt.Implements(textUnmarshalerType) {
f |= flagAddrTextUnmarshaler
}
}
actual, _ := typeFlagCache.LoadOrStore(t, &flagHintEntry{t, f})
published := actual.(*flagHintEntry)
typeFlagHint.Store(published)
return published.flags
}
// unmarshalerOf resolves the Unmarshaler for dst through the flag cache, so
// an interface value is built only where the cache says the assertion can
// succeed. An interface destination is asked dynamically, because the value
// it will hold may implement the interface even when the interface type
// itself does not.
func unmarshalerOf(dst reflect.Value) (Unmarshaler, bool) {
if dst.Kind() == reflect.Interface {
u, ok := dst.Interface().(Unmarshaler)
return u, ok
}
f := typeFlags(dst.Type())
if f&flagUnmarshaler != 0 {
u, ok := dst.Interface().(Unmarshaler)
return u, ok
}
if f&flagAddrUnmarshaler != 0 && dst.CanAddr() {
u, ok := dst.Addr().Interface().(Unmarshaler)
return u, ok
}
return nil, false
}
func (d *decoder) decode(tree map[string]any, v any) error {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Pointer || rv.IsNil() {
@@ -109,22 +201,24 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
}
}
// textUnmarshalerOf finds the encoding.TextUnmarshaler for dst: on the value
// itself, or on its address, so a pointer-receiver UnmarshalText is invoked on
// an addressable struct field. The TOML date-time types are excluded, because
// they carry time.Time's UnmarshalText through an embedded field while their
// only accepted form is a bare timestamp.
// textUnmarshalerOf is the same resolution for encoding.TextUnmarshaler,
// with the date-time types excluded for the reason typeFlags records.
func textUnmarshalerOf(dst reflect.Value) (encoding.TextUnmarshaler, bool) {
if !dst.CanInterface() || isDateTimeType(dst.Type()) {
return nil, false
}
if u, ok := dst.Interface().(encoding.TextUnmarshaler); ok {
return u, true
if dst.Kind() == reflect.Interface {
tu, ok := dst.Interface().(encoding.TextUnmarshaler)
return tu, ok
}
if dst.CanAddr() {
if u, ok := dst.Addr().Interface().(encoding.TextUnmarshaler); ok {
return u, true
}
f := typeFlags(dst.Type())
if f&flagTextUnmarshaler != 0 {
tu, ok := dst.Interface().(encoding.TextUnmarshaler)
return tu, ok
}
if f&flagAddrTextUnmarshaler != 0 && dst.CanAddr() {
tu, ok := dst.Addr().Interface().(encoding.TextUnmarshaler)
return tu, ok
}
return nil, false
}
@@ -147,6 +241,9 @@ func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
// deterministically: the smallest one.
unknown := ""
for key := range tbl {
if _, ok := schema.byName[key]; ok {
continue
}
if _, ok := schema.byName[strings.ToLower(key)]; ok {
continue
}
@@ -159,7 +256,12 @@ func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
}
}
for key, val := range tbl {
field, ok := schema.byName[strings.ToLower(key)]
// A key that is already lowercase, which document keys usually are,
// hits the map directly; only a miss pays for the case fold.
field, ok := schema.byName[key]
if !ok {
field, ok = schema.byName[strings.ToLower(key)]
}
if !ok {
if schema.embedMaps != nil {
// Leftover keys land in an untagged embedded map, the inverse