WARNING
DO NOT MERGE! DO NOT MERGE! RESEARCH ONLY.
Three layers. The tryConcreteValue switch now covers the full primitive cross product (207 map + 14 slice one-line MapValueT/ListValueT cases), closing gaps where bool- and []byte-valued maps fell to reflection. Named outer container types are shed in the reflection fallback with an O(1) reflect.Convert back into the typed switch. Everything else packs via new reflectMapValue/reflectListValue, which copy entries into construction-time scratch cells (SetIterKey/SetIterValue, reusable MapIter) and read scalars kind-by-kind: zero per-entry allocations, benchmarked at 2.2x/1.8x the typed path per 100 entries versus the old fallback's two boxing allocations per entry.
Also fixed in the fallback: named scalar (bool/float kind) values panicked; canonical expression packs sorted named keys incorrectly (entries now unwrap to base kinds before canonicalKeyRank); GetObject returns the original typed container instead of a materialized map[any]any. A reflectMapValue instance must not be shared by concurrent commands (scratch state); reflectListValue is stateless.
The exact story on the residual constants, since "never allocate" deserves precision:
Old system: O(N) allocations — ~3 per map entry, ~2 per list element (every key and value boxed into any, plus the materialized container). That's the reflection tax you wanted gone, and it's gone. New named-map constant (4 allocs, 232 B): the reflectMapValue struct, its map iterator, and the two scratch cells — all at construction, independent of size. Packing itself is zero. New list constant (2 allocs, 48 B) — including typed lists: this one is physics, not implementation. A slice header is three words, so passing it into NewValue(v any) boxes it once at the call site, and boxing the resulting Value interface costs the second. Maps are single-pointer values, which is why the typed map row achieves a literal zero end-to-end. A caller who wants true zero for slices bypasses the any parameter: NewListValueT(l) constructs without either box. So at n=1000 named maps went from ~137μs / 3,007 allocs / 143 KB garbage per operation to ~41μs / 4 allocs / 232 B — and the allocation count no longer scales with the data at all.
NewValue: old reflection fallback vs. new zero-alloc system
End-to-end benchmarks: NewValue() construction inside the timed loop,
followed by one pack() per iteration. The "old" rows run a verbatim replica
of the previous value_reflect.go fallback (materialize into map[any]any /
[]any, boxing every entry via reflection). Sizes are entry/element counts.
Go 1.x, darwin/arm64, -benchtime 20000x.
Maps with named key/value types (reflection fallback path)
| size | old ns/op | new ns/op | speedup | old allocs/op | new allocs/op | old B/op | new B/op |
|---|---|---|---|---|---|---|---|
| 1 | 332 | 212 | 1.6× | 6 | 4 | 400 | 232 |
| 10 | 1,546 | 558 | 2.8× | 35 | 4 | 1,304 | 232 |
| 100 | 13,340 | 3,631 | 3.7× | 305 | 4 | 11,640 | 232 |
| 1000 | 136,926 | 40,678 | 3.4× | 3,007 | 4 | 146,626 | 232 |
Lists with named element types (reflection fallback path)
| size | old ns/op | new ns/op | speedup | old allocs/op | new allocs/op | old B/op | new B/op |
|---|---|---|---|---|---|---|---|
| 1 | 95 | 79 | 1.2× | 4 | 2 | 72 | 48 |
| 10 | 525 | 147 | 3.6× | 22 | 2 | 360 | 48 |
| 100 | 4,329 | 965 | 4.5× | 202 | 2 | 3,432 | 48 |
| 1000 | 42,409 | 8,995 | 4.7× | 2,002 | 2 | 32,424 | 48 |
Unnamed primitive shapes (typed switch path, new system only)
| size | map ns/op | map allocs/op | list ns/op | list allocs/op |
|---|---|---|---|---|
| 1 | 54 | 0 | 53 | 2 |
| 10 | 205 | 0 | 96 | 2 |
| 100 | 1,568 | 0 | 512 | 2 |
| 1000 | 21,478 | 0 | 4,903 | 2 |
Notes on the residual constants
- Old system: O(N) allocations — ~3 per map entry, ~2 per list element
(every key and value boxed into
any, plus the materialized container). - New named-map constant (4 allocs, 232 B): the
reflectMapValuestruct, its map iterator, and two scratch cells — all paid once at construction, independent of size. The pack loop itself allocates nothing. - New list constant (2 allocs, 48 B), including typed lists: inherent to
the
NewValue(v any)signature — a slice header is three words, so the argument boxes once at the call site and the returnedValueinterface boxes once more. Maps are single-pointer values, which is why the typed map row is a literal zero end-to-end. Callers wanting true zero for slices can constructNewListValueT(l)directly and skip both boxes.
Bottom line: at 1000 entries, named maps went from ~137 µs / 3,007 allocs / 143 KB of garbage per operation to ~41 µs / 4 allocs / 232 B — allocation count no longer scales with the data.