2026-03-10 14:20:10 +01:00
|
|
|
|
# Optimisation du parsing des grandes séquences
|
|
|
|
|
|
|
|
|
|
|
|
## Contexte
|
|
|
|
|
|
|
|
|
|
|
|
OBITools4 doit pouvoir traiter des séquences de taille chromosomique (plusieurs Gbp), notamment
|
|
|
|
|
|
issues de fichiers GenBank/EMBL (assemblages de génomes) ou de fichiers FASTA convertis depuis
|
|
|
|
|
|
ces formats.
|
|
|
|
|
|
|
|
|
|
|
|
## Architecture actuelle
|
|
|
|
|
|
|
|
|
|
|
|
### Pipeline de lecture (`pkg/obiformats/`)
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
ReadFileChunk (goroutine)
|
|
|
|
|
|
→ ChannelFileChunk
|
|
|
|
|
|
→ N × _ParseGenbankFile / _ParseFastaFile (goroutines)
|
|
|
|
|
|
→ IBioSequence
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`ReadFileChunk` (`file_chunk_read.go`) lit le fichier par morceaux via une chaîne de
|
|
|
|
|
|
`PieceOfChunk` (rope). Chaque nœud fait `fileChunkSize` bytes :
|
|
|
|
|
|
|
|
|
|
|
|
- GenBank/EMBL : 128 MB (`1024*1024*128`)
|
|
|
|
|
|
- FASTA/FASTQ : 1 MB (`1024*1024`)
|
|
|
|
|
|
|
|
|
|
|
|
La chaîne est accumulée jusqu'à trouver la fin du dernier enregistrement complet (splitter),
|
|
|
|
|
|
puis `Pack()` est appelé pour fusionner tous les nœuds en un seul buffer contigu. Ce buffer
|
|
|
|
|
|
est transmis au parseur via `FileChunk.Raw *bytes.Buffer`.
|
|
|
|
|
|
|
|
|
|
|
|
### Parseur GenBank (`genbank_read.go`)
|
|
|
|
|
|
|
|
|
|
|
|
`GenbankChunkParser` reçoit un `io.Reader` sur le buffer packé, lit ligne par ligne via
|
|
|
|
|
|
`bufio.NewReader` (buffer 4096 bytes), et pour chaque ligne de la section `ORIGIN` :
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
line = string(bline) // allocation par ligne
|
|
|
|
|
|
cleanline := strings.TrimSpace(line) // allocation
|
|
|
|
|
|
parts := strings.SplitN(cleanline, " ", 7) // allocation []string + substrings
|
|
|
|
|
|
for i := 1; i < lparts; i++ {
|
|
|
|
|
|
seqBytes.WriteString(parts[i])
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Point positif : `seqBytes` est pré-alloué grâce à `lseq` extrait de la ligne `LOCUS`.
|
|
|
|
|
|
|
|
|
|
|
|
### Parseur FASTA (`fastaseq_read.go`)
|
|
|
|
|
|
|
|
|
|
|
|
`FastaChunkParser` lit **octet par octet** via `scanner.ReadByte()`. Pour 3 Gbp :
|
|
|
|
|
|
3 milliards d'appels. `seqBytes` est un `bytes.Buffer{}` sans pré-allocation.
|
|
|
|
|
|
|
|
|
|
|
|
## Problème principal
|
|
|
|
|
|
|
|
|
|
|
|
Pour une séquence de plusieurs Gbp, `Pack()` fusionne une chaîne de ~N nœuds de 128 MB en
|
|
|
|
|
|
un seul buffer contigu. C'est une allocation de N × 128 MB suivie d'une copie de toutes les
|
|
|
|
|
|
données. Bien que l'implémentation de `Pack()` soit efficace (libère les nœuds au fur et à
|
|
|
|
|
|
mesure via `slices.Grow`), la copie est inévitable avec l'architecture actuelle.
|
|
|
|
|
|
|
|
|
|
|
|
De plus, le parseur GenBank produit des dizaines de millions d'allocations temporaires pour
|
|
|
|
|
|
parser la section `ORIGIN` (une par ligne).
|
|
|
|
|
|
|
|
|
|
|
|
## Invariant clé découvert
|
|
|
|
|
|
|
|
|
|
|
|
**Si la rope a plus d'un nœud, le premier nœud seul ne se termine pas sur une frontière
|
|
|
|
|
|
d'enregistrement** (pas de `//\n` en fin de `piece1`).
|
|
|
|
|
|
|
|
|
|
|
|
Preuve par construction dans `ReadFileChunk` :
|
|
|
|
|
|
- `splitter` est appelé dès le premier nœud (ligne 157)
|
|
|
|
|
|
- Si `end >= 0` → frontière trouvée dans 128 MB → boucle interne sautée → rope à 1 nœud
|
|
|
|
|
|
- Si `end < 0` → boucle interne ajoute des nœuds → rope à ≥ 2 nœuds
|
|
|
|
|
|
|
|
|
|
|
|
Corollaire : si rope à 1 nœud, `Pack()` ne fait rien (aucun nœud suivant).
|
|
|
|
|
|
|
|
|
|
|
|
**Attention** : rope à ≥ 2 nœuds ne signifie pas qu'il n'y a qu'une seule séquence dans
|
|
|
|
|
|
la rope. La rope packée peut contenir plusieurs enregistrements complets. Exemple : records
|
|
|
|
|
|
de 80 MB → `nextpieces` (48 MB de reste) + nouveau nœud (128 MB) = rope à 2 nœuds
|
|
|
|
|
|
contenant 2 records complets + début d'un troisième.
|
|
|
|
|
|
|
|
|
|
|
|
L'invariant dit seulement que `piece1` seul est incomplet — pas que la rope entière
|
|
|
|
|
|
ne contient qu'un seul record.
|
|
|
|
|
|
|
|
|
|
|
|
**Invariant : le dernier FileChunk envoyé finit sur une frontière d'enregistrement.**
|
|
|
|
|
|
|
|
|
|
|
|
Deux chemins dans `ReadFileChunk` :
|
|
|
|
|
|
|
|
|
|
|
|
1. **Chemin normal** (`end >= 0` via `splitter`) : le buffer est explicitement tronqué à
|
|
|
|
|
|
`end` (ligne 200 : `pieces.data = pieces.data[:end]`). Frontière garantie par construction
|
|
|
|
|
|
pour tous les formats. ✓
|
|
|
|
|
|
|
|
|
|
|
|
2. **Chemin EOF** (`end < 0`, `end = pieces.Len()`) : tout le reste du fichier est envoyé.
|
|
|
|
|
|
- **GenBank/EMBL** : présuppose fichier bien formé (se termine par `//\n`). Le parseur
|
|
|
|
|
|
lève un `log.Fatalf` sur tout état inattendu — filet de sécurité suffisant. ✓
|
|
|
|
|
|
- **FASTQ** : présupposé, vérifié par le parseur. ✓
|
|
|
|
|
|
- **FASTA** : garanti par le format lui-même (fin d'enregistrement = EOF ou `>`). ✓
|
|
|
|
|
|
|
|
|
|
|
|
**Hypothèse de travail adoptée** : les fichiers d'entrée sont bien formés. Dans le pire cas,
|
|
|
|
|
|
le parseur lèvera une erreur explicite. Il n'y a pas de risque de corruption silencieuse.
|
|
|
|
|
|
|
|
|
|
|
|
## Piste d'optimisation : se dispenser de Pack()
|
|
|
|
|
|
|
|
|
|
|
|
### Idée centrale
|
|
|
|
|
|
|
|
|
|
|
|
Au lieu de fusionner la rope avant de la passer au parseur, **parser directement la rope
|
|
|
|
|
|
nœud par nœud**, et **écrire la séquence compactée in-place dans le premier nœud**.
|
|
|
|
|
|
|
|
|
|
|
|
Pourquoi c'est sûr :
|
|
|
|
|
|
- Le header (LOCUS, DEFINITION, SOURCE, FEATURES) est **petit** et traité en premier
|
|
|
|
|
|
- La séquence (ORIGIN) est **à la fin** du record
|
|
|
|
|
|
- Au moment d'écrire la séquence depuis l'offset 0 de `piece1`, le pointeur de lecture
|
|
|
|
|
|
est profond dans la rope (offset >> 0) → jamais de collision
|
|
|
|
|
|
- La séquence compactée est toujours plus courte que les données brutes
|
|
|
|
|
|
|
|
|
|
|
|
### Pré-allocation
|
|
|
|
|
|
|
|
|
|
|
|
Pour GenBank/EMBL : `lseq` est connu dès la ligne `LOCUS`/`ID` (première ligne, dans
|
|
|
|
|
|
`piece1`). On peut faire `slices.Grow(piece1.data, lseq)` dès ce moment.
|
|
|
|
|
|
|
|
|
|
|
|
Pour FASTA : pas de taille garantie dans le header, mais `rope.Len()` donne un majorant.
|
|
|
|
|
|
On peut utiliser `rope.Len() / 2` comme estimation initiale.
|
|
|
|
|
|
|
|
|
|
|
|
### Gestion des jonctions entre nœuds
|
|
|
|
|
|
|
|
|
|
|
|
Une ligne peut chevaucher deux nœuds (rare avec 128 MB, mais possible). Solution : carry
|
|
|
|
|
|
buffer de ~128 bytes pour les quelques bytes en fin de nœud.
|
|
|
|
|
|
|
|
|
|
|
|
### Cas FASTA/FASTQ multi-séquences
|
|
|
|
|
|
|
|
|
|
|
|
Un FileChunk peut contenir N séquences (notamment FASTA/FASTQ courts). Dans ce cas
|
|
|
|
|
|
l'écriture in-place dans `piece1` n'est pas applicable directement — on écrase des données
|
|
|
|
|
|
nécessaires aux séquences suivantes.
|
|
|
|
|
|
|
|
|
|
|
|
Stratégie par cas :
|
|
|
|
|
|
- **Rope à 1 nœud** (record ≤ 128 MB) : `Pack()` est trivial (no-op), parseur actuel OK
|
|
|
|
|
|
- **Rope à ≥ 2 nœuds** : par l'invariant, `piece1` ne contient pas de record complet →
|
|
|
|
|
|
une seule grande séquence → in-place applicable
|
|
|
|
|
|
|
|
|
|
|
|
### Format d'une ligne séquence GenBank (Après ORIGIN)
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
/^ *[0-9]+( [nuc]{10}){0,5} [nuc]{1,10}/
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Format d'une ligne séquence GenBank (Après SQ)
|
|
|
|
|
|
|
|
|
|
|
|
La ligne SQ contient aussi la taille de la séquence
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
/^ *( [nuc]{10}){0,5} [nuc]{1,10} *[0-9]+/
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Compactage in-place sur `bline` ([]byte brut, sans conversion `string`) :
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
w := 0
|
|
|
|
|
|
i := 0
|
|
|
|
|
|
for i < len(bline) && bline[i] == ' ' { i++ } // skip indentation
|
|
|
|
|
|
for i < len(bline) && bline[i] <= '9' { i++ } // skip position number
|
|
|
|
|
|
for ; i < len(bline); i++ {
|
|
|
|
|
|
if bline[i] != ' ' {
|
|
|
|
|
|
bline[w] = bline[i]
|
|
|
|
|
|
w++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// écrire bline[:w] directement dans piece1.data[seqOffset:]
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Changements nécessaires
|
|
|
|
|
|
|
|
|
|
|
|
1. **`FileChunk`** : exposer la rope `*PieceOfChunk` non-packée en plus (ou à la place)
|
|
|
|
|
|
de `Raw *bytes.Buffer`
|
|
|
|
|
|
2. **`GenbankChunkParser` / `EmblChunkParser`** : accepter `*PieceOfChunk`, parser la
|
|
|
|
|
|
rope séquentiellement avec carry buffer pour les jonctions
|
|
|
|
|
|
3. **`FastaChunkParser`** : idem, avec in-place conditionnel selon taille de la rope
|
|
|
|
|
|
4. **`ReadFileChunk`** : ne pas appeler `Pack()` avant envoi sur le channel (ou version
|
|
|
|
|
|
alternative `ReadFileChunkRope`)
|
|
|
|
|
|
|
|
|
|
|
|
## Fichiers concernés
|
|
|
|
|
|
|
|
|
|
|
|
- `pkg/obiformats/file_chunk_read.go` — structure rope, `ReadFileChunk`
|
|
|
|
|
|
- `pkg/obiformats/genbank_read.go` — `GenbankChunkParser`, `_ParseGenbankFile`
|
|
|
|
|
|
- `pkg/obiformats/embl_read.go` — `EmblChunkParser`, `ReadEMBL`
|
|
|
|
|
|
- `pkg/obiformats/fastaseq_read.go` — `FastaChunkParser`, `_ParseFastaFile`
|
|
|
|
|
|
- `pkg/obiformats/fastqseq_read.go` — parseur FASTQ (même structure)
|
2026-03-10 15:35:23 +01:00
|
|
|
|
|
|
|
|
|
|
## Plan d'implémentation : parseur GenBank sur rope
|
|
|
|
|
|
|
|
|
|
|
|
### Contexte
|
|
|
|
|
|
|
|
|
|
|
|
Baseline mesurée : `obiconvert gbpln640.seq.gz` → 49s real, 42s user, 29s sys, **57 GB RSS**.
|
|
|
|
|
|
Le sys élevé indique des allocations massives. Deux causes :
|
|
|
|
|
|
1. `Pack()` : fusionne toute la rope (N × 128 MB) en un buffer contigu avant de parser
|
|
|
|
|
|
2. Parser ORIGIN : `string(bline)` + `TrimSpace` + `SplitN` × millions de lignes
|
|
|
|
|
|
|
|
|
|
|
|
### 1. `gbRopeScanner`
|
|
|
|
|
|
|
|
|
|
|
|
Struct de lecture ligne par ligne sur la rope, sans allocation heap :
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
type gbRopeScanner struct {
|
|
|
|
|
|
current *PieceOfChunk
|
|
|
|
|
|
pos int
|
|
|
|
|
|
carry [256]byte // stack-allocated, max GenBank line = 80 chars
|
|
|
|
|
|
carryN int
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`ReadLine()` :
|
|
|
|
|
|
- Cherche `\n` dans `current.data[pos:]` via `bytes.IndexByte`
|
|
|
|
|
|
- Si trouvé sans carry : retourne slice direct du node (zéro alloc)
|
|
|
|
|
|
- Si trouvé avec carry : copie dans carry buffer, retourne `carry[:n]`
|
|
|
|
|
|
- Si non trouvé : copie le reste dans carry, avance au node suivant, recommence
|
|
|
|
|
|
- EOF : retourne `carry[:carryN]` puis nil
|
|
|
|
|
|
|
|
|
|
|
|
`extractSequence(dest []byte, UtoT bool) int` :
|
|
|
|
|
|
- Scan direct des bytes pour section ORIGIN, sans passer par ReadLine
|
|
|
|
|
|
- Machine d'états : lineStart → skip espaces/digits → copier nucléotides dans dest
|
|
|
|
|
|
- Stop sur `//` en début de ligne
|
|
|
|
|
|
- Zéro allocation, UtoT inline
|
|
|
|
|
|
|
|
|
|
|
|
### 2. `GenbankChunkParserRope`
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func GenbankChunkParserRope(source string, rope *PieceOfChunk,
|
|
|
|
|
|
withFeatureTable, UtoT bool) (obiseq.BioSequenceSlice, error)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- Même machine d'états que `GenbankChunkParser`, sur `[]byte` (`bytes.HasPrefix`)
|
|
|
|
|
|
- LOCUS : extrait `id` et `lseq` par scan direct (remplace `_seqlenght_rx`)
|
|
|
|
|
|
- FEATURES / default inFeature : taxid extrait par scan de `/db_xref="taxon:`
|
|
|
|
|
|
dans la source feature ; `featBytes` rempli seulement si `withFeatureTable=true`
|
|
|
|
|
|
- DEFINITION : toujours conservée
|
|
|
|
|
|
- ORIGIN : `dest = make([]byte, 0, lseq+20)` puis `s.extractSequence(dest, UtoT)`
|
|
|
|
|
|
|
|
|
|
|
|
### 3. Modifications `_ParseGenbankFile` et `ReadGenbank`
|
|
|
|
|
|
|
|
|
|
|
|
`_ParseGenbankFile` utilise `chunk.Rope` :
|
|
|
|
|
|
```go
|
|
|
|
|
|
sequences, err := GenbankChunkParserRope(chunk.Source, chunk.Rope, ...)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`ReadGenbank` passe `pack=false` :
|
|
|
|
|
|
```go
|
|
|
|
|
|
entry_channel := ReadFileChunk(..., false)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 4. Ce qui NE change pas
|
|
|
|
|
|
|
|
|
|
|
|
- `GenbankChunkParser` reste (référence, tests)
|
|
|
|
|
|
- `ReadFileChunk`, `Pack()`, autres parseurs (EMBL, FASTA, FASTQ) : inchangés
|
|
|
|
|
|
|
|
|
|
|
|
### 5. Gains attendus
|
|
|
|
|
|
|
|
|
|
|
|
- **RSS** : pic ≈ 128 MB × workers (au lieu de N × 128 MB)
|
|
|
|
|
|
- **Temps sys** : élimination des mmap/munmap pour les gros buffers
|
|
|
|
|
|
- **Temps user** : ~50M allocations éliminées
|
|
|
|
|
|
|
|
|
|
|
|
### 6. Vérification
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
/usr/local/go/bin/go build ./...
|
|
|
|
|
|
diff <(obiconvert gbpln640.seq.gz) gbpln640.reference.fasta
|
|
|
|
|
|
cd bugs/genbank && ./benchmark.sh gbpln640.seq.gz
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Cible : RSS < 1 GB, temps comparable ou meilleur.
|