#744

Pack any map or slice through NewValue without per-entry allocation

Openkhafwants to merge intofrom

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)

sizeold ns/opnew ns/opspeedupold allocs/opnew allocs/opold B/opnew B/op
13322121.6×64400232
101,5465582.8×3541,304232
10013,3403,6313.7×305411,640232
1000136,92640,6783.4×3,0074146,626232

Lists with named element types (reflection fallback path)

sizeold ns/opnew ns/opspeedupold allocs/opnew allocs/opold B/opnew B/op
195791.2×427248
105251473.6×22236048
1004,3299654.5×20223,43248
100042,4098,9954.7×2,002232,42448

Unnamed primitive shapes (typed switch path, new system only)

sizemap ns/opmap allocs/oplist ns/oplist allocs/op
1540532
102050962
1001,56805122
100021,47804,9032

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 reflectMapValue struct, 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 returned Value interface 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 construct NewListValueT(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.

Activity

4
dmishra-aeroreviewed2mo ago
dmishra-aeroreviewed2mo ago
dmishra-aeroapproved changes2mo ago