Skip to content

Commit decf2a1

Browse files
authored
refactor: enhance parsing efficiency with improved field and row estimation (#45)
* refactor: enhance parsing efficiency with improved field and row estimation * chore: fix lint error
1 parent 3c89b4f commit decf2a1

6 files changed

Lines changed: 174 additions & 5 deletions

File tree

field_parser.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,19 @@ func parseBuffer(buf []byte, sr *scanResult) *parseResult {
110110
// Check if pool capacity is sufficient, otherwise reallocate
111111
estimatedFields := len(buf) / avgFieldLenEstimate
112112
estimatedRows := len(buf) / avgRowLenEstimate
113+
if sr != nil {
114+
// Use delimiter counts from scan to size more accurately.
115+
if len(buf) > 0 {
116+
countFields := sr.separatorCount + sr.newlineCount + 1
117+
if countFields > estimatedFields {
118+
estimatedFields = countFields
119+
}
120+
countRows := sr.newlineCount + 1
121+
if countRows > estimatedRows {
122+
estimatedRows = countRows
123+
}
124+
}
125+
}
113126
if cap(result.fields) < estimatedFields {
114127
result.fields = make([]fieldInfo, 0, estimatedFields)
115128
}

parse.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ func ParseBytes(data []byte, comma rune) ([][]string, error) {
2222

2323
// Release parseResult back to pool
2424
releaseParseResult(pr)
25+
// Release scanResult back to pool
26+
releaseScanResult(sr)
2527

2628
return records, nil
2729
}
@@ -41,6 +43,7 @@ func ParseBytesStreaming(data []byte, comma rune, callback func([]string) error)
4143
// Parse: Extract fields and rows from scan result
4244
pr := parseBuffer(data, sr)
4345
defer releaseParseResult(pr)
46+
defer releaseScanResult(sr)
4447

4548
if pr == nil || len(pr.rows) == 0 {
4649
return nil

reader.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ type Reader struct {
6666
nonCommentRecordCount int // Count of non-comment records returned (for O(1) first record detection)
6767
initialized bool // Whether scan/parse have been run
6868
hasQuotes bool // True if input contains any quote characters (for fast path)
69+
chunkHasQuote []bool // Per-chunk quote presence (for validation fast path)
6970

7071
// Extended options (set via NewReaderWithOptions)
7172
skipBOM bool // Skip UTF-8 BOM if present
@@ -257,6 +258,17 @@ func (r *Reader) initialize() error {
257258

258259
// Copy hasQuotes flag for fast path optimization
259260
r.hasQuotes = r.scanResult.hasQuotes
261+
// Copy per-chunk quote presence for validation fast path
262+
if len(r.scanResult.chunkHasQuote) > 0 {
263+
if cap(r.chunkHasQuote) < len(r.scanResult.chunkHasQuote) {
264+
r.chunkHasQuote = make([]bool, len(r.scanResult.chunkHasQuote))
265+
} else {
266+
r.chunkHasQuote = r.chunkHasQuote[:len(r.scanResult.chunkHasQuote)]
267+
}
268+
copy(r.chunkHasQuote, r.scanResult.chunkHasQuote)
269+
} else {
270+
r.chunkHasQuote = nil
271+
}
260272

261273
// Parse: extract fields and rows from scan result
262274
// Note: parseBuffer already calls postProcessFields internally
@@ -278,6 +290,13 @@ func (r *Reader) initialize() error {
278290
// Because ReadAll is defined to read until EOF, it does not
279291
// treat end of file as an error to be reported.
280292
func (r *Reader) ReadAll() (records [][]string, err error) {
293+
if !r.initialized {
294+
if err := r.initialize(); err != nil {
295+
return nil, err
296+
}
297+
}
298+
// Defer allocation until we actually have a record
299+
// This ensures empty input returns nil (matching encoding/csv behavior)
281300
for {
282301
record, err := r.Read()
283302
if err == io.EOF {
@@ -286,6 +305,9 @@ func (r *Reader) ReadAll() (records [][]string, err error) {
286305
if err != nil {
287306
return records, err
288307
}
308+
if records == nil && r.parseResult != nil {
309+
records = make([][]string, 0, len(r.parseResult.rows))
310+
}
289311
records = append(records, record)
290312
}
291313
}
@@ -323,6 +345,7 @@ func (r *Reader) Release() {
323345
r.lastRecord = nil
324346
r.recordBuffer = nil
325347
r.fieldEnds = nil
348+
r.chunkHasQuote = nil
326349
}
327350

328351
// ReaderOptions contains extended configuration options for [Reader].

record_builder.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,11 @@ func (r *Reader) buildRecordWithValidation(row rowInfo, rowIdx int) ([]string, e
6868

6969
// Validate quotes unless LazyQuotes is enabled or no quotes exist in input
7070
if !r.LazyQuotes && r.hasQuotes {
71-
if err := r.validateFieldQuotes(rawStart, rawEnd, row.lineNum); err != nil {
72-
// Build partial record from accumulated content
73-
return r.buildPartialRecord(i), err
71+
if r.fieldMayContainQuote(rawStart, rawEnd) {
72+
if err := r.validateFieldQuotesWithField(field, rawStart, rawEnd, row.lineNum); err != nil {
73+
// Build partial record from accumulated content
74+
return r.buildPartialRecord(i), err
75+
}
7476
}
7577
}
7678

simd_scanner.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,18 @@ type scanResult struct {
9494
separatorMasks []uint64 // Separator masks per chunk
9595
newlineMasks []uint64 // Newline masks per chunk (CRLF normalized)
9696
chunkHasDQ []bool // Per-chunk flag: true if chunk contains escaped double quotes
97+
chunkHasQuote []bool // Per-chunk flag: true if chunk contains any quote
9798
hasQuotes bool // True if any quote characters exist in input
9899
finalQuoted uint64 // Final quote state
99100
chunkCount int // Number of processed chunks
100101
lastChunkBits int // Valid bits in last chunk (if < 64)
102+
separatorCount int // Total separators (after quote invalidation)
103+
newlineCount int // Total newlines (after quote invalidation)
101104
}
102105

103106
// scanResultPoolCapacity is the pre-allocated slice capacity for pooled scanResult objects.
104-
// 1024 chunks = ~64KB input (1024 * 64 bytes per chunk).
105-
const scanResultPoolCapacity = 1024
107+
// 64 chunks = ~4KB input (64 * 64 bytes per chunk) - small default, grows as needed.
108+
const scanResultPoolCapacity = 64
106109

107110
// scanResultPool provides reusable scanResult objects to reduce allocations.
108111
var scanResultPool = sync.Pool{
@@ -112,6 +115,7 @@ var scanResultPool = sync.Pool{
112115
separatorMasks: make([]uint64, 0, scanResultPoolCapacity),
113116
newlineMasks: make([]uint64, 0, scanResultPoolCapacity),
114117
chunkHasDQ: make([]bool, 0, scanResultPoolCapacity),
118+
chunkHasQuote: make([]bool, 0, scanResultPoolCapacity),
115119
}
116120
},
117121
}
@@ -124,10 +128,15 @@ func (sr *scanResult) reset() {
124128
if cap(sr.chunkHasDQ) > 0 {
125129
sr.chunkHasDQ = sr.chunkHasDQ[:0]
126130
}
131+
if cap(sr.chunkHasQuote) > 0 {
132+
sr.chunkHasQuote = sr.chunkHasQuote[:0]
133+
}
127134
sr.hasQuotes = false
128135
sr.finalQuoted = 0
129136
sr.chunkCount = 0
130137
sr.lastChunkBits = 0
138+
sr.separatorCount = 0
139+
sr.newlineCount = 0
131140
}
132141

133142
// releaseScanResult returns a scanResult to the pool for reuse.
@@ -170,6 +179,11 @@ func generateMasksAVX512(data []byte, separator byte) (quote, sep, cr, nl uint64
170179
crCmp := archsimd.BroadcastInt8x32('\r')
171180
nlCmp := archsimd.BroadcastInt8x32('\n')
172181

182+
return generateMasksAVX512WithCmp(data, quoteCmp, sepCmp, crCmp, nlCmp)
183+
}
184+
185+
// generateMasksAVX512WithCmp is an AVX-512 mask generator that reuses pre-broadcasted comparators.
186+
func generateMasksAVX512WithCmp(data []byte, quoteCmp, sepCmp, crCmp, nlCmp archsimd.Int8x32) (quote, sep, cr, nl uint64) {
173187
// Process low simdHalfChunk bytes (positions 0-31)
174188
// Precondition: data is at least simdChunkSize bytes (guaranteed by caller)
175189
low := archsimd.LoadInt8x32((*[simdHalfChunk]int8)(unsafe.Pointer(&data[0])))
@@ -350,6 +364,10 @@ func scanBuffer(buf []byte, separatorChar byte) *scanResult {
350364
result.reset()
351365
result.chunkCount = chunkCount
352366

367+
// NOTE: Pre-broadcasting AVX-512 comparators was attempted but removed because
368+
// declaring archsimd.Int8x32 variables causes Go to emit AVX zeroing instructions
369+
// even before the conditional check, causing SIGILL on CPUs without AVX support.
370+
353371
// Pre-size all mask slices to chunkCount for index-based assignment (avoids append overhead)
354372
// When capacity is insufficient, grow by 2x to reduce future reallocations
355373
if cap(result.quoteMasks) < chunkCount {
@@ -392,6 +410,18 @@ func scanBuffer(buf []byte, separatorChar byte) *scanResult {
392410
result.chunkHasDQ[i] = false
393411
}
394412
}
413+
if cap(result.chunkHasQuote) < chunkCount {
414+
newCap := chunkCount
415+
if newCap < cap(result.chunkHasQuote)*2 {
416+
newCap = cap(result.chunkHasQuote) * 2
417+
}
418+
result.chunkHasQuote = make([]bool, chunkCount, newCap)
419+
} else {
420+
result.chunkHasQuote = result.chunkHasQuote[:chunkCount]
421+
for i := range result.chunkHasQuote {
422+
result.chunkHasQuote[i] = false
423+
}
424+
}
395425

396426
state := scanState{}
397427

@@ -500,13 +530,22 @@ func scanBuffer(buf []byte, separatorChar byte) *scanResult {
500530
// Track if any quotes exist in the input (for fast path optimization)
501531
if quoteMaskOut != 0 {
502532
result.hasQuotes = true
533+
result.chunkHasQuote[chunkIdx] = true
503534
}
504535

505536
// Record chunks that have double quotes (using bool array instead of []int)
506537
if hasDoubleQuote {
507538
result.chunkHasDQ[chunkIdx] = true
508539
}
509540

541+
// Accumulate counts for preallocation sizing
542+
if sepMaskOut != 0 {
543+
result.separatorCount += bits.OnesCount64(sepMaskOut)
544+
}
545+
if newlineMaskOut != 0 {
546+
result.newlineCount += bits.OnesCount64(newlineMaskOut)
547+
}
548+
510549
// Slide masks: current = next, compute new next for chunkIdx+2
511550
curMasks = nextMasks
512551
curValidBits = simdChunkSize // next chunk was full unless it's the last

validation.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,39 @@
22

33
package simdcsv
44

5+
// fieldMayContainQuote returns true if any chunk overlapped by the field contains a quote.
6+
// This allows skipping validation when it's impossible for the field to contain quotes.
7+
func (r *Reader) fieldMayContainQuote(rawStart, rawEnd uint64) bool {
8+
if len(r.chunkHasQuote) == 0 {
9+
return true
10+
}
11+
if rawEnd <= rawStart {
12+
return false
13+
}
14+
startChunk := int(rawStart / simdChunkSize) //nolint:gosec // G115: rawStart bounded by buffer size (max 2GB)
15+
endChunk := int((rawEnd - 1) / simdChunkSize) //nolint:gosec // G115: rawEnd bounded by buffer size (max 2GB)
16+
if startChunk < 0 {
17+
return true
18+
}
19+
if endChunk >= len(r.chunkHasQuote) {
20+
endChunk = len(r.chunkHasQuote) - 1
21+
}
22+
for i := startChunk; i <= endChunk; i++ {
23+
if r.chunkHasQuote[i] {
24+
return true
25+
}
26+
}
27+
return false
28+
}
29+
530
// validateFieldQuotes validates quote usage in a field.
631
// This is the main entry point that dispatches to quoted or unquoted validation.
732
func (r *Reader) validateFieldQuotes(rawStart, rawEnd uint64, lineNum int) error {
33+
return r.validateFieldQuotesWithField(fieldInfo{}, rawStart, rawEnd, lineNum)
34+
}
35+
36+
// validateFieldQuotesWithField validates quote usage in a field using field metadata when available.
37+
func (r *Reader) validateFieldQuotesWithField(field fieldInfo, rawStart, rawEnd uint64, lineNum int) error {
838
if rawStart >= uint64(len(r.rawBuffer)) || rawEnd > uint64(len(r.rawBuffer)) || rawStart >= rawEnd {
939
return nil
1040
}
@@ -20,12 +50,71 @@ func (r *Reader) validateFieldQuotes(rawStart, rawEnd uint64, lineNum int) error
2050
// Adjust raw to start from the quote for validation
2151
adjustedRaw := raw[quoteOffset:]
2252
adjustedStart := rawStart + uint64(quoteOffset) //nolint:gosec // G115: quoteOffset is always non-negative from isQuotedFieldStart
53+
// Fast path: use parsed field metadata when opening quote is at start
54+
if quoteOffset == 0 && field.rawEndDelta != 0 {
55+
if err := r.validateQuotedFieldFast(adjustedRaw, adjustedStart, field, lineNum); err != nil {
56+
return err
57+
}
58+
return nil
59+
}
2360
return r.validateQuotedField(adjustedRaw, adjustedStart, lineNum)
2461
}
2562

2663
return r.validateUnquotedField(raw, rawStart, lineNum)
2764
}
2865

66+
// validateQuotedFieldFast validates a quoted field using parsed metadata to avoid rescanning.
67+
// raw must start with the opening quote.
68+
func (r *Reader) validateQuotedFieldFast(raw []byte, rawStart uint64, field fieldInfo, lineNum int) error {
69+
if len(raw) < 2 || raw[0] != '"' {
70+
return r.validateQuotedField(raw, rawStart, lineNum)
71+
}
72+
73+
closingIdx := int(field.length) + 1
74+
if closingIdx >= len(raw) || raw[closingIdx] != '"' {
75+
// No closing quote found at expected position
76+
return &ParseError{
77+
StartLine: lineNum,
78+
Line: lineNum,
79+
Column: int(rawStart) + len(raw), //nolint:gosec // G115: rawStart bounded by buffer size
80+
Err: ErrQuote,
81+
}
82+
}
83+
84+
switch field.rawEndDelta {
85+
case 1:
86+
// Closing quote should be immediately before delimiter/EOF.
87+
if closingIdx+1 != len(raw) {
88+
return &ParseError{
89+
StartLine: lineNum,
90+
Line: lineNum,
91+
Column: int(rawStart) + closingIdx + 2, //nolint:gosec // G115: rawStart bounded by buffer size
92+
Err: ErrQuote,
93+
}
94+
}
95+
case 2:
96+
// CRLF: raw includes trailing \r before the delimiter LF.
97+
if closingIdx+2 != len(raw) || raw[closingIdx+1] != '\r' {
98+
return &ParseError{
99+
StartLine: lineNum,
100+
Line: lineNum,
101+
Column: int(rawStart) + closingIdx + 2, //nolint:gosec // G115: rawStart bounded by buffer size
102+
Err: ErrQuote,
103+
}
104+
}
105+
default:
106+
// Extra data after closing quote (invalid)
107+
return &ParseError{
108+
StartLine: lineNum,
109+
Line: lineNum,
110+
Column: int(rawStart) + closingIdx + 2, //nolint:gosec // G115: rawStart bounded by buffer size
111+
Err: ErrQuote,
112+
}
113+
}
114+
115+
return nil
116+
}
117+
29118
// validateQuotedField validates a field that starts with a quote.
30119
// raw should start with the opening quote.
31120
func (r *Reader) validateQuotedField(raw []byte, rawStart uint64, lineNum int) error {

0 commit comments

Comments
 (0)