mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-17 16:38:54 +00:00
After batch-arena field allocation, profiling cat over 1M-record CSV showed the remaining ~5M allocations were almost entirely per-record (one each): the Mlrmap struct, the RecordAndContext wrapper, the CSV writer's []string, and the go-csv parser's own buffers. Address the first three: - mlrval.RecordArena gains NewRecord(), vending the Mlrmap struct itself from a per-batch slab (respecting --no-hash-records). Rolled out to every line-based reader (CSV, CSV-lite, TSV, DKVP, NIDX, PPRINT, XTAB, DKVPX) in place of NewMlrmapAsRecord. - The CSV reader batch-allocates RecordAndContext wrappers from a per-batch slab instead of one heap object per record (comment/output-string entries still allocate individually, but they are rare). - RecordWriterCSV reuses a single fieldsBuffer []string across records instead of allocating one per Write; WriteCSVRecordMaybeColorized consumes it synchronously and the writer is single-goroutine, so this is safe. Effect (big.*, 1M records, cat, best of 5): csv 0.26 -> 0.22 dkvp 0.51 -> 0.45 (Mlrmap slab) For CSV, cat's allocation-object count drops ~5.0M -> ~2.1M. The remaining ~2M are the go-csv parser's per-record backing string and field slice, which are intrinsic to parsing and would require a zero-copy/batch-slab parser rework. A CPU profile of cat now shows it is I/O-bound (syscall ~56%, bufio read+flush), with allocation/GC down to ~10% -- i.e. further allocation trimming no longer moves cat's wall-clock. GOGC=off confirms (no change). Verified: go test ./pkg/... and full regression suite pass; output is byte-identical across all formats including record-retaining verbs (tac), hashed and --no-hash-records. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
119 lines
3.7 KiB
Go
119 lines
3.7 KiB
Go
package mlrval
|
|
|
|
import "strconv"
|
|
|
|
// arenaChunkFields is the slab size (in fields) used when a RecordArena needs
|
|
// to grow. It is large enough that a typical record batch draws from one or two
|
|
// slabs, amortizing allocation overhead across thousands of fields.
|
|
const arenaChunkFields = 4096
|
|
|
|
// RecordArena is a batch (slab) allocator for record fields. A record reader
|
|
// allocates one arena per batch of records and draws each field's MlrmapEntry
|
|
// and Mlrval from contiguous slabs, turning two heap allocations per field into
|
|
// roughly two allocations per slab.
|
|
//
|
|
// Lifetime: a slab stays alive as long as any entry or value drawn from it is
|
|
// reachable. For streaming verbs the whole batch is processed and released
|
|
// together, so its slabs are freed as units. For accumulating verbs (e.g. sort)
|
|
// the slabs are retained for the duration -- the same bytes as before, but as a
|
|
// few large objects rather than millions of tiny ones, which also lowers
|
|
// resident-set size via reduced fragmentation.
|
|
//
|
|
// The arena grows on demand (allocating a fresh slab when the current one is
|
|
// exhausted), so the size hint passed to NewRecordArena need not be exact: it
|
|
// only sizes the first slab.
|
|
type RecordArena struct {
|
|
entries []MlrmapEntry
|
|
values []Mlrval
|
|
ei int
|
|
vi int
|
|
chunk int
|
|
|
|
// records is a slab of Mlrmap structs vended by NewRecord, so the record
|
|
// container itself is batch-allocated alongside its fields.
|
|
records []Mlrmap
|
|
ri int
|
|
}
|
|
|
|
// arenaChunkRecords is the slab size (in records) for NewRecord. It tracks the
|
|
// nominal records-per-batch so a batch typically draws from one slab.
|
|
const arenaChunkRecords = 512
|
|
|
|
// NewRecordArena returns an arena whose first slabs are sized to nfieldsHint
|
|
// (clamped to a sane range). Subsequent slabs, if needed, use arenaChunkFields.
|
|
func NewRecordArena(nfieldsHint int) *RecordArena {
|
|
chunk := nfieldsHint
|
|
if chunk < 1 {
|
|
chunk = 1
|
|
}
|
|
if chunk > arenaChunkFields {
|
|
chunk = arenaChunkFields
|
|
}
|
|
return &RecordArena{chunk: chunk}
|
|
}
|
|
|
|
// NewRecord vends a fresh empty record (lazily-hashed, like NewMlrmapAsRecord)
|
|
// from the arena's record slab, batch-allocating the Mlrmap struct itself. It
|
|
// respects the global hashRecords setting: with --no-hash-records it returns an
|
|
// unhashed map, matching NewMlrmapAsRecord.
|
|
func (a *RecordArena) NewRecord() *Mlrmap {
|
|
if !hashRecords {
|
|
return newMlrmapUnhashed()
|
|
}
|
|
if a.ri >= len(a.records) {
|
|
a.records = make([]Mlrmap, arenaChunkRecords)
|
|
a.ri = 0
|
|
}
|
|
m := &a.records[a.ri]
|
|
a.ri++
|
|
m.autoHash = true
|
|
return m
|
|
}
|
|
|
|
// PutDeferred appends a field to mlrmap, drawing the entry and its deferred-type
|
|
// value from the arena slabs. It mirrors PutReferenceMaybeDedupe's semantics for
|
|
// duplicate keys. The value is built from the raw input string with type
|
|
// inference deferred (MT_PENDING), exactly as FromDeferredType does.
|
|
func (a *RecordArena) PutDeferred(mlrmap *Mlrmap, key string, input string, dedupe bool) {
|
|
pe := mlrmap.findEntry(key)
|
|
if pe == nil {
|
|
mlrmap.linkNewEntry(a.newEntry(key, input))
|
|
return
|
|
}
|
|
if !dedupe {
|
|
pe.Value = a.newValue(input)
|
|
return
|
|
}
|
|
for i := 2; ; i++ {
|
|
newKey := key + "_" + strconv.Itoa(i)
|
|
if mlrmap.findEntry(newKey) == nil {
|
|
mlrmap.linkNewEntry(a.newEntry(newKey, input))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *RecordArena) newValue(input string) *Mlrval {
|
|
if a.vi >= len(a.values) {
|
|
a.values = make([]Mlrval, a.chunk)
|
|
a.vi = 0
|
|
}
|
|
v := &a.values[a.vi]
|
|
a.vi++
|
|
v.mvtype = MT_PENDING
|
|
v.printrep = input
|
|
v.printrepValid = true
|
|
return v
|
|
}
|
|
|
|
func (a *RecordArena) newEntry(key string, input string) *MlrmapEntry {
|
|
if a.ei >= len(a.entries) {
|
|
a.entries = make([]MlrmapEntry, a.chunk)
|
|
a.ei = 0
|
|
}
|
|
e := &a.entries[a.ei]
|
|
a.ei++
|
|
e.Key = key
|
|
e.Value = a.newValue(input)
|
|
return e
|
|
}
|