diff --git a/bench/CMakeLists.txt b/bench/CMakeLists.txt index 3706914..baa0d3f 100644 --- a/bench/CMakeLists.txt +++ b/bench/CMakeLists.txt @@ -70,6 +70,33 @@ add_test(NAME bench_frame_selfcheck COMMAND bench_lz4 --frame --selfcheck) set_tests_properties(bench_frame_selfcheck PROPERTIES LABELS gpu RUN_SERIAL TRUE) +# The Snappy CPU denominator (issue #164). No CUDA in it at all: there is no +# Snappy kernel yet, so the only decoder this times is the reference, and the +# binary needs neither a device nor a kernel header. It links the same +# oracle target tests/ pins and the same fixture library, because the Snappy +# streams in this project have ONE provenance (tests/fixtures.h) and a bench +# that compressed its own would be a second one nobody reconciles. +add_executable(bench_snappy bench_snappy.cpp) +target_link_libraries(bench_snappy PRIVATE cudec cudec_test_fixtures + snappy_oracle) +# The corpus digest reads src/xxhash64.h, the hash already in the tree. +target_include_directories(bench_snappy PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../src) +set_target_properties(bench_snappy PROPERTIES CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON) +# -O2 unconditionally for the reason bench_lz4 gives above: the documented +# container command sets no CMAKE_BUILD_TYPE, and an -O0 reference decoder +# would be timed instead of the reference decoder. +target_compile_options(bench_snappy PRIVATE -Wall -Wextra -Werror -O2) + +# Same rot protection as the entries above, and one more thing besides. The +# selfcheck builds both corpus shapes from a fixed PRNG and asserts the +# digest of each, so a moved compressor pin, a changed chunk size or a +# generator that lost its noise source reds CI - none of which would stop the +# corpus round-tripping, which is why the round trip alone is not the check. +# CPU-only, so it runs on the GPU-less runner. +add_test(NAME bench_snappy_selfcheck COMMAND bench_snappy --selfcheck) + # The Zstd worst-case corpus (issue #229). No CUDA in it at all: the frames # are constructed on the host, emitted through the reference's own # sequence-compression entry point and decoded by the reference, so the @@ -112,5 +139,6 @@ add_test(NAME bench_zstd_worst_selfcheck COMMAND bench_zstd --worst set_tests_properties( bench_selfcheck bench_worst4b_selfcheck bench_longmatch_selfcheck bench_assetlike_selfcheck bench_zstd_worst_selfcheck bench_frame_selfcheck + bench_snappy_selfcheck PROPERTIES TIMEOUT ${CUDEC_TEST_TIMEOUT_SECONDS}) cudec_assert_test_timeouts() diff --git a/bench/bench_lz4.cpp b/bench/bench_lz4.cpp index 5279fd5..034b912 100644 --- a/bench/bench_lz4.cpp +++ b/bench/bench_lz4.cpp @@ -774,20 +774,6 @@ double DecodeAllSeconds(const Corpus& corpus, unsigned char* scratch) { return std::chrono::duration(end - start).count(); } -std::string HostCpuName() { - std::ifstream in("/proc/cpuinfo"); - std::string line; - while (std::getline(in, line)) { - if (line.rfind("model name", 0) == 0) { - const size_t colon = line.find(':'); - if (colon != std::string::npos) { - return line.substr(colon + 2); - } - } - } - return "unknown host CPU"; -} - std::string CudaDeviceLine() { int count = 0; if (cudaGetDeviceCount(&count) != cudaSuccess || count == 0) { @@ -994,7 +980,7 @@ bool RunFrameRung(const std::vector& source, std::printf("- decoder: cudec_lz4f_decompress (host frame in, host bytes " "out; H2D, decode, D2H, assembly and checksums are all " "inside the timed call)\n"); - std::printf("- host CPU: %s\n", HostCpuName().c_str()); + std::printf("- host CPU: %s\n", cudec_bench::HostCpuName().c_str()); std::printf("- CUDA device: %s\n", CudaDeviceLine().c_str()); std::printf("- cudec: %d\n", cudec_version()); std::printf("- corpus: %s, %.2f MB original, %.2f MB frame (ratio " @@ -1095,7 +1081,7 @@ void PrintReport(const Corpus& corpus, const std::vector& sorted, std::printf("- decoder: CPU oracle, LZ4_decompress_safe (liblz4 %s), " "single thread\n", LZ4_versionString()); - std::printf("- host CPU: %s\n", HostCpuName().c_str()); + std::printf("- host CPU: %s\n", cudec_bench::HostCpuName().c_str()); std::printf("- CUDA device: %s\n", CudaDeviceLine().c_str()); std::printf("- cudec: %d (the CPU rows time the liblz4 oracle baseline; " "the GPU rows below, when --gpu is set, time cudec's " diff --git a/bench/bench_snappy.cpp b/bench/bench_snappy.cpp new file mode 100644 index 0000000..c6672f9 --- /dev/null +++ b/bench/bench_snappy.cpp @@ -0,0 +1,453 @@ +/* The Snappy benchmark harness: the CPU denominator every later GPU number + * is read against. There is no Snappy kernel yet, so this measures the + * reference decoder alone, and it says so in its own report rather than + * leaving a reader to infer it (docs/MASTERPLAN.md section 5, honest + * numbers). + * + * Two corpus shapes, because the batch API and the format disagree about + * what a unit is. Snappy's own framing is a whole stream per file; the + * shape this library decodes is a page, which the columnar formats emit at + * 64 KiB. A denominator taken on one shape does not transfer to the other, + * so both are built from the same bytes and reported separately. + * + * Every corpus carries a digest of what was actually built, so a run that + * silently compressed something else cannot be mistaken for a comparable + * one. */ +#include "bench_stats.h" +#include "cudec.h" +#include "fixtures.h" +#include "xxhash64.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +/* The page size the columnar formats emit and the batch API targets. */ +constexpr size_t kChunkBytes = 65536; + +constexpr size_t kMaxRuns = 1000000; + +/* The selfcheck source. Compressible enough that the streams are not all + * literals, several chunks long at the chunked shape, and from a fixed PRNG + * so a failure reproduces. */ +constexpr size_t kSelfcheckBytes = 3u << 20; + +enum class Shape { + /* One Snappy raw stream per input file. */ + kWhole, + /* One Snappy raw stream per 64 KiB of input. */ + kChunked, +}; + +struct Corpus { + std::string name; + Shape shape = Shape::kWhole; + std::vector> originals; + std::vector> compressed; + size_t original_bytes = 0; + size_t compressed_bytes = 0; + /* Printed verbatim in the methodology block, so it must stay true for + * whichever corpus ran. */ + std::string provenance = + "compressed in-harness by the pinned snappy oracle"; +}; + +const char* ShapeName(Shape shape) { + return shape == Shape::kWhole ? "whole-file streams" + : "64 KiB-chunked streams"; +} + +/* The corpus lock. + * + * What has to be caught is drift: a compressor pin that moved, a chunking + * rule that changed, an input file that is not the one the report names. + * All three change the produced streams, so the digest runs over those and + * not over the inputs - the inputs are already pinned by the manifest + * bench/get-corpora.sh writes, and adding a second fetch path or a second + * input lock here would be two authorities on one fact. + * + * The digest is a fold rather than one hash over the concatenation, so it + * costs 16 bytes per stream instead of a second copy of the corpus: each + * stream contributes its length and its own XXH64, little-endian, in + * corpus order, and the reported digest is the XXH64 of that array. + * Reordering, retruncating or recompressing any stream moves it. + * + * XXH64 and not SHA-256, stated so nobody reads more into it than is + * there: this is a drift detector over data the harness just built, not a + * defence against a chosen collision, and it is the hash already in the + * tree (src/xxhash64.h) rather than a new dependency for a bench. */ +void AppendLe64(uint64_t value, std::vector* out) { + for (unsigned i = 0; i < 8; i++) { + out->push_back(static_cast(value >> (i * 8))); + } +} + +uint64_t CorpusDigest(const Corpus& corpus) { + std::vector fold; + fold.reserve(corpus.compressed.size() * 16); + for (const auto& stream : corpus.compressed) { + AppendLe64(stream.size(), &fold); + AppendLe64(cudec_detail::Xxh64(stream.data(), stream.size()), &fold); + } + return cudec_detail::Xxh64(fold.data(), fold.size()); +} + +bool AppendFile(const std::string& path, Shape shape, Corpus* corpus) { + std::ifstream in(path, std::ios::binary); + if (!in) { + std::fprintf(stderr, "cannot open corpus file: %s\n", path.c_str()); + return false; + } + const size_t before = corpus->originals.size(); + if (shape == Shape::kChunked) { + while (true) { + std::vector chunk(kChunkBytes); + in.read(reinterpret_cast(chunk.data()), + static_cast(kChunkBytes)); + const std::streamsize got = in.gcount(); + if (got <= 0) { + break; + } + chunk.resize(static_cast(got)); + corpus->originals.push_back(std::move(chunk)); + } + } else { + std::vector whole; + char buffer[1 << 16]; + while (in.read(buffer, sizeof(buffer)) || in.gcount() > 0) { + whole.insert(whole.end(), buffer, buffer + in.gcount()); + } + if (!whole.empty()) { + corpus->originals.push_back(std::move(whole)); + } + } + /* Fail closed on I/O trouble and on zero contribution, per FILE rather + * than over the accumulated corpus: the accumulated test goes vacuous + * from the second argument on, and a file that contributed nothing must + * never end up attested in the methodology block. */ + if (in.bad()) { + std::fprintf(stderr, "read error in corpus file: %s\n", path.c_str()); + return false; + } + if (corpus->originals.size() == before) { + std::fprintf(stderr, "corpus file contributed no data: %s\n", + path.c_str()); + return false; + } + return true; +} + +void CompressAll(Corpus* corpus) { + for (const auto& original : corpus->originals) { + corpus->compressed.push_back(SnappyCompressBlock(original)); + corpus->original_bytes += original.size(); + corpus->compressed_bytes += corpus->compressed.back().size(); + } +} + +/* The oracle is the sole authority on validity, and it says so before any + * timing: a number taken on a stream the reference refuses, or on one that + * does not round-trip, is a number about nothing. */ +bool VerifyCorpus(const Corpus& corpus) { + for (size_t i = 0; i < corpus.compressed.size(); i++) { + std::vector decoded; + if (!SnappyOracleDecodes(corpus.compressed[i], &decoded)) { + std::fprintf(stderr, "the oracle refuses stream %zu of %s\n", i, + corpus.name.c_str()); + return false; + } + if (decoded != corpus.originals[i]) { + std::fprintf(stderr, "stream %zu of %s does not round-trip\n", i, + corpus.name.c_str()); + return false; + } + } + return true; +} + +/* One timed pass over the whole corpus. The timed region is + * snappy::RawUncompress alone: the destination buffers are allocated and + * the declared lengths read outside it, so the number is the decoder's and + * not the allocator's. + * + * Returns a negative duration if any stream fails, so a broken decode can + * never be reported as a fast one. */ +double DecodeAllSeconds(const Corpus& corpus, + std::vector>* buffers) { + const auto start = std::chrono::steady_clock::now(); + for (size_t i = 0; i < corpus.compressed.size(); i++) { + const auto& stream = corpus.compressed[i]; + if (!snappy::RawUncompress(reinterpret_cast(stream.data()), + stream.size(), (*buffers)[i].data())) { + return -1.0; + } + } + const auto end = std::chrono::steady_clock::now(); + return std::chrono::duration(end - start).count(); +} + +bool MakeBuffers(const Corpus& corpus, + std::vector>* buffers) { + buffers->clear(); + for (const auto& stream : corpus.compressed) { + size_t declared = 0; + if (!snappy::GetUncompressedLength( + reinterpret_cast(stream.data()), stream.size(), + &declared)) { + std::fprintf(stderr, "no declared length in a %s stream\n", + corpus.name.c_str()); + return false; + } + buffers->push_back(std::vector(declared)); + } + return true; +} + +void PrintReport(const Corpus& corpus, const std::vector& sorted, + size_t warmup, size_t runs) { + std::vector sizes; + for (const auto& original : corpus.originals) { + sizes.push_back(original.size()); + } + std::sort(sizes.begin(), sizes.end()); + const double to_gbps = static_cast(corpus.original_bytes) / 1e9; + + std::printf("## bench_snappy report\n"); + std::printf("- decoder: CPU oracle, snappy::RawUncompress (google/snappy " + "%d.%d.%d), single thread. cudec has no Snappy kernel yet, so " + "this report is the denominator and carries no cudec " + "number\n", + SNAPPY_MAJOR, SNAPPY_MINOR, SNAPPY_PATCHLEVEL); + std::printf("- host CPU: %s\n", cudec_bench::HostCpuName().c_str()); + std::printf("- cudec: %d\n", cudec_version()); + std::printf("- corpus: %s, %s, %zu streams, %.2f MB original, %.2f MB " + "compressed (ratio %.3f), %s\n", + corpus.name.c_str(), ShapeName(corpus.shape), + corpus.originals.size(), + static_cast(corpus.original_bytes) / 1e6, + static_cast(corpus.compressed_bytes) / 1e6, + static_cast(corpus.compressed_bytes) / + static_cast(corpus.original_bytes), + corpus.provenance.c_str()); + std::printf("- corpus digest: %016llx (XXH64 over per-stream length and " + "XXH64, little-endian, in corpus order)\n", + static_cast(CorpusDigest(corpus))); + std::printf("- stream sizes: min %zu / median %zu / max %zu bytes " + "uncompressed\n", + sizes.front(), sizes[sizes.size() / 2], sizes.back()); + std::printf("- method: %zu warmup + %zu measured runs, wall clock per " + "whole-corpus decode; the timed region is " + "snappy::RawUncompress only (no allocation, no length " + "parse); every stream round-trip-verified against the " + "original once before timing; percentiles are nearest-rank\n", + warmup, runs); + std::printf("- wall per run: p50 %.3f ms / p90 %.3f ms / p99 %.3f ms\n", + cudec_bench::Percentile(sorted, 50) * 1e3, + cudec_bench::Percentile(sorted, 90) * 1e3, + cudec_bench::Percentile(sorted, 99) * 1e3); + std::printf("- decode throughput: p50 %.3f GB/s / p90 %.3f GB/s / p99 " + "%.3f GB/s\n", + to_gbps / cudec_bench::Percentile(sorted, 50), + to_gbps / cudec_bench::Percentile(sorted, 90), + to_gbps / cudec_bench::Percentile(sorted, 99)); +} + +bool RunCorpus(Corpus* corpus, size_t warmup, size_t runs) { + CompressAll(corpus); + if (corpus->original_bytes == 0) { + std::fprintf(stderr, "corpus is empty - nothing to benchmark\n"); + return false; + } + if (!VerifyCorpus(*corpus)) { + return false; + } + std::vector> buffers; + if (!MakeBuffers(*corpus, &buffers)) { + return false; + } + for (size_t i = 0; i < warmup; i++) { + if (DecodeAllSeconds(*corpus, &buffers) < 0) { + std::fprintf(stderr, "a warmup decode failed\n"); + return false; + } + } + std::vector times; + for (size_t i = 0; i < runs; i++) { + const double seconds = DecodeAllSeconds(*corpus, &buffers); + if (seconds < 0) { + std::fprintf(stderr, "a measured decode failed\n"); + return false; + } + times.push_back(seconds); + } + std::sort(times.begin(), times.end()); + PrintReport(*corpus, times, warmup, runs); + return true; +} + +std::vector MakeSelfcheckSource(size_t bytes) { + std::vector out(bytes); + uint64_t state = 0x9E3779B97F4A7C15ull; + for (size_t i = 0; i < bytes; i++) { + state = state * 6364136223846793005ull + 1442695040888963407ull; + /* Runs of a repeating alphabet with occasional noise: copies for the + * decoder to execute, without collapsing to one long copy. */ + out[i] = (i % 61 == 0) ? static_cast(state >> 56) + : static_cast('a' + (i / 7) % 26); + } + return out; +} + +/* The selfcheck's corpus is generated from a fixed PRNG and compressed by + * the pinned oracle, so both shapes are reproducible byte for byte and + * their digests are constants. Asserting them is what makes this a rot + * check rather than a run: a compressor pin that moved, a chunk size that + * changed, or a generator that lost its noise source all move a digest, + * and none of them would stop the corpus round-tripping. */ +constexpr uint64_t kSelfcheckWholeDigest = 0x82b0f9596c1b94e8ull; +constexpr uint64_t kSelfcheckChunkedDigest = 0xe7c9cffbc38ed2e4ull; + +bool CheckDigest(const Corpus& corpus, uint64_t expected) { + const uint64_t actual = CorpusDigest(corpus); + if (actual == expected) { + return true; + } + std::fprintf(stderr, + "the %s selfcheck corpus digest moved: expected %016llx, " + "built %016llx - the corpus this harness constructs is not " + "the one its numbers were recorded on\n", + ShapeName(corpus.shape), + static_cast(expected), + static_cast(actual)); + return false; +} + +bool ParseCount(const char* text, size_t low, size_t high, size_t* out) { + char* end = nullptr; + const unsigned long long value = std::strtoull(text, &end, 10); + if (end == text || *end != '\0' || value < low || value > high) { + return false; + } + *out = static_cast(value); + return true; +} + +} // namespace + +int main(int argc, char** argv) { + size_t runs = 30; + size_t warmup = 3; + bool selfcheck = false; + bool whole = false; + bool chunked = false; + std::vector files; + for (int i = 1; i < argc; i++) { + const std::string arg = argv[i]; + if (arg == "--selfcheck") { + selfcheck = true; + } else if (arg == "--whole") { + whole = true; + } else if (arg == "--chunked") { + chunked = true; + } else if (arg == "--runs" && i + 1 < argc) { + if (!ParseCount(argv[++i], 1, kMaxRuns, &runs)) { + std::fprintf(stderr, "--runs must be in [1, %zu]\n", kMaxRuns); + return 2; + } + } else if (arg == "--warmup" && i + 1 < argc) { + if (!ParseCount(argv[++i], 0, kMaxRuns, &warmup)) { + std::fprintf(stderr, "--warmup must be in [0, %zu]\n", + kMaxRuns); + return 2; + } + } else if (arg == "--runs" || arg == "--warmup") { + std::fprintf(stderr, "%s needs a value\n", arg.c_str()); + return 2; + } else if (!arg.empty() && arg[0] == '-') { + std::fprintf(stderr, "usage: bench_snappy [--runs N] [--warmup N] " + "[--whole] [--chunked] [--selfcheck] " + "[corpus files...]\n"); + return 2; + } else { + files.push_back(arg); + } + } + /* Neither shape named means both, which is the recorded run. */ + if (!whole && !chunked) { + whole = true; + chunked = true; + } + if (selfcheck) { + warmup = 1; + runs = 3; + } + if (files.empty() && !selfcheck) { + std::fprintf(stderr, "bench_snappy needs corpus files (the recorded " + "run uses bench/corpora/silesia/*); --selfcheck " + "runs it on a generated source instead\n"); + return 2; + } + + const std::vector generated = + selfcheck ? MakeSelfcheckSource(kSelfcheckBytes) + : std::vector(); + + const Shape shapes[] = {Shape::kWhole, Shape::kChunked}; + for (Shape shape : shapes) { + if (shape == Shape::kWhole && !whole) { + continue; + } + if (shape == Shape::kChunked && !chunked) { + continue; + } + Corpus corpus; + corpus.shape = shape; + if (selfcheck) { + corpus.name = "generated (selfcheck)"; + corpus.provenance = "generated in-harness from a fixed PRNG, " + "compressed by the pinned snappy oracle"; + if (shape == Shape::kChunked) { + for (size_t off = 0; off < generated.size(); + off += kChunkBytes) { + const size_t take = + std::min(kChunkBytes, generated.size() - off); + const auto first = + generated.begin() + static_cast(off); + corpus.originals.push_back(std::vector( + first, first + static_cast(take))); + } + } else { + corpus.originals.push_back(generated); + } + } else { + for (const auto& path : files) { + if (!AppendFile(path, shape, &corpus)) { + return 1; + } + const size_t slash = path.find_last_of("/\\"); + corpus.name += + (corpus.name.empty() ? "" : "+") + + path.substr(slash == std::string::npos ? 0 : slash + 1); + } + } + if (!RunCorpus(&corpus, warmup, runs)) { + return 1; + } + if (selfcheck && !CheckDigest(corpus, shape == Shape::kWhole + ? kSelfcheckWholeDigest + : kSelfcheckChunkedDigest)) { + return 1; + } + std::printf("\n"); + } + return 0; +} diff --git a/bench/bench_stats.h b/bench/bench_stats.h index 932e42b..0fc0673 100644 --- a/bench/bench_stats.h +++ b/bench/bench_stats.h @@ -12,6 +12,8 @@ #define CUDEC_BENCH_STATS_H #include +#include +#include #include namespace cudec_bench { @@ -40,6 +42,24 @@ inline double GbpsFromMs(double gb, double ms) { return ms > 0.0 ? gb / (ms / 1e3) : 0.0; } +/* The host the numbers were taken on, read from the kernel rather than + * configured, so a report cannot attest a CPU the run did not happen on. + * Here rather than in one harness because every report block names it and a + * second hand-matched copy is what this header exists to prevent. */ +inline std::string HostCpuName() { + std::ifstream in("/proc/cpuinfo"); + std::string line; + while (std::getline(in, line)) { + if (line.rfind("model name", 0) == 0) { + const size_t colon = line.find(':'); + if (colon != std::string::npos) { + return line.substr(colon + 2); + } + } + } + return "unknown host CPU"; +} + } // namespace cudec_bench #endif /* CUDEC_BENCH_STATS_H */ diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 10d46e2..ef6676a 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -943,6 +943,68 @@ different mechanism and is not evaluated here; overlap belongs to the streaming context work, which owns a buffer across calls and so does not pay the pinning per call. +## M3: the Snappy CPU denominator (issue #164) + +There is no Snappy kernel yet. This entry is the denominator a later device +number will be read against, and the harness says so in its own report rather +than leaving a reader to infer it from the absence of a GPU row. + +Two corpus shapes, because the format and the batch API disagree about what a +unit is. Snappy's own framing is one stream per file; the shape this library +decodes is a page, which the columnar formats emit at 64 KiB. Both are built +from the same Silesia bytes by the pinned snappy 1.2.2 compressor, verified +stream by stream against the reference decoder before anything is timed, and +reported separately. + +Each report carries a digest of the corpus that was actually built, folded +over the produced streams rather than over the inputs (the inputs are already +pinned by the manifest `bench/get-corpora.sh` writes). It is order-sensitive, +so the digests below belong to the file order a glob produces. Recorded +2026-08-10 inside the digest-pinned `nvidia/cuda:12.6.2-devel-ubuntu24.04` +container, on the host CPU named in the blocks. Reproduce with +`bench_snappy --warmup 3 --runs 30 bench/corpora/silesia/*`. + +``` +## bench_snappy report +- decoder: CPU oracle, snappy::RawUncompress (google/snappy 1.2.2), single thread. cudec has no Snappy kernel yet, so this report is the denominator and carries no cudec number +- host CPU: AMD Ryzen 9 5950X 16-Core Processor +- cudec: 100 +- corpus: dickens+mozilla+mr+nci+ooffice+osdb+reymont+samba+sao+webster+x-ray+xml, whole-file streams, 12 streams, 211.94 MB original, 101.35 MB compressed (ratio 0.478), compressed in-harness by the pinned snappy oracle +- corpus digest: 7bd83d9e3b24fd44 (XXH64 over per-stream length and XXH64, little-endian, in corpus order) +- stream sizes: min 5345280 / median 10085684 / max 51220480 bytes uncompressed +- method: 3 warmup + 30 measured runs, wall clock per whole-corpus decode; the timed region is snappy::RawUncompress only (no allocation, no length parse); every stream round-trip-verified against the original once before timing; percentiles are nearest-rank +- wall per run: p50 177.088 ms / p90 181.655 ms / p99 186.636 ms +- decode throughput: p50 1.197 GB/s / p90 1.167 GB/s / p99 1.136 GB/s +``` + +``` +## bench_snappy report +- decoder: CPU oracle, snappy::RawUncompress (google/snappy 1.2.2), single thread. cudec has no Snappy kernel yet, so this report is the denominator and carries no cudec number +- host CPU: AMD Ryzen 9 5950X 16-Core Processor +- cudec: 100 +- corpus: dickens+mozilla+mr+nci+ooffice+osdb+reymont+samba+sao+webster+x-ray+xml, 64 KiB-chunked streams, 3239 streams, 211.94 MB original, 101.36 MB compressed (ratio 0.478), compressed in-harness by the pinned snappy oracle +- corpus digest: be088850546d917c (XXH64 over per-stream length and XXH64, little-endian, in corpus order) +- stream sizes: min 8066 / median 65536 / max 65536 bytes uncompressed +- method: 3 warmup + 30 measured runs, wall clock per whole-corpus decode; the timed region is snappy::RawUncompress only (no allocation, no length parse); every stream round-trip-verified against the original once before timing; percentiles are nearest-rank +- wall per run: p50 177.413 ms / p90 184.445 ms / p99 187.988 ms +- decode throughput: p50 1.195 GB/s / p90 1.149 GB/s / p99 1.127 GB/s +``` + +**Cutting the corpus into 64 KiB pages costs the reference decoder nothing +measurable.** 177.088 ms whole against 177.413 ms chunked is 0.18% apart, well +inside the p50-to-p99 spread of either row, and the compressed size moves by +10 KB in 101 MB. That matters for what comes later: a device number quoted +against the chunked denominator is not being flattered by a handicapped +baseline, because the two baselines are the same number. + +**Snappy's reference decoder is about 2.8x slower than liblz4's on these +bytes, at the same ratio.** The LZ4 CPU-oracle row further down this file +reports 3.410 GB/s p50 over the same 211.94 MB at ratio 0.483; this one +reports 1.197 GB/s at ratio 0.478. Both are single-thread wall clock on the +same host with the timed region held to the decode call alone, so the +comparison is between the two references and says nothing about either GPU +path. + ## Community AMD results **No community results yet.** Nothing in this section is a measurement; it