Skip to content

jsmn-go/parallel.go

:material-github: View on GitHub · 207 lines · live source, included at build time

// Package jsmngo provides a fast JSON tokenizer with parallel processing capabilities.
package jsmngo

import (
    "context"
    "runtime"
    "sync"
)

// chunkJob describes a contiguous slice of the input assigned to one worker.
type chunkJob struct {
    id     int
    start  int
    end    int
    offset int
}

// chunkResult carries the tokens produced for a single chunkJob.
type chunkResult struct {
    id   int
    toks []Token
    err  error
}

// parseParallelWithConfig tokenizes JSON in parallel with configuration and context support.
func parseParallelWithConfig(ctx context.Context, json []byte, config *Config) ([]Token, error) {
    splitPoints := findSplitPoints(json)
    numWorkers := runtime.NumCPU()

    // Not enough split points to justify parallelism; fall back to a single pass.
    if len(splitPoints) < numWorkers {
        return parseSerial(json, config)
    }

    jobs := buildChunkJobs(json, splitPoints, numWorkers)
    numJobs := len(jobs)

    jobCh := make(chan chunkJob, numJobs)
    for _, j := range jobs {
        jobCh <- j
    }
    close(jobCh)

    // Per-chunk token budget: an approximate guard so a single chunk cannot blow
    // past the overall limit. 0 means unlimited.
    maxTokensPerChunk := 0
    if config.MaxTokens > 0 {
        maxTokensPerChunk = config.MaxTokens / numJobs
    }

    // Buffer for the worst case so no worker can block on send (and thus never
    // reach wg.Done): every job produces one result (numJobs) and, on context
    // cancellation, each worker may emit one extra cancel result (numWorkers).
    // An under-sized buffer here deadlocks wg.Wait on mid-parse cancellation.
    resultsCh := make(chan chunkResult, numJobs+numWorkers)
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            chunkWorker(ctx, json, jobCh, resultsCh, maxTokensPerChunk, config)
        }()
    }

    wg.Wait()
    close(resultsCh)

    return mergeChunkResults(resultsCh, numJobs, config.MaxTokens)
}

// parseSerial tokenizes the whole input in a single parser pass.
func parseSerial(json []byte, config *Config) ([]Token, error) {
    capacity := config.getInitialCapacity(len(json))
    p := NewParser(capacity)
    count, err := p.Parse(json)
    if err != nil {
        return nil, err
    }
    if config.MaxTokens > 0 && count > config.MaxTokens {
        return nil, ErrTooManyTokens
    }
    return p.Tokens(), nil
}

// buildChunkJobs groups the top-level values into roughly numChunks contiguous,
// non-overlapping jobs that together cover the entire input. Grouping many
// values per chunk (instead of one chunk per split point) is what makes the
// parallel path pay off: it amortizes the per-chunk parser allocation across
// many values. Each chunk still begins and ends on a top-level boundary, so the
// chunk parses to exactly the tokens it contains (internal separators are
// skipped by the tokenizer just as in a serial pass).
func buildChunkJobs(json []byte, splitPoints []int, numChunks int) []chunkJob {
    values := len(splitPoints) + 1 // number of top-level values
    if numChunks < 1 {
        numChunks = 1
    }
    if numChunks > values {
        numChunks = values
    }

    jobs := make([]chunkJob, 0, numChunks)
    start := 0
    for c := 0; c < numChunks; c++ {
        // This chunk covers value segments [c*values/numChunks, segEnd).
        segEnd := (c + 1) * values / numChunks
        if segEnd >= values {
            jobs = append(jobs, chunkJob{id: c, start: start, end: len(json), offset: start})
            break
        }
        // Cut at the split point that ends the last value in this chunk; the next
        // chunk starts just past that separator.
        boundary := splitPoints[segEnd-1]
        jobs = append(jobs, chunkJob{id: c, start: start, end: boundary, offset: start})
        start = boundary + 1
    }
    return jobs
}

// chunkWorker pulls jobs until the channel drains or the context is canceled.
func chunkWorker(
    ctx context.Context,
    json []byte,
    jobCh <-chan chunkJob,
    resultsCh chan<- chunkResult,
    maxTokensPerChunk int,
    config *Config,
) {
    for {
        // Check cancellation first: a bare select races between a ready job
        // and ctx.Done() (Go picks randomly), so an already-canceled context
        // would only be honored intermittently.
        if err := ctx.Err(); err != nil {
            resultsCh <- chunkResult{err: err}
            return
        }
        select {
        case <-ctx.Done():
            resultsCh <- chunkResult{err: ctx.Err()}
            return
        case j, ok := <-jobCh:
            if !ok {
                return
            }
            toks, err := processChunk(json, j, maxTokensPerChunk, config)
            resultsCh <- chunkResult{id: j.id, toks: toks, err: err}
            if err != nil {
                return
            }
        }
    }
}

// processChunk tokenizes a single chunk and rebases token offsets to be global.
func processChunk(json []byte, j chunkJob, maxTokensPerChunk int, config *Config) ([]Token, error) {
    chunkData := json[j.start:j.end]
    p := NewParser(config.getInitialCapacity(len(chunkData)))

    if _, err := p.Parse(chunkData); err != nil {
        return nil, err
    }

    toks := p.Tokens()
    if maxTokensPerChunk > 0 && len(toks) > maxTokensPerChunk {
        return nil, ErrTooManyTokens
    }

    for i := range toks {
        toks[i].Start += j.offset
        toks[i].End += j.offset
    }
    return toks, nil
}

// mergeChunkResults re-orders worker output by job id and concatenates it,
// failing fast on the first error and enforcing the overall token limit.
func mergeChunkResults(resultsCh <-chan chunkResult, numJobs, maxTokens int) ([]Token, error) {
    jobResults := make([]chunkResult, numJobs)
    for r := range resultsCh {
        if r.err != nil {
            return nil, r.err // Fail fast on first error.
        }
        jobResults[r.id] = r
    }

    totalTokens := 0
    for _, r := range jobResults {
        totalTokens += len(r.toks)
    }
    if maxTokens > 0 && totalTokens > maxTokens {
        return nil, ErrTooManyTokens
    }

    // Concatenate chunks in order, rebasing each token's ParentIdx (an index into
    // the token array) by the number of tokens already appended. processChunk
    // only rebased Start/End; without this, nested tokens in chunks after the
    // first point into chunk-local index space and the parent graph is corrupt.
    finalTokens := make([]Token, 0, totalTokens)
    for _, r := range jobResults {
        base := len(finalTokens)
        for _, tok := range r.toks {
            if tok.ParentIdx != -1 {
                tok.ParentIdx += base
            }
            finalTokens = append(finalTokens, tok)
        }
    }
    return finalTokens, nil
}

// ParseParallelWithContext is a wrapper that uses default config with context support.
// This maintains backward compatibility while adding context support.
func ParseParallelWithContext(ctx context.Context, json []byte) ([]Token, error) {
    return ParseWithConfig(ctx, json, DefaultConfig())
}