Files
obitools4/pkg/obikmer/seqquorum_count.go
T
Eric Coissac 8cc02aa9c1 Refactor sequence processing for parallel loading and memory management
This commit introduces significant changes to the sequence processing pipeline, implementing parallel loading, k-mer budget control, explicit memory management, and refined logic for pair evaluation and resolution.
2026-11-03 22:53:54 +11:00

208 lines
4.8 KiB
Go

package obikmer
import (
"bufio"
"cmp"
"encoding/binary"
"io"
"os"
"slices"
"sync"
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obidefault"
)
// kmerOcc is one occurrence of a canonical k-mer in a sequence.
type kmerOcc struct {
kmer uint64
seq uint32
}
func cmpOcc(a, b kmerOcc) int {
if c := cmp.Compare(a.kmer, b.kmer); c != 0 {
return c
}
return cmp.Compare(a.seq, b.seq)
}
// CountAtLeast returns, sorted by k-mer, every canonical k-mer found in at
// least minSeq distinct sequences, with its exact sequence count.
//
// The result is held in memory: it is meant for quorum-filtered sets (the
// conserved k-mers), not for the complete k-mer spectrum. The first call
// seals the counter; AddSequence must not be called afterwards. The count
// phase uses obidefault.ParallelWorkers() goroutines over the partitions.
func (c *SeqKmerCounter) CountAtLeast(minSeq int) ([]SeqKmerCount, error) {
if err := c.seal(); err != nil {
return nil, err
}
if minSeq < 1 {
minSeq = 1
}
workers := min(obidefault.ParallelWorkers(), c.p)
// the largest partitions first: they are the hardest to fit in the budget
// and the last ones to finish, if they come late
order := make([]int, c.p)
for i := range order {
order[i] = i
}
slices.SortStableFunc(order, func(a, b int) int { return cmp.Compare(c.parts[b].kmers, c.parts[a].kmers) })
budget := newOccurrenceBudget(c.budget)
jobs := make(chan int)
results := make([][]SeqKmerCount, c.p)
var wg sync.WaitGroup
var errMu sync.Mutex
var firstErr error
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
var occ []kmerOcc // reused between partitions of similar sizes
for p := range jobs {
n := c.parts[p].kmers
held := budget.acquire(n)
if cap(occ) < n || cap(occ) > 2*n+1024 {
occ = make([]kmerOcc, 0, n)
}
var err error
occ, err = c.loadPartition(p, occ[:0])
if err == nil {
results[p] = reduceOccurrences(occ, minSeq)
}
budget.release(held)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = err
}
errMu.Unlock()
}
}
}()
}
for _, p := range order {
jobs <- p
}
close(jobs)
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
total := 0
for _, r := range results {
total += len(r)
}
all := make([]SeqKmerCount, 0, total)
for _, r := range results {
all = append(all, r...)
}
slices.SortFunc(all, func(a, b SeqKmerCount) int { return cmp.Compare(a.Kmer, b.Kmer) })
return all, nil
}
// loadPartition appends to occ every (canonical k-mer, sequence id) of the
// partition file.
func (c *SeqKmerCounter) loadPartition(p int, occ []kmerOcc) ([]kmerOcc, error) {
f, err := os.Open(c.parts[p].path)
if err != nil {
return occ, err
}
defer f.Close()
r := bufio.NewReaderSize(f, 64*1024)
var packed, bases []byte
for {
id, err := binary.ReadUvarint(r)
if err == io.EOF {
return occ, nil
}
if err != nil {
return occ, err
}
n, err := binary.ReadUvarint(r)
if err != nil {
return occ, err
}
nb := (int(n) + 3) / 4
packed = slices.Grow(packed[:0], nb)[:nb]
if _, err := io.ReadFull(r, packed); err != nil {
return occ, err
}
bases = slices.Grow(bases[:0], int(n))[:n]
unpackBases(bases, packed)
for kmer := range IterCanonicalKmers(bases, c.k) {
occ = append(occ, kmerOcc{kmer, uint32(id)})
}
}
}
// reduceOccurrences sorts occ and returns the k-mers present in at least
// minSeq distinct sequences. occ is reordered in place.
func reduceOccurrences(occ []kmerOcc, minSeq int) []SeqKmerCount {
slices.SortFunc(occ, cmpOcc)
var out []SeqKmerCount
for i := 0; i < len(occ); {
kmer := occ[i].kmer
var seqs uint32
repeated := false
j := i
for ; j < len(occ) && occ[j].kmer == kmer; j++ {
if j == i || occ[j].seq != occ[j-1].seq {
seqs++
} else {
repeated = true
}
}
if int(seqs) >= minSeq {
out = append(out, SeqKmerCount{Kmer: kmer, Sequences: seqs, Repeated: repeated})
}
i = j
}
return out
}
// occurrenceBudget bounds the k-mer occurrences held in memory at once by the
// goroutines that load the partitions.
type occurrenceBudget struct {
mu sync.Mutex
freed *sync.Cond
total int
free int
}
func newOccurrenceBudget(total int) *occurrenceBudget {
b := &occurrenceBudget{total: total, free: total}
b.freed = sync.NewCond(&b.mu)
return b
}
// acquire waits until n occurrences fit in the budget and takes them; a request
// above the whole budget waits for the budget to be free and takes all of it.
// It returns what was taken, to give back with release.
func (b *occurrenceBudget) acquire(n int) int {
n = min(n, b.total)
b.mu.Lock()
for b.free < n {
b.freed.Wait()
}
b.free -= n
b.mu.Unlock()
return n
}
func (b *occurrenceBudget) release(n int) {
b.mu.Lock()
b.free += n
b.mu.Unlock()
b.freed.Broadcast()
}