mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-18 00:45:47 +00:00
* Lazy per-record hashing: ~15-30% faster on common workloads Records (NewMlrmapAsRecord) eagerly allocated and populated a map[string]*MlrmapEntry on construction whenever hashRecords was true (the default). For streaming verbs that never look records up by key (e.g. `mlr cat`) that map is pure overhead: a heap allocation plus N map-inserts per record, and N more pointer-heavy objects for the GC to scan. Profiling 1M-record CSV shows runtime allocation/GC machinery dominating every workload, and `--no-hash-records` was 25-30% faster -- but that flag makes wide-record lookups O(n), the regression that motivated hashing in #1506. Make record hashing lazy instead: allocate no index up front; build it in findEntry on the first lookup, and only when the record is wide enough (FieldCount >= mlrmapHashThreshold) that linear search would hurt. Narrow records and never-looked-up records never pay for a map; wide records that are actually queried still get hash-accelerated lookups, matching the old eager-hash default. DSL maps (NewMlrmap) keep eager hashing to limit the behavioral surface. This is transparent: findEntry already fell back to linear scan when keysToEntries was nil, and every mutator already guarded on keysToEntries != nil. Measured (big.csv, 1M x 7 cols, default flags, best of 3): cat 0.62 -> 0.47 (~24%) put 1.08 -> 0.82 (~24%) stats1 0.66 -> 0.57 (~14%) sort 2.9 -> 2.0 (~30%) Wide-column case protected: 60-col file with field lookups, lazy (1.42s) matches old eager default (1.40s) and beats pure linear (1.55s). Verified: go test ./pkg/... and full regression suite pass; output is byte-identical to forced --hash-records for sort, stats1, cut, wide-column put, and duplicate-key dedupe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Batch-arena field allocation for line-based readers (approach B) Following the lazy-hashing commit, profiling showed the dominant remaining cost in read/write-bound workloads is allocation *operations* (not bytes): each input field allocated two heap objects -- an Mlrval (FromDeferredType) and an MlrmapEntry. For 1M x 7-field CSV that is ~13.4M of ~18.6M total allocations. Introduce mlrval.RecordArena, a per-batch slab allocator: a reader draws each field's entry and value from contiguous []MlrmapEntry / []Mlrval slabs, turning two allocations per field into roughly two per slab. The arena grows on demand, so the size hint need not be exact; on duplicate keys it mirrors PutReferenceMaybeDedupe semantics. findEntry/linkNewEntry already supported externally-constructed entries, so this is transparent. Wired into every line-based reader that builds records from deferred-type strings: CSV, CSV-lite, TSV, DKVP, NIDX, PPRINT, XTAB, DKVPX. (JSON values arrive already typed and are unaffected.) Readers with inline batch loops use a local arena; those that build records via a helper (DKVP/NIDX line splitter, XTAB stanza) hold the arena on the reader struct, reset per batch and also initialized in the constructor so direct/test callers never see nil. Measured (big.*, 1M records, default flags, cat, best of 3): csv 0.46 -> 0.27 (~41%) dkvp 0.75 -> 0.46 (~39%) nidx 1.92 -> 1.58 (~18%) (xtab ~flat: dominated by stanza parse/emit, not field allocation) For cat the allocation-object count drops from ~18.6M to ~4.85M and peak RSS from ~402MB to ~237MB (slabs are compact and freed as units). Alloc *bytes* are essentially unchanged -- confirming the cost was per-allocation overhead, not volume. Streaming and accumulating verbs (put/sort) are unchanged: their bottleneck is DSL-side allocation / heap scanning, not field construction. Verified: go test ./pkg/... and full regression suite pass; output is byte-identical across all formats (hashed and --no-hash-records). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
386 lines
10 KiB
Go
386 lines
10 KiB
Go
package input
|
|
|
|
// Multi-file cases:
|
|
//
|
|
// a,a a,b c d
|
|
// -- FILE1: -- FILE1: -- FILE1: -- FILE1:
|
|
// a,b,c a,b,c a,b,c a,b,c
|
|
// 1,2,3 1,2,3 1,2,3 1,2,3
|
|
// 4,5,6 4,5,6 4,5,6 4,5,6
|
|
// -- FILE2: -- FILE2:
|
|
// a,b,c d,e,f,g a,b,c d,e,f
|
|
// 7,8,9 3,4,5,6 7,8,9 3,4,5
|
|
// --OUTPUT: --OUTPUT: --OUTPUT: --OUTPUT:
|
|
// a,b,c a,b,c a,b,c a,b,c
|
|
// 1,2,3 1,2,3 1,2,3 1,2,3
|
|
// 4,5,6 4,5,6 4,5,6 4,5,6
|
|
// 7,8,9 7,8,9
|
|
// d,e,f,g d,e,f
|
|
// 3,4,5,6 3,4,5
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/johnkerl/miller/v6/pkg/cli"
|
|
"github.com/johnkerl/miller/v6/pkg/lib"
|
|
"github.com/johnkerl/miller/v6/pkg/mlrval"
|
|
"github.com/johnkerl/miller/v6/pkg/types"
|
|
)
|
|
|
|
// recordBatchGetterCSV points to either an explicit-CSV-header or
|
|
// implicit-CSV-header record-batch getter.
|
|
type recordBatchGetterCSV func(
|
|
reader *RecordReaderCSVLite,
|
|
linesChannel <-chan []string,
|
|
filename string,
|
|
context *types.Context,
|
|
errorChannel chan error,
|
|
) (
|
|
recordsAndContexts []*types.RecordAndContext,
|
|
eof bool,
|
|
)
|
|
|
|
type RecordReaderCSVLite struct {
|
|
readerOptions *cli.TReaderOptions
|
|
recordsPerBatch int64 // distinct from readerOptions.RecordsPerBatch for join/repl
|
|
|
|
fieldSplitter iFieldSplitter
|
|
recordBatchGetter recordBatchGetterCSV
|
|
|
|
inputLineNumber int64
|
|
headerStrings []string
|
|
|
|
useVoidRep bool
|
|
voidRep string // For pprint output, empty strings are mapped to "-"; this is for reading them back in
|
|
}
|
|
|
|
func NewRecordReaderCSVLite(
|
|
readerOptions *cli.TReaderOptions,
|
|
recordsPerBatch int64,
|
|
) (*RecordReaderCSVLite, error) {
|
|
reader := &RecordReaderCSVLite{
|
|
readerOptions: readerOptions,
|
|
recordsPerBatch: recordsPerBatch,
|
|
fieldSplitter: newFieldSplitter(readerOptions),
|
|
|
|
useVoidRep: false,
|
|
voidRep: "",
|
|
}
|
|
if reader.readerOptions.UseImplicitHeader {
|
|
reader.recordBatchGetter = getRecordBatchImplicitCSVHeader
|
|
} else {
|
|
reader.recordBatchGetter = getRecordBatchExplicitCSVHeader
|
|
}
|
|
return reader, nil
|
|
}
|
|
|
|
func (reader *RecordReaderCSVLite) Read(
|
|
filenames []string,
|
|
context types.Context,
|
|
readerChannel chan<- []*types.RecordAndContext, // list of *types.RecordAndContext
|
|
errorChannel chan error,
|
|
downstreamDoneChannel <-chan bool, // for mlr head
|
|
) {
|
|
if filenames != nil { // nil for mlr -n
|
|
if len(filenames) == 0 { // read from stdin
|
|
handle, err := lib.OpenStdin(
|
|
reader.readerOptions.Prepipe,
|
|
reader.readerOptions.PrepipeIsRaw,
|
|
reader.readerOptions.FileInputEncoding,
|
|
)
|
|
if err != nil {
|
|
errorChannel <- err
|
|
} else {
|
|
reader.processHandle(
|
|
handle,
|
|
"(stdin)",
|
|
&context,
|
|
readerChannel,
|
|
errorChannel,
|
|
downstreamDoneChannel,
|
|
)
|
|
}
|
|
} else {
|
|
for _, filename := range filenames {
|
|
handle, err := lib.OpenFileForRead(
|
|
filename,
|
|
reader.readerOptions.Prepipe,
|
|
reader.readerOptions.PrepipeIsRaw,
|
|
reader.readerOptions.FileInputEncoding,
|
|
)
|
|
if err != nil {
|
|
errorChannel <- err
|
|
} else {
|
|
reader.processHandle(
|
|
handle,
|
|
filename,
|
|
&context,
|
|
readerChannel,
|
|
errorChannel,
|
|
downstreamDoneChannel,
|
|
)
|
|
handle.Close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
readerChannel <- types.NewEndOfStreamMarkerList(&context)
|
|
}
|
|
|
|
func (reader *RecordReaderCSVLite) processHandle(
|
|
handle io.Reader,
|
|
filename string,
|
|
context *types.Context,
|
|
readerChannel chan<- []*types.RecordAndContext, // list of *types.RecordAndContext
|
|
errorChannel chan error,
|
|
downstreamDoneChannel <-chan bool, // for mlr head
|
|
) {
|
|
context.UpdateForStartOfFile(filename)
|
|
reader.inputLineNumber = 0
|
|
reader.headerStrings = nil
|
|
|
|
recordsPerBatch := reader.recordsPerBatch
|
|
lineReader := NewLineReader(handle, reader.readerOptions.IRS)
|
|
linesChannel := make(chan []string, recordsPerBatch)
|
|
go channelizedLineReader(lineReader, linesChannel, downstreamDoneChannel, recordsPerBatch)
|
|
|
|
for {
|
|
recordsAndContexts, eof := reader.recordBatchGetter(reader, linesChannel, filename, context, errorChannel)
|
|
if len(recordsAndContexts) > 0 {
|
|
readerChannel <- recordsAndContexts
|
|
}
|
|
if eof {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func getRecordBatchExplicitCSVHeader(
|
|
reader *RecordReaderCSVLite,
|
|
linesChannel <-chan []string,
|
|
filename string,
|
|
context *types.Context,
|
|
errorChannel chan error,
|
|
) (
|
|
recordsAndContexts []*types.RecordAndContext,
|
|
eof bool,
|
|
) {
|
|
recordsAndContexts = []*types.RecordAndContext{}
|
|
dedupeFieldNames := reader.readerOptions.DedupeFieldNames
|
|
|
|
lines, more := <-linesChannel
|
|
if !more {
|
|
return recordsAndContexts, true
|
|
}
|
|
|
|
arena := mlrval.NewRecordArena(len(lines) * 8)
|
|
|
|
for _, line := range lines {
|
|
|
|
reader.inputLineNumber++
|
|
|
|
// Strip CSV BOM
|
|
if reader.inputLineNumber == 1 {
|
|
if strings.HasPrefix(line, CSV_BOM) {
|
|
line = strings.Replace(line, CSV_BOM, "", 1)
|
|
}
|
|
}
|
|
|
|
// Check for comments-in-data feature
|
|
// TODO: function-pointer this away
|
|
if reader.readerOptions.CommentHandling != cli.CommentsAreData {
|
|
if strings.HasPrefix(line, reader.readerOptions.CommentString) {
|
|
if reader.readerOptions.CommentHandling == cli.PassComments {
|
|
recordsAndContexts = append(recordsAndContexts, types.NewOutputString(line+"\n", context))
|
|
continue
|
|
} else if reader.readerOptions.CommentHandling == cli.SkipComments {
|
|
continue
|
|
}
|
|
// else comments are data
|
|
}
|
|
}
|
|
|
|
if line == "" {
|
|
// Reset to new schema
|
|
reader.headerStrings = nil
|
|
continue
|
|
}
|
|
|
|
fields := reader.fieldSplitter.Split(line)
|
|
|
|
if reader.headerStrings == nil {
|
|
reader.headerStrings = fields
|
|
// Get data lines on subsequent loop iterations
|
|
} else {
|
|
if !reader.readerOptions.AllowRaggedCSVInput && len(reader.headerStrings) != len(fields) {
|
|
err := fmt.Errorf(
|
|
"CSV header/data length mismatch %d != %d at filename %s line %d",
|
|
len(reader.headerStrings), len(fields), filename, reader.inputLineNumber,
|
|
)
|
|
errorChannel <- err
|
|
return
|
|
}
|
|
|
|
record := mlrval.NewMlrmapAsRecord()
|
|
if !reader.readerOptions.AllowRaggedCSVInput {
|
|
for i, field := range fields {
|
|
if reader.useVoidRep && field == reader.voidRep {
|
|
field = ""
|
|
}
|
|
arena.PutDeferred(record, reader.headerStrings[i], field, dedupeFieldNames)
|
|
}
|
|
} else {
|
|
nh := int64(len(reader.headerStrings))
|
|
nd := int64(len(fields))
|
|
n := lib.IntMin2(nh, nd)
|
|
var i int64
|
|
for i = 0; i < n; i++ {
|
|
field := fields[i]
|
|
if reader.useVoidRep && field == reader.voidRep {
|
|
field = ""
|
|
}
|
|
arena.PutDeferred(record, reader.headerStrings[i], field, dedupeFieldNames)
|
|
}
|
|
if nh < nd {
|
|
// if header shorter than data: use 1-up itoa keys
|
|
for i = nh; i < nd; i++ {
|
|
key := strconv.FormatInt(i+1, 10)
|
|
arena.PutDeferred(record, key, fields[i], dedupeFieldNames)
|
|
}
|
|
}
|
|
if nh > nd {
|
|
// if header longer than data: use "" values
|
|
for i = nd; i < nh; i++ {
|
|
record.PutCopy(reader.headerStrings[i], mlrval.VOID)
|
|
}
|
|
}
|
|
}
|
|
|
|
context.UpdateForInputRecord()
|
|
recordsAndContexts = append(recordsAndContexts, types.NewRecordAndContext(record, context))
|
|
}
|
|
}
|
|
|
|
return recordsAndContexts, false
|
|
}
|
|
|
|
func getRecordBatchImplicitCSVHeader(
|
|
reader *RecordReaderCSVLite,
|
|
linesChannel <-chan []string,
|
|
filename string,
|
|
context *types.Context,
|
|
errorChannel chan error,
|
|
) (
|
|
recordsAndContexts []*types.RecordAndContext,
|
|
eof bool,
|
|
) {
|
|
recordsAndContexts = []*types.RecordAndContext{}
|
|
dedupeFieldNames := reader.readerOptions.DedupeFieldNames
|
|
|
|
lines, more := <-linesChannel
|
|
if !more {
|
|
return recordsAndContexts, true
|
|
}
|
|
|
|
arena := mlrval.NewRecordArena(len(lines) * 8)
|
|
|
|
for _, line := range lines {
|
|
|
|
reader.inputLineNumber++
|
|
|
|
// Check for comments-in-data feature
|
|
// TODO: function-pointer this away
|
|
if reader.readerOptions.CommentHandling != cli.CommentsAreData {
|
|
if strings.HasPrefix(line, reader.readerOptions.CommentString) {
|
|
if reader.readerOptions.CommentHandling == cli.PassComments {
|
|
recordsAndContexts = append(recordsAndContexts, types.NewOutputString(line+"\n", context))
|
|
continue
|
|
} else if reader.readerOptions.CommentHandling == cli.SkipComments {
|
|
continue
|
|
}
|
|
// else comments are data
|
|
}
|
|
}
|
|
|
|
// This is how to do a chomp:
|
|
line = strings.TrimRight(line, reader.readerOptions.IRS)
|
|
|
|
line = strings.TrimRight(line, "\r")
|
|
|
|
if line == "" {
|
|
// Reset to new schema
|
|
reader.headerStrings = nil
|
|
continue
|
|
}
|
|
|
|
fields := reader.fieldSplitter.Split(line)
|
|
|
|
if reader.headerStrings == nil {
|
|
n := len(fields)
|
|
reader.headerStrings = make([]string, n)
|
|
for i := range n {
|
|
reader.headerStrings[i] = strconv.Itoa(i + 1)
|
|
}
|
|
} else {
|
|
if !reader.readerOptions.AllowRaggedCSVInput && len(reader.headerStrings) != len(fields) {
|
|
err := fmt.Errorf(
|
|
"CSV header/data length mismatch %d != %d at filename %s line %d",
|
|
len(reader.headerStrings), len(fields), filename, reader.inputLineNumber,
|
|
)
|
|
errorChannel <- err
|
|
return
|
|
}
|
|
}
|
|
|
|
record := mlrval.NewMlrmapAsRecord()
|
|
if !reader.readerOptions.AllowRaggedCSVInput {
|
|
for i, field := range fields {
|
|
if reader.useVoidRep && field == reader.voidRep {
|
|
field = ""
|
|
}
|
|
arena.PutDeferred(record, reader.headerStrings[i], field, dedupeFieldNames)
|
|
}
|
|
} else {
|
|
nh := int64(len(reader.headerStrings))
|
|
nd := int64(len(fields))
|
|
n := lib.IntMin2(nh, nd)
|
|
var i int64
|
|
for i = 0; i < n; i++ {
|
|
field := fields[i]
|
|
if reader.useVoidRep && field == reader.voidRep {
|
|
field = ""
|
|
}
|
|
arena.PutDeferred(record, reader.headerStrings[i], field, dedupeFieldNames)
|
|
}
|
|
if nh < nd {
|
|
// if header shorter than data: use 1-up itoa keys
|
|
for i = nh; i < nd; i++ {
|
|
field := fields[i]
|
|
if reader.useVoidRep && field == reader.voidRep {
|
|
field = ""
|
|
}
|
|
key := strconv.FormatInt(i+1, 10)
|
|
arena.PutDeferred(record, key, field, dedupeFieldNames)
|
|
}
|
|
}
|
|
if nh > nd {
|
|
// if header longer than data: use "" values
|
|
for i = nd; i < nh; i++ {
|
|
_, err := record.PutReferenceMaybeDedupe(reader.headerStrings[i], mlrval.VOID.Copy(), dedupeFieldNames)
|
|
if err != nil {
|
|
errorChannel <- err
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
context.UpdateForInputRecord()
|
|
recordsAndContexts = append(recordsAndContexts, types.NewRecordAndContext(record, context))
|
|
}
|
|
|
|
return recordsAndContexts, false
|
|
}
|