I shipped CardinalityEstimation 1.15.0 to NuGet today – the largest batch of changes the library has seen, with twenty-one changes all landing together. The theme this release is “tighten the screws everywhere”: real bugs in the concurrent estimator, real allocations in the hot path, and a serializer that wasn’t safe to point at untrusted input. Here’s the rundown.
Security
- Hardened
CardinalityEstimatorSerializer.Readagainst DoS via crafted streams. The deserializer used to readbitsPerIndexand arraycountvalues straight off the wire – a malicious payload could ask forbitsPerIndex = 30(≈1 GB allocation) orcount = Int32.MaxValue(≈2 GB read). All sizes are now bounded against the same limits the public constructors enforce. - Bumped transitive
Newtonsoft.Jsonto 13.0.3 to clear NU1903 in the test project.
Concurrency bug fixes
ConcurrentCardinalityEstimator(GetHashCodeSpanDelegate, …)was silently discarding the supplied span delegate – it chained tothis(state), then the null check onhashFunctionwas always true and overwrote everything with the default XxHash128. Fixed.Add(string)andAdd(byte[])on the concurrent estimator threwNullReferenceExceptioninstead of the documentedArgumentNullExceptionwhen passednull. The non-concurrent version had always validated; now both do.ParallelMergeaccepted aparallelismDegreeargument and silently ignored it. The code built aParallelQueryinto a local that was never read, then called.AsParallel()again four lines later without the degree. The local is gone andWithDegreeOfParallelismis applied to the real query.MergeandEqualsordered their twoReaderWriterLockSlimacquisitions by comparingGetHashCode().object.GetHashCodeis not unique, so two distinct instances with colliding hashes produced an undefined ordering – an AB/BA deadlock window when one thread dida.Merge(b)while another didb.Merge(a). Replaced with a per-instance ID assigned viaInterlocked.Incrementat construction, so the order is strict and total.
Correctness bug fixes
CardinalityEstimator.Merge(IEnumerable<…>)doubledCountAdditionsfor the first element. The first non-null estimator was copied intoresult(which already copiedCountAdditions) and then immediately merged with itself. Fixed by skipping the merge on the seeded element.ConcurrentCardinalityEstimator(CardinalityEstimator other)substituted the default XxHash128 becauseCardinalityEstimatorexposed no API to read its hash function back. Conversion in either direction now preserves both the byte-array and span hash delegates losslessly via newHashFunction/HashFunctionSpanproperties on both types.
Performance
ConcurrentCardinalityEstimatordirect-count storage was aConcurrentBag<ulong>– everyAddwas O(1) butCountand the bag→HashSet conversion inGetStatewere O(n) with locking. Swapped toConcurrentDictionary<ulong, byte>, which deduplicates on insert and gives O(1)Count.- Eliminated heap allocations in primitive
Addoverloads.Add(int)/Add(long)/Add(double)etc. used to callBitConverter.GetBytes, allocating a freshbyte[4]orbyte[8]on every call. They now write into astackallocbuffer withBinaryPrimitivesand route through the span hash delegate.Add(string)does the same for short strings viaEncoding.UTF8.TryGetBytesinto a stackalloc buffer. - Replaced
Math.Pow(2, -sigma)in theCount()summation loop with a precomputedInversePowersOfTwotable. The transcendental call ran up to 65,536 times perCount(); the table lookup shaves a measurable chunk off large-cardinality reads. - Replaced
(int)Math.Pow(2, bitsPerIndex)with1 << bitsPerIndexin estimator constructors. Exact, no floating-point round trip, no transcendental call. - Bulk-write the dense lookup array in the serializer. The 2^b-byte array was being written one byte at a time through
BinaryWriter.Write(byte)(up to 65,536 individual calls); now it’s a singleWrite(byte[]).
API additions
- Public
HashFunctionandHashFunctionSpanproperties onCardinalityEstimatorandConcurrentCardinalityEstimator, so callers can introspect or preserve the hash function across copies, conversions, and serialization round-trips. CardinalityEstimatornow implementsICardinalityEstimatorMemorywithAddoverloads forSpan<byte>,ReadOnlySpan<byte>,Memory<byte>, andReadOnlyMemory<byte>. These route throughGetHashCodeSpanDelegateand avoid the byte-array allocation of the legacy path entirely.
Refactor
- Hoisted the duplicated estimator constants (
DirectCounterMaxElements,StackallocByteThreshold) and helpers (GetAlphaM,GetSubAlgorithmSelectionThreshold,CreateEmptyState) out of both estimator classes into a sharedHllConstantstype. Two implementations, one source of truth. - Documented the intentionally empty
if (dataFormatMajorVersion >= 3) { … }branch in the serializer. The block looked dead but actually intercepts v3+ payloads so they don’t fall through to the v2 byte read; the comment now explains why.
Tests & tooling
- Switched target frameworks from
net8.0/net9.0tonet8.0/net10.0. - Added 30+ tests closing coverage gaps across the estimators, serializer, hash functions, and extensions. Final coverage on
version-1.15: 98% line / 91% branch.
Documentation
- README rewrite. The previous version had an empty “Release Notes” section and no mention of thread safety, the
bparameter, merging, serialization, the span/memory overloads, or hash function selection. All of those now have their own section with runnable examples. Added thedotnet add package CardinalityEstimationinstall snippet alongside the legacyInstall-Packageform, and fixed a dead Google paper link. - XML doc tightening on
ICardinalityEstimatorMemory(clarified thatSpan<byte>/ReadOnlySpan<byte>are the true zero-allocation paths, notMemory<byte>) and onCardinalityEstimatorExtensions.CreateMultiple(thebparameter doc now matches the constructor’s: range, default, accuracy implication).
NuGet: CardinalityEstimation 1.15.0 · Strong-named: CardinalityEstimation.Signed 1.15.0 · Source: github.com/saguiitay/CardinalityEstimation
Should be a drop-in upgrade from 1.14.x – the binary serialization format is unchanged (same major version), the public API is additive, and the deserializer’s new validation only rejects payloads that wouldn’t have come from a legitimate serializer in the first place. Two things worth double-checking if you’ve been using the library in anger:
- If you ever passed a
GetHashCodeSpanDelegatetoConcurrentCardinalityEstimator, your delegate was being silently dropped before this release. After upgrading you’ll start getting the hash you actually asked for, which means counts may shift slightly on existing in-memory instances (serialized state is unaffected). - If you converted between
CardinalityEstimatorandConcurrentCardinalityEstimatorwhile using a non-default hash, the converted instance was using XxHash128 instead. Same situation – your hash is preserved now, so post-upgrade counts on freshly-converted instances will match the source.