Skip to content

Cram embed_ref=2 and huffman optimisation - #2082

Merged
daviesrob merged 3 commits into
samtools:developfrom
jkbonfield:cram_embed_ref2_opt
Aug 27, 2026
Merged

Cram embed_ref=2 and huffman optimisation#2082
daviesrob merged 3 commits into
samtools:developfrom
jkbonfield:cram_embed_ref2_opt

Conversation

@jkbonfield

@jkbonfield jkbonfield commented Aug 27, 2026

Copy link
Copy Markdown
Contributor
  • Put an upper limit on the number of cram huffman symbols to prevent malformed data consuming a lot of ram and cpu.
  • Prevent excessive memory and/or cpu usage in embed_ref=2 for extremely sparse data or huge deletion / ref-skip CIGAR ops.
  • Plus a trivial compiler warning fix for fuzz builds.

Removes an unused variable, silencing a compiler warning.
Only relevant to fuzzing builds.

Signed-off-by: James Bonfield <jkb@sanger.ac.uk>
@daviesrob daviesrob self-assigned this Aug 27, 2026
Comment thread cram/cram_encode.c Outdated
// Ensure ref and hist are large enough.
static inline int extend_ref(char **ref, uint32_t (**hist)[5], hts_pos_t pos,
typedef uint8_t hist_t;
#define MAX_HIST_T (uint32_t)((1LL << (8*sizeof(hist_t)))-1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is over-complicated, especially as hist_t is now uint8_t.

Suggested change
#define MAX_HIST_T (uint32_t)((1LL << (8*sizeof(hist_t)))-1)
#define MAX_HIST_T UINT8_MAX

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. It's better than the alternative I was thinking of anyway with const uint32_t instead.

Comment thread cram/cram_encode.c Outdated
Comment on lines +3989 to +3990
if (fd->multi_seq == -1 && c->curr_rec+10 < c->max_rec/4+10 &&
fd->last_slice && fd->last_slice+10 < c->max_rec/4+10 &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we remove the +10 on both sides of the comparison?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As it stood before, it passed the test on tiny containers of 3 records and switched to multi-seq mode.

It's lost in the mists of time[1] as to why I had the +10 there before, but it was wrong anyway. I think I was trying to do a small sample correction, but that's generally (a+const)/(b+const) rather than one-sided. The small constant basically means we don't take these decisions to switch mode unless there is a significant number of alignment records, which I wish to keep intact.

[1] Ie it's maybe in io_lib's history somewhere, but it predates the code arriving in htslib.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found it. It arrived in 2013. :-)

jkbonfield/io_lib@ecec3e6

There was a lot of code though as it was the creation of the multi_seq mode so the finer nuances weren't documented,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may have made sense when the +10 was only on one side, but now it's on both and c->curr_rec+10 < c->max_rec/4+10 is the same as c->curr_rec < c->max_rec/4 (ignoring the possibility of overflow).

@jkbonfield jkbonfield Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doh sorry, implementation failure. It's meant to be (c->curr_rec+10) < (c->max_rec+10)/4 I think for a small sample size correction. What I had before vaguely made sense if it was on the left side (or a -10 on the right obviously), but I'm not sure quite what I was thinking as it was 13 years ago.

Or put another way (if it was floating point): (c->curr_rec+10.0)/(c->max_rec+10.0) < 0.25.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically compare this:

$ perl -e '$N=20;for ($i=0;$i<$N;$i+=$N/10) {print $i/$N,"\t",($i+10)/($N
+10),"\n"}'
0	0.333333333333333
0.1	0.4
0.2	0.466666666666667
0.3	0.533333333333333
0.4	0.6
0.5	0.666666666666667
0.6	0.733333333333333
0.7	0.8
0.8	0.866666666666667
0.9	0.933333333333333

$ perl -e '$N=2000;for ($i=0;$i<$N;$i+=$N/10) {print $i/$N,"\t",($i+10)/($N+10),"\n"}'
0	0.00497512437810945
0.1	0.104477611940299
0.2	0.203980099502488
0.3	0.303482587064677
0.4	0.402985074626866
0.5	0.502487562189055
0.6	0.601990049751244
0.7	0.701492537313433
0.8	0.800995024875622
0.9	0.900497512437811

If we wanted to trigger behaviour when we drop below, say, 50% capacity then on a large block that's basically curr/max at 0.5. On a small block we need a more extreme ratio of 0.2ish, to compensate for error from a low sample size.

The +10 is entirely arbitrary of course, but the principle is sound. Unlike the original (or amended) code!

@daviesrob daviesrob Aug 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, that makes more sense, although the rewritten version is really just c->curr_rec+7.5 < c->max_rec/4. And to avoid the floating point, you'd probably go for either 7 or 8 on the left.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for incorrect original version of that, due to mathematical ineptitude on my part...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I now have c->curr_rec+8 < (c->max_rec+8)/4 which can only succeed when max_rec >= 32. I'm not sure if this is the correct ratio, but it's sufficient I think to act as a starting point.

All of this is heuristics anyway in how best to optimise the CRAM file and when to switch into multi-ref mode.
The intention was simply if you're consistently producing CRAM containers which are a 1/4tr of their requested size, in terms of the number of alignments you've put into them containing the same reference, then switch out of single-ref mode into multi-ref mode (and use the RI data series to indicate which reference is which instead of a constant value in the slice header).

The purpose was for unsorted data basically, where we were originally creating thousands of small containers with 1 or 2 reads in as the chances of have many reads for the same reference together is slim. We just don't want to switch to this mode inappropriately, but the bar is deliberately quite high.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on it basically being able to be simplified, but it obscures the derivation. (X+c)/(N+c) is a standard mechanism for small sample size correction in significance tests, but factoring it out just hides this.

It'll all be reordered by the compiler anyway I expect so let's stick with something clearer. Feel free to add a comment explaining the origins of it if you wish, although it's documented here for anyone who wishes to delve into git blame.

Comment thread cram/cram_codecs.c Outdated
The previously calculation was concerned about memory overflows, but
it still meant excessive numbers of codes could cause big memory and
very slow CPU.

It's now limited to 1,000,000 codes, which is still excessive given
our own files are typically a maximum of 1 code (as we use HUFFMAN
only as a means of encoding constant values as it yields 0-bit data
streams).

1 million codes per data-series is equivalent to a million records in
a cram slice each encoding a value (eg position) via huffman and
having zero duplicates.  Some data series could produce more than 1
token per record, such as no_ref encoded sequence (BB), but then we
only have 4 base types, or at most 16 with IUPAC.

So this is purely a memory and speed limit to prevent malformed data
from consuming resources.

Signed-off-by: James Bonfield <jkb@sanger.ac.uk>
@jkbonfield
jkbonfield force-pushed the cram_embed_ref2_opt branch from 3f0bdbd to 5ae6284 Compare August 27, 2026 13:07
The consensus historgram is now only uint8_t instead of uint32_t,
with protection for over-depth data. This reduces memory of the buffer
by 70%.

We also limit the size of region to approx 85Mb worth of consensus (it
was previously 102Mb worth, so comparable), but with a smaller memory
footprint the CPU overhead isn't so large. (NB possibly up to 1/3rd
smaller due to the size *= 1.5 growth loop.)

For smaller regions, we also have a 1% nbases/region_length check to
avoid the computation cost when it's unlikely to be of any benefit.
This is designed to catch cases where the number of query bases is
very low but the apparent coverage is high, such as due to large
deletions / ref-skips.

If either of these fail, it switches back to no_ref mode.  Note this
is a temporary change and it only becomes permanent if 5 containers in
a row make this switch. (Prior work.)

The impact on extreme bursty data (eg exon only sequencing) was tested.

With 50 clusters of 100 x 100bp reads, 1bp apart (so 100x depth), with
1Mb between clusters (so 5000 sequences in one container):

    embed_ref=2   34900 bytes, 1.78s encode, 0.13s decode
    no_ref        80071 bytes, 0.06s encode, 0.01s decode

At 90 x 100bp reads, embed_ref=2 falls back to no_ref as it fails the
nbases = 1% of region size check.

At 70 clusters of 100 x 100bp, we hit the first limit of consensus
memory size, and it also falls back to no_ref mode.

This feels like an appriopriately sparse level and/or container ref
size to not really care about the compression ratio.

Signed-off-by: James Bonfield <jkb@sanger.ac.uk>
@jkbonfield
jkbonfield force-pushed the cram_embed_ref2_opt branch from 5ae6284 to 03bcd45 Compare August 27, 2026 15:42
@daviesrob
daviesrob merged commit 0bead43 into samtools:develop Aug 27, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants