Refactor kmer index package to use disk-based partitioning with minimizer - Replace roaring64 bitmaps with disk-based kmer index - Implement partitioned kmer sets with delta-varint encoding - Add support for frequency filtering during construction - Introduce new builder pattern for index construction - Add streaming operations for set operations (union, intersect, etc.) - Add support for super-kmer encoding during construction - Update command line tool to use new index format - Remove dependency on roaring bitmap library This change introduces a new architecture for kmer indexing that is more memory efficient and scalable for large datasets.
16 KiB
Plan de refonte du package obikmer : index disk-based par partitions minimizer
Constat
Les roaring64 bitmaps ne sont pas adaptés au stockage de 10^10 k-mers
(k=31) dispersés sur un espace de 2^62. L'overhead structurel (containers
roaring par high key 32 bits) dépasse la taille des données elles-mêmes,
et les opérations Or() entre bitmaps fragmentés ne terminent pas en
temps raisonnable.
Principe de la nouvelle architecture
Un KmerSet est un ensemble trié de k-mers canoniques (uint64) stocké
sur disque, partitionné par minimizer. Chaque partition est un fichier
binaire contenant des uint64 triés, compressés par delta-varint.
Un KmerSetGroup est un répertoire contenant N ensembles partitionnés
de la même façon (même k, même m, même P).
Un KmerSet est un KmerSetGroup de taille 1 (singleton).
Les opérations ensemblistes se font partition par partition, en merge streaming, sans charger l'index complet en mémoire.
Cycle de vie d'un index
L'index a deux phases distinctes :
-
Phase de construction (mutable) : on ouvre un index, on y ajoute des séquences. Pour chaque séquence, les super-kmers sont extraits et écrits de manière compacte (2 bits/base) dans le fichier temporaire de partition correspondant (
minimizer % P). Les super-kmers sont une représentation compressée naturelle des k-mers chevauchants : un super-kmer de longueur L encode L-k+1 k-mers en ne stockant que ~L/4 bytes au lieu de (L-k+1) × 8 bytes. -
Phase de clôture (optimisation) : on ferme l'index, ce qui déclenche le traitement partition par partition (indépendant, parallélisable) :
- Charger les super-kmers de la partition
- En extraire tous les k-mers canoniques
- Trier le tableau de k-mers
- Dédupliquer (et compter si FrequencyFilter)
- Delta-encoder et écrire le fichier .kdi final Après clôture, l'index est statique et immuable.
-
Phase de lecture (immutable) : opérations ensemblistes, Jaccard, Quorum, Contains, itération. Toutes en streaming.
Format sur disque
Index finalisé
index_dir/
metadata.toml
set_0/
part_0000.kdi
part_0001.kdi
...
part_{P-1}.kdi
set_1/
part_0000.kdi
...
...
set_{N-1}/
...
Fichiers temporaires pendant la construction
index_dir/
.build/
set_0/
part_0000.skm # super-kmers encodés 2 bits/base
part_0001.skm
...
set_1/
...
Le répertoire .build/ est supprimé après Close().
metadata.toml
id = "mon_index"
k = 31
m = 13
partitions = 1024
type = "KmerSetGroup" # ou "KmerSet" (N=1)
size = 3 # nombre de sets (N)
sets_ids = ["genome_A", "genome_B", "genome_C"]
[user_metadata]
organism = "Triticum aestivum"
[sets_metadata]
# métadonnées individuelles par set si nécessaire
Fichier .kdi (Kmer Delta Index)
Format binaire :
[magic: 4 bytes "KDI\x01"]
[count: uint64 little-endian] # nombre de k-mers dans cette partition
[first: uint64 little-endian] # premier k-mer (valeur absolue)
[delta_1: varint] # arr[1] - arr[0]
[delta_2: varint] # arr[2] - arr[1]
...
[delta_{count-1}: varint] # arr[count-1] - arr[count-2]
Varint : encoding unsigned, 7 bits utiles par byte, bit de poids fort = continuation (identique au varint protobuf).
Fichier vide (partition sans k-mer) : magic + count=0.
Fichier .skm (Super-Kmer temporaire)
Format binaire, séquence de super-kmers encodés :
[len: uint16 little-endian] # longueur du super-kmer en bases
[sequence: ceil(len/4) bytes] # séquence encodée 2 bits/base, packed
...
Compression par rapport au stockage de k-mers bruts :
Un super-kmer de longueur L contient L-k+1 k-mers.
- Stockage super-kmer : 2 + ceil(L/4) bytes
- Stockage k-mers bruts : (L-k+1) × 8 bytes
Exemple avec k=31, super-kmer typique L=50 :
- Super-kmer : 2 + 13 = 15 bytes → encode 20 k-mers
- K-mers bruts : 20 × 8 = 160 bytes
- Facteur de compression : ~10×
Pour un génome de 10 Gbases (~10^10 k-mers bruts) :
- K-mers bruts : ~80 Go par set temporaire
- Super-kmers : ~8 Go par set temporaire
Avec FrequencyFilter et couverture 30× :
- K-mers bruts : ~2.4 To
- Super-kmers : ~240 Go
FrequencyFilter
Le FrequencyFilter n'est plus un type de données séparé. C'est un mode de construction du builder. Le résultat est un KmerSetGroup standard.
Principe
Pendant la construction, tous les super-kmers sont écrits dans les fichiers temporaires .skm, y compris les doublons (chaque occurrence de chaque séquence est écrite).
Pendant Close(), pour chaque partition :
- Charger tous les super-kmers de la partition
- Extraire tous les k-mers canoniques dans un tableau []uint64
- Trier le tableau
- Parcourir linéairement : les k-mers identiques sont consécutifs
- Compter les occurrences de chaque k-mer
- Si count >= minFreq → écrire dans le .kdi final (une seule fois)
- Sinon → ignorer
Dimensionnement
Pour un génome de 10 Gbases avec couverture 30× :
- N_brut ≈ 3×10^11 k-mers bruts
- Espace temporaire .skm ≈ 240 Go (compressé super-kmer)
- RAM par partition pendant Close() : Avec P=1024 : ~3×10^8 k-mers/partition × 8 = ~2.4 Go Avec P=4096 : ~7.3×10^7 k-mers/partition × 8 = ~600 Mo
Le choix de P détermine le compromis nombre de fichiers vs RAM par partition.
Sans FrequencyFilter (déduplication simple)
Pour de la déduplication simple (chaque k-mer écrit une fois), le builder peut dédupliquer au niveau des buffers en RAM avant flush. Cela réduit significativement l'espace temporaire car les doublons au sein d'un même buffer (provenant de séquences proches) sont éliminés immédiatement.
API publique visée
Structures
// KmerSetGroup est l'entité de base.
// Un KmerSet est un KmerSetGroup avec Size() == 1.
type KmerSetGroup struct {
// champs internes : path, k, m, P, N, metadata, état
}
// KmerSetGroupBuilder construit un KmerSetGroup mutable.
type KmerSetGroupBuilder struct {
// champs internes : buffers I/O par partition et par set,
// fichiers temporaires .skm, paramètres (minFreq, etc.)
}
Construction
// NewKmerSetGroupBuilder crée un builder pour un nouveau KmerSetGroup.
// directory : répertoire de destination
// k : taille des k-mers (1-31)
// m : taille des minimizers (-1 pour auto = ceil(k/2.5))
// n : nombre de sets dans le groupe
// P : nombre de partitions (-1 pour auto)
// options : options de construction (FrequencyFilter, etc.)
func NewKmerSetGroupBuilder(directory string, k, m, n, P int,
options ...BuilderOption) (*KmerSetGroupBuilder, error)
// WithMinFrequency active le mode FrequencyFilter.
// Seuls les k-mers vus >= minFreq fois sont conservés dans l'index
// final. Les super-kmers sont écrits avec leurs doublons pendant
// la construction ; le comptage exact se fait au Close().
func WithMinFrequency(minFreq int) BuilderOption
// AddSequence extrait les super-kmers d'une séquence et les écrit
// dans les fichiers temporaires de partition du set i.
func (b *KmerSetGroupBuilder) AddSequence(setIndex int, seq *obiseq.BioSequence)
// AddSuperKmer écrit un super-kmer dans le fichier temporaire de
// sa partition pour le set i.
func (b *KmerSetGroupBuilder) AddSuperKmer(setIndex int, sk SuperKmer)
// Close finalise la construction :
// - flush des buffers d'écriture
// - pour chaque partition de chaque set (parallélisable) :
// - charger les super-kmers depuis le .skm
// - extraire les k-mers canoniques
// - trier, dédupliquer (compter si freq filter)
// - delta-encoder et écrire le .kdi
// - écrire metadata.toml
// - supprimer le répertoire .build/
// Retourne le KmerSetGroup en lecture seule.
func (b *KmerSetGroupBuilder) Close() (*KmerSetGroup, error)
Lecture et opérations
// OpenKmerSetGroup ouvre un index finalisé en lecture seule.
func OpenKmerSetGroup(directory string) (*KmerSetGroup, error)
// --- Métadonnées (API inchangée) ---
func (ksg *KmerSetGroup) K() int
func (ksg *KmerSetGroup) M() int // nouveau : taille du minimizer
func (ksg *KmerSetGroup) Partitions() int // nouveau : nombre de partitions
func (ksg *KmerSetGroup) Size() int
func (ksg *KmerSetGroup) Id() string
func (ksg *KmerSetGroup) SetId(id string)
func (ksg *KmerSetGroup) HasAttribute(key string) bool
func (ksg *KmerSetGroup) GetAttribute(key string) (interface{}, bool)
func (ksg *KmerSetGroup) SetAttribute(key string, value interface{})
// ... etc (toute l'API attributs actuelle est conservée)
// --- Opérations ensemblistes ---
// Toutes produisent un nouveau KmerSetGroup singleton sur disque.
// Opèrent partition par partition en streaming.
func (ksg *KmerSetGroup) Union(outputDir string) (*KmerSetGroup, error)
func (ksg *KmerSetGroup) Intersect(outputDir string) (*KmerSetGroup, error)
func (ksg *KmerSetGroup) Difference(outputDir string) (*KmerSetGroup, error)
func (ksg *KmerSetGroup) QuorumAtLeast(q int, outputDir string) (*KmerSetGroup, error)
func (ksg *KmerSetGroup) QuorumExactly(q int, outputDir string) (*KmerSetGroup, error)
func (ksg *KmerSetGroup) QuorumAtMost(q int, outputDir string) (*KmerSetGroup, error)
// --- Opérations entre deux KmerSetGroups ---
// Les deux groupes doivent avoir les mêmes k, m, P.
func (ksg *KmerSetGroup) UnionWith(other *KmerSetGroup, outputDir string) (*KmerSetGroup, error)
func (ksg *KmerSetGroup) IntersectWith(other *KmerSetGroup, outputDir string) (*KmerSetGroup, error)
// --- Métriques (résultat en mémoire, pas de sortie disque) ---
func (ksg *KmerSetGroup) JaccardDistanceMatrix() *obidist.DistMatrix
func (ksg *KmerSetGroup) JaccardSimilarityMatrix() *obidist.DistMatrix
// --- Accès individuel ---
func (ksg *KmerSetGroup) Len(setIndex ...int) uint64
func (ksg *KmerSetGroup) Contains(setIndex int, kmer uint64) bool
func (ksg *KmerSetGroup) Iterator(setIndex int) iter.Seq[uint64]
Implémentation interne
Primitives bas niveau
varint.go : encode/decode varint uint64
func EncodeVarint(w io.Writer, v uint64) (int, error)
func DecodeVarint(r io.Reader) (uint64, error)
Format .kdi
kdi_writer.go : écriture d'un fichier .kdi à partir d'un flux
trié de uint64 (delta-encode au vol).
type KdiWriter struct { ... }
func NewKdiWriter(path string) (*KdiWriter, error)
func (w *KdiWriter) Write(kmer uint64) error
func (w *KdiWriter) Close() error
kdi_reader.go : lecture streaming d'un fichier .kdi (décode
les deltas au vol).
type KdiReader struct { ... }
func NewKdiReader(path string) (*KdiReader, error)
func (r *KdiReader) Next() (uint64, bool)
func (r *KdiReader) Count() uint64
func (r *KdiReader) Close() error
Format .skm
skm_writer.go : écriture de super-kmers encodés 2 bits/base.
type SkmWriter struct { ... }
func NewSkmWriter(path string) (*SkmWriter, error)
func (w *SkmWriter) Write(sk SuperKmer) error
func (w *SkmWriter) Close() error
skm_reader.go : lecture de super-kmers depuis un fichier .skm.
type SkmReader struct { ... }
func NewSkmReader(path string) (*SkmReader, error)
func (r *SkmReader) Next() (SuperKmer, bool)
func (r *SkmReader) Close() error
Merge streaming
kdi_merge.go : k-way merge de plusieurs flux triés.
type KWayMerge struct { ... }
func NewKWayMerge(readers []*KdiReader) *KWayMerge
func (m *KWayMerge) Next() (kmer uint64, count int, ok bool)
func (m *KWayMerge) Close() error
Builder
kmer_set_builder.go : construction d'un KmerSetGroup.
Le builder gère :
- P × N écrivains .skm bufferisés (un par partition × set)
- À la clôture : traitement partition par partition (parallélisable sur plusieurs cores)
Gestion mémoire des buffers d'écriture :
- Chaque SkmWriter a un buffer I/O de taille raisonnable (~64 Ko)
- Avec P=1024 et N=1 : 1024 × 64 Ko = 64 Mo de buffers
- Avec P=1024 et N=10 : 640 Mo de buffers
- Pas de buffer de k-mers en RAM : tout est écrit sur disque immédiatement via les super-kmers
RAM pendant Close() (tri d'une partition) :
- Charger les super-kmers → extraire les k-mers → tableau []uint64
- Avec P=1024 et 10^10 k-mers/set : ~10^7 k-mers/partition × 8 = ~80 Mo
- Avec FrequencyFilter (doublons) et couverture 30× : ~3×10^8/partition × 8 = ~2.4 Go (ajustable via P)
Structure disk-based
kmer_set_disk.go : KmerSetGroup en lecture seule.
kmer_set_disk_ops.go : opérations ensemblistes par merge
streaming partition par partition.
Ce qui change par rapport à l'API actuelle
Changements de sémantique
| Aspect | Ancien (roaring) | Nouveau (disk-based) |
|---|---|---|
| Stockage | En mémoire (roaring64.Bitmap) | Sur disque (.kdi delta-encoded) |
| Temporaire construction | En mémoire | Super-kmers sur disque (.skm 2 bits/base) |
| Mutabilité | Mutable à tout moment | Builder → Close() → immutable |
| Opérations ensemblistes | Résultat en mémoire | Résultat sur disque (nouveau répertoire) |
| Contains | O(1) roaring lookup | O(log n) recherche binaire sur .kdi |
| Itération | Roaring iterator | Streaming décodage delta-varint |
API conservée (signatures identiques ou quasi-identiques)
KmerSetGroup:K(),Size(),Id(),SetId()- Toute l'API attributs
JaccardDistanceMatrix(),JaccardSimilarityMatrix()Len(),Contains()
API modifiée
Union(),Intersect(), etc. : ajout du paramètreoutputDirQuorumAtLeast(), etc. : idem- Construction :
NewKmerSetGroupBuilder()+AddSequence()+Close()au lieu de manipulation directe
API supprimée
KmerSetcomme type distinct (remplacé par KmerSetGroup singleton)FrequencyFiltercomme type distinct (mode du Builder)- Tout accès direct à
roaring64.Bitmap KmerSet.Copy()(copie de répertoire à la place)KmerSet.Union(),.Intersect(),.Difference()(deviennent méthodes de KmerSetGroup avec outputDir)
Fichiers à créer / modifier dans pkg/obikmer
Nouveaux fichiers
| Fichier | Contenu |
|---|---|
varint.go |
Encode/Decode varint uint64 |
kdi_writer.go |
Écrivain de fichiers .kdi (delta-encoded) |
kdi_reader.go |
Lecteur streaming de fichiers .kdi |
skm_writer.go |
Écrivain de super-kmers encodés 2 bits/base |
skm_reader.go |
Lecteur de super-kmers depuis .skm |
kdi_merge.go |
K-way merge streaming de flux triés |
kmer_set_builder.go |
KmerSetGroupBuilder (construction) |
kmer_set_disk.go |
KmerSetGroup disk-based (lecture, métadonnées) |
kmer_set_disk_ops.go |
Opérations ensemblistes streaming |
Fichiers à supprimer
| Fichier | Raison |
|---|---|
kmer_set.go |
Remplacé par kmer_set_disk.go |
kmer_set_group.go |
Idem |
kmer_set_attributes.go |
Intégré dans kmer_set_disk.go |
kmer_set_persistence.go |
L'index est nativement sur disque |
kmer_set_group_quorum.go |
Intégré dans kmer_set_disk_ops.go |
frequency_filter.go |
Mode du Builder, plus de type séparé |
kmer_index_builder.go |
Remplacé par kmer_set_builder.go |
Fichiers conservés tels quels
| Fichier | Contenu |
|---|---|
encodekmer.go |
Encodage/décodage k-mers |
superkmer.go |
Structure SuperKmer |
superkmer_iter.go |
IterSuperKmers, IterCanonicalKmers |
encodefourmer.go |
Encode4mer |
counting.go |
Count4Mer |
kmermap.go |
KmerMap (usage indépendant) |
debruijn.go |
Graphe de de Bruijn |
Ordre d'implémentation
varint.go+ testsskm_writer.go+skm_reader.go+ testskdi_writer.go+kdi_reader.go+ testskdi_merge.go+ testskmer_set_builder.go+ tests (construction + Close)kmer_set_disk.go(structure, métadonnées, Open)kmer_set_disk_ops.go+ tests (Union, Intersect, Quorum, Jaccard)- Adaptation de
pkg/obitools/obikindex/ - Suppression des anciens fichiers roaring
- Adaptation des tests existants
Chaque étape est testable indépendamment.
Dépendances externes
Supprimées
github.com/RoaringBitmap/roaring: plus nécessaire pour les index k-mers (vérifier si d'autres packages l'utilisent encore)
Ajoutées
- Aucune. Varint, delta-encoding, merge, encodage 2 bits/base : tout est implémentable en Go standard.