# 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) ## 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.