Скрипт, который упал, вы почините за пять минут. Скрипт, который отработал молча и выдал неправильный ответ, вы не почините никогда, потому что не узнаете, что он сломан.

В работе с FASTA и FASTQ таких мест неприлично много. Почти все они когда‑то были задуманы как удобство.

Вот три, с которыми я сталкивался чаще всего.

Первое. Инструмент пишет FASTQ, а качества у записи нет. Вместо ошибки он подставляет строку из I. Файл получается валидный, парсер доволен, а фильтр по качеству дальше по пайплайну видит идеальные риды и пропускает всё подряд.

Второе. R1 и R2 разъехались: один файл отфильтровали, второй забыли. На выходе абсолютно корректный FASTQ, просто риды спарены не с теми. Формат не нарушен ни в одном байте. Ни один валидатор не возразит.

Третье. Определение Phred‑кодировки по образцу, который одинаково хорошо подходит и под Phred+33, и под Phred+64. Инструмент молча выбирает вариант. Иногда правильный.

Общее у всех трёх одно: вместо ошибки вы получаете правдоподобный результат. И это худший из возможных исходов, потому что ошибку вы бы заметили.

Полгода назад я начал писать fastx, библиотеку и CLI для FASTA/FASTQ на Rust. Основное решение в ней сформулировано так: там, где можно либо угадать, либо признать неоднозначность, признавать неоднозначность.

Что это

Потоковый парсер и писатель FASTA/FASTQ, плюс набор операций вокруг них, которые обычно нужны в один и тот же день: обратный комплемент, трансляция, тримминг по качеству, N50, случайный доступ по .fai, работа с парными ридами. Ещё CLI, если библиотека избыточна.

Парсер держит один растущий буфер и одну запись, поэтому FASTQ на 300 гигабайт стоит ровно столько же памяти, сколько FASTQ на 300 байт. read_into переиспользует запись, и тогда в горячем цикле не происходит ни одного выделения памяти.

use fastx::{FastxReader, Sequence};

let mut reader = fastx::open("reads.fq.gz")?;
let mut record = Sequence::default();
let mut bases = 0u64;
while reader.read_into(&mut record)? {
    bases += record.len() as u64;
}

Формат определяется по расширению и по первым байтам, так что gzip с неправильным именем тоже откроется. Внутри #![forbid(unsafe_code)] и две зависимости: memchr и flate2 для сжатия. С default-features = false остаётся только memchr.

Зачем ещё один парсер

Вопрос справедливый, в Rust уже есть хорошие библиотеки для этого. Отвечу прямо: я не утверждаю, что быстрее. Парсинг у всех упирается в один порядок величин, и на реальных данных разница между приличными реализациями теряется на фоне диска и распаковки. Мерить, кто на десять процентов быстрее там, где узкое место в другом месте, занятие бессмысленное.

Упор я делал на другое. На то, чтобы библиотека не портила данные молча, и на то, чтобы вокруг парсера сразу лежало то, за чем обычно идёшь в соседний инструмент: .fai, совместимый с samtools faidx в обе стороны, настоящий BGZF с seek, проверка парности ридов, тримминг, CLI одним бинарником.

Если вам нужен просто быстрый парсер и остальное вы делаете сами, берите любой, разница будет невелика.

Решения, о которых стоит знать

FASTQ разбирается по длине, а не по счёту строк. Качество читается до тех пор, пока его длина не сравняется с длиной последовательности. Из этого бесплатно следует поддержка многострочного FASTQ и полное безразличие к символу @ в начале строки качества, на котором ломается наивный построчный разбор.

Ничего не выдумывается. Запись FASTQ без качества это ошибка, а не выдуманные I. Запись FASTQ‑записи в FASTA‑писатель отбрасывает качество, потому что именно это и должен делать fq2fa.

Определение кодировки честно признаёт неоднозначность.

QualityEncoding::detect(sample) // None, если образец подходит и под Phred+33, и под Phred+64

Смещение Phred в модуле qual всегда указывается явно. Мне кажется, любое API, где кодировку можно случайно не указать, рано или поздно даст неверный ответ.

Неопознанный формат это ошибка. Если ни расширение, ни первый байт не сказали, что перед нами, вы получите Error::UnknownFormat, а не догадку. Формат всегда можно задать руками через ReaderBuilder::format.

Ошибки несут номер строки и id записи. Битая запись не пропускается тихо, она превращается в ошибку с координатами, по которым файл можно открыть и посмотреть глазами.

Индекс отказывается от рваных записей, ровно как samtools faidx: короче остальных может быть только последняя строка записи, длиннее не может быть ни одна.

Парные риды

Это тот случай, ради которого стоило писать отдельный модуль.

R1 и R2 обязаны идти в ногу. Когда они расходятся, а расходятся они регулярно (один файл прогнали через фильтр без второго, или файлы отсортированы по‑разному), на выходе получается совершенно правильный FASTQ. Просто пары в нём неправильные. Дальше по пайплайну об этом не узнает никто.

PairedReader проверяет имена мейтов на каждой паре, и рассинхрон становится ошибкой на первой же записи вместо неверного ответа в конце:

use fastx::paired::PairedReader;

let mut reader = PairedReader::open("reads_R1.fq.gz", "reads_R2.fq.gz")?;
reader.for_each_pair(|pair| {
    assert!(pair.names_match());
    Ok(())
})?;

Interleaved‑вход работает так же, через PairedReader::open_interleaved. Если файл мейта кончился раньше, это ошибка, а не тихая остановка.

BGZF, то есть сжатие и seek одновременно

Обычный gzip это один поток deflate, и seek в нём невозможен в принципе. BGZF, который пишет bgzip, это валидный gzip, нарезанный на независимые блоки по 64 KiB, поэтому с индексом .gzi любое смещение достаётся одним переходом.

Сжатый вывод в fastx по умолчанию BGZF именно поэтому. BgzfReader реализует Read + Seek в несжатых координатах, так что подставляется в любой код, обобщённый по этим трейтам. BgzfWriter отдаёт индекс, который построил, пока писал:

use fastx::bgzf::BgzfWriter;

let mut writer = BgzfWriter::create("reads.fq.gz")?;
writer.write_all(b"@r\nACGT\n+\nIIII\n")?;
let (_file, index) = writer.finish_with_index()?;
index.write_to_path("reads.fq.gz")?; // reads.fq.gz.gzi

Один и тот же .fai описывает файл независимо от того, сжат он или нет, потому что смещения в нём несжатые.

Границы блоков стоят немного сжатия: FASTQ на 81 MiB вышел на 4.9% крупнее, чем при обычном gzip на том же уровне. Взамен вы получаете случайный доступ, которого обычный gzip не даст ни за какие деньги. Если он всё‑таки не нужен, есть Compression::Gzip.

Про скорость, честно

Микробенчмарки на ноутбуке (Windows 11, x86-64, release, один поток, медиана из 100 замеров):

что измерялось

скорость

чтение FASTQ, риды 150 bp, read_into

~1.0 GiB/s

чтение FASTQ, итератором (аллокация на запись)

~620 MiB/s

чтение FASTA, записи 10 kb, перенос по 60

~1.8 GiB/s

запись FASTQ

~1.4 GiB/s

обратный комплемент

~1.3 GiB/s

трансляция

~330 MiB/s

Абсолютные числа у вас будут другими, воспроизводится через cargo bench. Интересна тут только разница между первыми двумя строками, потому что единственное, чем они отличаются, это одна аллокация на запись.

А теперь то, что полезнее любого микробенчмарка. Сквозной прогон через CLI на FASTQ размером 81 MiB (250 000 ридов по 150 bp):

этап

время

комментарий

только разбор

0.12–0.21 с

400–700 MiB/s

разбор и статистика

0.27–0.41 с

состав и гистограмма качества

разбор и запись без сжатия

~0.25 с

чтение gzip

+0.6–0.8 с

распаковка, один поток

запись gzip, уровень 6

10.7–18.6 с

38.9 MB на выходе

запись gzip, уровень 1

2.8 с

42.2 MB на выходе

Разброс между прогонами на этом ноутбуке широкий, на строках с gzip до 1.7 раза, так что диапазоны это именно диапазоны. Пара «уровень 1 против уровня 6» мерялась в одном прогоне, поэтому соотношение 6.5x здесь единственное число, которому можно верить без оговорок.

Отсюда два вывода, которые стоит применить.

Первый: парсер не ваше узкое место. На 700 MiB/s он быстрее большинства дисков и почти любой сети. Оптимизировать его дальше бессмысленно, вы просто будете быстрее ждать диск.

Второй: узкое место это компрессия, и рычаг тут уровень. Уровень 1 быстрее шестого в 6.5 раза при выходе на 8% больше. Для промежуточных файлов, которые тут же прочитает следующая стадия пайплайна, шестой уровень это чистая потеря времени.

Чего тут нет

Это не фреймворк для биоинформатики. Нет выравнивания, нет работы с SAM/BAM/VCF, нет ничего про сборку. Только последовательности и то, что делают с ними в первый день.

Библиотеке полгода, в реальных боевых пайплайнах она не обкатана. Тестов 164 штуки (юнит, интеграционные, CLI через настоящий бинарник, property, doc), CI на Linux, macOS и Windows, cargo deny на лицензии и RUSTSEC, проверка MSRV и короткий прогон фаззинга. В SECURITY.md записано, что входные файлы считаются враждебными, а тихо неверные данные считаются уязвимостью наравне с паникой. Но тесты это не то же самое, что годы использования, и я это понимаю.

Если найдёте место, где библиотека молча делает не то, я буду считать это багом, а не мелочью. Пишите в issues.

Ссылки

Код: github.com/ScioFuturum/fastx Документация: docs.rs/fastx Лицензия двойная, MIT или Apache-2.0. MSRV 1.74.

toml

[dependencies]
fastx-io = "0.1"
cargo install fastx-io