mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-17 16:38:54 +00:00
Lazy per-record hashing: ~15-30% faster on common workloads (#2081)
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>
This commit is contained in:
parent
24cbded682
commit
fcff967c32
2 changed files with 69 additions and 3 deletions
|
|
@ -63,13 +63,34 @@ func HashRecords(onOff bool) {
|
|||
hashRecords = onOff
|
||||
}
|
||||
|
||||
// mlrmapHashThreshold is the field-count at or above which a lazily-hashable
|
||||
// record builds its key-to-entry index on first lookup. Below this, linear
|
||||
// search through the (short) linked list is cheaper than allocating and
|
||||
// populating a map -- and, crucially, records that are never looked up (e.g.
|
||||
// `mlr cat`) never pay for a map at all. Wide records that do get looked up
|
||||
// still get hash-accelerated access, preserving the fix for
|
||||
// https://github.com/johnkerl/miller/issues/1506.
|
||||
const mlrmapHashThreshold = 12
|
||||
|
||||
type Mlrmap struct {
|
||||
FieldCount int64
|
||||
Head *MlrmapEntry
|
||||
Tail *MlrmapEntry
|
||||
|
||||
// This can be nil if hashRecords is off.
|
||||
// keysToEntries is the key-to-entry index for hash-accelerated lookups.
|
||||
// It can be nil in three situations:
|
||||
// - hashing is disabled entirely (`mlr --no-hash-records`), in which
|
||||
// case autoHash is false and the index is never built;
|
||||
// - the map is lazily hashable (autoHash true) but no lookup has yet
|
||||
// triggered index construction, or the record is narrow enough that
|
||||
// linear search is preferred;
|
||||
// - the map is empty.
|
||||
keysToEntries map[string]*MlrmapEntry
|
||||
|
||||
// autoHash, when true, lets findEntry lazily build keysToEntries on the
|
||||
// first lookup of a sufficiently-wide record. It is false for explicitly
|
||||
// unhashed maps (`--no-hash-records`).
|
||||
autoHash bool
|
||||
}
|
||||
|
||||
type MlrmapEntry struct {
|
||||
|
|
@ -94,7 +115,7 @@ type MlrmapPair struct {
|
|||
|
||||
func NewMlrmapAsRecord() *Mlrmap {
|
||||
if hashRecords {
|
||||
return newMlrmapHashed()
|
||||
return newMlrmapLazyHashed()
|
||||
}
|
||||
return newMlrmapUnhashed()
|
||||
}
|
||||
|
|
@ -102,6 +123,22 @@ func NewMlrmap() *Mlrmap {
|
|||
return newMlrmapHashed()
|
||||
}
|
||||
|
||||
// newMlrmapLazyHashed is the default for record-stream data. It allocates no
|
||||
// key-to-entry index up front; findEntry builds one on demand only when a
|
||||
// lookup occurs on a wide record (see mlrmapHashThreshold). This avoids a map
|
||||
// allocation and N map-inserts per record for the common case of streaming
|
||||
// over many narrow records, while retaining hash-accelerated lookups for wide
|
||||
// records that are actually queried.
|
||||
func newMlrmapLazyHashed() *Mlrmap {
|
||||
return &Mlrmap{
|
||||
FieldCount: 0,
|
||||
Head: nil,
|
||||
Tail: nil,
|
||||
keysToEntries: nil,
|
||||
autoHash: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Faster on record-stream data as noted above.
|
||||
func newMlrmapUnhashed() *Mlrmap {
|
||||
return &Mlrmap{
|
||||
|
|
|
|||
|
|
@ -194,6 +194,14 @@ func (mlrmap *Mlrmap) findEntry(key string) *MlrmapEntry {
|
|||
if mlrmap.keysToEntries != nil {
|
||||
return mlrmap.keysToEntries[key]
|
||||
}
|
||||
// Lazily build the key-to-entry index when a lookup happens on a record
|
||||
// wide enough to benefit. Narrow records (the common case) and records
|
||||
// that are never looked up stay on the linear-search path and never pay
|
||||
// for a map. See mlrmapHashThreshold.
|
||||
if mlrmap.autoHash && mlrmap.FieldCount >= mlrmapHashThreshold {
|
||||
mlrmap.buildIndex()
|
||||
return mlrmap.keysToEntries[key]
|
||||
}
|
||||
for pe := mlrmap.Head; pe != nil; pe = pe.Next {
|
||||
if pe.Key == key {
|
||||
return pe
|
||||
|
|
@ -202,6 +210,20 @@ func (mlrmap *Mlrmap) findEntry(key string) *MlrmapEntry {
|
|||
return nil
|
||||
}
|
||||
|
||||
// buildIndex populates keysToEntries from the linked list. Once built, all
|
||||
// mutators keep it in sync (each guards on keysToEntries != nil). On duplicate
|
||||
// keys the last entry wins, matching the linear-search semantics of findEntry
|
||||
// (which returns the first match), since records are deduped on insert.
|
||||
func (mlrmap *Mlrmap) buildIndex() {
|
||||
m := make(map[string]*MlrmapEntry, mlrmap.FieldCount)
|
||||
for pe := mlrmap.Head; pe != nil; pe = pe.Next {
|
||||
if _, ok := m[pe.Key]; !ok {
|
||||
m[pe.Key] = pe
|
||||
}
|
||||
}
|
||||
mlrmap.keysToEntries = m
|
||||
}
|
||||
|
||||
// findEntryByPositionalIndex is for '$[1]' etc. in the DSL.
|
||||
//
|
||||
// Notes:
|
||||
|
|
@ -459,7 +481,14 @@ func (mlrmap *Mlrmap) Clear() {
|
|||
}
|
||||
|
||||
func (mlrmap *Mlrmap) Copy() *Mlrmap {
|
||||
other := NewMlrmapMaybeHashed(mlrmap.isHashed())
|
||||
var other *Mlrmap
|
||||
if mlrmap.autoHash {
|
||||
// Preserve lazy-hashing semantics: don't force an eager index on the
|
||||
// copy just because the source happens to have built one.
|
||||
other = newMlrmapLazyHashed()
|
||||
} else {
|
||||
other = NewMlrmapMaybeHashed(mlrmap.isHashed())
|
||||
}
|
||||
for pe := mlrmap.Head; pe != nil; pe = pe.Next {
|
||||
other.PutCopy(pe.Key, pe.Value)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue