From 3697bf8a893f141738dc7c7e65be389cf5cb50ed Mon Sep 17 00:00:00 2001 From: Andreas Joachim Peters Date: Tue, 22 Sep 2026 09:16:39 +0200 Subject: [PATCH 1/5] [ntuple] Use a plain Real32 column for uncompressed Double32_t. Double32_t always forced split encoding, even with compression off. Use kReal32 when there is nothing to encode against. --- tree/ntuple/src/RFieldBase.cxx | 8 ++++++-- tree/ntuple/test/ntuple_types.cxx | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tree/ntuple/src/RFieldBase.cxx b/tree/ntuple/src/RFieldBase.cxx index 42b33d86d3a7d..44ef16b781d3f 100644 --- a/tree/ntuple/src/RFieldBase.cxx +++ b/tree/ntuple/src/RFieldBase.cxx @@ -933,8 +933,12 @@ void ROOT::RFieldBase::AutoAdjustColumnTypes(const ROOT::RNTupleWriteOptions &op SetColumnRepresentatives({rep}); } - if (fTypeAlias == "Double32_t") - SetColumnRepresentatives({{ROOT::ENTupleColumnType::kSplitReal32}}); + if (fTypeAlias == "Double32_t") { + if (options.GetCompression() != 0) + SetColumnRepresentatives({{ROOT::ENTupleColumnType::kSplitReal32}}); + else + SetColumnRepresentatives({{ROOT::ENTupleColumnType::kReal32}}); + } } void ROOT::RFieldBase::ConnectPageSink(ROOT::Internal::RPageSink &pageSink, ROOT::NTupleSize_t firstEntry) diff --git a/tree/ntuple/test/ntuple_types.cxx b/tree/ntuple/test/ntuple_types.cxx index e206d29a36cc8..fea3b38764d06 100644 --- a/tree/ntuple/test/ntuple_types.cxx +++ b/tree/ntuple/test/ntuple_types.cxx @@ -1814,6 +1814,29 @@ TEST(RNTuple, Double32) EXPECT_DOUBLE_EQ(std::numeric_limits::denorm_min(), *d2Float); } +TEST(RNTuple, Double32Uncompressed) +{ + FileRaii fileGuard("test_ntuple_double32_uncompressed.root"); + + auto model = RNTupleModel::Create(); + model->AddField(RFieldBase::Create("d", "Double32_t").Unwrap()); + + auto options = RNTupleWriteOptions(); + options.SetCompression(0); + { + auto writer = RNTupleWriter::Recreate(std::move(model), "ntuple", fileGuard.GetPath(), options); + auto d = writer->GetModel().GetDefaultEntry().GetPtr("d"); + *d = 1.25; + writer->Fill(); + } + + auto reader = RNTupleReader::Open("ntuple", fileGuard.GetPath()); + EXPECT_EQ(ROOT::ENTupleColumnType::kReal32, reader->GetModel().GetConstField("d").GetColumnRepresentatives()[0][0]); + EXPECT_EQ("Double32_t", reader->GetModel().GetConstField("d").GetTypeAlias()); + reader->LoadEntry(0); + EXPECT_DOUBLE_EQ(1.25, *reader->GetModel().GetDefaultEntry().GetPtr("d")); +} + TEST(RNTuple, Double32Extended) { FileRaii fileGuard("test_ntuple_double32_extended.root"); From d4f4fb2f1cf99226cbe7ba013e155c6751db9809 Mon Sep 17 00:00:00 2001 From: Andreas Joachim Peters Date: Tue, 22 Sep 2026 09:16:39 +0200 Subject: [PATCH 2/5] [ntuple] Report importer payload size and on-disk file size. Add RImportReport so Import() exposes compressed page payload, uncompressed pages, and the destination TFile size after the writer commits. --- tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx | 16 ++- tree/ntupleutil/src/RNTupleImporter.cxx | 123 +++++++++++++------ 2 files changed, 102 insertions(+), 37 deletions(-) diff --git a/tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx b/tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx index ab68dd70faef4..75c2fd33301e8 100644 --- a/tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx +++ b/tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx @@ -105,7 +105,15 @@ public: /// Used to make adjustments to the fields of the output model. using FieldModifier_t = std::function; - /// Used to report every ~100 MB (compressed), and at the end about the status of the import. + /// Summary printed after the RNTuple writer commits (footer, streamer info, last cluster). + struct RImportReport { + std::uint64_t fCompressedPayloadBytes = 0; ///< Sealed column page blobs (RPageSinkFile.szWritePayload) + std::uint64_t fUncompressedPageBytes = 0; ///< Logical page bytes before compression (RPageSinkFile.szZip) + std::uint64_t fEntries = 0; + std::uint64_t fFileBytesOnDisk = 0; ///< Destination TFile size after commit (matches ls -lh) + }; + + /// Used to report every ~100 MB of compressed page payload, and at the end about the status of the import. class RProgressCallback { public: virtual ~RProgressCallback() = default; @@ -114,7 +122,7 @@ public: Call(nbytesWritten, neventsWritten); } virtual void Call(std::uint64_t nbytesWritten, std::uint64_t neventsWritten) = 0; - virtual void Finish(std::uint64_t nbytesWritten, std::uint64_t neventsWritten) = 0; + virtual void Finish(const RImportReport &report) = 0; }; private: @@ -219,6 +227,7 @@ private: /// No standard output, conversely if set to false, schema information and progress is printed. bool fIsQuiet = false; + RImportReport fLastImportReport{}; std::unique_ptr fProgressCallback; FieldModifier_t fFieldModifier; @@ -265,6 +274,9 @@ public: /// Whether or not information and progress is printed to stdout. void SetIsQuiet(bool value) { fIsQuiet = value; } + /// Metrics from the most recent Import() call (always filled, even when quiet). + RImportReport GetLastImportReport() const { return fLastImportReport; } + /// Add custom method to adjust column representations. Will be called for every field of the frozen model /// before it is attached to the page sink void SetFieldModifier(const FieldModifier_t &modifier) { fFieldModifier = modifier; } diff --git a/tree/ntupleutil/src/RNTupleImporter.cxx b/tree/ntupleutil/src/RNTupleImporter.cxx index 4a77740bcad65..4bcc1b1d1fc95 100644 --- a/tree/ntupleutil/src/RNTupleImporter.cxx +++ b/tree/ntupleutil/src/RNTupleImporter.cxx @@ -35,24 +35,47 @@ #include #include #include +#include #include +#include #include namespace { +std::string FormatBinarySize(std::uint64_t nbytes) +{ + const char *suffix = "B"; + double v = static_cast(nbytes); + if (v >= 1024.0 * 1024.0 * 1024.0) { + v /= 1024.0 * 1024.0 * 1024.0; + suffix = "GiB"; + } else if (v >= 1024.0 * 1024.0) { + v /= 1024.0 * 1024.0; + suffix = "MiB"; + } else if (v >= 1024.0) { + v /= 1024.0; + suffix = "KiB"; + } + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(v >= 100.0 ? 0 : (v >= 10.0 ? 1 : 2)); + os << v << ' ' << suffix; + return os.str(); +} + class RDefaultProgressCallback : public ROOT::Experimental::RNTupleImporter::RProgressCallback { private: - static constexpr std::uint64_t gUpdateFrequencyBytes = 100 * 1000 * 1000; // report every 100 MB + static constexpr std::uint64_t gUpdateFrequencyBytes = 100 * 1000 * 1000; // report every 100 MB payload std::uint64_t fNbytesNext = gUpdateFrequencyBytes; public: ~RDefaultProgressCallback() override {} void Call(std::uint64_t nbytesWritten, std::uint64_t neventsWritten) final { - // Report if more than 100 MB (compressed) where written since the last status update + // Report if more than 100 MB of compressed page payload were written since the last status update. if (nbytesWritten < fNbytesNext) return; - std::cout << "Wrote " << nbytesWritten / 1000 / 1000 << "MB, " << neventsWritten << " entries\n"; + std::cout << "Wrote " << nbytesWritten / 1000 / 1000 << "MB payload, " << neventsWritten << " entries\n"; fNbytesNext += gUpdateFrequencyBytes; if (nbytesWritten > fNbytesNext) { // If we already passed the next threshold, increase by a sensible amount. @@ -60,9 +83,22 @@ class RDefaultProgressCallback : public ROOT::Experimental::RNTupleImporter::RPr } } - void Finish(std::uint64_t nbytesWritten, std::uint64_t neventsWritten) final + void Finish(const ROOT::Experimental::RNTupleImporter::RImportReport &report) final { - std::cout << "Done, wrote " << nbytesWritten / 1000 / 1000 << "MB, " << neventsWritten << " entries\n"; + std::cout << "Done: " << report.fEntries << " entries\n"; + std::cout << " compressed page payload: " << FormatBinarySize(report.fCompressedPayloadBytes) << '\n'; + std::cout << " on disk: " << FormatBinarySize(report.fFileBytesOnDisk) << '\n'; + if (report.fFileBytesOnDisk > report.fCompressedPayloadBytes) { + const auto overhead = report.fFileBytesOnDisk - report.fCompressedPayloadBytes; + std::cout << " file overhead (approx): " << FormatBinarySize(overhead) + << " (page lists, footer, streamer info, ROOT keys)\n"; + } + if (report.fUncompressedPageBytes > 0 && report.fCompressedPayloadBytes > 0) { + const double ratio = static_cast(report.fCompressedPayloadBytes) / + static_cast(report.fUncompressedPageBytes); + std::cout << " page compression ratio: " << std::fixed << std::setprecision(3) << ratio + << " (payload / uncompressed pages)\n"; + } } }; @@ -394,15 +430,12 @@ void ROOT::Experimental::RNTupleImporter::Import() std::make_unique(ntupleName, *targetDir, fWriteOptions); sink->GetMetrics().Enable(); auto ctrZippedBytes = sink->GetMetrics().GetCounter("RPageSinkFile.szWritePayload"); + auto ctrUnzipBytes = sink->GetMetrics().GetCounter("RPageSinkFile.szZip"); if (fWriteOptions.GetUseBufferedWrite()) { sink = std::make_unique(std::move(sink)); } - auto ntplWriter = ROOT::Internal::CreateRNTupleWriter(std::move(fModel), std::move(sink)); - // The guard needs to be destructed before the writer goes out of scope - RImportGuard importGuard(*this); - fProgressCallback = fIsQuiet ? nullptr : std::make_unique(); auto nEntries = fSourceTree->GetEntries(); @@ -411,36 +444,56 @@ void ROOT::Experimental::RNTupleImporter::Import() nEntries = fMaxEntries; } - for (decltype(nEntries) i = 0; i < nEntries; ++i) { - fSourceTree->GetEntry(i); - - for (auto &[_, c] : fLeafCountCollections) { - const auto sizeOfRecord = c.fRecordField->GetValueSize(); - c.fFieldBuffer.resize(sizeOfRecord * (*c.fCountVal)); - - const auto nLeafs = c.fRecordField->GetConstSubfields().size(); - for (std::size_t l = 0; l < nLeafs; ++l) { - const auto offset = c.fRecordField->GetOffsets()[l]; - const auto sizeOfLeaf = c.fRecordField->GetConstSubfields()[l]->GetValueSize(); - const auto idxImportBranch = c.fLeafBranchIndexes[l]; - for (Int_t j = 0; j < *c.fCountVal; ++j) { - memcpy(c.fFieldBuffer.data() + j * sizeOfRecord + offset, - fImportBranches[idxImportBranch].fBranchBuffer.get() + (j * sizeOfLeaf), sizeOfLeaf); + std::uint64_t payloadBytes = 0; + std::uint64_t unzipBytes = 0; + { + auto ntplWriter = ROOT::Internal::CreateRNTupleWriter(std::move(fModel), std::move(sink)); + // The guard needs to be destructed before the writer goes out of scope + RImportGuard importGuard(*this); + + for (decltype(nEntries) i = 0; i < nEntries; ++i) { + fSourceTree->GetEntry(i); + + for (auto &[_, c] : fLeafCountCollections) { + const auto sizeOfRecord = c.fRecordField->GetValueSize(); + c.fFieldBuffer.resize(sizeOfRecord * (*c.fCountVal)); + + const auto nLeafs = c.fRecordField->GetConstSubfields().size(); + for (std::size_t l = 0; l < nLeafs; ++l) { + const auto offset = c.fRecordField->GetOffsets()[l]; + const auto sizeOfLeaf = c.fRecordField->GetConstSubfields()[l]->GetValueSize(); + const auto idxImportBranch = c.fLeafBranchIndexes[l]; + for (Int_t j = 0; j < *c.fCountVal; ++j) { + memcpy(c.fFieldBuffer.data() + j * sizeOfRecord + offset, + fImportBranches[idxImportBranch].fBranchBuffer.get() + (j * sizeOfLeaf), sizeOfLeaf); + } } } - } - for (auto &t : fImportTransformations) { - auto result = t->Transform(fImportBranches[t->fImportBranchIdx], fImportFields[t->fImportFieldIdx]); - if (!result) - throw RException(R__FORWARD_ERROR(result)); - } + for (auto &t : fImportTransformations) { + auto result = t->Transform(fImportBranches[t->fImportBranchIdx], fImportFields[t->fImportFieldIdx]); + if (!result) + throw RException(R__FORWARD_ERROR(result)); + } - ntplWriter->Fill(*fEntry); + ntplWriter->Fill(*fEntry); - if (fProgressCallback) - fProgressCallback->Call(ctrZippedBytes->GetValueAsInt(), i); - } + if (fProgressCallback) + fProgressCallback->Call(ctrZippedBytes->GetValueAsInt(), i); + } + + payloadBytes = ctrZippedBytes->GetValueAsInt(); + if (ctrUnzipBytes) + unzipBytes = ctrUnzipBytes->GetValueAsInt(); + } // ntplWriter commits footer / streamer info / last cluster here + + fDestFile->Flush(); + RImportReport report; + report.fCompressedPayloadBytes = payloadBytes; + report.fUncompressedPageBytes = unzipBytes; + report.fEntries = nEntries; + report.fFileBytesOnDisk = static_cast(fDestFile->GetSize()); + fLastImportReport = report; if (fProgressCallback) - fProgressCallback->Finish(ctrZippedBytes->GetValueAsInt(), nEntries); + fProgressCallback->Finish(report); } From a63f08664c9aa1e23d97c34f240c798e6e9e250d Mon Sep 17 00:00:00 2001 From: Andreas Joachim Peters Date: Tue, 22 Sep 2026 09:16:52 +0200 Subject: [PATCH 3/5] [ntuple] Remove a performance bottleneck in RNTupleImporter. GetConstSubfields() was allocating a new vector on every TTree entry, which made tree2ntuple and other imports much slower than they should be. Caching the leaf-count layout once removes that bottleneck and makes the conversion run much quicker. --- tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx | 8 +++++ tree/ntupleutil/src/RNTupleImporter.cxx | 34 +++++++++++++------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx b/tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx index 75c2fd33301e8..0032188f2a395 100644 --- a/tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx +++ b/tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx @@ -199,6 +199,14 @@ private: ROOT::RRecordField *fRecordField = nullptr; ///< Points to the item field of the untyped collection field in the model. std::vector fFieldBuffer; ///< The collection field memory representation. Bound to the entry. + /// Cached after Freeze() so Import() does not reallocate GetConstSubfields() on every entry. + std::size_t fSizeOfRecord = 0; + struct RPackedLeaf { + std::size_t fOffset = 0; + std::size_t fValueSize = 0; + std::size_t fImportBranchIdx = 0; + }; + std::vector fPackedLeaves; }; /// Transform a NULL terminated C string branch into an `std::string` field diff --git a/tree/ntupleutil/src/RNTupleImporter.cxx b/tree/ntupleutil/src/RNTupleImporter.cxx index 4bcc1b1d1fc95..14bb7001c2eca 100644 --- a/tree/ntupleutil/src/RNTupleImporter.cxx +++ b/tree/ntupleutil/src/RNTupleImporter.cxx @@ -396,6 +396,20 @@ ROOT::RResult ROOT::Experimental::RNTupleImporter::PrepareSchema() } for (auto &[_, c] : fLeafCountCollections) { fEntry->BindRawPtr(c.fFieldName, &c.fFieldBuffer); + c.fSizeOfRecord = c.fRecordField->GetValueSize(); + const auto subfields = c.fRecordField->GetConstSubfields(); + const auto &offsets = c.fRecordField->GetOffsets(); + R__ASSERT(subfields.size() == c.fLeafBranchIndexes.size()); + assert(c.fPackedLeaves.empty()); + c.fPackedLeaves.reserve(subfields.size()); + for (std::size_t l = 0; l < subfields.size(); ++l) { + RImportLeafCountCollection::RPackedLeaf packed; + packed.fOffset = offsets[l]; + packed.fValueSize = subfields[l]->GetValueSize(); + packed.fImportBranchIdx = c.fLeafBranchIndexes[l]; + c.fPackedLeaves.push_back(packed); + } + c.fFieldBuffer.reserve(static_cast(c.fMaxLength) * c.fSizeOfRecord); } if (!fIsQuiet) @@ -455,17 +469,15 @@ void ROOT::Experimental::RNTupleImporter::Import() fSourceTree->GetEntry(i); for (auto &[_, c] : fLeafCountCollections) { - const auto sizeOfRecord = c.fRecordField->GetValueSize(); - c.fFieldBuffer.resize(sizeOfRecord * (*c.fCountVal)); - - const auto nLeafs = c.fRecordField->GetConstSubfields().size(); - for (std::size_t l = 0; l < nLeafs; ++l) { - const auto offset = c.fRecordField->GetOffsets()[l]; - const auto sizeOfLeaf = c.fRecordField->GetConstSubfields()[l]->GetValueSize(); - const auto idxImportBranch = c.fLeafBranchIndexes[l]; - for (Int_t j = 0; j < *c.fCountVal; ++j) { - memcpy(c.fFieldBuffer.data() + j * sizeOfRecord + offset, - fImportBranches[idxImportBranch].fBranchBuffer.get() + (j * sizeOfLeaf), sizeOfLeaf); + const auto nItems = static_cast(*c.fCountVal); + const auto sizeOfRecord = c.fSizeOfRecord; + c.fFieldBuffer.resize(sizeOfRecord * nItems); + + for (const auto &leaf : c.fPackedLeaves) { + const auto *src = fImportBranches[leaf.fImportBranchIdx].fBranchBuffer.get(); + auto *dst = c.fFieldBuffer.data() + leaf.fOffset; + for (std::size_t j = 0; j < nItems; ++j) { + memcpy(dst + j * sizeOfRecord, src + j * leaf.fValueSize, leaf.fValueSize); } } } From 30defb20bf9c1861a72ef2a8846e600794e7ae70 Mon Sep 17 00:00:00 2001 From: Andreas Joachim Peters Date: Mon, 19 Oct 2026 10:23:06 +0200 Subject: [PATCH 4/5] [zip] Add optional LHC4 compression via lhc4codec. Wire kLHC4 through ROOT zip, with builtin or system lhc4codec (pinned to c433157), and the Flash/Beam/Prism/Mosaic/Oracle/Crystal/Laser/Grating/Auto backends behind process-wide R__SetLHC4* settings for the codec, the byte filters, the Auto gain budget and codec set, and the pretrained Crystal profiles. Failure dumps compile only with R__LHC4_FAILURE_DUMP. RNTuple: EnableColumnEncoding lets pages be stored without split/zigzag/delta when the LHC4 filters do that work; Double32_t follows usePlainColumns. The page sink keeps a thread-local Crystal profile per column type so parallel page compression does not share one setting; lhc4_crystal5 stays the dynamic compressor and the pretrained per-column models are lhc4_crystal_profile5. Zip helpers and tests cover the new algorithm. Conversion tools unpack ROOT split encodings before an LHC4 recode. Tools: tree2ntuple converts a TTree into one RNTuple per compression variant (native_zstd, native_lzma, lhc4_zstd, lhc4_bzip3, lhc4_beamN, lhc4_prismN, lhc4_mosaicN, lhc4_crystalN, lhc4_crystal_profileN, lhc4_oracleN, lhc4_flashN, lhc4_laserN, lhc4_grating, lhc4_auto) with --variants, --level, --no-out, --verify, --jobs, --page-size, --bench-percent and the --auto-* settings, and prints size, page ratio, gain against native_zstd, the filter statistics and the Auto codec mix. rntuple2rntuple re-encodes an RNTuple the same way. Both carry the read benchmarks of RNTupleBench.hxx (--bench, rntuple2rntuple --bench-only): pure page decompression, LoadEntry on one thread and LoadEntry with IMT. rntupleinspect judges page formats; rntupletryzip recompresses pages in memory (native probes re-apply ROOT encodings; --threads races codecs and filters). --- builtins/lhc4codec/CMakeLists.txt | 144 ++ cmake/modules/FindLHC4CODEC.cmake | 124 ++ cmake/modules/RootBuildOptions.cmake | 2 + cmake/modules/RootConfiguration.cmake | 5 + cmake/modules/SearchInstalledSoftware.cmake | 25 + config/RConfigure.in | 2 + core/CMakeLists.txt | 3 + core/lhc4codec/CMakeLists.txt | 29 + core/lhc4codec/README.md | 329 ++++ core/lhc4codec/inc/ZipLHC4.h | 193 ++ core/lhc4codec/src/ZipLHC4.cxx | 558 ++++++ core/zip/inc/Compression.h | 12 +- core/zip/src/Compression.cxx | 1 + core/zip/src/RZip.cxx | 29 +- core/zip/test/ZipTest.cxx | 7 + main/CMakeLists.txt | 12 + main/src/RNTupleBench.hxx | 554 ++++++ main/src/rntuple2rntuple.cxx | 1785 ++++++++++++++++++ main/src/rntupleinspect.cxx | 757 ++++++++ main/src/rntupletryzip.cxx | 1391 ++++++++++++++ main/src/tree2ntuple.cxx | 1651 ++++++++++++++++ tree/ntuple/inc/ROOT/RFieldBase.hxx | 4 +- tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx | 4 + tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx | 19 +- tree/ntuple/inc/ROOT/RNTupleZip.hxx | 172 +- tree/ntuple/inc/ROOT/RPageStorage.hxx | 8 +- tree/ntuple/src/RFieldBase.cxx | 49 +- tree/ntuple/src/RNTupleDescriptor.cxx | 20 + tree/ntuple/src/RPageStorage.cxx | 98 +- tree/ntuple/test/ntuple_types.cxx | 47 + tree/ntuple/test/ntuple_zip.cxx | 231 +++ 31 files changed, 8172 insertions(+), 93 deletions(-) create mode 100644 builtins/lhc4codec/CMakeLists.txt create mode 100644 cmake/modules/FindLHC4CODEC.cmake create mode 100644 core/lhc4codec/CMakeLists.txt create mode 100644 core/lhc4codec/README.md create mode 100644 core/lhc4codec/inc/ZipLHC4.h create mode 100644 core/lhc4codec/src/ZipLHC4.cxx create mode 100644 main/src/RNTupleBench.hxx create mode 100644 main/src/rntuple2rntuple.cxx create mode 100644 main/src/rntupleinspect.cxx create mode 100644 main/src/rntupletryzip.cxx create mode 100644 main/src/tree2ntuple.cxx diff --git a/builtins/lhc4codec/CMakeLists.txt b/builtins/lhc4codec/CMakeLists.txt new file mode 100644 index 0000000000000..cce579dbc7763 --- /dev/null +++ b/builtins/lhc4codec/CMakeLists.txt @@ -0,0 +1,144 @@ +# Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. +# All rights reserved. +# +# For the licensing terms see $ROOTSYS/LICENSE. +# For the list of contributors see $ROOTSYS/README/CREDITS. + +# Build lhc4codec as part of ROOT. Upstream: +# https://gitlab.cern.ch/apeters/lhc4codec.git +# +# If LHC4CODEC_SOURCE_DIR is set, that checkout is built via add_subdirectory +# (offline / development). Otherwise the source is fetched with ExternalProject +# (clad-style git clone; requires network and GitLab credentials). + +include(ExternalProject) + +# **PLEASE UPDATE ALSO THE FOLLOWING LINE WHEN UPDATING THE VERSION** +# 19 Oct 2026, https://gitlab.cern.ch/apeters/lhc4codec.git @ c433157 (Flash/Mosaic/Oracle docs, Crystal design note, RNTuple tools chapter) +set(ROOT_LHC4CODEC_VERSION 2.5.0) +set(ROOT_LHC4CODEC_GIT_REPOSITORY "https://gitlab.cern.ch/apeters/lhc4codec.git") +set(ROOT_LHC4CODEC_GIT_TAG "c433157729d96b4e15cd29971d9f88a25eb43496") + +set(_lhc4codec_build_args + -DLHC4CODEC_BUILD_CLI=OFF + -DLHC4CODEC_BUILD_TESTS=OFF + -DLHC4CODEC_METAL=OFF + -DLHC4CODEC_HIP=OFF + -DLHC4CODEC_NATIVE=OFF + -DLHC4CODEC_LTO=OFF + -DLHC4CODEC_ZSTD=AUTO + -DLHC4CODEC_LZMA=AUTO + -DLHC4CODEC_BZIP3=BUNDLED + -DCMAKE_CXX_STANDARD=20 + -DCMAKE_POSITION_INDEPENDENT_CODE=ON +) + +if(LHC4CODEC_SOURCE_DIR) + if(NOT EXISTS "${LHC4CODEC_SOURCE_DIR}/CMakeLists.txt") + message(FATAL_ERROR "LHC4CODEC_SOURCE_DIR=\"${LHC4CODEC_SOURCE_DIR}\" does not contain a CMake project") + endif() + + message(STATUS "Building builtin lhc4codec from ${LHC4CODEC_SOURCE_DIR}") + + set(LHC4CODEC_BUILD_CLI OFF CACHE BOOL "" FORCE) + set(LHC4CODEC_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(LHC4CODEC_METAL OFF CACHE BOOL "" FORCE) + set(LHC4CODEC_HIP OFF CACHE BOOL "" FORCE) + set(LHC4CODEC_NATIVE OFF CACHE BOOL "" FORCE) + set(LHC4CODEC_LTO OFF CACHE BOOL "" FORCE) + set(LHC4CODEC_ZSTD AUTO CACHE STRING "" FORCE) + set(LHC4CODEC_LZMA AUTO CACHE STRING "" FORCE) + set(LHC4CODEC_BZIP3 BUNDLED CACHE STRING "" FORCE) + set(CMAKE_POSITION_INDEPENDENT_CODE ON) + + add_subdirectory(${LHC4CODEC_SOURCE_DIR} ${CMAKE_BINARY_DIR}/builtins/lhc4codec-build EXCLUDE_FROM_ALL) + + set(LHC4CODEC_INCLUDE_DIRS ${LHC4CODEC_SOURCE_DIR}/include) + set(LHC4CODEC_LIBRARIES lhc4codec_static) + + if(NOT TARGET LHC4CODEC::LHC4CODEC) + add_library(LHC4CODEC::LHC4CODEC ALIAS lhc4codec_static) + endif() +else() + message(STATUS "Downloading and building lhc4codec ${ROOT_LHC4CODEC_VERSION} from " + "${ROOT_LHC4CODEC_GIT_REPOSITORY} (${ROOT_LHC4CODEC_GIT_TAG})") + + set(ROOT_LHC4CODEC_PREFIX ${CMAKE_BINARY_DIR}/builtins/lhc4codec-prefix) + set(ROOT_LHC4CODEC_SOURCE_DIR ${ROOT_LHC4CODEC_PREFIX}/src/BUILTIN_LHC4CODEC) + set(ROOT_LHC4CODEC_BUILD_DIR ${ROOT_LHC4CODEC_PREFIX}/src/BUILTIN_LHC4CODEC-build) + # Staged headers live outside the git tree so the path exists at configure time + # (CMake validates INTERFACE_INCLUDE_DIRECTORIES on imported targets). + set(ROOT_LHC4CODEC_INCLUDE_DIR ${ROOT_LHC4CODEC_PREFIX}/include) + set(ROOT_LHC4CODEC_LIBRARY + ${ROOT_LHC4CODEC_BUILD_DIR}/${CMAKE_STATIC_LIBRARY_PREFIX}lhc4codec${CMAKE_STATIC_LIBRARY_SUFFIX}) + set(ROOT_LHC4CODEC_BZIP3_LIBRARY + ${ROOT_LHC4CODEC_BUILD_DIR}/${CMAKE_STATIC_LIBRARY_PREFIX}lhc4_bzip3_bundled${CMAKE_STATIC_LIBRARY_SUFFIX}) + file(MAKE_DIRECTORY ${ROOT_LHC4CODEC_INCLUDE_DIR}) + + if(MSVC) + set(_lhc4codec_build_type Release) + if(winrtdebug) + set(_lhc4codec_build_type Debug) + endif() + if(NOT CMAKE_GENERATOR MATCHES Ninja) + set(_lhc4codec_build_flags --config ${_lhc4codec_build_type}) + endif() + set(_lhc4codec_extra_cmake_args + -DCMAKE_CXX_FLAGS_RELWITHDEBINFO=${CMAKE_CXX_FLAGS_RELWITHDEBINFO} + -DCMAKE_CXX_FLAGS_RELEASE=${CMAKE_CXX_FLAGS_RELEASE} + -DCMAKE_CXX_FLAGS_DEBUG=${CMAKE_CXX_FLAGS_DEBUG}) + else() + set(_lhc4codec_build_type Release) + set(_lhc4codec_build_flags "") + set(_lhc4codec_extra_cmake_args "") + endif() + + ExternalProject_Add( + BUILTIN_LHC4CODEC + GIT_REPOSITORY ${ROOT_LHC4CODEC_GIT_REPOSITORY} + GIT_TAG ${ROOT_LHC4CODEC_GIT_TAG} + GIT_SUBMODULES third_party/bzip3 + PREFIX ${ROOT_LHC4CODEC_PREFIX} + UPDATE_COMMAND "" + CMAKE_ARGS -G ${CMAKE_GENERATOR} + -DCMAKE_INSTALL_PREFIX= + -DCMAKE_BUILD_TYPE=${_lhc4codec_build_type} + ${_lhc4codec_build_args} + ${_lhc4codec_extra_cmake_args} + BUILD_COMMAND ${CMAKE_COMMAND} --build ${_lhc4codec_build_flags} + --target lhc4codec_static lhc4_bzip3_bundled + INSTALL_COMMAND "" + LOG_DOWNLOAD 1 LOG_CONFIGURE 1 LOG_BUILD 1 LOG_OUTPUT_ON_FAILURE 1 + BUILD_BYPRODUCTS ${ROOT_LHC4CODEC_LIBRARY} ${ROOT_LHC4CODEC_BZIP3_LIBRARY} + TIMEOUT 600 + ) + + # Copy public headers after the git clone, before the nested configure/build. + ExternalProject_Add_Step(BUILTIN_LHC4CODEC stage-headers + COMMAND ${CMAKE_COMMAND} -E rm -rf ${ROOT_LHC4CODEC_INCLUDE_DIR}/lhc4codec + COMMAND ${CMAKE_COMMAND} -E copy_directory + /include/lhc4codec + ${ROOT_LHC4CODEC_INCLUDE_DIR}/lhc4codec + DEPENDEES download + DEPENDERS configure + ) + + add_library(LHC4CODEC::LHC4CODEC IMPORTED STATIC GLOBAL) + add_dependencies(LHC4CODEC::LHC4CODEC BUILTIN_LHC4CODEC) + set_target_properties(LHC4CODEC::LHC4CODEC PROPERTIES + IMPORTED_LOCATION ${ROOT_LHC4CODEC_LIBRARY} + INTERFACE_INCLUDE_DIRECTORIES ${ROOT_LHC4CODEC_INCLUDE_DIR} + INTERFACE_LINK_LIBRARIES ${ROOT_LHC4CODEC_BZIP3_LIBRARY} + ) + + set(LHC4CODEC_INCLUDE_DIRS ${ROOT_LHC4CODEC_INCLUDE_DIR}) + set(LHC4CODEC_LIBRARIES ${ROOT_LHC4CODEC_LIBRARY}) +endif() + +set(LHC4CODEC_FOUND TRUE) +set(LHC4CODEC_VERSION ${ROOT_LHC4CODEC_VERSION}) + +set(LHC4CODEC_INCLUDE_DIRS ${LHC4CODEC_INCLUDE_DIRS} PARENT_SCOPE) +set(LHC4CODEC_LIBRARIES ${LHC4CODEC_LIBRARIES} PARENT_SCOPE) +set(LHC4CODEC_FOUND ${LHC4CODEC_FOUND} PARENT_SCOPE) +set(LHC4CODEC_VERSION ${LHC4CODEC_VERSION} PARENT_SCOPE) diff --git a/cmake/modules/FindLHC4CODEC.cmake b/cmake/modules/FindLHC4CODEC.cmake new file mode 100644 index 0000000000000..9452b93ab05d1 --- /dev/null +++ b/cmake/modules/FindLHC4CODEC.cmake @@ -0,0 +1,124 @@ +# Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. +# All rights reserved. +# +# For the licensing terms see $ROOTSYS/LICENSE. +# For the list of contributors see $ROOTSYS/README/CREDITS. + +#.rst: +# FindLHC4CODEC +# ------------- +# +# Find the lhc4codec library header and define variables. +# +# Imported Targets +# ^^^^^^^^^^^^^^^^ +# +# This module defines :prop_tgt:`IMPORTED` target ``LHC4CODEC::LHC4CODEC``, +# if lhc4codec has been found. +# +# Result Variables +# ^^^^^^^^^^^^^^^^ +# +# This module defines the following variables: +# +# :: +# +# LHC4CODEC_FOUND - True if lhc4codec is found. +# LHC4CODEC_INCLUDE_DIRS - Where to find lhc4codec/lhc4codec_c.h +# LHC4CODEC_LIBRARIES - The lhc4codec library path +# LHC4CODEC_VERSION - The lhc4codec version string, if available +# LHC4CODEC_INSTALL_HELP - How to install a missing package (always set) + +# Prebuilt packages: https://gitlab.cern.ch/apeters/lhc4codec-bin +set(LHC4CODEC_INSTALL_HELP [=[ +lhc4codec was not found (need lhc4codec/lhc4codec_c.h and liblhc4codec). + +Install a prebuilt package from https://gitlab.cern.ch/apeters/lhc4codec-bin : + + # AlmaLinux 9 / 10 (x86_64) + sudo curl -fsSL -o /etc/yum.repos.d/lhc4codec.repo \ + https://gitlab.cern.ch/apeters/lhc4codec-bin/-/raw/master/lhc4codec-el9.repo # or -el10 + sudo dnf install lhc4codec lhc4codec-devel + + # macOS, Apple silicon (macOS 13+) + brew tap apeters/lhc4codec https://gitlab.cern.ch/apeters/lhc4codec-bin.git + brew install apeters/lhc4codec/lhc4codec + +Or point CMake at an existing prefix: -DLHC4CODEC_ROOT=/path/to/prefix +Or let ROOT clone and build it: -Dbuiltin_lhc4codec=ON + (fetches https://gitlab.cern.ch/apeters/lhc4codec.git) +]=]) + +set(_lhc4codec_hints) +if(LHC4CODEC_ROOT) + list(APPEND _lhc4codec_hints "${LHC4CODEC_ROOT}") +endif() +if(DEFINED ENV{LHC4CODEC_ROOT}) + list(APPEND _lhc4codec_hints "$ENV{LHC4CODEC_ROOT}") +endif() + +if(APPLE) + # Homebrew: arm64 default is /opt/homebrew, Intel is /usr/local. + list(APPEND _lhc4codec_hints /opt/homebrew /usr/local) + find_program(_lhc4codec_brew brew) + if(_lhc4codec_brew) + execute_process(COMMAND "${_lhc4codec_brew}" --prefix lhc4codec + OUTPUT_VARIABLE _lhc4codec_brew_formula + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + if(_lhc4codec_brew_formula) + list(APPEND _lhc4codec_hints "${_lhc4codec_brew_formula}") + endif() + execute_process(COMMAND "${_lhc4codec_brew}" --prefix + OUTPUT_VARIABLE _lhc4codec_brew_prefix + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + if(_lhc4codec_brew_prefix) + list(APPEND _lhc4codec_hints "${_lhc4codec_brew_prefix}") + endif() + endif() + unset(_lhc4codec_brew CACHE) +endif() + +find_path(LHC4CODEC_INCLUDE_DIR lhc4codec/lhc4codec_c.h + HINTS ${_lhc4codec_hints} + PATH_SUFFIXES include +) + +find_library(LHC4CODEC_LIBRARY + NAMES lhc4codec lhc4codec_static + HINTS ${_lhc4codec_hints} + PATH_SUFFIXES lib lib64 +) + +if(LHC4CODEC_INCLUDE_DIR AND EXISTS "${LHC4CODEC_INCLUDE_DIR}/lhc4codec/lhc4codec_c.h") + file(READ "${LHC4CODEC_INCLUDE_DIR}/lhc4codec/lhc4codec_c.h" _lhc4codec_header) + string(REGEX MATCH "#define LHC4CODEC_VERSION \"([^\"]+)\"" _lhc4codec_version_match "${_lhc4codec_header}") + if(CMAKE_MATCH_1) + set(LHC4CODEC_VERSION "${CMAKE_MATCH_1}") + endif() + unset(_lhc4codec_header) + unset(_lhc4codec_version_match) +endif() + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(LHC4CODEC + REQUIRED_VARS LHC4CODEC_LIBRARY LHC4CODEC_INCLUDE_DIR + VERSION_VAR LHC4CODEC_VERSION +) + +if(LHC4CODEC_FOUND) + set(LHC4CODEC_INCLUDE_DIRS "${LHC4CODEC_INCLUDE_DIR}") + set(LHC4CODEC_LIBRARIES "${LHC4CODEC_LIBRARY}") + + if(NOT TARGET LHC4CODEC::LHC4CODEC) + add_library(LHC4CODEC::LHC4CODEC UNKNOWN IMPORTED) + set_target_properties(LHC4CODEC::LHC4CODEC PROPERTIES + IMPORTED_LOCATION "${LHC4CODEC_LIBRARIES}" + INTERFACE_INCLUDE_DIRECTORIES "${LHC4CODEC_INCLUDE_DIRS}") + endif() +endif() + +unset(_lhc4codec_hints) +unset(_lhc4codec_brew_formula) +unset(_lhc4codec_brew_prefix) diff --git a/cmake/modules/RootBuildOptions.cmake b/cmake/modules/RootBuildOptions.cmake index 5fb1634b6e947..1889ba47627e1 100644 --- a/cmake/modules/RootBuildOptions.cmake +++ b/cmake/modules/RootBuildOptions.cmake @@ -95,6 +95,7 @@ ROOT_BUILD_OPTION(builtin_glu OFF "Build libtess-GLU from an internal source tar ROOT_BUILD_OPTION(builtin_gsl OFF "Build GSL from an automatically downloaded source tarball (requires network) [GPL]") ROOT_BUILD_OPTION(builtin_gtest OFF "Build googletest from an automatically downloaded source tarball (requires network)") ROOT_BUILD_OPTION(builtin_jpeg OFF "Build libjpeg from an automatically downloaded source tarball (requires network)") +ROOT_BUILD_OPTION(builtin_lhc4codec OFF "Build bundled copy of lhc4codec (git clone or LHC4CODEC_SOURCE_DIR)") ROOT_BUILD_OPTION(builtin_llvm ON "Build bundled copy of LLVM (advanced option)") MARK_AS_ADVANCED(builtin_llvm) ROOT_BUILD_OPTION(builtin_lz4 OFF "Build lz4 from an automatically downloaded source tarball (requires network)") @@ -123,6 +124,7 @@ ROOT_BUILD_OPTION(coverage OFF "Enable compile flags for coverage testing") ROOT_BUILD_OPTION(cuda OFF "Enable support for CUDA (requires CUDA toolkit >= 7.5)") ROOT_BUILD_OPTION(curl ON "Enable support for HTTP(S) through libcurl") ROOT_BUILD_OPTION(daos OFF "Enable RNTuple support for Intel DAOS") +ROOT_BUILD_OPTION(lhc4codec OFF "Enable LHC4 compression for RNTuple/TFile (requires lhc4codec library)") ROOT_BUILD_OPTION(dataframe ON "Enable ROOT RDataFrame") ROOT_BUILD_OPTION(davix ON "Enable support for Davix (HTTP/WebDAV access)") ROOT_BUILD_OPTION(dcache OFF "Enable support for dCache (requires libdcap from DESY)") diff --git a/cmake/modules/RootConfiguration.cmake b/cmake/modules/RootConfiguration.cmake index 7055457189e9d..7caa84c001462 100644 --- a/cmake/modules/RootConfiguration.cmake +++ b/cmake/modules/RootConfiguration.cmake @@ -363,6 +363,11 @@ if(clad) else() set(hasclad undef) endif() +if(lhc4codec) + set(haslhc4codec define) +else() + set(haslhc4codec undef) +endif() if(cocoa) set(hascocoa define) else() diff --git a/cmake/modules/SearchInstalledSoftware.cmake b/cmake/modules/SearchInstalledSoftware.cmake index 71b257e95ea7e..0761a93509609 100644 --- a/cmake/modules/SearchInstalledSoftware.cmake +++ b/cmake/modules/SearchInstalledSoftware.cmake @@ -404,6 +404,31 @@ if(builtin_lz4) add_subdirectory(builtins/lz4) endif() +#---Check for LHC4CODEC-------------------------------------------------------------- +set(LHC4CODEC_SOURCE_DIR "" CACHE PATH + "Optional path to an lhc4codec checkout; if empty and builtin_lhc4codec=ON, the source is git-cloned") + +if(lhc4codec) + if(builtin_lhc4codec) + list(APPEND ROOT_BUILTINS LHC4CODEC) + add_subdirectory(builtins/lhc4codec) + else() + find_package(LHC4CODEC QUIET) + if(NOT LHC4CODEC_FOUND) + if(NOT LHC4CODEC_INSTALL_HELP) + set(LHC4CODEC_INSTALL_HELP + "lhc4codec was not found. Install lhc4codec-devel / brew tap apeters/lhc4codec, set -DLHC4CODEC_ROOT, or use -Dbuiltin_lhc4codec=ON.") + endif() + message(FATAL_ERROR "${LHC4CODEC_INSTALL_HELP}") + endif() + if(LHC4CODEC_VERSION) + message(STATUS "Found LHC4CODEC: ${LHC4CODEC_LIBRARIES} (version ${LHC4CODEC_VERSION})") + else() + message(STATUS "Found LHC4CODEC: ${LHC4CODEC_LIBRARIES}") + endif() + endif() +endif() + #---Check for X11 which is mandatory lib on Unix-------------------------------------- if(x11) message(STATUS "Looking for X11") diff --git a/config/RConfigure.in b/config/RConfigure.in index 2b2370256b4ed..350f4d5e48996 100644 --- a/config/RConfigure.in +++ b/config/RConfigure.in @@ -65,4 +65,6 @@ #@hasgeom@ R__HAS_GEOM /**/ +#@haslhc4codec@ R__HAS_LHC4CODEC /**/ + #endif diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 3d2069d3d1d38..74c56a894b494 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -81,6 +81,9 @@ add_subdirectory(zip) add_subdirectory(lzma) add_subdirectory(lz4) add_subdirectory(zstd) +if(lhc4codec) + add_subdirectory(lhc4codec) +endif() add_subdirectory(macosx) add_subdirectory(unix) diff --git a/core/lhc4codec/CMakeLists.txt b/core/lhc4codec/CMakeLists.txt new file mode 100644 index 0000000000000..189694b0e666f --- /dev/null +++ b/core/lhc4codec/CMakeLists.txt @@ -0,0 +1,29 @@ +############################################################################ +# CMakeLists.txt file for building ROOT core/lhc4codec package +############################################################################ + +add_library(ROOTLHC4Zip OBJECT src/ZipLHC4.cxx) +set_target_properties(ROOTLHC4Zip PROPERTIES + CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF + POSITION_INDEPENDENT_CODE ON) +target_link_libraries(ROOTLHC4Zip PRIVATE LHC4CODEC::LHC4CODEC) +target_include_directories(ROOTLHC4Zip + PUBLIC $ +) +# Compile with -DR__LHC4_FAILURE_DUMP (or uncomment below) to write failed +# zip/unzip frames under /tmp/root-lhc4-failure-/. +# target_compile_definitions(ROOTLHC4Zip PRIVATE R__LHC4_FAILURE_DUMP) + +target_sources(Core PRIVATE $) + +target_link_libraries(Core PRIVATE LHC4CODEC::LHC4CODEC) + +# Bundled bzip3 is a separate static lib in the lhc4codec build; propagate it when +# built via add_subdirectory (ExternalProject uses INTERFACE_LINK_LIBRARIES). +if(TARGET lhc4_bzip3_bundled) + target_link_libraries(Core PRIVATE lhc4_bzip3_bundled) +endif() + +target_include_directories(Core PUBLIC $) + +ROOT_INSTALL_HEADERS() diff --git a/core/lhc4codec/README.md b/core/lhc4codec/README.md new file mode 100644 index 0000000000000..705b0656a2f1c --- /dev/null +++ b/core/lhc4codec/README.md @@ -0,0 +1,329 @@ +# LHC4 compression in ROOT + +ROOT can use [lhc4codec](https://gitlab.cern.ch/apeters/lhc4codec.git) as an optional +compression backend for RNTuple, `TFile`, and any other component that goes through +the shared `RZip` layer in `core/zip`. + +LHC4 is exposed as compression algorithm **`kLHC4`** (numeric id **6**). A typical +setting is **`606`**: algorithm 6, level 6. + +This directory contains the ROOT wrapper (`ZipLHC4.cxx`) that plugs lhc4codec into +`R__zipMultipleAlgorithm()` / `R__unzip()`. RNTuple itself needs no separate codec +code; it calls `RNTupleCompressor::Zip()` which delegates to the shared machinery. + +## Building ROOT with lhc4codec + +LHC4 support is **off by default**. Enable it at configure time with `-Dlhc4codec=ON`. + +The upstream library lives on CERN GitLab and requires credentials to clone: + +```text +https://gitlab.cern.ch/apeters/lhc4codec.git +``` + +### Option A: built-in copy (automatic git clone) + +If you have network access and GitLab credentials for CERN, ROOT can clone and build +lhc4codec automatically (same pattern as clad): + +```sh +cmake -S /path/to/root -B /path/to/root-build \ + -Dlhc4codec=ON \ + -Dbuiltin_lhc4codec=ON + +cmake --build /path/to/root-build -j +``` + +The repository and commit are pinned in `builtins/lhc4codec/CMakeLists.txt` +(`ROOT_LHC4CODEC_GIT_TAG`). GitLab authentication must work non-interactively +(SSH key, credential helper, or CI token). + +### Option B: built-in copy (local checkout) + +Use a local tree instead of cloning (offline development or a custom branch): + +```sh +git clone https://gitlab.cern.ch/apeters/lhc4codec.git # requires CERN access + +cmake -S /path/to/root -B /path/to/root-build \ + -Dlhc4codec=ON \ + -Dbuiltin_lhc4codec=ON \ + -DLHC4CODEC_SOURCE_DIR=/path/to/lhc4codec + +cmake --build /path/to/root-build -j +``` + +`builtin_lhc4codec=ON` builds only the library (CLI, tests, Metal/HIP backends, and +LTO are disabled inside ROOT). When `LHC4CODEC_SOURCE_DIR` is set it takes precedence +over the automatic git clone. + +### Option C: system-installed library (RPM or Homebrew) + +Prebuilt packages are published to +[lhc4codec-bin](https://gitlab.cern.ch/apeters/lhc4codec-bin): + +```sh +# AlmaLinux 9 / 10 (x86_64) +sudo curl -fsSL -o /etc/yum.repos.d/lhc4codec.repo \ + https://gitlab.cern.ch/apeters/lhc4codec-bin/-/raw/master/lhc4codec-el9.repo # or -el10 +sudo dnf install lhc4codec lhc4codec-devel + +# macOS, Apple silicon (macOS 13+) +brew tap apeters/lhc4codec https://gitlab.cern.ch/apeters/lhc4codec-bin.git +brew install apeters/lhc4codec/lhc4codec +``` + +Then configure ROOT (Homebrew prefixes `/opt/homebrew` and `/usr/local` are +searched automatically): + +```sh +cmake -S /path/to/root -B /path/to/root-build -Dlhc4codec=ON +cmake --build /path/to/root-build -j +``` + +If the library is in a custom prefix: + +```sh +cmake -S /path/to/root -B /path/to/root-build \ + -Dlhc4codec=ON \ + -DLHC4CODEC_ROOT=/opt/lhc4codec +``` + +If `-Dlhc4codec=ON` is set and the library is missing, CMake prints these +install steps and stops. `-Dbuiltin_lhc4codec=ON` is the alternative that +clones `https://gitlab.cern.ch/apeters/lhc4codec.git` instead. + +CMake discovers the library through `cmake/modules/FindLHC4CODEC.cmake`. + +When enabled, ROOT defines `R__HAS_LHC4CODEC` in `RConfigure.h`. + +## CMake options + +| Option | Default | Meaning | +|--------|---------|---------| +| `lhc4codec` | `OFF` | Enable LHC4 compression support in Core | +| `builtin_lhc4codec` | `OFF` | Build lhc4codec inside ROOT (git clone or local tree) | +| `LHC4CODEC_SOURCE_DIR` | empty | Optional local checkout; skips git clone when set | + +## Using LHC4 in RNTuple + +```cpp +#include "ROOT/RNTupleWriteOptions.hxx" +#include "Compression.h" + +ROOT::Experimental::RNTupleWriteOptions options; + +// shorthand: 606 = kLHC4, level 6 +options.SetCompression(606); + +// or explicitly +options.SetCompression(ROOT::RCompressionSetting::EAlgorithm::kLHC4, + ROOT::RCompressionSetting::ELevel::kDefaultLHC4); +``` + +Level selects the lhc4codec speed/ratio trade-off (1–9 via the ROOT compression +level; levels 10–11 are available inside lhc4codec but not mapped through the +standard ROOT level field today). + +## Optional behaviour: backends, filters, and BWT mode + +Beyond algorithm + level, process-wide toggles control lhc4codec behaviour. +They follow the same global pattern as `R__SetZipMode()` for the default codec. + +### Compression backends + +| `R__SetLHC4Codec()` | Effect | +|---------------------|--------| +| `kLHC4CodecBeam` (0, default) | Native LHC4 Beam (LZ77) frames; `kLHC4CodecLz` is a legacy alias | +| `kLHC4CodecBwt` (1) | Native LHC4 BWT block mode | +| `kLHC4CodecZstd` (2) | Native zstd frames (if linked) | +| `kLHC4CodecBzip3` (3) | Native bzip3 frames (if linked) | +| `kLHC4CodecLzma` (4) | Native xz/LZMA frames (if linked) | +| `kLHC4CodecAuto` (5) | Race available codecs at the chosen level | +| `kLHC4CodecMosaic` (6) | Native LHC4 Mosaic frames | +| `kLHC4CodecOracle` (7) | Native LHC4 Oracle frames (not raced by Auto) | +| `kLHC4CodecCrystal` (8) | Native LHC4 Crystal frames | +| `kLHC4CodecFlash` (9) | Native LHC4 Flash frames: byte-aligned LZ without an entropy stage (not raced by Auto) | +| `kLHC4CodecLaser` (10) | Native LHC4 Laser frames: LZMA2-class range coder with a decoupled decoder | +| `kLHC4CodecGrating` (11) | Native LHC4 Grating frames: order-0 integer rANS, no levels (not raced by Auto) | + +Query availability with `R__LHC4CodecAvailable(codec)`. Beam, Bwt, Mosaic, Oracle, +Crystal, Flash, Laser, Grating, and Auto are always available; optional backends (Zstd, Bzip3, Lzma) must +be linked for them to participate in the Auto race. `R__SetLHC4Bwt(1/0)` remains as +a convenience alias for switching between Beam and Bwt. + +### Byte filters (Beam/Bwt only) + +When using lhc4codec filters, disable RNTuple's own column encodings so the compressor +can apply byte transforms on plain page data. With LHC4 compression, disabling column +encoding also enables lhc4codec filters automatically when the page sink is created: + +```cpp +#include "ROOT/RNTupleWriteOptions.hxx" + +ROOT::Experimental::RNTupleWriteOptions options; +options.SetEnableColumnEncoding(false); +options.SetCompression(ROOT::RCompressionSetting::EAlgorithm::kLHC4, 6); +// R__SetLHC4Filters(1) is applied automatically for LHC4 + +// ... create writer with options ... +``` + +Explicit control before writing: + +```cpp +#include "ZipLHC4.h" + +R__SetLHC4Codec(kLHC4CodecBeam); +R__SetLHC4Filters(1); // shuffle/delta/zigzag/dict auto-detect +R__SetLHC4FilterFallback(1); // also try raw input, keep smaller (default) +R__SetLHC4FilterRle(1); // try post-filter byte-RLE (default) +R__SetLHC4FilterDict(1); // allow dict32/64 filters (default) + +// External backends (when `R__LHC4CodecAvailable()` returns 1) also support byte filters +via the L4EF wrapper frame: +R__SetLHC4Codec(kLHC4CodecZstd); +R__SetLHC4Filters(0); // filters are ignored for zstd/bzip3 + +// Auto races zstd/beam/crystal by default. Widen or restrict the set: +R__SetLHC4Codec(kLHC4CodecAuto); +R__SetLHC4AutoCodecs(kLHC4AutoCodecsRoot); // default +R__SetLHC4AutoCodecs(kLHC4AutoCodecsAll); // include mosaic/bwt/laser/lzma/bzip3 +R__SetLHC4AutoCodecs(kLHC4CodecBit(kLHC4CodecZstd) | kLHC4CodecBit(kLHC4CodecCrystal)); +``` + +| API | Default | Effect | +|-----|---------|--------| +| `R__SetLHC4Codec(int)` | Beam | Select Beam / Bwt / Zstd / Bzip3 / Lzma / Auto / Mosaic / Oracle / Crystal / Flash / Laser / Grating | +| `R__SetLHC4AutoMinGainPct(int)` | 1 | Auto only: min % gain to prefer a slower decoder | +| `R__SetLHC4AutoMaxLevel(int)` | off | Auto only: race each codec at max effort instead of level | +| `R__SetLHC4AutoCodecs(unsigned)` | `kLHC4AutoCodecsRoot` | Auto only: bitmask of codecs to race (`kLHC4CodecBit`) | +| `R__SetLHC4Filters(int)` | off | Auto-detect + apply byte transforms | +| `R__SetLHC4FilterFallback(int)` | on | With filters: also compress raw and keep smaller | +| `R__SetLHC4FilterRle(int)` | on | Try byte-RLE on filter/intermediate path | +| `R__SetLHC4FilterDict(int)` | on | Allow dictionary-remap filters | +| `R__SetLHC4Bwt(int)` | off | Legacy alias for Bwt/Beam codec selection | + +When the codec is **Zstd** or **Bzip3**, enabled byte filters are stored in an **L4EF** +wrapper frame around the native backend payload (transparent to `R__unzipLHC4()`). + +Getters: `R__GetLHC4*()` for each setter above, plus `R__LHC4CodecAvailable()`. + +**When to enable what** + +- **Filters**: physics branches with integers/floats stored column-wise in pages. +- **Filter RLE**: repetitive filtered bytes (long zero runs in shuffled columns). +- **BWT / Bwt codec**: repetitive or text-like payloads where the Beam path is less effective. +- **Mosaic / Crystal**: additional native LHC4 backends; Crystal targets best ratio with fast decode. +- **Oracle**: learned-context demo codec; available for forced selection, not raced by Auto. +- **Flash**: byte-aligned LZ without an entropy stage, the fastest decoder; for ingestion + tiers where decode speed matters more than ratio. Forced selection only. +- **Laser**: LZMA2-class range coder with a decoupled, branchless decoder; LZMA ratio at + higher decode speed. Part of `kLHC4AutoCodecsAll`. +- **Grating**: order-0 integer rANS over 1/2/4/8-byte symbols with an escape for the tail; + has no levels. Suited to peaked integer columns (counts, indices). Forced selection only. +- **Zstd / Bzip3 / Lzma**: when you want native frames from those tools inside ROOT's LC wrapper. +- **Auto**: benchmark-style codec selection. By default it races the ROOT set + (`kLHC4AutoCodecsRoot`: Zstd, Beam, Crystal) and picks a decode-speed-aware winner + (`R__SetLHC4AutoMinGainPct`, default 1%). Use `R__SetLHC4AutoCodecs(kLHC4AutoCodecsAll)` + or a custom `kLHC4CodecBit(...)` mask to change the candidates. Passing `0` resets to + the ROOT default. `R__SetLHC4AutoMaxLevel(1)` races each candidate at its maximum + effort instead of the shared compression level. `tree2ntuple` / `rntuple2rntuple` + expose the same mask as `--auto-codecs root|all|zstd,beam,...`. + `--level N` (1–11, default 5) sets the RNTuple compression level for LHC4 + variants; `lhc4_laser9` is equivalent to `--level 9 --only-variant lhc4_laser5`. + Grating ignores the level. `rntupleinspect` samples packed pages and reports + whether the remaining payload is mantissa-limited SplitReal32 (no LHC4 + headroom) or peaked integers (Grating / Crystal). `rntuple2rntuple` unpacks + split/delta/zigzag encodings before LHC4 re-encode (matching `tree2ntuple`); + `--keep-column-encoding` recompresses packed pages instead. `rntupletryzip` + unpacks split/delta/zigzag encodings the same way, then recompresses in + memory (no dictionaries, no output file), so CMSSW MiniAOD streamer fields + do not block a size trial. `--keep-column-encoding` restores packed-page + trials. + `--brute-filters` races matching lossless filters per page on every hardware + core (`--threads N` to cap). Without `--brute-filters`, `--threads` races + codecs and pages in one pool (TLS codec override, shared inflated buffers). + +### Crystal static profiles + +Standard Crystal is the dynamic coder with byte filters, the same kind of compressor +as Beam or zstd. `R__SetLHC4ProfilePerColumn(1)` turns on pretrained per-column models +while `R__GetLHC4Profile()` stays `kLHC4ProfileNone`. The choice is stored on the zip +call for that page, so IMT workers compressing different columns do not share one profile. +Decompression reads the profile id from the page. + +| Column type | Profile | +|-------------|---------| +| `Real32` | `kLHC4ProfileFloatKinematics` | +| `Index32`, `UInt32` | `kLHC4ProfilePoissonCounts` | +| `Char` | `kLHC4ProfileText` | +| anything else (`Real64`, `Index64`, `Int32`, split columns, …) | dynamic per-page model | + +`R__SetLHC4Profile()` forces one profile onto every column. `R__SetLHC4ProfileId(id)` +overrides the on-wire id of that forced profile (`0` uses the enum value). Custom ids +must be >= 5 and come from `R__LHC4TrainAndRegisterProfile()`. + +`lhc4_crystal5` is standard Crystal. `lhc4_crystal_profile5` enables the per-column +choice. `--profile float|poisson|monotonic|text|custom` forces that one model on +`lhc4_crystal_profile5` only. + +Decompression does **not** need these flags; the inner frame type and filter path are +restored automatically on read. + +Set the toggles **before** writing compressed data. They apply to all subsequent LHC4 +compressions in the process until changed. + +### Debugging corrupt frames + +Failure dumps are compiled out by default. Rebuild `ROOTLHC4Zip` with +`-DR__LHC4_FAILURE_DUMP` (see `core/lhc4codec/CMakeLists.txt`) to write +artifacts to `/tmp/root-lhc4-failure-/` when compression or decompression +fails: + +| File | Contents | +|------|----------| +| `.meta` | stage, status, codec, filter settings, page sizes | +| `.uncompressed.bin` | raw page bytes (when available, e.g. `--verify` or zip errors) | +| `.compressed_lc.bin` | full ROOT `LC` frame (9-byte header + lhc4codec payload) | + +This is triggered automatically from `R__zipLHC4`, `R__unzipLHC4`, and RNTuple +`--verify` round-trip failures. Reproduce with: + +```sh +tree2ntuple --verify --only-variant lhc4_bzip3 ... +``` + +Then inspect the dump and replay with the cerncodec CLI or a small standalone +decompress program. + +## On-disk format + +ROOT wraps each compressed block in the usual 9-byte header used by ZLIB/ZSTD/LZ4: + +```text +'L' 'C' | 3-byte compressed size | 3-byte uncompressed size | LHC4 frame +``` + +The inner payload is a standard lhc4codec frame (magic `LHC4`). + +## Tests + +With `-Dtesting=ON`, the following cover LHC4 when `lhc4codec` is enabled: + +- `core/zip/test/ZipTest.cxx` — buffer-size sweep for `kLHC4` +- `tree/ntuple/test/ntuple_zip.cxx` — round-trip, filters, BWT, Mosaic/Crystal/Oracle, Auto, and Auto codec mask + +## Layout + +```text +core/lhc4codec/ ROOT wrapper (this directory) + inc/ZipLHC4.h Public C API: zip/unzip + codec/filter toggles + src/ZipLHC4.cxx Calls lhc4codec C++ CompressParams API + +builtins/lhc4codec/ Git clone or add_subdirectory of upstream source +cmake/modules/FindLHC4CODEC.cmake +``` + +For details on lhc4codec itself (levels, block sizes, CLI benchmarks), see the +upstream README in the lhc4codec repository. diff --git a/core/lhc4codec/inc/ZipLHC4.h b/core/lhc4codec/inc/ZipLHC4.h new file mode 100644 index 0000000000000..3972d9ab71f49 --- /dev/null +++ b/core/lhc4codec/inc/ZipLHC4.h @@ -0,0 +1,193 @@ +// Original Author: ROOT Team +/************************************************************************* + * Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. * + * All rights reserved. * + * * + * For the licensing terms see $ROOTSYS/LICENSE. * + * For the list of contributors see $ROOTSYS/README/CREDITS. * + *************************************************************************/ + +#ifndef ROOT_ZipLHC4 +#define ROOT_ZipLHC4 + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + kLHC4CodecBeam = 0, ///< Native LHC4 Beam (LZ77) frames + kLHC4CodecLz = 0, ///< Legacy name for Beam + kLHC4CodecBwt = 1, + kLHC4CodecZstd = 2, + kLHC4CodecBzip3 = 3, + kLHC4CodecLzma = 4, + kLHC4CodecAuto = 5, + kLHC4CodecMosaic = 6, + kLHC4CodecOracle = 7, + kLHC4CodecCrystal = 8, + kLHC4CodecFlash = 9, ///< Byte-aligned LZ without an entropy stage (ingestion tier) + kLHC4CodecLaser = 10, ///< LZMA2-class binary compressor with a decoupled range decoder + kLHC4CodecGrating = 11, ///< Order-0 integer rANS over 1/2/4/8-byte symbols; has no levels + kLHC4CodecCount = 12, +}; + +/// Bit in an Auto codec mask: `1u << kLHC4Codec*`. Auto, Oracle, Flash and Grating are not raceable. +#define kLHC4CodecBit(c) (1u << (unsigned)(c)) +/// Default Auto race for ROOT / RNTuple: zstd | beam | crystal (fast decoders). +#define kLHC4AutoCodecsRoot \ + (kLHC4CodecBit(kLHC4CodecZstd) | kLHC4CodecBit(kLHC4CodecBeam) | kLHC4CodecBit(kLHC4CodecCrystal)) +/// Every codec Auto can race (Oracle, Flash and Grating are never included). +#define kLHC4AutoCodecsAll \ + (kLHC4AutoCodecsRoot | kLHC4CodecBit(kLHC4CodecMosaic) | kLHC4CodecBit(kLHC4CodecBwt) | \ + kLHC4CodecBit(kLHC4CodecLaser) | kLHC4CodecBit(kLHC4CodecLzma) | kLHC4CodecBit(kLHC4CodecBzip3)) + +/// Pretrained Crystal static profiles (lhc4codec::Profile). None keeps dynamic per-page models. +enum { + kLHC4ProfileNone = 0, + kLHC4ProfileFloatKinematics = 1, ///< IEEE-754 float columns (pT, eta, phi, mass, energy) + kLHC4ProfilePoissonCounts = 2, ///< multiplicities, track counts, jagged offsets + kLHC4ProfileIntMonotonic = 3, ///< event IDs, timestamps, entry counters + kLHC4ProfileText = 4, ///< text, JSON, XML + kLHC4ProfileCustom = 5, ///< id registered with R__LHC4TrainAndRegisterProfile +}; + +void R__zipLHC4(int cxlevel, int *srcsize, const char *src, int *tgtsize, char *tgt, int *irep); +void R__unzipLHC4(int *srcsize, const unsigned char *src, int *tgtsize, unsigned char *tgt, int *irep); + +/// Select the lhc4codec backend (Beam/Lz, Bwt, Zstd, Bzip3, Lzma, Auto, Mosaic, +/// Oracle, Crystal, Flash, Laser, Grating). Defaults to Beam. +void R__SetLHC4Codec(int codec); +int R__GetLHC4Codec(void); +/// Returns 1 when the requested backend was linked into lhc4codec, else 0. +/// Auto is always available; it races backends that are both linked and present +/// in `R__GetLHC4AutoCodecs()`. +int R__LHC4CodecAvailable(int codec); + +/// Auto only: min % size gain to prefer a slower decoder (default 1). 0 = smallest wins. +void R__SetLHC4AutoMinGainPct(int pct); +int R__GetLHC4AutoMinGainPct(void); + +/// Auto only: race each codec at max effort instead of the compression level (default off). +void R__SetLHC4AutoMaxLevel(int enable); +int R__GetLHC4AutoMaxLevel(void); + +/// Auto only: bitmask of codecs to race (`kLHC4CodecBit`). Default is +/// `kLHC4AutoCodecsRoot` (zstd | beam | crystal). 0 resets to that default. +/// `kLHC4AutoCodecsAll` races every available flavour except Oracle, Flash and Grating. +void R__SetLHC4AutoCodecs(unsigned mask); +unsigned R__GetLHC4AutoCodecs(void); + +/// Enable lhc4codec byte filters (shuffle/delta/zigzag/dict) for columnar data. +void R__SetLHC4Filters(int enable); +int R__GetLHC4Filters(void); + +/// With filters: also compress raw input and keep the smaller result (default on). +void R__SetLHC4FilterFallback(int enable); +int R__GetLHC4FilterFallback(void); + +/// Try post-filter byte-RLE when it shrinks the intermediate (default on). +void R__SetLHC4FilterRle(int enable); +int R__GetLHC4FilterRle(void); + +/// Allow dictionary-remap filters (dict32/64) in auto-detect (default on). +void R__SetLHC4FilterDict(int enable); +int R__GetLHC4FilterDict(void); + +/// Bitmask of FilterId values detection may nominate (`1ull << id`). +/// Default is all (`~0ull`). 0 resets to all. A singleton bit forces that +/// filter (no entropy reject); use with `R__SetLHC4FilterFallback(0)` to +/// measure a transform even when it loses. +void R__SetLHC4FilterMask(unsigned long long mask); +unsigned long long R__GetLHC4FilterMask(void); + +/// Crystal static profile (`kLHC4Profile*`) forced on every column. +/// `kLHC4ProfileNone` (default) keeps the dynamic Crystal coder and byte filters. +/// A non-none profile skips per-page model headers. +void R__SetLHC4Profile(int profile); +int R__GetLHC4Profile(void); + +/// When enabled, and no profile is forced, RNTuple selects a pretrained profile from +/// the column type. Default is off, so Crystal behaves like the other codecs. +void R__SetLHC4ProfilePerColumn(int enable); +int R__GetLHC4ProfilePerColumn(void); + +/// Profile for `R__zipLHC4` calls on this thread. `-1` (default) uses `R__GetLHC4Profile()`. +/// RNTuple sets this around each column page so workers do not share one profile. +void R__SetLHC4CallProfile(int profile); +int R__GetLHC4CallProfile(void); + +/// Per-thread filter trial for `R__zipLHC4`. `filters`/`fallback` of -1 and +/// `mask` of 0 follow the process-wide settings. rntupletryzip uses this so +/// workers can race different filters without clobbering each other. +void R__SetLHC4CallFilterTrial(int filters, unsigned long long mask, int fallback); + +/// Codec for `R__zipLHC4` calls on this thread. `-1` (default) uses `R__GetLHC4Codec()`. +/// rntupletryzip sets this so workers can race Crystal/Laser/… without clobbering +/// the process-wide codec. +void R__SetLHC4CallCodec(int codec); +int R__GetLHC4CallCodec(void); + +/// Call profile id for `R__zipLHC4` calls on this thread. 0 uses the call profile enum value. +void R__SetLHC4CallProfileId(unsigned id); +unsigned R__GetLHC4CallProfileId(void); + +/// Profile id when `R__GetLHC4Profile()` is not none. 0 uses the profile enum value. +/// Custom trained profiles use an id >= 5. +void R__SetLHC4ProfileId(unsigned id); +unsigned R__GetLHC4ProfileId(void); + +/// Train a custom static profile from columnar sample bytes and register it. +/// `profileId` must be >= 5. `stride` is the element width in bytes. Returns 1 on success. +int R__LHC4TrainAndRegisterProfile(unsigned profileId, const void *sample, size_t sampleSize, int stride); + +/// Legacy convenience: enable BWT block mode (sets codec to Bwt/Beam). +void R__SetLHC4Bwt(int enable); +int R__GetLHC4Bwt(void); + +/// Reset aggregated lhc4codec compression statistics. +void R__ResetLHC4CompressStats(void); +/// Print aggregated lhc4codec compression statistics to stdout. +void R__PrintLHC4CompressStats(void); + +/// Page-outcome counters from the most recent compress-stats window. +struct R__LHC4CompressStatsSummary { + unsigned long long num_pages; + unsigned long long uncompressed_bytes; + unsigned long long compressed_bytes; + unsigned long long filtered_won_pages; + unsigned long long raw_won_pages; + unsigned long long no_transform_pages; + unsigned long long stored_fallback_pages; + unsigned long long plain_pages; +}; +void R__GetLHC4CompressStatsSummary(struct R__LHC4CompressStatsSummary *out); + +/// Auto-mode winner histogram (indexed by kLHC4Codec*; only populated for lhc4_auto runs). +/// Slots for Auto and Oracle are unused: Auto is the racer, Oracle is not raced. +struct R__LHC4AutoCodecStatsSummary { + unsigned long long auto_selections; + unsigned long long codec_hits[kLHC4CodecCount]; +}; +void R__GetLHC4AutoCodecStatsSummary(struct R__LHC4AutoCodecStatsSummary *out); + +/// Optional inputs for R__DumpLHC4Failure (null pointers / zero sizes are omitted from dump files). +struct R__LHC4FailureDumpInfo { + const char *fStage; ///< e.g. "zip", "unzip", "verify_unzip", "verify_mismatch" + const void *fUncompressed; ///< raw page bytes before compression (may be null) + size_t fUncompressedSize; + const void *fCompressedLc; ///< full ROOT LC frame (9-byte header + lhc4codec payload) + size_t fCompressedLcSize; + int fCxLevel; ///< compression level if known, else -1 + const char *fStatusMessage; ///< human-readable error (may be null) +}; +/// Write fUncompressed / fCompressedLc payloads and a .meta sidecar under /tmp/root-lhc4-failure-/. +/// No-op unless compiled with `-DR__LHC4_FAILURE_DUMP`. +void R__DumpLHC4Failure(const struct R__LHC4FailureDumpInfo *info); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/core/lhc4codec/src/ZipLHC4.cxx b/core/lhc4codec/src/ZipLHC4.cxx new file mode 100644 index 0000000000000..8674826fe8466 --- /dev/null +++ b/core/lhc4codec/src/ZipLHC4.cxx @@ -0,0 +1,558 @@ +// Original Author: ROOT Team + +/************************************************************************* + * Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. * + * All rights reserved. * + * * + * For the licensing terms see $ROOTSYS/LICENSE. * + * For the list of contributors see $ROOTSYS/README/CREDITS. * + *************************************************************************/ + +#include "ZipLHC4.h" + +#include "lhc4codec/lhc4codec.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#else +#include +#define getpid _getpid +#endif + +#if !defined(R__unlikely) +# define R__unlikely(expr) __builtin_expect(!!(expr), 0) +#endif + +static const int kHeaderSize = 9; +static const unsigned char kLHC4Version = 1; + +// TODO: At some point, we need to decide on settings or make it part of the level option. +static int R__LHC4Codec = static_cast(lhc4codec::Codec::Beam); +static int R__LHC4Filters = 0; +static int R__LHC4FilterFallback = 1; +static int R__LHC4FilterRle = 1; +static int R__LHC4FilterDict = 1; +static unsigned long long R__LHC4FilterMask = ~0ull; +static int R__LHC4AutoMinGainPct = 1; +static int R__LHC4AutoMaxLevel = 0; +static unsigned R__LHC4AutoCodecs = kLHC4AutoCodecsRoot; +static int R__LHC4Profile = kLHC4ProfileNone; +static unsigned R__LHC4ProfileId = 0; +static int R__LHC4ProfilePerColumn = 0; +// -1: this thread follows R__LHC4Profile. Any other value is the profile for zip calls here. +static thread_local int R__LHC4CallProfile = -1; +static thread_local unsigned R__LHC4CallProfileId = 0; +// -1 / 0: this thread follows the process-wide filter knobs. +static thread_local int R__LHC4CallFilters = -1; +static thread_local int R__LHC4CallFilterFallback = -1; +static thread_local unsigned long long R__LHC4CallFilterMask = 0; +// -1: this thread follows R__LHC4Codec. +static thread_local int R__LHC4CallCodec = -1; +static int R__LHC4LastCxLevel = -1; + +namespace { + +#ifdef R__LHC4_FAILURE_DUMP +std::atomic gFailureDumpCounter{0}; + +const char *CodecName(int codec) +{ + switch (codec) { + case kLHC4CodecBwt: return "bwt"; + case kLHC4CodecZstd: return "zstd"; + case kLHC4CodecBzip3: return "bzip3"; + case kLHC4CodecLzma: return "lzma"; + case kLHC4CodecAuto: return "auto"; + case kLHC4CodecMosaic: return "mosaic"; + case kLHC4CodecOracle: return "oracle"; + case kLHC4CodecCrystal: return "crystal"; + case kLHC4CodecFlash: return "flash"; + case kLHC4CodecLaser: return "laser"; + case kLHC4CodecGrating: return "grating"; + default: return "beam"; + } +} + +std::string FailureDumpDirectory() +{ + static std::string dir; + if (dir.empty()) { + dir = std::string("/tmp/root-lhc4-failure-") + std::to_string(getpid()); + std::error_code ec; + std::filesystem::create_directories(dir, ec); + } + return dir; +} + +bool WriteBinaryFile(const std::string &path, const void *data, std::size_t size) +{ + if (!data || size == 0) + return false; + std::ofstream out(path, std::ios::binary); + if (!out) + return false; + out.write(static_cast(data), static_cast(size)); + return static_cast(out); +} + +void WriteMetaFile(const std::string &path, const R__LHC4FailureDumpInfo &info) +{ + std::ofstream out(path); + if (!out) + return; + + out << "stage=" << (info.fStage ? info.fStage : "") << '\n'; + out << "status=" << (info.fStatusMessage ? info.fStatusMessage : "") << '\n'; + out << "codec=" << CodecName(R__LHC4Codec) << " (" << R__LHC4Codec << ")\n"; + out << "cxlevel=" << info.fCxLevel << '\n'; + out << "filters=" << R__GetLHC4Filters() << '\n'; + out << "filter_fallback=" << R__GetLHC4FilterFallback() << '\n'; + out << "filter_rle=" << R__GetLHC4FilterRle() << '\n'; + out << "filter_dict=" << R__GetLHC4FilterDict() << '\n'; + out << "auto_min_gain_pct=" << R__GetLHC4AutoMinGainPct() << '\n'; + out << "auto_max_level=" << R__GetLHC4AutoMaxLevel() << '\n'; + out << "auto_codecs=0x" << std::hex << R__GetLHC4AutoCodecs() << std::dec << '\n'; + out << "profile=" << R__GetLHC4Profile() << '\n'; + out << "profile_id=" << R__GetLHC4ProfileId() << '\n'; + out << "uncompressed_size=" << info.fUncompressedSize << '\n'; + out << "compressed_lc_size=" << info.fCompressedLcSize << '\n'; + + if (info.fCompressedLc && info.fCompressedLcSize >= static_cast(kHeaderSize)) { + const auto *lc = static_cast(info.fCompressedLc); + const std::size_t payload = static_cast(lc[3]) | (static_cast(lc[4]) << 8) | + (static_cast(lc[5]) << 16); + const std::size_t expected = static_cast(lc[6]) | (static_cast(lc[7]) << 8) | + (static_cast(lc[8]) << 16); + out << "lc_payload_size=" << payload << '\n'; + out << "lc_expected_uncompressed=" << expected << '\n'; + } +} +#endif // R__LHC4_FAILURE_DUMP + +} // namespace + +extern "C" void R__DumpLHC4Failure(const R__LHC4FailureDumpInfo *info) +{ +#ifndef R__LHC4_FAILURE_DUMP + (void)info; +#else + if (!info) + return; + + const unsigned id = gFailureDumpCounter.fetch_add(1, std::memory_order_relaxed); + const std::string base = FailureDumpDirectory() + "/" + std::to_string(id); + + WriteMetaFile(base + ".meta", *info); + if (info->fUncompressed && info->fUncompressedSize > 0) + WriteBinaryFile(base + ".uncompressed.bin", info->fUncompressed, info->fUncompressedSize); + if (info->fCompressedLc && info->fCompressedLcSize > 0) + WriteBinaryFile(base + ".compressed_lc.bin", info->fCompressedLc, info->fCompressedLcSize); + + std::cerr << "LHC4 failure dump #" << id << " written to " << base + << ".{meta,uncompressed.bin,compressed_lc.bin} (stage=" + << (info->fStage ? info->fStage : "?") << ", uncompressed=" << info->fUncompressedSize + << " B, compressed_lc=" << info->fCompressedLcSize << " B, codec=" << CodecName(R__LHC4Codec) + << ", cxlevel=" << info->fCxLevel << ")\n"; +#endif +} + +static lhc4codec::Codec ToLHC4Codec(int codec) +{ + switch (codec) { + case kLHC4CodecBwt: return lhc4codec::Codec::Bwt; + case kLHC4CodecZstd: return lhc4codec::Codec::Zstd; + case kLHC4CodecBzip3: return lhc4codec::Codec::Bzip3; + case kLHC4CodecLzma: return lhc4codec::Codec::Lzma; + case kLHC4CodecAuto: return lhc4codec::Codec::Auto; + case kLHC4CodecMosaic: return lhc4codec::Codec::Mosaic; + case kLHC4CodecOracle: return lhc4codec::Codec::Oracle; + case kLHC4CodecCrystal: return lhc4codec::Codec::Crystal; + case kLHC4CodecFlash: return lhc4codec::Codec::Flash; + case kLHC4CodecLaser: return lhc4codec::Codec::Laser; + case kLHC4CodecGrating: return lhc4codec::Codec::Grating; + default: return lhc4codec::Codec::Beam; + } +} + +extern "C" void R__SetLHC4Codec(int codec) +{ + R__LHC4Codec = codec; +} + +extern "C" int R__GetLHC4Codec(void) +{ + return R__LHC4Codec; +} + +extern "C" int R__LHC4CodecAvailable(int codec) +{ + return lhc4codec::codec_available(ToLHC4Codec(codec)) ? 1 : 0; +} + +extern "C" void R__SetLHC4AutoMinGainPct(int pct) +{ + R__LHC4AutoMinGainPct = pct; +} + +extern "C" int R__GetLHC4AutoMinGainPct(void) +{ + return R__LHC4AutoMinGainPct; +} + +extern "C" void R__SetLHC4AutoMaxLevel(int enable) +{ + R__LHC4AutoMaxLevel = enable ? 1 : 0; +} + +extern "C" int R__GetLHC4AutoMaxLevel(void) +{ + return R__LHC4AutoMaxLevel; +} + +extern "C" void R__SetLHC4AutoCodecs(unsigned mask) +{ + R__LHC4AutoCodecs = mask ? mask : kLHC4AutoCodecsRoot; +} + +extern "C" unsigned R__GetLHC4AutoCodecs(void) +{ + return R__LHC4AutoCodecs; +} + +extern "C" void R__SetLHC4Filters(int enable) +{ + R__LHC4Filters = enable ? 1 : 0; +} + +extern "C" int R__GetLHC4Filters(void) +{ + return R__LHC4Filters; +} + +extern "C" void R__SetLHC4FilterFallback(int enable) +{ + R__LHC4FilterFallback = enable ? 1 : 0; +} + +extern "C" int R__GetLHC4FilterFallback(void) +{ + return R__LHC4FilterFallback; +} + +extern "C" void R__SetLHC4FilterRle(int enable) +{ + R__LHC4FilterRle = enable ? 1 : 0; +} + +extern "C" int R__GetLHC4FilterRle(void) +{ + return R__LHC4FilterRle; +} + +extern "C" void R__SetLHC4FilterDict(int enable) +{ + R__LHC4FilterDict = enable ? 1 : 0; +} + +extern "C" int R__GetLHC4FilterDict(void) +{ + return R__LHC4FilterDict; +} + +extern "C" void R__SetLHC4FilterMask(unsigned long long mask) +{ + R__LHC4FilterMask = mask ? mask : ~0ull; +} + +extern "C" unsigned long long R__GetLHC4FilterMask(void) +{ + return R__LHC4FilterMask; +} + +extern "C" void R__SetLHC4Profile(int profile) +{ + if (profile < kLHC4ProfileNone || profile > kLHC4ProfileCustom) + profile = kLHC4ProfileNone; + R__LHC4Profile = profile; + lhc4codec::set_thread_profile(static_cast(R__LHC4Profile)); +} + +extern "C" int R__GetLHC4Profile(void) +{ + return R__LHC4Profile; +} + +extern "C" void R__SetLHC4ProfilePerColumn(int enable) +{ + R__LHC4ProfilePerColumn = enable ? 1 : 0; +} + +extern "C" int R__GetLHC4ProfilePerColumn(void) +{ + return R__LHC4ProfilePerColumn; +} + +extern "C" void R__SetLHC4ProfileId(unsigned id) +{ + R__LHC4ProfileId = id; +} + +extern "C" unsigned R__GetLHC4ProfileId(void) +{ + return R__LHC4ProfileId; +} + +extern "C" void R__SetLHC4CallProfile(int profile) +{ + if (profile < -1 || profile > kLHC4ProfileCustom) + profile = -1; + R__LHC4CallProfile = profile; +} + +extern "C" int R__GetLHC4CallProfile(void) +{ + return R__LHC4CallProfile; +} + +extern "C" void R__SetLHC4CallProfileId(unsigned id) +{ + R__LHC4CallProfileId = id; +} + +extern "C" unsigned R__GetLHC4CallProfileId(void) +{ + return R__LHC4CallProfileId; +} + +extern "C" void R__SetLHC4CallFilterTrial(int filters, unsigned long long mask, int fallback) +{ + R__LHC4CallFilters = filters; + R__LHC4CallFilterMask = mask; + R__LHC4CallFilterFallback = fallback; +} + +extern "C" void R__SetLHC4CallCodec(int codec) +{ + R__LHC4CallCodec = codec; +} + +extern "C" int R__GetLHC4CallCodec(void) +{ + return R__LHC4CallCodec; +} + +static int EffectiveLHC4Profile() +{ + return R__LHC4CallProfile >= 0 ? R__LHC4CallProfile : R__LHC4Profile; +} + +extern "C" int R__LHC4TrainAndRegisterProfile(unsigned profileId, const void *sample, size_t sampleSize, int stride) +{ + if (!sample || sampleSize == 0) + return 0; + return lhc4codec::train_and_register_profile( + profileId, std::span(static_cast(sample), sampleSize), stride) + ? 1 + : 0; +} + +extern "C" void R__SetLHC4Bwt(int enable) +{ + if (enable) + R__LHC4Codec = kLHC4CodecBwt; + else if (R__LHC4Codec == kLHC4CodecBwt) + R__LHC4Codec = kLHC4CodecBeam; +} + +extern "C" int R__GetLHC4Bwt(void) +{ + return R__LHC4Codec == kLHC4CodecBwt ? 1 : 0; +} + +static lhc4codec::CompressParams MakeLHC4CompressParams(int cxlevel) +{ + lhc4codec::CompressParams params; + // lhc4codec treats level 0 as the Flash codec. Keep ROOT's LHC4 levels on the + // selected backend (1..kMaxLevel). + if (cxlevel < 1) + cxlevel = 1; + if (cxlevel > lhc4codec::kMaxLevel) + cxlevel = lhc4codec::kMaxLevel; + params.codec = ToLHC4Codec(R__LHC4CallCodec >= 0 ? R__LHC4CallCodec : R__LHC4Codec); + params.level = cxlevel; + params.filters = (R__LHC4CallFilters >= 0 ? R__LHC4CallFilters : R__LHC4Filters) != 0; + params.filter_fallback = (R__LHC4CallFilterFallback >= 0 ? R__LHC4CallFilterFallback : R__LHC4FilterFallback) != 0; + params.filter_rle = R__LHC4FilterRle != 0; + params.filter_dict = R__LHC4FilterDict != 0; + params.filter_mask = R__LHC4CallFilterMask ? R__LHC4CallFilterMask : R__LHC4FilterMask; + params.auto_min_gain_pct = R__LHC4AutoMinGainPct; + params.auto_max_level = R__LHC4AutoMaxLevel != 0; + params.auto_codecs = R__LHC4AutoCodecs; + params.profile = static_cast(EffectiveLHC4Profile()); + // A per-call profile is a builtin id. The process-wide id applies only to the forced profile. + params.profile_id = R__LHC4CallProfile >= 0 ? 0u : R__LHC4ProfileId; + return params; +} + +static void DumpZipFailure(int cxlevel, int srcsize, const char *src, const char *status) +{ + R__LHC4FailureDumpInfo dump{}; + dump.fStage = "zip"; + dump.fUncompressed = src; + dump.fUncompressedSize = static_cast(srcsize); + dump.fCxLevel = cxlevel; + dump.fStatusMessage = status; + R__DumpLHC4Failure(&dump); +} + +void R__zipLHC4(int cxlevel, int *srcsize, const char *src, int *tgtsize, char *tgt, int *irep) +{ + *irep = 0; + R__LHC4LastCxLevel = cxlevel; + // CompressParams carries the profile. Also set the lhc4codec thread-local default + // so this worker matches the column (or the process-wide override). + lhc4codec::set_thread_profile(static_cast(EffectiveLHC4Profile())); + + const auto params = MakeLHC4CompressParams(cxlevel); + if (R__unlikely(!lhc4codec::codec_available(params.codec))) { + std::cerr << "Error in zip LHC4: requested codec is not available in this build" << std::endl; + DumpZipFailure(cxlevel, *srcsize, src, "requested codec is not available in this build"); + return; + } + + const auto result = lhc4codec::compress( + std::span(reinterpret_cast(src), static_cast(*srcsize)), + std::span(reinterpret_cast(&tgt[kHeaderSize]), + static_cast(*tgtsize - kHeaderSize)), + params); + + if (R__unlikely(!result.ok())) { + const std::string msg(lhc4codec::status_message(result.status)); + if (R__unlikely(result.status != lhc4codec::Status::BufferTooSmall)) { + std::cerr << "Error in zip LHC4: " << msg << std::endl; + DumpZipFailure(cxlevel, *srcsize, src, msg.c_str()); + } + return; + } + + const size_t written = result.value; + *irep = static_cast(written + kHeaderSize); + + const size_t deflate_size = written; + const size_t inflate_size = static_cast(*srcsize); + tgt[0] = 'L'; + tgt[1] = 'C'; + tgt[2] = static_cast(kLHC4Version); + tgt[3] = deflate_size & 0xff; + tgt[4] = (deflate_size >> 8) & 0xff; + tgt[5] = (deflate_size >> 16) & 0xff; + tgt[6] = inflate_size & 0xff; + tgt[7] = (inflate_size >> 8) & 0xff; + tgt[8] = (inflate_size >> 16) & 0xff; +} + +void R__unzipLHC4(int *srcsize, const unsigned char *src, int *tgtsize, unsigned char *tgt, int *irep) +{ + *irep = 0; + + if (R__unlikely(src[0] != 'L' || src[1] != 'C')) { + std::cerr << "R__unzipLHC4: algorithm run against buffer with incorrect header (got " << src[0] << src[1] + << "; expected LC)." << std::endl; + R__LHC4FailureDumpInfo dump{}; + dump.fStage = "unzip"; + dump.fCompressedLc = src; + dump.fCompressedLcSize = static_cast(*srcsize); + dump.fUncompressedSize = static_cast(*tgtsize); + dump.fCxLevel = R__LHC4LastCxLevel; + dump.fStatusMessage = "incorrect LC header"; + R__DumpLHC4Failure(&dump); + return; + } + + if (R__unlikely(src[2] != kLHC4Version)) { + std::cerr << "R__unzipLHC4: incompatible LHC4 on-disk version (got " << static_cast(src[2]) << "; expected " + << static_cast(kLHC4Version) << ")" << std::endl; + R__LHC4FailureDumpInfo dump{}; + dump.fStage = "unzip"; + dump.fCompressedLc = src; + dump.fCompressedLcSize = static_cast(*srcsize); + dump.fUncompressedSize = static_cast(*tgtsize); + dump.fCxLevel = R__LHC4LastCxLevel; + dump.fStatusMessage = "incompatible LHC4 on-disk version"; + R__DumpLHC4Failure(&dump); + return; + } + + const auto result = lhc4codec::decompress( + std::span(reinterpret_cast(&src[kHeaderSize]), + static_cast(*srcsize - kHeaderSize)), + std::span(reinterpret_cast(tgt), static_cast(*tgtsize))); + + if (R__unlikely(!result.ok())) { + const std::string msg(lhc4codec::status_message(result.status)); + if (R__unlikely(result.status != lhc4codec::Status::BufferTooSmall)) { + std::cerr << "Error in unzip LHC4: " << msg << std::endl; + R__LHC4FailureDumpInfo dump{}; + dump.fStage = "unzip"; + dump.fCompressedLc = src; + dump.fCompressedLcSize = static_cast(*srcsize); + dump.fUncompressedSize = static_cast(*tgtsize); + dump.fCxLevel = R__LHC4LastCxLevel; + dump.fStatusMessage = msg.c_str(); + R__DumpLHC4Failure(&dump); + } + return; + } + + *irep = static_cast(result.value); +} + +extern "C" void R__ResetLHC4CompressStats(void) +{ + lhc4codec::reset_compress_stats(); +} + +extern "C" void R__PrintLHC4CompressStats(void) +{ + const auto stats = lhc4codec::get_compress_stats(); + std::cout << lhc4codec::format_compress_stats(stats); +} + +extern "C" void R__GetLHC4CompressStatsSummary(R__LHC4CompressStatsSummary *out) +{ + if (!out) + return; + const auto stats = lhc4codec::get_compress_stats(); + out->num_pages = stats.num_pages; + out->uncompressed_bytes = stats.uncompressed_bytes; + out->compressed_bytes = stats.compressed_bytes; + out->filtered_won_pages = stats.filtered_won_pages; + out->raw_won_pages = stats.raw_won_pages; + out->no_transform_pages = stats.no_transform_pages; + out->stored_fallback_pages = stats.stored_fallback_pages; + out->plain_pages = stats.plain_pages; +} + +extern "C" void R__GetLHC4AutoCodecStatsSummary(R__LHC4AutoCodecStatsSummary *out) +{ + if (!out) + return; + const auto stats = lhc4codec::get_compress_stats(); + out->auto_selections = stats.auto_selections; + // The library counts hits only for the codecs Auto can race; its array may be + // shorter than ROOT's enum (Grating, for instance, is never raced). + constexpr int kLibHits = static_cast(std::size(stats.auto_codec_hits)); + for (int i = 0; i < kLHC4CodecCount; ++i) + out->codec_hits[i] = (i < kLibHits) ? stats.auto_codec_hits[i] : 0ull; +} diff --git a/core/zip/inc/Compression.h b/core/zip/inc/Compression.h index 62301753b686c..bd071f3e9f7fd 100644 --- a/core/zip/inc/Compression.h +++ b/core/zip/inc/Compression.h @@ -42,6 +42,12 @@ namespace ROOT { /// [207 - 208] /// - LZ4 is recommended to be used with compression level 4 [404] /// - ZSTD is recommended to be used with compression level 5 [505] +/// - LHC4 is recommended to be used with compression level 6 [606] +/// - LHC4 backends (Beam, Bwt, Zstd, Bzip3, Lzma, Auto, Mosaic, Oracle, Crystal) +/// and filter / Auto-mask / Crystal profile options are toggled globally via +/// R__SetLHC4Codec(), R__SetLHC4Filters(), R__SetLHC4AutoCodecs(), +/// R__SetLHC4Profile(), and related helpers before +/// compression (see ZipLHC4.h). Decompression auto-detects the inner frame type. struct RCompressionSetting { struct EDefaults { /// Note: this is only temporarily a struct and will become a enum class hence the name convention @@ -80,7 +86,9 @@ struct RCompressionSetting { /// Compression level reserved for old ROOT compression algorithm kDefaultOld = 6, /// Compression level reserved for LZMA compression algorithm (slowest compression with smallest files) - kDefaultLZMA = 7 + kDefaultLZMA = 7, + /// Compression level reserved for LHC4 compression algorithm + kDefaultLHC4 = 6 }; }; struct EAlgorithm { /// Note: this is only temporarily a struct and will become a enum class hence the name @@ -101,6 +109,8 @@ struct RCompressionSetting { kLZ4, /// Use ZSTD compression kZSTD, + /// Use LHC4 compression + kLHC4, /// Undefined compression algorithm (must be kept the last of the list in case a new algorithm is added). kUndefined }; diff --git a/core/zip/src/Compression.cxx b/core/zip/src/Compression.cxx index 237b423ef85e8..6259d30a002f0 100644 --- a/core/zip/src/Compression.cxx +++ b/core/zip/src/Compression.cxx @@ -32,6 +32,7 @@ namespace ROOT { case EAlgorithm::EValues::kOldCompressionAlgo: return "Old compression algorithm"; case EAlgorithm::EValues::kLZ4: return "lz4"; case EAlgorithm::EValues::kZSTD: return "zstd"; + case EAlgorithm::EValues::kLHC4: return "lhc4"; default: return "Undefined compression algorithm"; } } diff --git a/core/zip/src/RZip.cxx b/core/zip/src/RZip.cxx index 3424fee86e2ec..f7f04695953b5 100644 --- a/core/zip/src/RZip.cxx +++ b/core/zip/src/RZip.cxx @@ -93,6 +93,9 @@ void R__unzipZSTD(int *srcsize, const unsigned char *src, int *tgtsize, unsigned #endif +#ifdef R__HAS_LHC4CODEC +#include "ZipLHC4.h" +#endif #include "zlib.h" @@ -180,6 +183,10 @@ void R__zipMultipleAlgorithm(int cxlevel, int *srcsize, const char *src, int *tg R__zipLZ4(cxlevel, srcsize, src, tgtsize, tgt, irep); } else if (compressionAlgorithm == ROOT::RCompressionSetting::EAlgorithm::kZSTD) { R__zipZSTD(cxlevel, srcsize, src, tgtsize, tgt, irep); +#ifdef R__HAS_LHC4CODEC + } else if (compressionAlgorithm == ROOT::RCompressionSetting::EAlgorithm::kLHC4) { + R__zipLHC4(cxlevel, srcsize, src, tgtsize, tgt, irep); +#endif } else if (compressionAlgorithm == ROOT::RCompressionSetting::EAlgorithm::kOldCompressionAlgo || compressionAlgorithm == ROOT::RCompressionSetting::EAlgorithm::kUseGlobal) { R__zipOld(cxlevel, srcsize, src, tgtsize, tgt, irep); } else { @@ -353,10 +360,21 @@ static int is_valid_header_zstd(const unsigned char *src) return src[0] == 'Z' && src[1] == 'S' && src[2] == '\1'; } +#ifdef R__HAS_LHC4CODEC +static int is_valid_header_lhc4(const unsigned char *src) +{ + return src[0] == 'L' && src[1] == 'C' && src[2] == '\1'; +} +#endif + static int is_valid_header(const unsigned char *src) { return is_valid_header_zlib(src) || is_valid_header_old(src) || is_valid_header_lzma(src) || - is_valid_header_lz4(src) || is_valid_header_zstd(src); + is_valid_header_lz4(src) || is_valid_header_zstd(src) +#ifdef R__HAS_LHC4CODEC + || is_valid_header_lhc4(src) +#endif + ; } ROOT::RCompressionSetting::EAlgorithm::EValues R__getCompressionAlgorithm(const unsigned char *buf, size_t bufsize) @@ -366,6 +384,10 @@ ROOT::RCompressionSetting::EAlgorithm::EValues R__getCompressionAlgorithm(const if (is_valid_header_zstd(const_cast(buf))) return ROOT::RCompressionSetting::EAlgorithm::kZSTD; +#ifdef R__HAS_LHC4CODEC + if (is_valid_header_lhc4(const_cast(buf))) + return ROOT::RCompressionSetting::EAlgorithm::kLHC4; +#endif if (is_valid_header_zlib(const_cast(buf))) return ROOT::RCompressionSetting::EAlgorithm::kZLIB; if (is_valid_header_lz4(const_cast(buf))) @@ -470,6 +492,11 @@ void R__unzip(int *srcsize, const uch *src, int *tgtsize, uch *tgt, int *irep) } else if (is_valid_header_zstd(src)) { R__unzipZSTD(srcsize, src, tgtsize, tgt, irep); return; +#ifdef R__HAS_LHC4CODEC + } else if (is_valid_header_lhc4(src)) { + R__unzipLHC4(srcsize, src, tgtsize, tgt, irep); + return; +#endif } /* Old zlib format */ diff --git a/core/zip/test/ZipTest.cxx b/core/zip/test/ZipTest.cxx index f1b40f7853950..d68daa13ec0fc 100644 --- a/core/zip/test/ZipTest.cxx +++ b/core/zip/test/ZipTest.cxx @@ -68,3 +68,10 @@ TEST(RZip, ZipBufferSizesZSTD) { testZipBufferSizes(ROOT::RCompressionSetting::EAlgorithm::kZSTD); } + +#ifdef R__HAS_LHC4CODEC +TEST(RZip, ZipBufferSizesLHC4) +{ + testZipBufferSizes(ROOT::RCompressionSetting::EAlgorithm::kLHC4); +} +#endif diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 8df316efca455..324f939d2a77e 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -45,6 +45,18 @@ ROOT_EXECUTABLE(rootnb.exe nbmain.cxx LIBRARIES Core CMAKENOEXPORT) #---ReadSpeed------------------------------------------------------------------------------------- ROOT_EXECUTABLE(rootreadspeed src/readspeed.cxx LIBRARIES RIO Tree TreePlayer ReadSpeed CMAKENOEXPORT) +ROOT_EXECUTABLE(tree2ntuple src/tree2ntuple.cxx + LIBRARIES Core RIO Tree ROOTNTuple ROOTNTupleUtil CMAKENOEXPORT) + +ROOT_EXECUTABLE(rntuple2rntuple src/rntuple2rntuple.cxx + LIBRARIES Core RIO ROOTNTuple CMAKENOEXPORT) + +ROOT_EXECUTABLE(rntupleinspect src/rntupleinspect.cxx + LIBRARIES Core RIO ROOTNTuple ROOTNTupleUtil CMAKENOEXPORT) + +ROOT_EXECUTABLE(rntupletryzip src/rntupletryzip.cxx + LIBRARIES Core RIO ROOTNTuple ROOTNTupleUtil CMAKENOEXPORT) + #---CreateHaddCommandLineOptions------------------------------------------------------------------ generateHeader(hadd ${CMAKE_SOURCE_DIR}/main/src/hadd-argparse.py diff --git a/main/src/RNTupleBench.hxx b/main/src/RNTupleBench.hxx new file mode 100644 index 0000000000000..425b747711855 --- /dev/null +++ b/main/src/RNTupleBench.hxx @@ -0,0 +1,554 @@ +// Author: Andreas Joachim Peters 2026 + +/************************************************************************* + * Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. * + * All rights reserved. * + * * + * For the licensing terms see $ROOTSYS/LICENSE. * + * For the list of contributors see $ROOTSYS/README/CREDITS. * + *************************************************************************/ + +#ifndef ROOT_RNTUPLE_BENCH_HXX +#define ROOT_RNTUPLE_BENCH_HXX + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace RNTupleBench { + +struct PageDecompResult { + bool fValid = false; + std::string fError; + std::size_t fNumPages = 0; + std::size_t fTotalCompBytes = 0; + std::size_t fTotalUncompBytes = 0; + double fTimePerPassSec = 0.0; + double fDecompMBs = 0.0; +}; + +struct EventReaderResult { + bool fValid = false; + std::string fError; + int fThreads = 1; + std::uint64_t fNEntries = 0; + double fReadWallSec = 0.0; + double fTotalMBs = 0.0; + double fEntriesSec = 0.0; + double fDecompWallSec = 0.0; + double fDecompCpuSec = 0.0; + double fDecompMBs = 0.0; + long long fPages = 0; + double fUncompMB = 0.0; +}; + +struct BenchmarkResult { + std::string fLabel; + std::string fPath; + std::uint64_t fFileSizeBytes = 0; + PageDecompResult fPageDecomp; + EventReaderResult fSingleCore; + EventReaderResult fMultiCore; +}; + +inline std::string Basename(std::string path) +{ + const auto slash = path.find_last_of("/\\"); + if (slash != std::string::npos) + path.erase(0, slash + 1); + const auto dot = path.rfind('.'); + if (dot != std::string::npos) + path.erase(dot); + return path; +} + +inline std::string FindFirstNTupleName(const std::string &path, const std::string &preferred = "") +{ + auto file = std::unique_ptr(TFile::Open(path.c_str(), "READ")); + if (!file || file->IsZombie()) + return ""; + + if (!preferred.empty()) { + if (file->Get(preferred.c_str())) + return preferred; + } + + TIter next(file->GetListOfKeys()); + while (auto key = static_cast(next())) { + if (std::strcmp(key->GetClassName(), "ROOT::RNTuple") == 0) + return key->GetName(); + } + return ""; +} + +inline const ROOT::Experimental::Detail::RNTuplePerfCounter *FindCounter( + const ROOT::Experimental::Detail::RNTupleMetrics &metrics, const std::string &name) +{ + if (auto *c = metrics.GetCounter("RNTupleReader.RPageStorage." + name)) + return c; + if (auto *c = metrics.GetCounter("RNTupleReader.RPageSourceFile." + name)) + return c; + if (auto *c = metrics.GetCounter("RPageStorage." + name)) + return c; + if (auto *c = metrics.GetCounter("RPageSourceFile." + name)) + return c; + if (auto *c = metrics.GetCounter(name)) + return c; + if (auto *c = metrics.GetLocalCounter(name)) + return c; + return nullptr; +} + +// `percent` in (0, 100] keeps a prefix of compressed page bytes, in cluster order. +inline std::uint64_t ScaledCount(std::uint64_t total, double percent) +{ + if (total == 0 || !(percent < 100.0)) + return total; + const auto n = static_cast(std::ceil(static_cast(total) * (percent / 100.0))); + if (n == 0) + return 1; + return std::min(n, total); +} + +inline PageDecompResult RunPageDecompBenchmark(const std::string &path, const std::string &ntupleName, int iters = 5, + double percent = 100.0) +{ + PageDecompResult res; + if (iters < 1) + iters = 1; + + // 1. Open reader to attach and register compression profiles (e.g. LHC4 Crystal profile) in lhc4codec + std::unique_ptr reader; + try { + reader = ROOT::RNTupleReader::Open(ntupleName, path); + } catch (const std::exception &e) { + res.fError = e.what(); + return res; + } + const auto &desc = reader->GetDescriptor(); + + // 2. Open file directly to extract sealed pages into RAM (fast POSIX pread or TFile fallback) + int fd = open(path.c_str(), O_RDONLY); + std::unique_ptr file; + if (fd < 0) { + file = std::unique_ptr(TFile::Open(path.c_str(), "READ")); + if (!file || file->IsZombie()) { + res.fError = "Failed to open file: " + path; + return res; + } + } + + struct PageRef { + std::uint64_t fOffset = 0; + std::size_t fComp = 0; + std::size_t fUncomp = 0; + }; + std::vector refs; + std::uint64_t totalComp = 0; + + for (const auto &clusterDesc : desc.GetClusterIterable()) { + for (const auto &colRange : clusterDesc.GetColumnRangeIterable()) { + if (colRange.IsSuppressed()) + continue; + auto colId = colRange.GetPhysicalColumnId(); + const auto &colDesc = desc.GetColumnDescriptor(colId); + auto nBits = colDesc.GetBitsOnStorage(); + if (nBits == 0) + continue; + const auto &pageRange = clusterDesc.GetPageRange(colId); + + for (const auto &pageInfo : pageRange.GetPageInfos()) { + auto loc = pageInfo.GetLocator(); + if (loc.GetType() == ROOT::RNTupleLocator::kTypePageZero) + continue; + auto compBytes = loc.GetNBytesOnStorage(); + auto uncompBytes = (static_cast(nBits) * pageInfo.GetNElements() + 7) / 8; + if (compBytes == 0 || uncompBytes == 0) + continue; + + PageRef ref; + ref.fOffset = loc.GetPosition(); + ref.fComp = compBytes; + ref.fUncomp = uncompBytes; + totalComp += compBytes; + refs.push_back(ref); + } + } + } + + if (percent < 100.0) { + const auto budget = ScaledCount(totalComp, percent); + std::uint64_t acc = 0; + std::size_t keep = 0; + for (const auto &ref : refs) { + acc += ref.fComp; + ++keep; + if (acc >= budget) + break; + } + refs.resize(keep); + } + + struct PageData { + std::vector fCompBuffer; + std::size_t fUncompSize = 0; + }; + std::vector pages; + std::size_t maxUncomp = 0; + pages.reserve(refs.size()); + for (const auto &ref : refs) { + PageData pd; + pd.fCompBuffer.resize(ref.fComp); + pd.fUncompSize = ref.fUncomp; + if (fd >= 0) { + ssize_t n = pread(fd, pd.fCompBuffer.data(), ref.fComp, ref.fOffset); + if (n != static_cast(ref.fComp)) { + // Ignore read error; buffer will remain uncompressed or zeroed + } + } else if (file) { + file->Seek(ref.fOffset); + file->ReadBuffer(pd.fCompBuffer.data(), ref.fComp); + } + res.fTotalCompBytes += ref.fComp; + res.fTotalUncompBytes += ref.fUncomp; + if (ref.fUncomp > maxUncomp) + maxUncomp = ref.fUncomp; + pages.push_back(std::move(pd)); + } + + if (fd >= 0) { + close(fd); + fd = -1; + } + + res.fNumPages = pages.size(); + if (pages.empty() || res.fTotalUncompBytes == 0) { + res.fError = "No sealed pages found to decompress"; + return res; + } + + std::vector outBuf(maxUncomp * 2 + 65536); + + // Warm-up pass + for (const auto &p : pages) { + if (p.fCompBuffer.size() < p.fUncompSize) { + try { + ROOT::Internal::RNTupleDecompressor::Unzip( + p.fCompBuffer.data(), p.fCompBuffer.size(), p.fUncompSize, outBuf.data()); + } catch (...) { + // Ignore corrupted or uncompressed pages during raw decomp + } + } else { + std::memcpy(outBuf.data(), p.fCompBuffer.data(), p.fUncompSize); + } + } + + // Timed passes + auto t0 = std::chrono::high_resolution_clock::now(); + for (int iter = 0; iter < iters; ++iter) { + for (const auto &p : pages) { + if (p.fCompBuffer.size() < p.fUncompSize) { + try { + ROOT::Internal::RNTupleDecompressor::Unzip( + p.fCompBuffer.data(), p.fCompBuffer.size(), p.fUncompSize, outBuf.data()); + } catch (...) { + } + } else { + std::memcpy(outBuf.data(), p.fCompBuffer.data(), p.fUncompSize); + } + } + } + auto t1 = std::chrono::high_resolution_clock::now(); + + double totalSec = std::chrono::duration(t1 - t0).count(); + res.fTimePerPassSec = totalSec / iters; + double uncompMB = static_cast(res.fTotalUncompBytes) / (1024.0 * 1024.0); + res.fDecompMBs = res.fTimePerPassSec > 0.0 ? uncompMB / res.fTimePerPassSec : 0.0; + res.fValid = true; + return res; +} + +inline EventReaderResult RunEventReaderBenchmark(const std::string &path, const std::string &ntupleName, int threads, + std::uint64_t maxEntries = 0, double percent = 100.0) +{ + EventReaderResult res; + res.fThreads = threads; + + if (threads > 1) { + ROOT::EnableImplicitMT(threads); + } else if (ROOT::IsImplicitMTEnabled()) { + ROOT::DisableImplicitMT(); + } + + try { + ROOT::RNTupleReadOptions readOpts; + readOpts.SetEnableMetrics(true); + auto reader = ROOT::RNTupleReader::Open(ntupleName, path, readOpts); + std::uint64_t totalEntries = reader->GetNEntries(); + std::uint64_t nEntriesToRead = totalEntries; + if (maxEntries > 0 && maxEntries < totalEntries) + nEntriesToRead = maxEntries; + else if (percent < 100.0) + nEntriesToRead = ScaledCount(totalEntries, percent); + res.fNEntries = nEntriesToRead; + + auto t0 = std::chrono::high_resolution_clock::now(); + for (std::uint64_t entryId = 0; entryId < nEntriesToRead; ++entryId) { + reader->LoadEntry(entryId); + } + auto t1 = std::chrono::high_resolution_clock::now(); + res.fReadWallSec = std::chrono::duration(t1 - t0).count(); + + const auto &metrics = reader->GetMetrics(); + auto *szUnzip = FindCounter(metrics, "szUnzip"); + auto *timeWallUnzip = FindCounter(metrics, "timeWallUnzip"); + auto *timeCpuUnzip = FindCounter(metrics, "timeCpuUnzip"); + auto *nPageUnsealed = FindCounter(metrics, "nPageUnsealed"); + + res.fUncompMB = szUnzip ? szUnzip->GetValueAsInt() / (1024.0 * 1024.0) : 0.0; + res.fTotalMBs = res.fReadWallSec > 0.0 ? res.fUncompMB / res.fReadWallSec : 0.0; + res.fEntriesSec = res.fReadWallSec > 0.0 ? static_cast(res.fNEntries) / res.fReadWallSec : 0.0; + res.fDecompWallSec = timeWallUnzip ? timeWallUnzip->GetValueAsInt() / 1e9 : 0.0; + res.fDecompCpuSec = timeCpuUnzip ? timeCpuUnzip->GetValueAsInt() / 1e9 : 0.0; + res.fDecompMBs = res.fDecompWallSec > 0.0 ? res.fUncompMB / res.fDecompWallSec : 0.0; + res.fPages = nPageUnsealed ? nPageUnsealed->GetValueAsInt() : 0; + res.fValid = true; + } catch (const std::exception &e) { + res.fError = e.what(); + } + + if (ROOT::IsImplicitMTEnabled()) { + ROOT::DisableImplicitMT(); + } + return res; +} + +inline BenchmarkResult RunBenchmarkForFile(const std::string &label, const std::string &path, + const std::string &ntupleName, int multiThreads = 0, int iters = 5, + std::uint64_t maxEntries = 0, double benchPercent = 100.0) +{ + if (multiThreads <= 0) { + multiThreads = static_cast(std::thread::hardware_concurrency()); + if (multiThreads <= 0) + multiThreads = 8; + } + + BenchmarkResult res; + res.fLabel = label; + res.fPath = path; + + FileStat_t st{}; + if (gSystem->GetPathInfo(path.c_str(), st) == 0) { + res.fFileSizeBytes = static_cast(st.fSize); + } + + std::string actualName = ntupleName; + if (actualName.empty()) { + actualName = FindFirstNTupleName(path); + if (actualName.empty()) { + res.fPageDecomp.fError = "No RNTuple found in " + path; + res.fSingleCore.fError = res.fPageDecomp.fError; + res.fMultiCore.fError = res.fPageDecomp.fError; + return res; + } + } + + std::cout << "Benchmarking " << label << " (" << path << ")"; + if (benchPercent < 100.0) + std::cout << " [first " << benchPercent << "% of pages and entries]"; + std::cout << "..." << std::endl; + res.fPageDecomp = RunPageDecompBenchmark(path, actualName, iters, benchPercent); + res.fSingleCore = RunEventReaderBenchmark(path, actualName, 1, maxEntries, benchPercent); + res.fMultiCore = RunEventReaderBenchmark(path, actualName, multiThreads, maxEntries, benchPercent); + return res; +} + +inline std::string FormatMB(double mb) +{ + std::ostringstream os; + os << std::fixed << std::setprecision(1) << mb << " MB"; + return os.str(); +} + +inline std::string FormatSpeed(double mbs) +{ + std::ostringstream os; + os << std::fixed << std::setprecision(1) << mbs << " MB/s"; + return os.str(); +} + +inline std::string FormatTime(double sec) +{ + std::ostringstream os; + os << std::fixed << std::setprecision(sec < 10.0 ? 3 : 2) << sec << " s"; + return os.str(); +} + +inline std::string FormatEntriesPerSec(double eps) +{ + std::uint64_t v = static_cast(eps + 0.5); + std::string s = std::to_string(v); + std::string formatted; + int n = 0; + for (auto it = s.rbegin(); it != s.rend(); ++it) { + if (n > 0 && n % 3 == 0) + formatted.push_back(','); + formatted.push_back(*it); + ++n; + } + std::reverse(formatted.begin(), formatted.end()); + return formatted + " ent/s"; +} + +inline void PrintBenchmarkSummary(const std::vector &results) +{ + if (results.empty()) + return; + + constexpr int kLabelWidth = 26; + + // ------------------------------------------------------------------------- + // Table 1: Pure In-Memory Page Decompression (Algorithm Benchmark) + // ------------------------------------------------------------------------- + std::cout << "\n=== Pure In-Memory Page Decompression Benchmark (Algorithm) ===\n"; + std::cout << std::left << std::setw(kLabelWidth) << "Variant / File" + << std::right << std::setw(14) << "Comp Size" + << std::setw(15) << "Uncomp Size" + << std::setw(10) << "Pages" + << std::setw(14) << "Pass Time" + << std::setw(18) << "Decomp Throughput" + << std::setw(14) << "vs Baseline" + << '\n'; + std::cout << std::string(kLabelWidth + 14 + 15 + 10 + 14 + 18 + 14, '-') << '\n'; + + double baseDecompMBs = 0.0; + for (std::size_t i = 0; i < results.size(); ++i) { + const auto &r = results[i]; + std::cout << std::left << std::setw(kLabelWidth) << r.fLabel << std::right; + if (r.fPageDecomp.fValid) { + double compMB = static_cast(r.fPageDecomp.fTotalCompBytes) / (1024.0 * 1024.0); + double uncompMB = static_cast(r.fPageDecomp.fTotalUncompBytes) / (1024.0 * 1024.0); + if (i == 0) + baseDecompMBs = r.fPageDecomp.fDecompMBs; + + std::ostringstream ratioStr; + if (baseDecompMBs > 0.0) { + double ratio = r.fPageDecomp.fDecompMBs / baseDecompMBs; + ratioStr << std::fixed << std::setprecision(2) << ratio << "x"; + } else { + ratioStr << "baseline"; + } + + std::cout << std::setw(14) << FormatMB(compMB) + << std::setw(15) << FormatMB(uncompMB) + << std::setw(10) << r.fPageDecomp.fNumPages + << std::setw(14) << FormatTime(r.fPageDecomp.fTimePerPassSec) + << std::setw(18) << FormatSpeed(r.fPageDecomp.fDecompMBs) + << std::setw(14) << ratioStr.str(); + } else { + std::cout << " [Error: " << r.fPageDecomp.fError << "]"; + } + std::cout << '\n'; + } + + // ------------------------------------------------------------------------- + // Table 2: Single-Threaded Event Reader Benchmark (LoadEntry, 1 Thread) + // ------------------------------------------------------------------------- + std::cout << "\n=== Single-Threaded RNTuple Event Reader (LoadEntry, 1 Thread) ===\n"; + std::cout << std::left << std::setw(kLabelWidth) << "Variant / File" + << std::right << std::setw(13) << "Read Wall" + << std::setw(15) << "Total MB/s" + << std::setw(18) << "Entries / sec" + << std::setw(14) << "Decomp Wall" + << std::setw(18) << "Decomp MB/s" + << std::setw(10) << "Pages" + << '\n'; + std::cout << std::string(kLabelWidth + 13 + 15 + 18 + 14 + 18 + 10, '-') << '\n'; + + for (const auto &r : results) { + std::cout << std::left << std::setw(kLabelWidth) << r.fLabel << std::right; + if (r.fSingleCore.fValid) { + std::cout << std::setw(13) << FormatTime(r.fSingleCore.fReadWallSec) + << std::setw(15) << FormatSpeed(r.fSingleCore.fTotalMBs) + << std::setw(18) << FormatEntriesPerSec(r.fSingleCore.fEntriesSec) + << std::setw(14) << FormatTime(r.fSingleCore.fDecompWallSec) + << std::setw(18) << FormatSpeed(r.fSingleCore.fDecompMBs) + << std::setw(10) << r.fSingleCore.fPages; + } else { + std::cout << " [Error: " << r.fSingleCore.fError << "]"; + } + std::cout << '\n'; + } + + // ------------------------------------------------------------------------- + // Table 3: Multi-Threaded Event Reader Benchmark (LoadEntry, N Threads) + // ------------------------------------------------------------------------- + int mtThreads = results[0].fMultiCore.fThreads; + std::cout << "\n=== Multi-Threaded RNTuple Event Reader (LoadEntry, " << mtThreads << " Threads IMT) ===\n"; + std::cout << std::left << std::setw(kLabelWidth) << "Variant / File" + << std::right << std::setw(9) << "Threads" + << std::setw(13) << "Read Wall" + << std::setw(15) << "Total MB/s" + << std::setw(18) << "Entries / sec" + << std::setw(14) << "Decomp Wall" + << std::setw(18) << "Decomp MB/s" + << std::setw(12) << "Speedup" + << '\n'; + std::cout << std::string(kLabelWidth + 9 + 13 + 15 + 18 + 14 + 18 + 12, '-') << '\n'; + + for (const auto &r : results) { + std::cout << std::left << std::setw(kLabelWidth) << r.fLabel << std::right; + if (r.fMultiCore.fValid) { + std::ostringstream speedupStr; + if (r.fSingleCore.fValid && r.fMultiCore.fReadWallSec > 0.0) { + double speedup = r.fSingleCore.fReadWallSec / r.fMultiCore.fReadWallSec; + speedupStr << std::fixed << std::setprecision(2) << speedup << "x"; + } else { + speedupStr << "n/a"; + } + + std::cout << std::setw(9) << r.fMultiCore.fThreads + << std::setw(13) << FormatTime(r.fMultiCore.fReadWallSec) + << std::setw(15) << FormatSpeed(r.fMultiCore.fTotalMBs) + << std::setw(18) << FormatEntriesPerSec(r.fMultiCore.fEntriesSec) + << std::setw(14) << FormatTime(r.fMultiCore.fDecompWallSec) + << std::setw(18) << FormatSpeed(r.fMultiCore.fDecompMBs) + << std::setw(12) << speedupStr.str(); + } else { + std::cout << " [Error: " << r.fMultiCore.fError << "]"; + } + std::cout << '\n'; + } + + std::cout << "\nNotes:\n"; + std::cout << " - Algorithm benchmark measures pure decompression of the included sealed pages in RAM" + " (timed passes do no I/O).\n"; + std::cout << " - Single/Multi-core benchmarks measure RNTupleReader::LoadEntry over the included entries.\n"; + std::cout << " - Speedup = Single-Core Read Wall / Multi-Core Read Wall (higher is better scaling).\n\n"; +} + +} // namespace RNTupleBench + +#endif // ROOT_RNTUPLE_BENCH_HXX diff --git a/main/src/rntuple2rntuple.cxx b/main/src/rntuple2rntuple.cxx new file mode 100644 index 0000000000000..3329992747ecf --- /dev/null +++ b/main/src/rntuple2rntuple.cxx @@ -0,0 +1,1785 @@ +/// \file rntuple2rntuple.cxx +/// \brief Re-encode an RNTuple into several output files for compression studies. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "RNTupleBench.hxx" + +#include +#include +#include +#include +#include + +#ifdef R__HAS_LHC4CODEC +#include +#else +// Worker-result keys compile even when ROOT was built without lhc4codec. +enum { + kLHC4CodecBeam = 0, + kLHC4CodecLz = 0, + kLHC4CodecBwt = 1, + kLHC4CodecZstd = 2, + kLHC4CodecBzip3 = 3, + kLHC4CodecLzma = 4, + kLHC4CodecAuto = 5, + kLHC4CodecMosaic = 6, + kLHC4CodecOracle = 7, + kLHC4CodecCrystal = 8, + kLHC4CodecFlash = 9, + kLHC4CodecLaser = 10, + kLHC4CodecGrating = 11, + kLHC4CodecCount = 12, +}; +#define kLHC4CodecBit(c) (1u << (unsigned)(c)) +#define kLHC4AutoCodecsRoot \ + (kLHC4CodecBit(kLHC4CodecZstd) | kLHC4CodecBit(kLHC4CodecBeam) | kLHC4CodecBit(kLHC4CodecCrystal)) +#define kLHC4AutoCodecsAll \ + (kLHC4AutoCodecsRoot | kLHC4CodecBit(kLHC4CodecMosaic) | kLHC4CodecBit(kLHC4CodecBwt) | \ + kLHC4CodecBit(kLHC4CodecLaser) | kLHC4CodecBit(kLHC4CodecLzma) | kLHC4CodecBit(kLHC4CodecBzip3)) +enum { + kLHC4ProfileNone = 0, + kLHC4ProfileFloatKinematics = 1, + kLHC4ProfilePoissonCounts = 2, + kLHC4ProfileIntMonotonic = 3, + kLHC4ProfileText = 4, + kLHC4ProfileCustom = 5, +}; +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#endif + +namespace { + +using ROOT::Internal::MakeUninitArray; +using ROOT::Internal::RCluster; +using ROOT::Internal::RColumnElementBase; +using ROOT::Internal::RPageAllocatorHeap; +using ROOT::Internal::RPagePersistentSink; +using ROOT::Internal::RPageSink; +using ROOT::Internal::RPageSinkFile; +using ROOT::Internal::RPageSource; +using ROOT::Internal::RPageStorage; +using ROOT::Internal::RNTupleSerializer; +using ROOT::Experimental::Internal::RNTupleMerger; +using ROOT::Experimental::Internal::RNTupleMergeOptions; +using ROOT::RNTupleReadOptions; +using ROOT::RNTupleWriteOptions; + +constexpr const char *kUsage = R"(Usage: rntuple2rntuple [options] input.root [ntupleName] [outputBase] + +Options: + --keep-column-encoding Do not unpack ROOT RNTuple split/delta/zigzag encodings; recompress + the already-packed pages (same as rntupletryzip --keep-column-encoding). + Default for LHC4 variants is to unpack, then write plain columns. + --page-size N Set max unzipped page size in bytes (RNTupleWriteOptions default: 1 MiB). + Columns start with small pages and grow up to this limit. + --initial-page-size N Advanced: set the initial unzipped page size in bytes (default: 256 B). + Only needed for micro-benchmarks; large values require much more memory. + --level N Compression level for LHC4 variants (1-11, default 5). Native ZSTD/LZMA + stay at 5/7. Grating has no levels. lhc4_laser9 is the same as + --level 9 --only-variant lhc4_laser5. + --auto-min-gain N Min % size gain for lhc4codec Auto to prefer a slower decoder + (default: 1). 0 = always pick the smallest payload. + --auto-max-level For lhc4codec Auto, race each codec at its maximum effort + instead of the RNTuple compression level (slower encode). + --auto-codecs LIST Codecs Auto may race: root (default: zstd,beam,crystal), + all, or a comma list (zstd,beam,crystal,mosaic,prism,laser,lzma,bzip3). + --profile NAME On lhc4_crystal_profile5, force one profile on every column: + float, poisson, monotonic, text, or custom. + Default: that variant picks per column. lhc4_crystal5 stays dynamic. + --profile-id N Profile id (0 uses the --profile enum value; custom ids are >= 5). + --bench Run decompression benchmarks (algorithm + single/multi-core event + reader) on output files after re-encoding. + --bench-only Do not re-encode; run the benchmark suite directly on the given + RNTuple file(s). Can take multiple files. + --bench-threads N Number of threads for multi-core event reader benchmark + (default: hardware concurrency). + --bench-iters N Number of passes for pure page decompression benchmark (default: 5). + --bench-entries N Number of entries to read in event reader benchmark (default: 0 = all). + --bench-percent N Probe the first N percent only (default: 100). Stops the page + unzip after that fraction of compressed bytes, in cluster + order, and LoadEntry after the same fraction of entries. + Example: --bench-percent 5. --bench-entries overrides the + entry count. + --jobs N Run up to N variants in parallel (default: 1). Use 0 for auto + (number of CPU threads, capped by variant count). Requires fork(). + --quiet Suppress per-variant progress (used for parallel workers). + --no-out Run conversions without keeping output files; print summary only. + --only-variant LABEL Run only the given format (see labels below). + --worker-result PATH Internal: write one-line key/value result metrics to PATH. + -h, --help Show this help. + +Re-encode an RNTuple with different page compressors: + + _native.root native RNTuple column encodings + ZSTD (level 5) + _native_lzma.root native RNTuple column encodings + LZMA (level 7) + LHC4 variants use --level (default 5) in the file suffix (lhc4_beam5, lhc4_laser9, ...). + _lhc4_zstd.root lhc4codec filters + zstd + _lhc4_bzip3.root lhc4codec filters + bzip3 + _lhc4_beamN.root lhc4codec filters + Beam (lhc4_lz5 is a legacy alias) + _lhc4_mosaicN.root lhc4codec filters + Mosaic + _lhc4_prismN.root lhc4codec filters + Prism + _lhc4_crystalN.root lhc4codec filters + Crystal (dynamic model and byte + filters, same kind of compressor as Beam) + _lhc4_crystal_profileN.root lhc4codec Crystal with pretrained per-column + profiles (Real32=float, Index32/UInt32=poisson, Char=text) + _lhc4_oracleN.root lhc4codec filters + Oracle (not raced by Auto) + _lhc4_flashN.root lhc4codec filters + Flash (byte-aligned LZ without an + entropy stage, fastest decoder; not raced by Auto) + _lhc4_laserN.root lhc4codec filters + Laser (LZMA2-class range coder + with a decoupled decoder, raced by --auto-codecs all) + _lhc4_grating.root lhc4codec filters + Grating (order-0 integer rANS; has no + levels; not raced by Auto) + _lhc4_auto.root lhc4codec filters + Auto codec race (see --auto-min-gain, + --auto-max-level, --auto-codecs) + +By default, LHC4 variants unpack ROOT column encodings (split/delta/zigzag) to +plain in-memory values, then re-encode with lhc4codec byte filters +(shuffle/delta/zigzag/dict/RLE as applicable). That matches tree2ntuple's +lhc4_* write path. Use --keep-column-encoding to recompress already-packed +pages without unpacking. + +Parallel mode launches one process per format so lhc4codec global settings and +compression statistics stay isolated. + +If ntupleName is omitted, the first RNTuple in the input file is used. +If outputBase is omitted, it defaults to "_recode". +)"; + +struct RunOptions { + bool fKeepColumnEncoding = false; + bool fNoOut = false; + bool fQuiet = false; + int fJobs = 1; + std::optional fPageSize; + std::optional fInitialPageSize; + int fLevel = 5; + bool fLevelExplicit = false; + std::optional fAutoMinGainPct; + bool fAutoMaxLevel = false; + std::optional fAutoCodecs; + int fProfile = kLHC4ProfileNone; + unsigned fProfileId = 0; + std::string fOnlyVariant; + std::string fWorkerResult; + std::string fInputFile; + std::string fNTupleName; + std::string fOutputBase; + + bool fBench = false; + bool fBenchOnly = false; + int fBenchThreads = 0; + int fBenchIters = 5; + std::uint64_t fBenchEntries = 0; + double fBenchPercent = 100.0; + std::vector fPositionalArgs; +}; + +struct FilterStatsSummary { + bool fValid = false; + bool fPageRatioValid = false; + double fPageRatio = 0.0; + double fFilterOnPct = 0.0; + double fFilterOffPct = 0.0; + double fRawWonPct = 0.0; + double fNoTransformPct = 0.0; +}; + +struct AutoCodecStatsSummary { + bool fValid = false; + std::uint64_t fSelections = 0; + std::uint64_t fCodecHits[kLHC4CodecCount] = {}; +}; + +struct VariantResult { + std::string fLabel; + std::uint64_t fOutputBytes = 0; + double fElapsedSec = 0.0; + FilterStatsSummary fFilters; + AutoCodecStatsSummary fAutoCodec; +}; + +std::string BasenameNoExt(std::string path) +{ + const auto slash = path.find_last_of("/\\"); + if (slash != std::string::npos) + path.erase(0, slash + 1); + const auto dot = path.rfind('.'); + if (dot != std::string::npos) + path.erase(dot); + return path; +} + +std::string JoinPath(const std::string &base, const std::string &suffix) +{ + return base + suffix + ".root"; +} + +void RemoveIfExists(const std::string &path) +{ + if (!gSystem->AccessPathName(path.c_str())) + gSystem->Unlink(path.c_str()); +} + +std::string MakeTempRootPath() +{ + TString tmp; + FILE *f = gSystem->TempFileName(tmp, nullptr, ".root"); + if (!f) + throw std::runtime_error("failed to create temporary output file"); + std::fclose(f); + return tmp.Data(); +} + +std::string ResolveVariantOutputPath(const RunOptions &opts, const std::string &suffix) +{ + if (opts.fNoOut) + return MakeTempRootPath(); + return JoinPath(opts.fOutputBase, suffix); +} + +std::optional FileSizeBytes(const std::string &path) +{ + FileStat_t st{}; + if (gSystem->GetPathInfo(path.c_str(), st) != 0) + return std::nullopt; + return static_cast(st.fSize); +} + +std::string FormatBytes(std::uint64_t nbytes) +{ + const char *suffix = "B"; + double v = static_cast(nbytes); + if (v >= 1024.0 * 1024.0 * 1024.0) { + v /= 1024.0 * 1024.0 * 1024.0; + suffix = "GiB"; + } else if (v >= 1024.0 * 1024.0) { + v /= 1024.0 * 1024.0; + suffix = "MiB"; + } else if (v >= 1024.0) { + v /= 1024.0; + suffix = "KiB"; + } + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(v >= 100.0 ? 0 : (v >= 10.0 ? 1 : 2)); + os << v << ' ' << suffix; + return os.str(); +} + +std::string FormatSeconds(double sec) +{ + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(sec >= 100.0 ? 0 : 1); + os << sec << 's'; + return os.str(); +} + +std::string FormatRatio(std::uint64_t outputBytes, std::uint64_t inputBytes) +{ + if (inputBytes == 0) + return "n/a"; + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(3); + os << static_cast(outputBytes) / static_cast(inputBytes); + return os.str(); +} + +std::string FormatPageRatio(const FilterStatsSummary &filters) +{ + if (!filters.fPageRatioValid) + return "n/a"; + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(3); + os << filters.fPageRatio; + return os.str(); +} + +std::string FormatPct(double pct) +{ + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(1); + os << pct << '%'; + return os.str(); +} + +bool ParseAutoCodecName(const std::string &name, unsigned &bit) +{ + if (name == "beam" || name == "lz") { + bit = kLHC4CodecBit(kLHC4CodecBeam); + return true; + } + if (name == "bwt" || name == "prism") { + bit = kLHC4CodecBit(kLHC4CodecBwt); + return true; + } + if (name == "zstd") { + bit = kLHC4CodecBit(kLHC4CodecZstd); + return true; + } + if (name == "bzip3") { + bit = kLHC4CodecBit(kLHC4CodecBzip3); + return true; + } + if (name == "lzma" || name == "xz") { + bit = kLHC4CodecBit(kLHC4CodecLzma); + return true; + } + if (name == "mosaic") { + bit = kLHC4CodecBit(kLHC4CodecMosaic); + return true; + } + if (name == "crystal") { + bit = kLHC4CodecBit(kLHC4CodecCrystal); + return true; + } + if (name == "laser") { + bit = kLHC4CodecBit(kLHC4CodecLaser); + return true; + } + // Flash and Grating are forced-only backends; Auto never races them. + return false; +} + +unsigned ParseAutoCodecMask(const std::string &list) +{ + if (list == "all") + return kLHC4AutoCodecsAll; + if (list == "root" || list.empty()) + return kLHC4AutoCodecsRoot; + + unsigned mask = 0; + std::size_t pos = 0; + while (pos <= list.size()) { + const auto end = list.find(',', pos); + const auto tok = list.substr(pos, (end == std::string::npos ? list.size() : end) - pos); + if (!tok.empty()) { + unsigned bit = 0; + if (!ParseAutoCodecName(tok, bit)) + throw std::runtime_error("unknown or non-raceable --auto-codecs token: " + tok); + mask |= bit; + } + if (end == std::string::npos) + break; + pos = end + 1; + } + if (mask == 0) + throw std::runtime_error("--auto-codecs list is empty"); + return mask; +} + +int ParseProfileName(const std::string &name) +{ + if (name == "none" || name == "off" || name == "dynamic") + return kLHC4ProfileNone; + if (name == "float" || name == "float-kinematics" || name == "kinematics") + return kLHC4ProfileFloatKinematics; + if (name == "poisson" || name == "poisson-counts" || name == "counts") + return kLHC4ProfilePoissonCounts; + if (name == "monotonic" || name == "int-monotonic" || name == "int") + return kLHC4ProfileIntMonotonic; + if (name == "text" || name == "json") + return kLHC4ProfileText; + if (name == "custom") + return kLHC4ProfileCustom; + throw std::runtime_error("unknown --profile: " + name + " (none, float, poisson, monotonic, text, custom)"); +} + +const char *FormatProfileName(int profile) +{ + switch (profile) { + case kLHC4ProfileFloatKinematics: return "float"; + case kLHC4ProfilePoissonCounts: return "poisson"; + case kLHC4ProfileIntMonotonic: return "monotonic"; + case kLHC4ProfileText: return "text"; + case kLHC4ProfileCustom: return "custom"; + default: return "none"; + } +} + +std::string FormatCrystalProfileStatus(const RunOptions &opts) +{ + std::string profileFlavour = "per column"; + if (opts.fProfile != kLHC4ProfileNone) { + profileFlavour = FormatProfileName(opts.fProfile); + if (opts.fProfileId != 0) + profileFlavour += " (id " + std::to_string(opts.fProfileId) + ")"; + profileFlavour += " (all columns)"; + } + return "lhc4_crystal5 dynamic; lhc4_crystal_profile5 " + profileFlavour; +} + +std::string FormatAutoCodecMask(unsigned mask) +{ + if (mask == kLHC4AutoCodecsRoot) + return "root (zstd,beam,crystal)"; + if (mask == kLHC4AutoCodecsAll) + return "all"; + + struct Row { + const char *fName; + unsigned fBit; + }; + static constexpr Row kRows[] = {{"zstd", kLHC4CodecBit(kLHC4CodecZstd)}, + {"beam", kLHC4CodecBit(kLHC4CodecBeam)}, + {"crystal", kLHC4CodecBit(kLHC4CodecCrystal)}, + {"mosaic", kLHC4CodecBit(kLHC4CodecMosaic)}, + {"prism", kLHC4CodecBit(kLHC4CodecBwt)}, + {"laser", kLHC4CodecBit(kLHC4CodecLaser)}, + {"lzma", kLHC4CodecBit(kLHC4CodecLzma)}, + {"bzip3", kLHC4CodecBit(kLHC4CodecBzip3)}}; + std::string out; + for (const auto &row : kRows) { + if (!(mask & row.fBit)) + continue; + if (!out.empty()) + out += ','; + out += row.fName; + } + return out.empty() ? "none" : out; +} + +std::string FindFirstRNTupleName(const std::string &fileName) +{ + auto file = std::unique_ptr(TFile::Open(fileName.c_str())); + if (!file || file->IsZombie()) + throw std::runtime_error("cannot open input file: " + fileName); + + TIter next(file->GetListOfKeys()); + while (auto key = static_cast(next())) { + if (std::strcmp(key->GetClassName(), "ROOT::RNTuple") == 0) + return key->GetName(); + } + throw std::runtime_error("no RNTuple found in " + fileName); +} + +bool ParseBoolValue(const std::string &value) +{ + return value == "1" || value == "true" || value == "yes"; +} + +void WriteWorkerResult(const std::string &path, const VariantResult &result) +{ + std::ofstream out(path); + if (!out) + throw std::runtime_error("cannot write worker result: " + path); + out << "label=" << result.fLabel << '\n'; + out << "output_bytes=" << result.fOutputBytes << '\n'; + out << "elapsed_sec=" << std::setprecision(10) << result.fElapsedSec << '\n'; + out << "filter_valid=" << (result.fFilters.fValid ? 1 : 0) << '\n'; + out << "filter_on_pct=" << result.fFilters.fFilterOnPct << '\n'; + out << "filter_off_pct=" << result.fFilters.fFilterOffPct << '\n'; + out << "raw_won_pct=" << result.fFilters.fRawWonPct << '\n'; + out << "no_transform_pct=" << result.fFilters.fNoTransformPct << '\n'; + out << "page_ratio_valid=" << (result.fFilters.fPageRatioValid ? 1 : 0) << '\n'; + out << "page_ratio=" << std::setprecision(10) << result.fFilters.fPageRatio << '\n'; + out << "auto_valid=" << (result.fAutoCodec.fValid ? 1 : 0) << '\n'; + out << "auto_selections=" << result.fAutoCodec.fSelections << '\n'; + out << "auto_zstd=" << result.fAutoCodec.fCodecHits[kLHC4CodecZstd] << '\n'; + out << "auto_beam=" << result.fAutoCodec.fCodecHits[kLHC4CodecBeam] << '\n'; + out << "auto_lz=" << result.fAutoCodec.fCodecHits[kLHC4CodecBeam] << '\n'; + out << "auto_mosaic=" << result.fAutoCodec.fCodecHits[kLHC4CodecMosaic] << '\n'; + out << "auto_bwt=" << result.fAutoCodec.fCodecHits[kLHC4CodecBwt] << '\n'; + out << "auto_lzma=" << result.fAutoCodec.fCodecHits[kLHC4CodecLzma] << '\n'; + out << "auto_bzip3=" << result.fAutoCodec.fCodecHits[kLHC4CodecBzip3] << '\n'; + out << "auto_crystal=" << result.fAutoCodec.fCodecHits[kLHC4CodecCrystal] << '\n'; + out << "auto_laser=" << result.fAutoCodec.fCodecHits[kLHC4CodecLaser] << '\n'; +} + +VariantResult ReadWorkerResult(const std::string &path) +{ + std::ifstream in(path); + if (!in) + throw std::runtime_error("cannot read worker result: " + path); + + VariantResult result; + std::string line; + while (std::getline(in, line)) { + const auto eq = line.find('='); + if (eq == std::string::npos) + continue; + const std::string key = line.substr(0, eq); + const std::string value = line.substr(eq + 1); + if (key == "label") + result.fLabel = value; + else if (key == "output_bytes") + result.fOutputBytes = std::stoull(value); + else if (key == "elapsed_sec") + result.fElapsedSec = std::stod(value); + else if (key == "filter_valid") + result.fFilters.fValid = ParseBoolValue(value); + else if (key == "filter_on_pct") + result.fFilters.fFilterOnPct = std::stod(value); + else if (key == "filter_off_pct") + result.fFilters.fFilterOffPct = std::stod(value); + else if (key == "raw_won_pct") + result.fFilters.fRawWonPct = std::stod(value); + else if (key == "no_transform_pct") + result.fFilters.fNoTransformPct = std::stod(value); + else if (key == "page_ratio_valid") + result.fFilters.fPageRatioValid = ParseBoolValue(value); + else if (key == "page_ratio") + result.fFilters.fPageRatio = std::stod(value); + else if (key == "auto_valid") + result.fAutoCodec.fValid = ParseBoolValue(value); + else if (key == "auto_selections") + result.fAutoCodec.fSelections = std::stoull(value); + else if (key == "auto_zstd") + result.fAutoCodec.fCodecHits[kLHC4CodecZstd] = std::stoull(value); + else if (key == "auto_beam" || key == "auto_lz") + result.fAutoCodec.fCodecHits[kLHC4CodecBeam] = std::stoull(value); + else if (key == "auto_mosaic") + result.fAutoCodec.fCodecHits[kLHC4CodecMosaic] = std::stoull(value); + else if (key == "auto_bwt") + result.fAutoCodec.fCodecHits[kLHC4CodecBwt] = std::stoull(value); + else if (key == "auto_lzma") + result.fAutoCodec.fCodecHits[kLHC4CodecLzma] = std::stoull(value); + else if (key == "auto_bzip3") + result.fAutoCodec.fCodecHits[kLHC4CodecBzip3] = std::stoull(value); + else if (key == "auto_crystal") + result.fAutoCodec.fCodecHits[kLHC4CodecCrystal] = std::stoull(value); + else if (key == "auto_laser") + result.fAutoCodec.fCodecHits[kLHC4CodecLaser] = std::stoull(value); + } + return result; +} + +constexpr int kDefaultLHC4Level = 5; +constexpr int kMaxLHC4Level = 11; // lhc4codec::kMaxLevel + +int ParseLHC4Level(const std::string &text) +{ + int level = 0; + try { + std::size_t idx = 0; + level = std::stoi(text, &idx); + if (idx != text.size()) + throw std::invalid_argument("trailing characters"); + } catch (const std::exception &) { + throw std::runtime_error("--level expects an integer from 1 to " + std::to_string(kMaxLHC4Level)); + } + if (level < 1 || level > kMaxLHC4Level) + throw std::runtime_error("--level must be between 1 and " + std::to_string(kMaxLHC4Level)); + return level; +} + +int TrailingVariantLevel(const std::string &label) +{ + if (label.empty() || !std::isdigit(static_cast(label.back()))) + return 0; + std::size_t i = label.size(); + while (i > 0 && std::isdigit(static_cast(label[i - 1]))) + --i; + if (i == 0 || i == label.size()) + return 0; + try { + std::size_t idx = 0; + const std::string digits = label.substr(i); + const int n = std::stoi(digits, &idx); + if (idx != digits.size() || n < 1 || n > kMaxLHC4Level) + return 0; + return n; + } catch (const std::exception &) { + return 0; + } +} + +std::string TrimCopy(const std::string &text) +{ + const auto start = text.find_first_not_of(" \t"); + const auto end = text.find_last_not_of(" \t"); + if (start == std::string::npos || end == std::string::npos) + return {}; + return text.substr(start, end - start + 1); +} + +void InferLevelFromVariantLabels(RunOptions &opts) +{ + if (opts.fLevelExplicit || opts.fOnlyVariant.empty()) + return; + std::optional inferred; + std::stringstream ss(opts.fOnlyVariant); + std::string item; + while (std::getline(ss, item, ',')) { + item = TrimCopy(item); + if (item.empty()) + continue; + const int n = TrailingVariantLevel(item); + if (n == 0) + continue; + if (!inferred) + inferred = n; + else if (*inferred != n) + throw std::runtime_error("conflicting levels in --only-variant; pass --level N"); + } + if (inferred) + opts.fLevel = *inferred; +} + +RunOptions ParseArgs(int argc, char **argv) +{ + RunOptions opts; + std::vector positional; + + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "-h" || arg == "--help") { + std::cout << kUsage; + std::exit(0); + } + if (arg == "--keep-column-encoding") { + opts.fKeepColumnEncoding = true; + continue; + } + if (arg == "--page-size") { + if (i + 1 >= argc) + throw std::runtime_error("--page-size requires an argument"); + opts.fPageSize = static_cast(std::stoull(argv[++i])); + continue; + } + if (arg.rfind("--page-size=", 0) == 0) { + opts.fPageSize = static_cast(std::stoull(arg.substr(12))); + continue; + } + if (arg == "--initial-page-size") { + if (i + 1 >= argc) + throw std::runtime_error("--initial-page-size requires an argument"); + opts.fInitialPageSize = static_cast(std::stoull(argv[++i])); + continue; + } + if (arg.rfind("--initial-page-size=", 0) == 0) { + opts.fInitialPageSize = static_cast(std::stoull(arg.substr(20))); + continue; + } + if (arg == "--level") { + if (i + 1 >= argc) + throw std::runtime_error("--level requires an argument"); + opts.fLevel = ParseLHC4Level(argv[++i]); + opts.fLevelExplicit = true; + continue; + } + if (arg.rfind("--level=", 0) == 0) { + opts.fLevel = ParseLHC4Level(arg.substr(8)); + opts.fLevelExplicit = true; + continue; + } + if (arg == "--auto-min-gain") { + if (i + 1 >= argc) + throw std::runtime_error("--auto-min-gain requires an argument"); + opts.fAutoMinGainPct = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--auto-min-gain=", 0) == 0) { + opts.fAutoMinGainPct = std::stoi(arg.substr(16)); + continue; + } + if (arg == "--auto-max-level") { + opts.fAutoMaxLevel = true; + continue; + } + if (arg == "--auto-codecs") { + if (i + 1 >= argc) + throw std::runtime_error("--auto-codecs requires an argument"); + opts.fAutoCodecs = ParseAutoCodecMask(argv[++i]); + continue; + } + if (arg.rfind("--auto-codecs=", 0) == 0) { + opts.fAutoCodecs = ParseAutoCodecMask(arg.substr(14)); + continue; + } + if (arg == "--profile") { + if (i + 1 >= argc) + throw std::runtime_error("--profile requires an argument"); + opts.fProfile = ParseProfileName(argv[++i]); + continue; + } + if (arg.rfind("--profile=", 0) == 0) { + opts.fProfile = ParseProfileName(arg.substr(10)); + continue; + } + if (arg == "--profile-id") { + if (i + 1 >= argc) + throw std::runtime_error("--profile-id requires an argument"); + opts.fProfileId = static_cast(std::stoul(argv[++i])); + continue; + } + if (arg.rfind("--profile-id=", 0) == 0) { + opts.fProfileId = static_cast(std::stoul(arg.substr(13))); + continue; + } + if (arg == "--bench") { + opts.fBench = true; + continue; + } + if (arg == "--bench-only") { + opts.fBench = true; + opts.fBenchOnly = true; + continue; + } + if (arg == "--bench-threads") { + if (i + 1 >= argc) + throw std::runtime_error("--bench-threads requires an argument"); + opts.fBenchThreads = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--bench-threads=", 0) == 0) { + opts.fBenchThreads = std::stoi(arg.substr(16)); + continue; + } + if (arg == "--bench-iters") { + if (i + 1 >= argc) + throw std::runtime_error("--bench-iters requires an argument"); + opts.fBenchIters = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--bench-iters=", 0) == 0) { + opts.fBenchIters = std::stoi(arg.substr(14)); + continue; + } + if (arg == "--bench-entries") { + if (i + 1 >= argc) + throw std::runtime_error("--bench-entries requires an argument"); + opts.fBenchEntries = std::stoull(argv[++i]); + continue; + } + if (arg.rfind("--bench-entries=", 0) == 0) { + opts.fBenchEntries = std::stoull(arg.substr(16)); + continue; + } + if (arg == "--bench-percent") { + if (i + 1 >= argc) + throw std::runtime_error("--bench-percent requires an argument"); + opts.fBenchPercent = std::stod(argv[++i]); + if (!(opts.fBenchPercent > 0.0 && opts.fBenchPercent <= 100.0)) + throw std::runtime_error("--bench-percent must be in (0, 100]"); + continue; + } + if (arg.rfind("--bench-percent=", 0) == 0) { + opts.fBenchPercent = std::stod(arg.substr(16)); + if (!(opts.fBenchPercent > 0.0 && opts.fBenchPercent <= 100.0)) + throw std::runtime_error("--bench-percent must be in (0, 100]"); + continue; + } + if (arg == "--max-page-size") { + if (i + 1 >= argc) + throw std::runtime_error("--max-page-size requires an argument"); + opts.fPageSize = static_cast(std::stoull(argv[++i])); + continue; + } + if (arg.rfind("--max-page-size=", 0) == 0) { + opts.fPageSize = static_cast(std::stoull(arg.substr(16))); + continue; + } + if (arg == "--quiet") { + opts.fQuiet = true; + continue; + } + if (arg == "--no-out") { + opts.fNoOut = true; + continue; + } + if (arg == "--jobs") { + if (i + 1 >= argc) + throw std::runtime_error("--jobs requires an argument"); + opts.fJobs = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--jobs=", 0) == 0) { + opts.fJobs = std::stoi(arg.substr(7)); + continue; + } + if (arg == "--only-variant") { + if (i + 1 >= argc) + throw std::runtime_error("--only-variant requires an argument"); + opts.fOnlyVariant = argv[++i]; + continue; + } + if (arg.rfind("--only-variant=", 0) == 0) { + opts.fOnlyVariant = arg.substr(15); + continue; + } + if (arg == "--worker-result") { + if (i + 1 >= argc) + throw std::runtime_error("--worker-result requires an argument"); + opts.fWorkerResult = argv[++i]; + continue; + } + if (arg.rfind("--worker-result=", 0) == 0) { + opts.fWorkerResult = arg.substr(16); + continue; + } + if (!arg.empty() && arg[0] == '-') { + throw std::runtime_error("unknown option: " + arg); + } + positional.push_back(arg); + } + + if (positional.empty()) + throw std::runtime_error("missing input file (see --help)"); + + opts.fPositionalArgs = positional; + + if (opts.fBenchOnly) { + opts.fInputFile = positional[0]; + return opts; + } + + opts.fInputFile = positional[0]; + if (positional.size() >= 2) + opts.fNTupleName = positional[1]; + else + opts.fNTupleName = FindFirstRNTupleName(opts.fInputFile); + + if (positional.size() >= 3) + opts.fOutputBase = positional[2]; + else + opts.fOutputBase = BasenameNoExt(opts.fInputFile) + "_recode"; + + if (positional.size() > 3) + throw std::runtime_error("too many positional arguments (see --help)"); + + if (opts.fPageSize && *opts.fPageSize == 0) + throw std::runtime_error("--page-size must be > 0"); + if (opts.fInitialPageSize && *opts.fInitialPageSize == 0) + throw std::runtime_error("--initial-page-size must be > 0"); + if (opts.fPageSize && opts.fInitialPageSize && *opts.fInitialPageSize > *opts.fPageSize) + throw std::runtime_error("--initial-page-size must not exceed --page-size"); + if (opts.fAutoMinGainPct && *opts.fAutoMinGainPct < 0) + throw std::runtime_error("--auto-min-gain must be >= 0"); + + InferLevelFromVariantLabels(opts); + + return opts; +} + +int ResolveAutoMinGainPct(const RunOptions &opts) +{ + return opts.fAutoMinGainPct ? *opts.fAutoMinGainPct : 1; +} + +bool ResolveAutoMaxLevel(const RunOptions &opts) +{ + return opts.fAutoMaxLevel; +} + +unsigned ResolveAutoCodecs(const RunOptions &opts) +{ + return opts.fAutoCodecs ? *opts.fAutoCodecs : kLHC4AutoCodecsRoot; +} + +void ApplyPageSizeOptions(RNTupleWriteOptions &writeOpts, const RunOptions &runOpts) +{ + if (runOpts.fPageSize) + writeOpts.SetMaxUnzippedPageSize(*runOpts.fPageSize); + + if (runOpts.fInitialPageSize) { + writeOpts.SetInitialUnzippedPageSize(*runOpts.fInitialPageSize); + + constexpr std::size_t kDefaultInitialPageSize = 256; + if (*runOpts.fInitialPageSize > kDefaultInitialPageSize) { + std::size_t autoBudget = writeOpts.GetApproxZippedClusterSize(); + if (writeOpts.GetCompression() != 0) + autoBudget += writeOpts.GetApproxZippedClusterSize(); + writeOpts.SetPageBufferBudget(autoBudget * (*runOpts.fInitialPageSize) / kDefaultInitialPageSize); + } + } +} + +std::string FormatPageSizeSettings(const RunOptions &opts) +{ + if (!opts.fPageSize && !opts.fInitialPageSize) + return "default (initial=256 B, max=1 MiB)"; + + const auto initial = + opts.fInitialPageSize ? FormatBytes(*opts.fInitialPageSize) : std::string("256 B (default)"); + const auto max = opts.fPageSize ? FormatBytes(*opts.fPageSize) : std::string("1 MiB (default)"); + return "initial=" + initial + ", max=" + max; +} + +void AppendPageSizeArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fPageSize) { + args.emplace_back("--page-size"); + args.push_back(std::to_string(*opts.fPageSize)); + } + if (opts.fInitialPageSize) { + args.emplace_back("--initial-page-size"); + args.push_back(std::to_string(*opts.fInitialPageSize)); + } +} + +void AppendLevelArgs(std::vector &args, const RunOptions &opts) +{ + args.emplace_back("--level"); + args.push_back(std::to_string(opts.fLevel)); +} + +void AppendAutoMinGainArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fAutoMinGainPct) { + args.emplace_back("--auto-min-gain"); + args.push_back(std::to_string(*opts.fAutoMinGainPct)); + } +} + +void AppendAutoMaxLevelArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fAutoMaxLevel) + args.emplace_back("--auto-max-level"); +} + +void AppendAutoCodecsArgs(std::vector &args, const RunOptions &opts) +{ + if (!opts.fAutoCodecs) + return; + args.emplace_back("--auto-codecs"); + if (*opts.fAutoCodecs == kLHC4AutoCodecsAll) + args.emplace_back("all"); + else if (*opts.fAutoCodecs == kLHC4AutoCodecsRoot) + args.emplace_back("root"); + else + args.push_back(FormatAutoCodecMask(*opts.fAutoCodecs)); +} + +void AppendProfileArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fProfile == kLHC4ProfileNone && opts.fProfileId == 0) + return; + args.emplace_back("--profile"); + args.emplace_back(FormatProfileName(opts.fProfile)); + if (opts.fProfileId != 0) { + args.emplace_back("--profile-id"); + args.push_back(std::to_string(opts.fProfileId)); + } +} + +void AppendNoOutArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fNoOut) + args.emplace_back("--no-out"); +} + +#ifdef R__HAS_LHC4CODEC +void ResetLHC4Globals(int autoMinGainPct, bool autoMaxLevel, unsigned autoCodecs) +{ + R__SetLHC4Codec(kLHC4CodecBeam); + R__SetLHC4Filters(0); + R__SetLHC4FilterFallback(1); + R__SetLHC4FilterRle(1); + R__SetLHC4FilterDict(1); + R__SetLHC4AutoMinGainPct(autoMinGainPct); + R__SetLHC4AutoMaxLevel(autoMaxLevel ? 1 : 0); + R__SetLHC4AutoCodecs(autoCodecs); + R__SetLHC4Profile(kLHC4ProfileNone); + R__SetLHC4ProfileId(0); + R__SetLHC4ProfilePerColumn(0); +} + +RNTupleWriteOptions MakeLHC4WriteOptions(int level, bool keepColumnEncoding) +{ + RNTupleWriteOptions opts; + opts.SetEnableColumnEncoding(keepColumnEncoding); + opts.SetCompression(ROOT::RCompressionSetting::EAlgorithm::kLHC4, level); + return opts; +} + +void ConfigureLHC4FilterGlobals(int codec, bool enableByteFilters, int autoMinGainPct, bool autoMaxLevel, + unsigned autoCodecs, int profile, unsigned profileId, bool profilePerColumn) +{ + ResetLHC4Globals(autoMinGainPct, autoMaxLevel, autoCodecs); + R__SetLHC4Codec(codec); + if (enableByteFilters) + R__SetLHC4Filters(1); + // Standard Crystal stays dynamic. The profile flavour opts into static models. + if (codec == kLHC4CodecCrystal && profilePerColumn) { + if (profile != kLHC4ProfileNone) { + R__SetLHC4Profile(profile); + R__SetLHC4ProfileId(profileId); + } else { + R__SetLHC4ProfilePerColumn(1); + } + } +} + +FilterStatsSummary ReadFilterStatsSummary() +{ + R__LHC4CompressStatsSummary stats{}; + R__GetLHC4CompressStatsSummary(&stats); + FilterStatsSummary out; + if (stats.num_pages == 0) + return out; + out.fValid = true; + const double denom = static_cast(stats.num_pages); + out.fFilterOnPct = 100.0 * static_cast(stats.filtered_won_pages) / denom; + const auto filterOff = stats.raw_won_pages + stats.no_transform_pages + stats.stored_fallback_pages + + stats.plain_pages; + out.fFilterOffPct = 100.0 * static_cast(filterOff) / denom; + out.fRawWonPct = 100.0 * static_cast(stats.raw_won_pages) / denom; + out.fNoTransformPct = 100.0 * static_cast(stats.no_transform_pages) / denom; + if (stats.uncompressed_bytes > 0) { + out.fPageRatioValid = true; + out.fPageRatio = static_cast(stats.compressed_bytes) / + static_cast(stats.uncompressed_bytes); + } + return out; +} + +AutoCodecStatsSummary ReadAutoCodecStatsSummary() +{ + R__LHC4AutoCodecStatsSummary stats{}; + R__GetLHC4AutoCodecStatsSummary(&stats); + AutoCodecStatsSummary out; + if (stats.auto_selections == 0) + return out; + out.fValid = true; + out.fSelections = stats.auto_selections; + for (int i = 0; i < kLHC4CodecCount; ++i) + out.fCodecHits[i] = stats.codec_hits[i]; + return out; +} +#endif + +struct Variant { + const char *fLabel = nullptr; + std::string fSuffix; + RNTupleWriteOptions fOptions; + bool fUsesLHC4Globals = false; + bool fEnableLHC4ByteFilters = false; + int fLHC4Codec = 0; + bool fRequireCodecAvailable = false; + bool fLHC4ProfilePerColumn = false; +}; + +RNTupleWriteOptions MakeNativeWriteOptions(ROOT::RCompressionSetting::EAlgorithm::EValues algorithm, int level) +{ + RNTupleWriteOptions opts; + opts.SetCompression(algorithm, level); + return opts; +} + +std::vector MakeVariants(const RunOptions &runOpts) +{ + std::vector variants; + + variants.push_back({"native_zstd", "_native", + MakeNativeWriteOptions(ROOT::RCompressionSetting::EAlgorithm::kZSTD, + ROOT::RCompressionSetting::ELevel::kDefaultZSTD), + false, false, 0, false}); + variants.push_back({"native_lzma", "_native_lzma", + MakeNativeWriteOptions(ROOT::RCompressionSetting::EAlgorithm::kLZMA, + ROOT::RCompressionSetting::ELevel::kDefaultLZMA), + false, false, 0, false}); + +#ifdef R__HAS_LHC4CODEC + const int kLevel = runOpts.fLevel; + const auto lhc4Opts = MakeLHC4WriteOptions(kLevel, runOpts.fKeepColumnEncoding); + const std::string lv = std::to_string(kLevel); + + variants.push_back({"lhc4_zstd", "_lhc4_zstd", lhc4Opts, true, true, kLHC4CodecZstd, true}); + variants.push_back({"lhc4_bzip3", "_lhc4_bzip3", lhc4Opts, true, true, kLHC4CodecBzip3, true}); + variants.push_back({"lhc4_beam5", "_lhc4_beam" + lv, lhc4Opts, true, true, kLHC4CodecBeam, false}); + variants.push_back({"lhc4_mosaic5", "_lhc4_mosaic" + lv, lhc4Opts, true, true, kLHC4CodecMosaic, false}); + variants.push_back({"lhc4_prism5", "_lhc4_prism" + lv, lhc4Opts, true, true, kLHC4CodecBwt, false}); + variants.push_back({"lhc4_crystal5", "_lhc4_crystal" + lv, lhc4Opts, true, true, kLHC4CodecCrystal, false}); + variants.push_back({"lhc4_crystal_profile5", "_lhc4_crystal_profile" + lv, lhc4Opts, true, true, kLHC4CodecCrystal, + false, true}); + variants.push_back({"lhc4_oracle5", "_lhc4_oracle" + lv, lhc4Opts, true, true, kLHC4CodecOracle, false}); + variants.push_back({"lhc4_flash5", "_lhc4_flash" + lv, lhc4Opts, true, true, kLHC4CodecFlash, false}); + variants.push_back({"lhc4_laser5", "_lhc4_laser" + lv, lhc4Opts, true, true, kLHC4CodecLaser, false}); + // Grating has no levels (the model does not search); the RNTuple level is ignored. + variants.push_back({"lhc4_grating", "_lhc4_grating", lhc4Opts, true, true, kLHC4CodecGrating, false}); + variants.push_back({"lhc4_auto", "_lhc4_auto", lhc4Opts, true, true, kLHC4CodecAuto, false}); +#endif + + for (auto &variant : variants) + ApplyPageSizeOptions(variant.fOptions, runOpts); + + return variants; +} + +const Variant *FindVariant(const std::vector &variants, const std::string &label) +{ + std::string resolved = label; + if (resolved == "lhc4_lz5" || resolved == "lhc4_lz" || resolved == "lhc4_beam") + resolved = "lhc4_beam5"; + for (const auto &variant : variants) { + if (resolved == variant.fLabel) + return &variant; + } + const int n = TrailingVariantLevel(resolved); + std::string family = resolved; + if (n > 0) + family = resolved.substr(0, resolved.size() - std::to_string(n).size()); + if (family.empty()) + return nullptr; + const std::string fallback = family + std::to_string(kDefaultLHC4Level); + for (const auto &variant : variants) { + if (fallback == variant.fLabel) + return &variant; + } + for (const auto &variant : variants) { + if (family == variant.fLabel) + return &variant; + } + return nullptr; +} + +std::unique_ptr MakeColumnElement(const ROOT::RColumnDescriptor &col) +{ + auto elem = RColumnElementBase::Generate(col.GetType()); + elem->SetBitsOnStorage(col.GetBitsOnStorage()); + if (const auto range = col.GetValueRange()) + elem->SetValueRange(range->fMin, range->fMax); + return elem; +} + +/// Unzip + unpack RNTuple column encodings, then pack as plain columns and zip with the destination compressor. +/// Does not reconstruct a C++ model, so streamer fields do not need dictionaries. +void RecodeUnpackedPages(RPageSource &source, RPagePersistentSink &destination) +{ + source.Attach(RNTupleSerializer::EDescriptorDeserializeMode::kForWriting); + + std::vector> srcElems; + std::vector> dstElems; + std::size_t nPhysical = 0; + { + auto guard = source.GetSharedDescriptorGuard(); + (void)destination.InitFromDescriptor(guard.GetRef(), false /* copyClusters */, true /* drop encodings */); + for (const auto &extra : guard->GetExtraTypeInfoIterable()) + destination.UpdateExtraTypeInfo(extra); + + nPhysical = guard->GetNPhysicalColumns(); + srcElems.resize(nPhysical); + dstElems.resize(nPhysical); + const auto &dstDesc = destination.GetDescriptor(); + for (std::size_t i = 0; i < nPhysical; ++i) { + srcElems[i] = MakeColumnElement(guard->GetColumnDescriptor(i)); + dstElems[i] = MakeColumnElement(dstDesc.GetColumnDescriptor(i)); + } + } + + RPageAllocatorHeap pageAlloc; + ROOT::Internal::RClusterPool clusterPool{source}; + const auto writeChecksum = destination.GetWriteOptions().GetEnablePageChecksums(); + const auto compression = destination.GetWriteOptions().GetCompression(); + const auto checksumSize = writeChecksum * RPageStorage::kNBytesPageChecksum; + + ROOT::DescriptorId_t clusterId; + { + auto guard = source.GetSharedDescriptorGuard(); + clusterId = guard->FindClusterId(0, 0); + } + + struct ActiveColumn { + ROOT::DescriptorId_t fId = 0; + std::vector fPages; + }; + + while (clusterId != ROOT::kInvalidDescriptorId) { + ROOT::NTupleSize_t nEntries = 0; + RCluster::ColumnSet_t loadSet; + std::vector suppressed; + std::vector active; + ROOT::DescriptorId_t nextClusterId = ROOT::kInvalidDescriptorId; + { + auto guard = source.GetSharedDescriptorGuard(); + const auto &clusterDesc = guard->GetClusterDescriptor(clusterId); + nEntries = clusterDesc.GetNEntries(); + nextClusterId = guard->FindNextClusterId(clusterId); + for (std::size_t colId = 0; colId < nPhysical; ++colId) { + if (!clusterDesc.ContainsColumn(colId)) + continue; + if (clusterDesc.GetColumnRange(colId).IsSuppressed()) { + suppressed.push_back(colId); + continue; + } + ActiveColumn col; + col.fId = colId; + col.fPages = clusterDesc.GetPageRange(colId).GetPageInfos(); + loadSet.emplace(colId); + active.push_back(std::move(col)); + } + } + + for (auto colId : suppressed) + destination.CommitSuppressedColumn({colId, nullptr}); + + const RCluster *cluster = loadSet.empty() ? nullptr : clusterPool.GetCluster(clusterId, loadSet); + + std::vector> buffers; + std::vector pagesV; + std::vector groups; + pagesV.reserve(active.size()); + groups.reserve(active.size()); + + for (const auto &col : active) { + RPageStorage::SealedPageSequence_t sealedPages; + sealedPages.resize(col.fPages.size()); + for (std::size_t pageIdx = 0; pageIdx < col.fPages.size(); ++pageIdx) { + const auto &pageInfo = col.fPages[pageIdx]; + const auto nElements = pageInfo.GetNElements(); + ROOT::Internal::RPage unpacked; + + if (pageInfo.GetLocator().GetType() == ROOT::RNTupleLocator::kTypePageZero) { + unpacked = pageAlloc.NewPage(dstElems[col.fId]->GetSize(), nElements); + unpacked.GrowUnchecked(nElements); + std::memset(unpacked.GetBuffer(), 0, unpacked.GetNBytes()); + } else { + if (!cluster) + throw std::runtime_error("missing cluster pages while unpacking column " + + std::to_string(col.fId)); + ROOT::Internal::ROnDiskPage::Key key{col.fId, pageIdx}; + auto onDiskPage = cluster->GetOnDiskPage(key); + const auto pageChecksumSize = pageInfo.HasChecksum() * RPageStorage::kNBytesPageChecksum; + RPageStorage::RSealedPage inPage; + inPage.SetNElements(nElements); + inPage.SetHasChecksum(pageInfo.HasChecksum()); + inPage.SetBufferSize(pageInfo.GetLocator().GetNBytesOnStorage() + pageChecksumSize); + if (!onDiskPage || onDiskPage->GetSize() != inPage.GetBufferSize()) + throw std::runtime_error("on-disk page size mismatch while unpacking column " + + std::to_string(col.fId)); + inPage.SetBuffer(onDiskPage->GetAddress()); + inPage.VerifyChecksumIfEnabled().ThrowOnError(); + unpacked = RPageSource::UnsealPage(inPage, *srcElems[col.fId], pageAlloc).Unwrap(); + } + + const auto bufSize = unpacked.GetNBytes() + checksumSize; + auto buffer = MakeUninitArray(bufSize); + const auto profileId = destination.EnsureColumnProfile(col.fId, unpacked, *dstElems[col.fId]); + RPageSink::RSealPageConfig sealConf; + sealConf.fPage = &unpacked; + sealConf.fElement = dstElems[col.fId].get(); + sealConf.fCompressionSettings = compression; + sealConf.fCompressionProfileId = profileId; + sealConf.fWriteChecksum = writeChecksum; + sealConf.fBuffer = buffer.get(); + sealedPages[pageIdx] = RPageSink::SealPage(sealConf); + buffers.emplace_back(std::move(buffer)); + } + pagesV.push_back(std::move(sealedPages)); + groups.emplace_back(col.fId, pagesV.back().cbegin(), pagesV.back().cend()); + } + + if (!groups.empty()) + destination.CommitSealedPageV(groups); + destination.CommitCluster(nEntries); + clusterId = nextClusterId; + } + + destination.CommitClusterGroup(); + destination.CommitDataset(); +} + +std::optional ReencodeVariant(const std::string &inputFile, const std::string &ntupleName, + const std::string &outputFile, const Variant &variant, bool quiet, + int autoMinGainPct, bool autoMaxLevel, unsigned autoCodecs, int profile, + unsigned profileId, bool discardOutput) +{ +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals) { + if (variant.fRequireCodecAvailable && !R__LHC4CodecAvailable(variant.fLHC4Codec)) { + if (!quiet) + std::cerr << "Skipping " << variant.fLabel << ": lhc4codec backend is not available in this build\n"; + return std::nullopt; + } + ConfigureLHC4FilterGlobals(variant.fLHC4Codec, variant.fEnableLHC4ByteFilters, autoMinGainPct, autoMaxLevel, + autoCodecs, profile, profileId, variant.fLHC4ProfilePerColumn); + } else { + ResetLHC4Globals(autoMinGainPct, autoMaxLevel, autoCodecs); + } +#else + if (variant.fUsesLHC4Globals) { + if (!quiet) + std::cerr << "Skipping " << variant.fLabel << ": ROOT was built without lhc4codec support\n"; + return std::nullopt; + } +#endif + + RemoveIfExists(outputFile); + if (!quiet) { + if (discardOutput) + std::cout << "Converting " << variant.fLabel << " (--no-out)" << std::endl; + else + std::cout << "Writing " << variant.fLabel << " -> " << outputFile << std::endl; + } + +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals) + R__ResetLHC4CompressStats(); +#endif + + const auto t0 = std::chrono::steady_clock::now(); + + auto source = RPageSource::Create(ntupleName, inputFile, RNTupleReadOptions{}); + + const auto writeOpts = variant.fOptions; + auto destination = std::make_unique(ntupleName, outputFile, writeOpts); + + const bool unpackEncodings = !writeOpts.GetEnableColumnEncoding(); + if (unpackEncodings) { + if (!quiet) + std::cout << "Unpacking RNTuple column encodings (split/delta/zigzag) before re-encode\n"; + RecodeUnpackedPages(*source, *destination); + } else { + std::vector sources{source.get()}; + RNTupleMergeOptions mergeOpts; + mergeOpts.fCompressionSettings = writeOpts.GetCompression(); + RNTupleMerger merger{std::move(destination)}; + merger.Merge(sources, mergeOpts).ThrowOnError(); + } + + const auto t1 = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = t1 - t0; + +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals && !quiet) { + std::cout << "Compression statistics (" << variant.fLabel << "):\n"; + R__PrintLHC4CompressStats(); + } +#endif + + if (!quiet) + std::cout << " done\n"; + + VariantResult result; + result.fLabel = variant.fLabel; + result.fElapsedSec = elapsed.count(); + if (const auto size = FileSizeBytes(outputFile)) + result.fOutputBytes = *size; +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals) + result.fFilters = ReadFilterStatsSummary(); + if (std::strcmp(variant.fLabel, "lhc4_auto") == 0) + result.fAutoCodec = ReadAutoCodecStatsSummary(); +#endif + if (discardOutput) + RemoveIfExists(outputFile); + return result; +} + +std::string FormatGainVsNativeZstd(const VariantResult &row, std::uint64_t nativeZstdBytes) +{ + if (row.fLabel == "native_zstd") + return "0.0%"; + if (nativeZstdBytes == 0) + return "n/a"; + const double gain = 100.0 * + (static_cast(nativeZstdBytes) - static_cast(row.fOutputBytes)) / + static_cast(nativeZstdBytes); + return FormatPct(gain); +} + +std::uint64_t FindNativeZstdOutputBytes(const std::vector &results) +{ + for (const auto &row : results) { + if (row.fLabel == "native_zstd") + return row.fOutputBytes; + } + return 0; +} + +void PrintAutoCodecSummary(const AutoCodecStatsSummary &stats) +{ + if (!stats.fValid) + return; + + std::cout << "\nlhc4_auto backend mix (" << stats.fSelections << " page" + << (stats.fSelections == 1 ? "" : "s") << "):\n"; + + struct Row { + const char *fName; + int fCodec; + }; + static constexpr Row kOrder[] = {{"zstd", kLHC4CodecZstd}, {"beam", kLHC4CodecBeam}, + {"mosaic", kLHC4CodecMosaic}, {"prism", kLHC4CodecBwt}, + {"laser", kLHC4CodecLaser}, {"lzma", kLHC4CodecLzma}, + {"bzip3", kLHC4CodecBzip3}, {"crystal", kLHC4CodecCrystal}}; + + for (const auto &row : kOrder) { + const auto n = stats.fCodecHits[row.fCodec]; + if (n == 0) + continue; + const double pct = 100.0 * static_cast(n) / static_cast(stats.fSelections); + std::cout << " " << std::left << std::setw(8) << row.fName << std::right << std::setw(8) << n << " (" + << std::fixed << std::setprecision(1) << pct << "%)\n"; + } +} + +void PrintSummaryTable(const RunOptions &opts, std::uint64_t inputBytes, + const std::vector &results) +{ + std::cout << "\n=== rntuple2rntuple summary ===\n"; + std::cout << "Input: " << opts.fInputFile << " [" << opts.fNTupleName << "]\n"; + std::cout << "Input size: " << FormatBytes(inputBytes) << '\n'; + if (opts.fNoOut) + std::cout << "Output: (none, --no-out)\n"; + else + std::cout << "Output base: " << opts.fOutputBase << '\n'; + std::cout << "Page size: " << FormatPageSizeSettings(opts) << '\n'; + std::cout << "LHC4 level: " << opts.fLevel << '\n'; + std::cout << "Auto min gain: " << ResolveAutoMinGainPct(opts) << "%\n"; + if (ResolveAutoMaxLevel(opts)) + std::cout << "Auto max level: on\n"; + std::cout << "Auto codecs: " << FormatAutoCodecMask(ResolveAutoCodecs(opts)) << '\n'; + std::cout << "Crystal: " << FormatCrystalProfileStatus(opts) << "\n\n"; + + const std::uint64_t nativeZstdBytes = FindNativeZstdOutputBytes(results); + + constexpr int kLabelWidth = 24; + std::cout << std::left << std::setw(kLabelWidth) << "Format" << std::right << std::setw(12) << "Output" + << std::setw(10) << "Ratio" << std::setw(11) << "Page ratio" << std::setw(10) << "Gain" + << std::setw(10) << "Time" << std::setw(12) << "Filter on" << std::setw(13) << "Filter off" + << std::setw(12) << "Raw won" << std::setw(14) << "No transform" << '\n'; + std::cout << std::string(kLabelWidth + 12 + 10 + 11 + 10 + 10 + 12 + 13 + 12 + 14, '-') << '\n'; + + for (const auto &row : results) { + std::cout << std::left << std::setw(kLabelWidth) << row.fLabel << std::right << std::setw(12) + << FormatBytes(row.fOutputBytes) << std::setw(10) << FormatRatio(row.fOutputBytes, inputBytes) + << std::setw(11) << FormatPageRatio(row.fFilters) << std::setw(10) + << FormatGainVsNativeZstd(row, nativeZstdBytes) << std::setw(10) + << FormatSeconds(row.fElapsedSec); + + if (row.fFilters.fValid) { + std::cout << std::setw(12) << FormatPct(row.fFilters.fFilterOnPct) << std::setw(13) + << FormatPct(row.fFilters.fFilterOffPct) << std::setw(12) + << FormatPct(row.fFilters.fRawWonPct) << std::setw(14) + << FormatPct(row.fFilters.fNoTransformPct); + } else { + std::cout << std::setw(12) << "n/a" << std::setw(13) << "n/a" << std::setw(12) << "n/a" << std::setw(14) + << "n/a"; + } + std::cout << '\n'; + } + + std::cout << "\nRatio = RNTuple output size / input file size (lower is smaller).\n"; + std::cout << "Page ratio = lhc4codec compressed payload / uncompressed page bytes (lhc4_* only).\n"; + std::cout << "Gain = space saved vs native_zstd output (positive = smaller file, native_zstd is 0%).\n"; + std::cout << "Filter on = lhc4codec byte transform kept on wire (filtered won).\n"; + std::cout << "Filter off = raw won + no transform + stored fallback + plain pages.\n"; + + for (const auto &row : results) { + if (row.fLabel == "lhc4_auto") + PrintAutoCodecSummary(row.fAutoCodec); + } +} + +std::vector RunSequential(const RunOptions &opts) +{ + std::vector results; + for (const auto &variant : MakeVariants(opts)) { + if (auto row = ReencodeVariant(opts.fInputFile, opts.fNTupleName, ResolveVariantOutputPath(opts, variant.fSuffix), + variant, opts.fQuiet, ResolveAutoMinGainPct(opts), ResolveAutoMaxLevel(opts), + ResolveAutoCodecs(opts), opts.fProfile, opts.fProfileId, opts.fNoOut)) { + results.push_back(*row); + } + } + return results; +} + +#if !defined(_WIN32) +std::vector BuildWorkerArgv(const char *executable, const RunOptions &opts, const Variant &variant, + const std::string &resultPath) +{ + std::vector args; + args.emplace_back(executable); + args.emplace_back("--only-variant"); + args.push_back(variant.fLabel); + args.emplace_back("--worker-result"); + args.push_back(resultPath); + args.emplace_back("--quiet"); + if (opts.fKeepColumnEncoding) + args.emplace_back("--keep-column-encoding"); + AppendLevelArgs(args, opts); + AppendPageSizeArgs(args, opts); + AppendAutoMinGainArgs(args, opts); + AppendAutoMaxLevelArgs(args, opts); + AppendAutoCodecsArgs(args, opts); + AppendProfileArgs(args, opts); + AppendNoOutArgs(args, opts); + args.push_back(opts.fInputFile); + args.push_back(opts.fNTupleName); + args.push_back(opts.fOutputBase); + return args; +} + +[[noreturn]] void ExecWorker(const std::vector &args) +{ + std::vector argv; + argv.reserve(args.size() + 1); + for (const auto &arg : args) + argv.push_back(const_cast(arg.c_str())); + argv.push_back(nullptr); + execvp(argv[0], argv.data()); + std::cerr << "rntuple2rntuple: failed to exec worker: " << args[0] << " (" << std::strerror(errno) << ")\n"; + _exit(127); +} + +unsigned ResolveJobCount(int jobs, std::size_t numVariants) +{ + if (jobs == 0) { + const unsigned hw = std::max(1u, std::thread::hardware_concurrency()); + return static_cast(std::min(hw, numVariants)); + } + if (jobs < 0) + throw std::runtime_error("--jobs must be >= 0"); + return static_cast(std::min(static_cast(jobs), numVariants)); +} + +std::vector RunParallel(const RunOptions &opts, const char *executable) +{ + const auto variants = MakeVariants(opts); + const unsigned jobCount = ResolveJobCount(opts.fJobs, variants.size()); + if (jobCount <= 1) + return RunSequential(opts); + + std::cout << "Running " << variants.size() << " variants with up to " << jobCount + << " parallel jobs (one process per format)\n"; + + const std::string resultDir = + std::string("/tmp/rntuple2rntuple-") + std::to_string(static_cast(getpid())); + gSystem->mkdir(resultDir.c_str(), true); + + struct RunningJob { + pid_t fPid = -1; + std::string fResultPath; + std::string fLabel; + }; + + std::vector running; + running.reserve(jobCount); + std::vector results; + results.reserve(variants.size()); + std::size_t nextVariant = 0; + int failedWorkers = 0; + + auto reapOne = [&]() { + int status = 0; + const pid_t done = wait(&status); + if (done <= 0) + throw std::runtime_error("wait() failed while collecting parallel workers"); + + auto it = std::find_if(running.begin(), running.end(), + [done](const RunningJob &job) { return job.fPid == done; }); + if (it == running.end()) + throw std::runtime_error("unexpected worker pid " + std::to_string(done)); + + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + std::cerr << "Worker failed for variant " << it->fLabel << '\n'; + ++failedWorkers; + } else { + results.push_back(ReadWorkerResult(it->fResultPath)); + } + gSystem->Unlink(it->fResultPath.c_str()); + running.erase(it); + }; + + auto launchOne = [&](const Variant &variant) { + RunningJob job; + job.fLabel = variant.fLabel; + job.fResultPath = resultDir + "/" + variant.fLabel + ".result"; + RemoveIfExists(job.fResultPath); + + const pid_t pid = fork(); + if (pid < 0) + throw std::runtime_error(std::string("fork() failed for variant ") + variant.fLabel); + + if (pid == 0) { + const auto workerArgs = BuildWorkerArgv(executable, opts, variant, job.fResultPath); + ExecWorker(workerArgs); + } + + job.fPid = pid; + running.push_back(std::move(job)); + }; + + while (nextVariant < variants.size() || !running.empty()) { + while (running.size() < jobCount && nextVariant < variants.size()) { + launchOne(variants[nextVariant]); + ++nextVariant; + } + if (!running.empty()) + reapOne(); + } + + gSystem->Unlink(resultDir.c_str()); + + if (failedWorkers > 0) + throw std::runtime_error(std::to_string(failedWorkers) + " parallel worker(s) failed"); + + std::sort(results.begin(), results.end(), [&variants](const VariantResult &a, const VariantResult &b) { + auto rank = [&variants](const std::string &label) { + for (std::size_t i = 0; i < variants.size(); ++i) { + if (variants[i].fLabel == label) + return i; + } + return variants.size(); + }; + return rank(a.fLabel) < rank(b.fLabel); + }); + + return results; +} +#endif + +int RunWorkerMode(const RunOptions &opts) +{ + const auto variants = MakeVariants(opts); + const Variant *variant = FindVariant(variants, opts.fOnlyVariant); + if (!variant) + throw std::runtime_error("unknown variant label: " + opts.fOnlyVariant); + + ROOT::EnableImplicitMT(); + + const bool quiet = !opts.fWorkerResult.empty(); + auto result = ReencodeVariant(opts.fInputFile, opts.fNTupleName, ResolveVariantOutputPath(opts, variant->fSuffix), + *variant, quiet, ResolveAutoMinGainPct(opts), ResolveAutoMaxLevel(opts), + ResolveAutoCodecs(opts), opts.fProfile, opts.fProfileId, opts.fNoOut); + if (!result) + return 1; + + if (!opts.fWorkerResult.empty()) { + WriteWorkerResult(opts.fWorkerResult, *result); + return 0; + } + + const auto inputSize = FileSizeBytes(opts.fInputFile); + if (inputSize) + PrintSummaryTable(opts, *inputSize, {*result}); + return 0; +} + +int RunMain(const RunOptions &opts, const char *executable) +{ + if (opts.fBenchOnly) { + std::vector> filesToBench; + std::string explicitNTupleName; + if (opts.fPositionalArgs.size() == 2 && opts.fPositionalArgs[1].find(".root") == std::string::npos) { + filesToBench.push_back({RNTupleBench::Basename(opts.fPositionalArgs[0]), opts.fPositionalArgs[0]}); + explicitNTupleName = opts.fPositionalArgs[1]; + } else { + for (const auto &p : opts.fPositionalArgs) { + filesToBench.push_back({RNTupleBench::Basename(p), p}); + } + } + + std::vector benchResults; + for (const auto &[label, path] : filesToBench) { + benchResults.push_back(RNTupleBench::RunBenchmarkForFile( + label, path, explicitNTupleName, opts.fBenchThreads, opts.fBenchIters, opts.fBenchEntries, + opts.fBenchPercent)); + } + RNTupleBench::PrintBenchmarkSummary(benchResults); + return 0; + } + + if (!opts.fOnlyVariant.empty()) + return RunWorkerMode(opts); + + const auto inputSize = FileSizeBytes(opts.fInputFile); + if (!inputSize) + throw std::runtime_error("cannot stat input file: " + opts.fInputFile); + + if (opts.fKeepColumnEncoding && !opts.fQuiet) + std::cout << "Using ROOT column encodings + lhc4codec backend for LHC4 variants\n"; + if ((opts.fPageSize || opts.fInitialPageSize) && !opts.fQuiet) + std::cout << "Page size: " << FormatPageSizeSettings(opts) << '\n'; + if (!opts.fQuiet) + std::cout << "LHC4 level: " << opts.fLevel << '\n'; + if (!opts.fQuiet) + std::cout << "Auto min gain: " << ResolveAutoMinGainPct(opts) << "%\n"; + if (ResolveAutoMaxLevel(opts) && !opts.fQuiet) + std::cout << "Auto max level: on\n"; + if (!opts.fQuiet) + std::cout << "Auto codecs: " << FormatAutoCodecMask(ResolveAutoCodecs(opts)) << '\n'; + if (!opts.fQuiet) + std::cout << "Crystal: " << FormatCrystalProfileStatus(opts) << '\n'; + + std::vector results; +#if defined(_WIN32) + if (opts.fJobs != 1 && !opts.fQuiet) + std::cerr << "rntuple2rntuple: parallel mode is not supported on Windows; running sequentially\n"; + ROOT::EnableImplicitMT(); + results = RunSequential(opts); +#else + if (opts.fJobs == 1) { + ROOT::EnableImplicitMT(); + results = RunSequential(opts); + } else { + results = RunParallel(opts, executable); + } +#endif + + if (!opts.fQuiet) + PrintSummaryTable(opts, *inputSize, results); + + if (opts.fBench && !opts.fNoOut) { + const auto variants = MakeVariants(opts); + std::vector> filesToBench; + for (const auto &row : results) { + const auto *v = FindVariant(variants, row.fLabel); + if (v) { + std::string outPath = ResolveVariantOutputPath(opts, v->fSuffix); + filesToBench.push_back({row.fLabel, outPath}); + } + } + std::vector benchResults; + for (const auto &[label, path] : filesToBench) { + benchResults.push_back(RNTupleBench::RunBenchmarkForFile( + label, path, opts.fNTupleName, opts.fBenchThreads, opts.fBenchIters, opts.fBenchEntries, + opts.fBenchPercent)); + } + RNTupleBench::PrintBenchmarkSummary(benchResults); + } + + return 0; +} + +} // anonymous namespace + +int main(int argc, char **argv) +{ + try { + const auto opts = ParseArgs(argc, argv); + return RunMain(opts, argv[0]); + } catch (const std::exception &e) { + std::cerr << "rntuple2rntuple: " << e.what() << '\n'; + std::cerr << kUsage; + return 1; + } +} diff --git a/main/src/rntupleinspect.cxx b/main/src/rntupleinspect.cxx new file mode 100644 index 0000000000000..f4d1aa2529799 --- /dev/null +++ b/main/src/rntupleinspect.cxx @@ -0,0 +1,757 @@ +/// \file rntupleinspect.cxx +/// \brief Judge RNTuple column formats and remaining compressor headroom. + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#endif + +namespace { + +using ROOT::Experimental::RNTupleInspector; +using ROOT::Internal::RColumnElementBase; +using ROOT::Internal::RNTupleDecompressor; + +constexpr const char *kUsage = R"(Usage: rntupleinspect [options] input.root [ntupleName] + +Inspect an RNTuple's on-disk column types and estimate whether a stronger +page compressor (LHC4 Crystal/Laser/Grating, or a higher zstd level) can +still shrink the file. + +The tool samples packed pages, measures byte-plane entropy, and compares +that Shannon limit with the size already stored by the file's compressor. +High-entropy SplitReal32 mantissa planes are a floor: new codecs will not +move them. Integer and offset columns are often already spent. + +Options: + --top N Rank the N largest on-disk columns (default: 20). + --pages N Sample up to N pages per ranked column (default: 2). + --list List RNTuples in the file and exit. + --csv Also print a CSV of per-column verdicts. + --verbose Keep TClass dictionary warnings (CMSSW MiniAOD files emit many). + -h, --help Show this help. + +If ntupleName is omitted, Events is used when present, otherwise the RNTuple +with the largest on-disk payload. CMSSW files often also contain MetaData. +Dictionary warnings are suppressed unless --verbose is passed; they do not +block page inspection. To actually recompress pages without CMSSW dictionaries, +use rntupletryzip. +)"; + +enum class Kind { kFloat, kInt, kIndex, kBit, kText, kOther }; + +enum class Headroom { kNone, kLow, kMedium, kHigh, kUnknown }; + +struct RunOptions { + std::string fInputFile; + std::string fNTupleName; + int fTop = 20; + int fPages = 2; + bool fCsv = false; + bool fList = false; + bool fVerbose = false; +}; + +struct ColRow { + ROOT::DescriptorId_t fId = 0; + std::string fField; + std::string fTypeName; + Kind fKind = Kind::kOther; + bool fSplit = false; + std::uint16_t fBits = 0; + std::uint64_t fNElem = 0; + std::uint64_t fNPages = 0; + std::uint64_t fComp = 0; + std::uint64_t fUncomp = 0; + int fSampledPages = 0; + double fEntropy = -1; ///< mean packed-byte entropy, bits/byte + double fLsbEntropy = -1; ///< least-significant byte plane + double fMsbEntropy = -1; ///< most-significant byte plane + double fHeadroom = -1; ///< residual vs 0th-order plane Shannon, as fraction of packed bytes + Headroom fVerdict = Headroom::kUnknown; + std::string fAdvice; +}; + +Kind Classify(ROOT::ENTupleColumnType t) +{ + using T = ROOT::ENTupleColumnType; + switch (t) { + case T::kReal64: + case T::kReal32: + case T::kReal16: + case T::kSplitReal64: + case T::kSplitReal32: + case T::kReal32Trunc: + case T::kReal32Quant: return Kind::kFloat; + case T::kIndex64: + case T::kIndex32: + case T::kSplitIndex64: + case T::kSplitIndex32: return Kind::kIndex; + case T::kInt64: + case T::kUInt64: + case T::kInt32: + case T::kUInt32: + case T::kInt16: + case T::kUInt16: + case T::kInt8: + case T::kUInt8: + case T::kSplitInt64: + case T::kSplitUInt64: + case T::kSplitInt32: + case T::kSplitUInt32: + case T::kSplitInt16: + case T::kSplitUInt16: return Kind::kInt; + case T::kBit: return Kind::kBit; + case T::kChar: + case T::kByte: return Kind::kText; + default: return Kind::kOther; + } +} + +const char *KindName(Kind k) +{ + switch (k) { + case Kind::kFloat: return "float"; + case Kind::kInt: return "int"; + case Kind::kIndex: return "index"; + case Kind::kBit: return "bit"; + case Kind::kText: return "text"; + default: return "other"; + } +} + +const char *HeadroomName(Headroom h) +{ + switch (h) { + case Headroom::kNone: return "none"; + case Headroom::kLow: return "low"; + case Headroom::kMedium: return "medium"; + case Headroom::kHigh: return "high"; + default: return "n/a"; + } +} + +bool IsSplitType(ROOT::ENTupleColumnType t) +{ + using T = ROOT::ENTupleColumnType; + switch (t) { + case T::kSplitIndex64: + case T::kSplitIndex32: + case T::kSplitReal64: + case T::kSplitReal32: + case T::kSplitInt64: + case T::kSplitUInt64: + case T::kSplitInt32: + case T::kSplitUInt32: + case T::kSplitInt16: + case T::kSplitUInt16: return true; + default: return false; + } +} + +class IgnoreDictWarnings { +public: + IgnoreDictWarnings() : fOld(gErrorIgnoreLevel) { gErrorIgnoreLevel = kError; } + ~IgnoreDictWarnings() { gErrorIgnoreLevel = fOld; } + IgnoreDictWarnings(const IgnoreDictWarnings &) = delete; + IgnoreDictWarnings &operator=(const IgnoreDictWarnings &) = delete; + +private: + Int_t fOld = 0; +}; + +struct NTupleInfo { + std::string fName; + ROOT::NTupleSize_t fNEntries = 0; + std::uint64_t fComp = 0; + std::uint64_t fUncomp = 0; + std::size_t fNColumns = 0; +}; + +std::vector ListRNTupleNames(const std::string &path) +{ + auto file = std::unique_ptr(TFile::Open(path.c_str(), "READ")); + if (!file || file->IsZombie()) + throw std::runtime_error("cannot open " + path); + std::vector names; + TIter next(file->GetListOfKeys()); + while (auto key = static_cast(next())) { + if (std::strcmp(key->GetClassName(), "ROOT::RNTuple") != 0) + continue; + const std::string name = key->GetName(); + if (std::find(names.begin(), names.end(), name) == names.end()) + names.push_back(name); + } + return names; +} + +NTupleInfo DescribeRNTuple(const std::string &path, const std::string &name) +{ + auto inspector = RNTupleInspector::Create(name, path); + const auto &desc = inspector->GetDescriptor(); + NTupleInfo info; + info.fName = name; + info.fNEntries = desc.GetNEntries(); + info.fComp = inspector->GetCompressedSize(); + info.fUncomp = inspector->GetUncompressedSize(); + info.fNColumns = desc.GetNPhysicalColumns(); + return info; +} + +std::vector DescribeAllRNTuples(const std::string &path) +{ + const auto names = ListRNTupleNames(path); + if (names.empty()) + throw std::runtime_error("no RNTuple in " + path); + std::vector infos; + infos.reserve(names.size()); + for (const auto &name : names) + infos.push_back(DescribeRNTuple(path, name)); + return infos; +} + +std::string SelectRNTuple(const std::vector &infos, const std::string &requested) +{ + if (infos.empty()) + throw std::runtime_error("no RNTuple in file"); + if (!requested.empty()) { + for (const auto &info : infos) { + if (info.fName == requested) + return requested; + } + std::ostringstream os; + os << "unknown RNTuple '" << requested << "'; available:"; + for (const auto &info : infos) + os << ' ' << info.fName; + throw std::runtime_error(os.str()); + } + for (const auto &info : infos) { + if (info.fName == "Events") + return info.fName; + } + auto best = std::max_element(infos.begin(), infos.end(), + [](const NTupleInfo &a, const NTupleInfo &b) { return a.fComp < b.fComp; }); + return best->fName; +} + +std::string FormatBytes(std::uint64_t n); + +void PrintRNTupleCatalog(const std::vector &infos, const std::string &selected) +{ + if (infos.empty()) + return; + std::cout << "RNTuples in file:\n"; + for (const auto &info : infos) { + const char *mark = (info.fName == selected) ? " * " : " "; + std::cout << mark << std::left << std::setw(22) << info.fName << std::right << std::setw(10) << info.fNEntries + << " entries " << std::setw(12) << FormatBytes(info.fComp) << " payload " << info.fNColumns + << " columns\n"; + } + if (infos.size() > 1 && !selected.empty()) + std::cout << " (* selected; pass a name to inspect another, e.g. MetaData)\n"; + std::cout << '\n'; +} + +std::string FormatBytes(std::uint64_t n) +{ + std::ostringstream os; + os << std::fixed << std::setprecision(2); + if (n >= (1ull << 30)) + os << (n / 1073741824.0) << " GiB"; + else if (n >= (1ull << 20)) + os << (n / 1048576.0) << " MiB"; + else if (n >= 1024) + os << (n / 1024.0) << " KiB"; + else + os << n << " B"; + return os.str(); +} + +double Entropy(const unsigned char *p, std::size_t n) +{ + if (n == 0) + return 0; + std::size_t hist[256] = {}; + for (std::size_t i = 0; i < n; ++i) + hist[p[i]]++; + double e = 0; + for (int i = 0; i < 256; ++i) { + if (!hist[i]) + continue; + const double q = static_cast(hist[i]) / static_cast(n); + e -= q * std::log2(q); + } + return e; +} + +double StridedEntropy(const unsigned char *p, std::size_t n, int plane, int width) +{ + if (width <= 0 || n < static_cast(width)) + return 0; + const std::size_t nElem = n / static_cast(width); + std::vector planeBytes(nElem); + for (std::size_t i = 0; i < nElem; ++i) + planeBytes[i] = p[i * static_cast(width) + static_cast(plane)]; + return Entropy(planeBytes.data(), planeBytes.size()); +} + +Headroom HeadroomFromFrac(double frac) +{ + if (frac < 0.03) + return Headroom::kNone; + if (frac < 0.10) + return Headroom::kLow; + if (frac < 0.25) + return Headroom::kMedium; + return Headroom::kHigh; +} + +std::string Advise(const ColRow &c) +{ + const double ratio = (c.fComp > 0) ? static_cast(c.fUncomp) / static_cast(c.fComp) : 0; + + if (c.fKind == Kind::kFloat) { + if (c.fVerdict == Headroom::kNone && c.fLsbEntropy > 7.5) + return "mantissa floor; LHC4 will not shrink this"; + if (c.fVerdict == Headroom::kNone) + return "already at split+zstd floor"; + if (c.fVerdict == Headroom::kLow) + return "Crystal float profile, maybe a few %"; + return "Crystal / Laser on float pages"; + } + if (c.fKind == Kind::kInt) { + if (c.fVerdict == Headroom::kNone && c.fEntropy > 7.0) + return "unique integers; not compressible"; + if (c.fComp < 4096 || ratio > 8 || c.fVerdict == Headroom::kNone) + return "already packed; ignore for file size"; + if (c.fVerdict == Headroom::kHigh || (c.fMsbEntropy >= 0 && c.fMsbEntropy < 1.0)) + return "Grating or Crystal poisson"; + return "Grating / Crystal on integer pages"; + } + if (c.fKind == Kind::kIndex) { + if (ratio > 20) + return "offset columns already spent"; + return "keep split index; zstd/delta is enough"; + } + if (c.fKind == Kind::kText) + return "Prism / Mosaic / BWT"; + if (c.fKind == Kind::kBit) + return "bit columns already packed"; + return "measure with --auto-codecs all"; +} + +RunOptions ParseArgs(int argc, char **argv) +{ + RunOptions opts; + std::vector positional; + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "-h" || arg == "--help") { + std::cout << kUsage; + std::exit(0); + } + if (arg == "--csv") { + opts.fCsv = true; + continue; + } + if (arg == "--list") { + opts.fList = true; + continue; + } + if (arg == "--verbose" || arg == "-v") { + opts.fVerbose = true; + continue; + } + if (arg == "--top") { + if (i + 1 >= argc) + throw std::runtime_error("--top requires an argument"); + opts.fTop = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--top=", 0) == 0) { + opts.fTop = std::stoi(arg.substr(6)); + continue; + } + if (arg == "--pages") { + if (i + 1 >= argc) + throw std::runtime_error("--pages requires an argument"); + opts.fPages = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--pages=", 0) == 0) { + opts.fPages = std::stoi(arg.substr(8)); + continue; + } + if (!arg.empty() && arg[0] == '-') + throw std::runtime_error("unknown option: " + arg); + positional.push_back(arg); + } + if (positional.empty()) + throw std::runtime_error("missing input file (see --help)"); + if (positional.size() > 2) + throw std::runtime_error("too many positional arguments (see --help)"); + opts.fInputFile = positional[0]; + if (positional.size() == 2) + opts.fNTupleName = positional[1]; + if (opts.fTop < 1) + throw std::runtime_error("--top must be >= 1"); + if (opts.fPages < 1) + throw std::runtime_error("--pages must be >= 1"); + return opts; +} + +class PageReader { +public: + explicit PageReader(const std::string &path) + { +#if !defined(_WIN32) + fFd = ::open(path.c_str(), O_RDONLY); +#endif + if (fFd < 0) { + fFile.reset(TFile::Open(path.c_str(), "READ")); + if (!fFile || fFile->IsZombie()) + throw std::runtime_error("cannot open " + path + " for page reads"); + } + } + + ~PageReader() + { +#if !defined(_WIN32) + if (fFd >= 0) + ::close(fFd); +#endif + } + + bool Read(std::uint64_t offset, void *buf, std::size_t n) + { +#if !defined(_WIN32) + if (fFd >= 0) + return ::pread(fFd, buf, n, static_cast(offset)) == static_cast(n); +#endif + if (!fFile) + return false; + fFile->Seek(offset); + return !fFile->ReadBuffer(static_cast(buf), static_cast(n)); + } + +private: + int fFd = -1; + std::unique_ptr fFile; +}; + +void SampleColumn(ColRow &col, const ROOT::RNTupleDescriptor &desc, PageReader &reader, int maxPages) +{ + const int width = (col.fBits >= 8 && (col.fBits % 8) == 0) ? col.fBits / 8 : 0; + double hSum = 0; + double lsbSum = 0; + double msbSum = 0; + double packedSum = 0; + double sealedSum = 0; + int nPages = 0; + int nPlane = 0; + + for (const auto &clusterDesc : desc.GetClusterIterable()) { + if (nPages >= maxPages) + break; + bool present = false; + for (const auto &colRange : clusterDesc.GetColumnRangeIterable()) { + if (colRange.GetPhysicalColumnId() == col.fId && !colRange.IsSuppressed()) { + present = true; + break; + } + } + if (!present) + continue; + + const auto &pageRange = clusterDesc.GetPageRange(col.fId); + for (const auto &pageInfo : pageRange.GetPageInfos()) { + if (nPages >= maxPages) + break; + const auto &loc = pageInfo.GetLocator(); + if (loc.GetType() == ROOT::RNTupleLocator::kTypePageZero) + continue; + const auto sealedBytes = loc.GetNBytesOnStorage(); + const auto packedBytes = + (static_cast(col.fBits) * pageInfo.GetNElements() + 7) / 8; + if (sealedBytes == 0 || packedBytes == 0) + continue; + + std::vector sealed(sealedBytes); + if (!reader.Read(loc.GetPosition(), sealed.data(), sealedBytes)) + continue; + std::vector raw(packedBytes); + try { + RNTupleDecompressor::Unzip(sealed.data(), sealed.size(), packedBytes, raw.data()); + } catch (const std::exception &) { + continue; + } + + packedSum += static_cast(packedBytes); + sealedSum += static_cast(sealedBytes); + nPages++; + + double h = Entropy(raw.data(), raw.size()); + if (width >= 2 && width <= 8) { + const auto nElem = pageInfo.GetNElements(); + if (col.fSplit && nElem > 0 && packedBytes >= nElem * static_cast(width)) { + double planeH = 0; + for (int p = 0; p < width; ++p) + planeH += Entropy(raw.data() + static_cast(p) * nElem, nElem); + h = planeH / width; + lsbSum += Entropy(raw.data(), nElem); + msbSum += Entropy(raw.data() + static_cast(width - 1) * nElem, nElem); + } else { + lsbSum += StridedEntropy(raw.data(), raw.size(), 0, width); + msbSum += StridedEntropy(raw.data(), raw.size(), width - 1, width); + } + nPlane++; + } + hSum += h; + } + } + + col.fSampledPages = nPages; + if (nPages == 0) + return; + + col.fEntropy = hSum / nPages; + if (nPlane > 0) { + col.fLsbEntropy = lsbSum / nPlane; + col.fMsbEntropy = msbSum / nPlane; + } + + const double shannon = packedSum * col.fEntropy / 8.0; + const double residual = std::max(0.0, sealedSum - shannon); + col.fHeadroom = (packedSum > 0) ? residual / packedSum : 0; + + // Mantissa-limited floats: LSB plane is noise. Treat as no residual even if + // 0th-order Shannon is slightly below the sealed size. + if (col.fKind == Kind::kFloat && col.fLsbEntropy > 7.5 && col.fHeadroom < 0.08) + col.fHeadroom = 0; + + col.fVerdict = HeadroomFromFrac(col.fHeadroom); + if (col.fComp < 2048) + col.fVerdict = Headroom::kNone; +} + +} // namespace + +int main(int argc, char **argv) +{ + try { + auto opts = ParseArgs(argc, argv); + FileStat_t st{}; + if (gSystem->GetPathInfo(opts.fInputFile.c_str(), st) != 0) + throw std::runtime_error("cannot stat " + opts.fInputFile); + + std::unique_ptr quiet; + if (!opts.fVerbose) + quiet = std::make_unique(); + + const auto catalog = DescribeAllRNTuples(opts.fInputFile); + if (opts.fList) { + std::cout << "File: " << opts.fInputFile << " (" << FormatBytes(st.fSize) << ")\n"; + PrintRNTupleCatalog(catalog, ""); + return 0; + } + opts.fNTupleName = SelectRNTuple(catalog, opts.fNTupleName); + + auto inspector = RNTupleInspector::Create(opts.fNTupleName, opts.fInputFile); + const auto &desc = inspector->GetDescriptor(); + + std::cout << "File: " << opts.fInputFile << " (" << FormatBytes(st.fSize) << ")\n"; + PrintRNTupleCatalog(catalog, opts.fNTupleName); + std::cout << "RNTuple: " << opts.fNTupleName << '\n'; + std::cout << "Entries: " << desc.GetNEntries() << " clusters: " << desc.GetNClusters() + << " columns: " << desc.GetNPhysicalColumns() << '\n'; + std::cout << "Pages: " << inspector->GetCompressionSettingsAsString() << " payload " + << FormatBytes(inspector->GetCompressedSize()) << " / " + << FormatBytes(inspector->GetUncompressedSize()) << " uncompressed (" << std::fixed + << std::setprecision(2) << inspector->GetCompressionFactor() << "x)\n\n"; + + std::cout << "=== on-disk column types ===\n"; + inspector->PrintColumnTypeInfo(); + std::cout << '\n'; + + std::vector cols; + struct KindAcc { + int count = 0; + std::uint64_t nPages = 0; + std::uint64_t comp = 0; + std::uint64_t uncomp = 0; + }; + KindAcc byKind[6]{}; + + for (const auto &colDesc : desc.GetColumnIterable()) { + if (colDesc.IsAliasColumn()) + continue; + const auto id = colDesc.GetPhysicalId(); + const auto &ci = inspector->GetColumnInspector(id); + ColRow row; + row.fId = id; + row.fField = desc.GetQualifiedFieldName(colDesc.GetFieldId()); + if (colDesc.GetIndex() != 0) + row.fField += "[" + std::to_string(colDesc.GetIndex()) + "]"; + row.fTypeName = RColumnElementBase::GetColumnTypeName(colDesc.GetType()); + row.fKind = Classify(colDesc.GetType()); + row.fSplit = IsSplitType(colDesc.GetType()); + row.fBits = colDesc.GetBitsOnStorage(); + row.fNElem = ci.GetNElements(); + row.fNPages = ci.GetNPages(); + row.fComp = ci.GetCompressedSize(); + row.fUncomp = ci.GetUncompressedSize(); + cols.push_back(std::move(row)); + auto &acc = byKind[static_cast(cols.back().fKind)]; + acc.count++; + acc.nPages += cols.back().fNPages; + acc.comp += cols.back().fComp; + acc.uncomp += cols.back().fUncomp; + } + + const auto totComp = inspector->GetCompressedSize(); + const auto totUncomp = inspector->GetUncompressedSize(); + std::cout << "=== payload by page kind ===\n"; + std::cout << std::left << std::setw(8) << "kind" << std::right << std::setw(6) << "cols" << std::setw(12) + << "uncomp" << std::setw(10) << "%raw" << std::setw(12) << "on disk" << std::setw(10) << "%file" + << std::setw(9) << "ratio" << '\n'; + for (auto k : {Kind::kFloat, Kind::kInt, Kind::kIndex, Kind::kBit, Kind::kText, Kind::kOther}) { + const auto &a = byKind[static_cast(k)]; + if (a.count == 0) + continue; + const double pu = totUncomp ? 100.0 * a.uncomp / totUncomp : 0; + const double pc = totComp ? 100.0 * a.comp / totComp : 0; + const double ratio = a.comp ? static_cast(a.uncomp) / a.comp : 0; + std::cout << std::left << std::setw(8) << KindName(k) << std::right << std::setw(6) << a.count << std::setw(12) + << FormatBytes(a.uncomp) << std::setw(9) << std::fixed << std::setprecision(1) << pu << "%" + << std::setw(12) << FormatBytes(a.comp) << std::setw(9) << pc << "%" << std::setw(9) + << std::setprecision(2) << ratio << '\n'; + } + std::cout << '\n'; + + std::sort(cols.begin(), cols.end(), [](const ColRow &a, const ColRow &b) { return a.fComp > b.fComp; }); + + PageReader pages(opts.fInputFile); + const int nRank = std::min(opts.fTop, static_cast(cols.size())); + std::unordered_set sampled; + for (int i = 0; i < nRank; ++i) { + SampleColumn(cols[i], desc, pages, opts.fPages); + sampled.insert(cols[i].fId); + } + // Always sample the heaviest column of each kind so the verdict is not + // blinded when the top-N are all the same type. + for (auto k : {Kind::kFloat, Kind::kInt, Kind::kIndex, Kind::kBit, Kind::kText}) { + for (auto &c : cols) { + if (c.fKind != k || sampled.count(c.fId)) + continue; + SampleColumn(c, desc, pages, opts.fPages); + sampled.insert(c.fId); + break; + } + } + + for (auto &c : cols) + c.fAdvice = Advise(c); + + std::uint64_t exhaustedComp = 0; + std::uint64_t judgedComp = 0; + int nNone = 0, nLow = 0, nMed = 0, nHigh = 0; + for (const auto &c : cols) { + if (c.fVerdict == Headroom::kUnknown) + continue; + judgedComp += c.fComp; + if (c.fVerdict == Headroom::kNone) { + exhaustedComp += c.fComp; + nNone++; + } else if (c.fVerdict == Headroom::kLow) + nLow++; + else if (c.fVerdict == Headroom::kMedium) + nMed++; + else + nHigh++; + } + + std::cout << "=== compressor potential ===\n"; + if (judgedComp == 0) { + std::cout << "No pages could be sampled.\n"; + } else { + const double spentPct = totComp ? 100.0 * exhaustedComp / totComp : 0; + std::cout << "Sampled " << sampled.size() << " columns (" << nNone << " none, " << nLow << " low, " << nMed + << " medium, " << nHigh << " high headroom).\n"; + std::cout << "On-disk bytes already at the entropy floor: " << FormatBytes(exhaustedComp) << " (" + << std::setprecision(1) << std::fixed << spentPct << "% of payload).\n"; + if (spentPct >= 75 && byKind[static_cast(Kind::kFloat)].comp > totComp / 2) { + std::cout << "Verdict: little headroom. File size is dominated by high-entropy " + "SplitReal32 kinematics; Laser/Crystal/Grating will not move it.\n"; + } else if (nHigh > 0 && byKind[static_cast(Kind::kInt)].comp > totComp * 0.15) { + std::cout << "Verdict: integer pages still have room. Try Grating or Crystal poisson " + "on the int columns; leave the float kinematics alone.\n"; + } else if (nMed + nHigh > 0) { + std::cout << "Verdict: mixed. Measure with rntuple2rntuple --auto-codecs all; " + "expect gains only on the medium/high columns below.\n"; + } else { + std::cout << "Verdict: remaining columns are already well packed. A new codec is " + "unlikely to beat the current page compressor.\n"; + } + } + std::cout << '\n'; + + std::cout << "=== largest on-disk columns ===\n"; + std::cout << std::left << std::setw(36) << "field" << std::setw(14) << "type" << std::right << std::setw(11) + << "on disk" << std::setw(7) << "ratio" << std::setw(6) << "H" << std::setw(8) << "room" + << std::left << " advice" << '\n'; + for (int i = 0; i < nRank; ++i) { + const auto &c = cols[i]; + const double ratio = c.fComp ? static_cast(c.fUncomp) / c.fComp : 0; + std::string name = c.fField; + if (name.size() > 35) + name = name.substr(0, 32) + "..."; + std::cout << std::left << std::setw(36) << name << std::setw(14) << c.fTypeName << std::right << std::setw(11) + << FormatBytes(c.fComp) << std::setw(7) << std::setprecision(2) << std::fixed << ratio + << std::setw(6); + if (c.fEntropy >= 0) + std::cout << std::setprecision(2) << c.fEntropy; + else + std::cout << " -"; + std::cout << std::setw(8) << HeadroomName(c.fVerdict) << " " << std::left << c.fAdvice << '\n'; + } + + if (opts.fCsv) { + std::cout << "\nfield,colType,kind,bits,nPages,uncomp,comp,ratio,entropy,lsb,msb,headroom,verdict,advice\n"; + for (int i = 0; i < nRank; ++i) { + const auto &c = cols[i]; + const double ratio = c.fComp ? static_cast(c.fUncomp) / c.fComp : 0; + std::cout << c.fField << ',' << c.fTypeName << ',' << KindName(c.fKind) << ',' << c.fBits << ',' + << c.fNPages << ',' << c.fUncomp << ',' << c.fComp << ',' << std::setprecision(4) << ratio << ',' + << c.fEntropy << ',' << c.fLsbEntropy << ',' << c.fMsbEntropy << ',' << c.fHeadroom << ',' + << HeadroomName(c.fVerdict) << ',' << '"' << c.fAdvice << '"' << '\n'; + } + } + return 0; + } catch (const std::exception &e) { + std::cerr << "rntupleinspect: " << e.what() << '\n'; + std::cerr << kUsage; + return 1; + } +} diff --git a/main/src/rntupletryzip.cxx b/main/src/rntupletryzip.cxx new file mode 100644 index 0000000000000..993b17b699213 --- /dev/null +++ b/main/src/rntupletryzip.cxx @@ -0,0 +1,1391 @@ +/// \file rntupletryzip.cxx +/// \brief Re-encode RNTuple pages in memory and report sizes, without C++ dictionaries. + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#ifdef R__HAS_LHC4CODEC +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#endif + +namespace { + +using ROOT::Experimental::RNTupleInspector; +using ROOT::Internal::GenerateColumnElement; +using ROOT::Internal::RColumnElementBase; +using ROOT::Internal::RNTupleCompressor; +using ROOT::Internal::RNTupleDecompressor; + +constexpr int kDefaultLHC4Level = 5; +constexpr int kDefaultZstdLevel = 5; +constexpr int kDefaultLzmaLevel = 7; + +constexpr const char *kUsage = R"(Usage: rntupletryzip [options] input.root [ntupleName] + +Unzip RNTuple pages from the on-disk descriptor, unpack split/delta/zigzag +encodings to logical values, and recompress them in memory. No C++ dictionaries +are loaded and no output file is written. This is the path that still works on +CMSSW MiniAOD files whose streamer fields used to block rntuple2rntuple. + +By default this matches tree2ntuple / rntuple2rntuple LHC4: compress plain +column bytes, not already-split pages. Native variants (zstd/lzma) re-apply +ROOT split/delta/zigzag encodings before compressing, matching a native +RNTuple write. Remaining, unselected columns are counted at their current +on-disk size in the projected file total. + +Options: + --top N Recode the N largest on-disk columns (default: 20). + --all-columns Recode every physical column (slow on MiniAOD-scale files). + --pages N Pages per column (default: 4, spread across clusters). + 0 = all pages of the selected columns. + --level N LHC4 compression level (1-11, default: 5). Grating ignores it. + --only-variant LIST Comma-separated codecs (default: + native_zstd,native_lzma,lhc4_crystal,lhc4_laser,lhc4_grating). + --keep-column-encoding Compress already-packed split pages (old tryzip behaviour). + --no-filters LHC4: compress bytes with byte filters off. + --brute-filters LHC4: for each page, try none, auto-detect, and every + width-matched numeric filter; keep the smallest lossless + encoding (oracle). none and auto are also reported as + whole-set baselines. A singleton filter_mask forces the + transform (Crystal will not entropy-screen it back to raw). + --brute-filters=all Also try text/BCJ filters on mismatched widths. bitround + is lossy and is not raced. + --threads N Recode threads for codecs/pages and filter trials + (default: all hardware cores). + --list-variants Print available codec names and exit. + --verify Round-trip unzip after each recompress (slow). + --csv Also print a CSV of per-column scaled sizes. + --verbose Keep TClass dictionary warnings; print per-column progress. + -h, --help Show this help. + +If ntupleName is omitted, Events is used when present, otherwise the RNTuple +with the largest on-disk payload. +)"; + +enum class Kind { kFloat, kInt, kIndex, kBit, kText, kOther }; + +struct RunOptions { + std::string fInputFile; + std::string fNTupleName; + int fTop = 20; + int fPages = 4; + int fLevel = kDefaultLHC4Level; + bool fAllColumns = false; + bool fCsv = false; + bool fVerbose = false; + bool fVerify = false; + bool fListVariants = false; + bool fKeepColumnEncoding = false; + bool fNoFilters = false; + bool fBruteFilters = false; + bool fBruteAll = false; + int fThreads = 0; // 0 = hardware_concurrency + std::vector fOnly; +}; + +struct NTupleInfo { + std::string fName; + ROOT::NTupleSize_t fNEntries = 0; + std::uint64_t fComp = 0; + std::uint64_t fUncomp = 0; + std::size_t fNColumns = 0; +}; + +struct ColRow { + ROOT::DescriptorId_t fId = 0; + std::string fField; + std::string fTypeName; + Kind fKind = Kind::kOther; + ROOT::ENTupleColumnType fType = ROOT::ENTupleColumnType::kUnknown; + std::uint16_t fBits = 0; + std::uint64_t fNPages = 0; + std::uint64_t fComp = 0; + std::uint64_t fUncomp = 0; +}; + +struct PageLoc { + std::uint64_t fOffset = 0; + std::uint64_t fSealed = 0; + std::size_t fPacked = 0; + std::uint32_t fNElements = 0; +}; + +struct ZipVariant { + std::string fLabel; + int fCompression = 0; + bool fLHC4 = false; + int fLHC4Codec = 0; + bool fFilters = true; + bool fProfilePerColumn = false; +}; + +struct FilterSpec { + const char *fName = nullptr; + int fId = 0; // 0 = none, -1 = auto, >0 = FilterId + int fWidth = 0; // 0 = any width +}; + +const FilterSpec kFilterSpecs[] = { + {"none", 0, 0}, + {"auto", -1, 0}, + {"shuffle16", 1, 16}, + {"shuffle32", 2, 32}, + {"shuffle64", 3, 64}, + {"delta16", 4, 16}, + {"delta32", 5, 32}, + {"delta64", 6, 64}, + {"zigzag_delta16", 7, 16}, + {"zigzag_delta32", 8, 32}, + {"zigzag_delta64", 9, 64}, + {"delta_shuffle32", 10, 32}, + {"zigzag_delta_shuffle32", 11, 32}, + {"bitshuffle32", 12, 32}, + {"dict32", 13, 32}, + {"dict64", 14, 64}, + {"delta_shuffle64", 15, 64}, + {"zigzag_shuffle32", 16, 32}, + {"zigzag_shuffle64", 17, 64}, + {"bitshuffle64", 18, 64}, + {"delta_bitshuffle32", 19, 32}, + {"dict16", 20, 16}, + {"for16", 21, 16}, + {"for32", 22, 32}, + {"for64", 23, 64}, + {"alp32", 24, 32}, + {"alp64", 25, 64}, + {"xor_shuffle32", 26, 32}, + {"xor_shuffle64", 27, 64}, + {"delta2_shuffle32", 28, 32}, + {"zigzag_delta2_shuffle32", 29, 32}, + {"delta2_shuffle64", 30, 64}, + {"bcj_x86", 31, 0}, + {"digit_norm", 32, 0}, + {"word_norm", 33, 0}, + {"base64", 34, 0}, + {"bitround32", 35, 32}, + {"bitround64", 36, 64}, + {"lag2_shuffle32", 37, 32}, + {"lag2_shuffle64", 38, 64}, + {"xor_lag2_shuffle32", 39, 32}, + {"bcj_arm64", 40, 0}, +}; + +unsigned ChooseThreads(int requested, std::size_t nJobs) +{ + unsigned hw = std::thread::hardware_concurrency(); + if (hw == 0) + hw = 1; + unsigned n = requested > 0 ? static_cast(requested) : hw; + if (nJobs == 0) + return 1; + if (n > nJobs) + n = static_cast(nJobs); + if (n < 1) + n = 1; + return n; +} + +bool FilterMatchesColumn(const FilterSpec &f, int bits, bool bruteAll) +{ + if (f.fId <= 0) + return true; + if (bruteAll) + return true; + if (f.fId == 35 || f.fId == 36) + return false; // bitround is lossy; only with --brute-filters=all + if (f.fWidth == 0) + return false; + return f.fWidth == bits; +} + +class IgnoreDictWarnings { +public: + IgnoreDictWarnings() : fOld(gErrorIgnoreLevel) { gErrorIgnoreLevel = kError; } + ~IgnoreDictWarnings() { gErrorIgnoreLevel = fOld; } + IgnoreDictWarnings(const IgnoreDictWarnings &) = delete; + IgnoreDictWarnings &operator=(const IgnoreDictWarnings &) = delete; + +private: + Int_t fOld = 0; +}; + +class PageReader { +public: + explicit PageReader(const std::string &path) + { +#if !defined(_WIN32) + fFd = ::open(path.c_str(), O_RDONLY); +#endif + if (fFd < 0) { + fFile.reset(TFile::Open(path.c_str(), "READ")); + if (!fFile || fFile->IsZombie()) + throw std::runtime_error("cannot open " + path + " for page reads"); + } + } + + ~PageReader() + { +#if !defined(_WIN32) + if (fFd >= 0) + ::close(fFd); +#endif + } + + bool Read(std::uint64_t offset, void *buf, std::size_t n) + { +#if !defined(_WIN32) + if (fFd >= 0) + return ::pread(fFd, buf, n, static_cast(offset)) == static_cast(n); +#endif + if (!fFile) + return false; + fFile->Seek(offset); + return !fFile->ReadBuffer(static_cast(buf), static_cast(n)); + } + +private: + int fFd = -1; + std::unique_ptr fFile; +}; + +Kind Classify(ROOT::ENTupleColumnType t) +{ + using T = ROOT::ENTupleColumnType; + switch (t) { + case T::kReal64: + case T::kReal32: + case T::kReal16: + case T::kSplitReal64: + case T::kSplitReal32: + case T::kReal32Trunc: + case T::kReal32Quant: return Kind::kFloat; + case T::kIndex64: + case T::kIndex32: + case T::kSplitIndex64: + case T::kSplitIndex32: return Kind::kIndex; + case T::kInt64: + case T::kUInt64: + case T::kInt32: + case T::kUInt32: + case T::kInt16: + case T::kUInt16: + case T::kInt8: + case T::kUInt8: + case T::kSplitInt64: + case T::kSplitUInt64: + case T::kSplitInt32: + case T::kSplitUInt32: + case T::kSplitInt16: + case T::kSplitUInt16: return Kind::kInt; + case T::kBit: return Kind::kBit; + case T::kChar: + case T::kByte: return Kind::kText; + default: return Kind::kOther; + } +} + +bool IsSplitColumnType(ROOT::ENTupleColumnType t) +{ + using T = ROOT::ENTupleColumnType; + switch (t) { + case T::kSplitIndex64: + case T::kSplitIndex32: + case T::kSplitReal64: + case T::kSplitReal32: + case T::kSplitInt64: + case T::kSplitInt32: + case T::kSplitInt16: + case T::kSplitUInt64: + case T::kSplitUInt32: + case T::kSplitUInt16: return true; + default: return false; + } +} + +/// On-disk type a native RNTuple write would use: split floats, zigzag-split +/// integers, delta-split offsets. Types without a split form stay as they are. +ROOT::ENTupleColumnType NativeSplitType(ROOT::ENTupleColumnType t) +{ + using T = ROOT::ENTupleColumnType; + switch (t) { + case T::kReal32: + case T::kSplitReal32: return T::kSplitReal32; + case T::kReal64: + case T::kSplitReal64: return T::kSplitReal64; + case T::kInt16: + case T::kSplitInt16: return T::kSplitInt16; + case T::kInt32: + case T::kSplitInt32: return T::kSplitInt32; + case T::kInt64: + case T::kSplitInt64: return T::kSplitInt64; + case T::kUInt16: + case T::kSplitUInt16: return T::kSplitUInt16; + case T::kUInt32: + case T::kSplitUInt32: return T::kSplitUInt32; + case T::kUInt64: + case T::kSplitUInt64: return T::kSplitUInt64; + case T::kIndex32: + case T::kSplitIndex32: return T::kSplitIndex32; + case T::kIndex64: + case T::kSplitIndex64: return T::kSplitIndex64; + default: return t; + } +} + +std::unique_ptr MakeNativePacker(const RColumnElementBase &src) +{ + const auto nativeType = NativeSplitType(src.GetIdentifier().fOnDiskType); + auto dst = GenerateColumnElement(src.GetIdentifier().fInMemoryType, nativeType); + if (src.GetBitsOnStorage() != dst->GetBitsOnStorage()) { + try { + dst->SetBitsOnStorage(src.GetBitsOnStorage()); + } catch (const std::exception &) { + } + } + if (const auto range = src.GetValueRange()) + dst->SetValueRange(range->first, range->second); + return dst; +} + +const char *KindName(Kind k) +{ + switch (k) { + case Kind::kFloat: return "float"; + case Kind::kInt: return "int"; + case Kind::kIndex: return "index"; + case Kind::kBit: return "bit"; + case Kind::kText: return "text"; + default: return "other"; + } +} + +std::string FormatBytes(std::uint64_t n) +{ + std::ostringstream os; + os << std::fixed << std::setprecision(2); + if (n >= (1ull << 30)) + os << (n / 1073741824.0) << " GiB"; + else if (n >= (1ull << 20)) + os << (n / 1048576.0) << " MiB"; + else if (n >= 1024) + os << (n / 1024.0) << " KiB"; + else + os << n << " B"; + return os.str(); +} + +std::string ShortField(const std::string &name) +{ + if (name.size() <= 40) + return name; + return "..." + name.substr(name.size() - 37); +} + +std::vector SplitComma(const std::string &s) +{ + std::vector out; + std::string cur; + for (char c : s) { + if (c == ',') { + if (!cur.empty()) + out.push_back(cur); + cur.clear(); + } else if (!std::isspace(static_cast(c))) { + cur.push_back(c); + } + } + if (!cur.empty()) + out.push_back(cur); + return out; +} + +std::vector ListRNTupleNames(const std::string &path) +{ + auto file = std::unique_ptr(TFile::Open(path.c_str(), "READ")); + if (!file || file->IsZombie()) + throw std::runtime_error("cannot open " + path); + std::vector names; + TIter next(file->GetListOfKeys()); + while (auto key = static_cast(next())) { + if (std::strcmp(key->GetClassName(), "ROOT::RNTuple") != 0) + continue; + const std::string name = key->GetName(); + if (std::find(names.begin(), names.end(), name) == names.end()) + names.push_back(name); + } + return names; +} + +NTupleInfo DescribeRNTuple(const std::string &path, const std::string &name) +{ + auto inspector = RNTupleInspector::Create(name, path); + const auto &desc = inspector->GetDescriptor(); + NTupleInfo info; + info.fName = name; + info.fNEntries = desc.GetNEntries(); + info.fComp = inspector->GetCompressedSize(); + info.fUncomp = inspector->GetUncompressedSize(); + info.fNColumns = desc.GetNPhysicalColumns(); + return info; +} + +std::vector DescribeAllRNTuples(const std::string &path) +{ + const auto names = ListRNTupleNames(path); + if (names.empty()) + throw std::runtime_error("no RNTuple in " + path); + std::vector infos; + infos.reserve(names.size()); + for (const auto &name : names) + infos.push_back(DescribeRNTuple(path, name)); + return infos; +} + +std::string SelectRNTuple(const std::vector &infos, const std::string &requested) +{ + if (infos.empty()) + throw std::runtime_error("no RNTuple in file"); + if (!requested.empty()) { + for (const auto &info : infos) { + if (info.fName == requested) + return requested; + } + std::ostringstream os; + os << "unknown RNTuple '" << requested << "'; available:"; + for (const auto &info : infos) + os << ' ' << info.fName; + throw std::runtime_error(os.str()); + } + for (const auto &info : infos) { + if (info.fName == "Events") + return info.fName; + } + auto best = std::max_element(infos.begin(), infos.end(), + [](const NTupleInfo &a, const NTupleInfo &b) { return a.fComp < b.fComp; }); + return best->fName; +} + +void PrintRNTupleCatalog(const std::vector &infos, const std::string &selected) +{ + if (infos.empty()) + return; + std::cout << "RNTuples in file:\n"; + for (const auto &info : infos) { + const char *mark = (info.fName == selected) ? " * " : " "; + std::cout << mark << std::left << std::setw(22) << info.fName << std::right << std::setw(10) << info.fNEntries + << " entries " << std::setw(12) << FormatBytes(info.fComp) << " payload\n"; + } + if (infos.size() > 1 && !selected.empty()) + std::cout << " (* selected)\n"; + std::cout << '\n'; +} + +int AlgoLevel(ROOT::RCompressionSetting::EAlgorithm::EValues algo, int level) +{ + return ROOT::CompressionSettings(algo, level); +} + +#ifdef R__HAS_LHC4CODEC +int ProfileForKind(Kind k) +{ + switch (k) { + case Kind::kFloat: return kLHC4ProfileFloatKinematics; + case Kind::kInt: return kLHC4ProfilePoissonCounts; + case Kind::kIndex: return kLHC4ProfileIntMonotonic; + case Kind::kText: return kLHC4ProfileText; + default: return kLHC4ProfileNone; + } +} + +void ResetLHC4() +{ + R__SetLHC4Codec(kLHC4CodecBeam); + R__SetLHC4Filters(0); + R__SetLHC4FilterFallback(1); + R__SetLHC4FilterRle(1); + R__SetLHC4FilterDict(1); + R__SetLHC4FilterMask(0); + R__SetLHC4Profile(kLHC4ProfileNone); + R__SetLHC4ProfileId(0); + R__SetLHC4ProfilePerColumn(0); + R__SetLHC4CallProfile(-1); + R__SetLHC4CallProfileId(0); + R__SetLHC4CallFilterTrial(-1, 0, -1); + R__SetLHC4CallCodec(-1); + R__SetLHC4AutoCodecs(0); + R__SetLHC4AutoMaxLevel(0); +} + +void ClearCallOverrides() +{ + R__SetLHC4CallCodec(-1); + R__SetLHC4CallFilterTrial(-1, 0, -1); + R__SetLHC4CallProfile(-1); +} + +// Per-thread filter trial. Does not touch process-wide codec/filter knobs. +void ApplyFilterTrialOnThread(int filterId) +{ + if (filterId == 0) { + R__SetLHC4CallFilterTrial(0, 0, 1); + return; + } + if (filterId < 0) { + R__SetLHC4CallFilterTrial(1, ~0ull, 1); + return; + } + R__SetLHC4CallFilterTrial(1, 1ull << static_cast(filterId), 0); +} + +void ApplyVariantOnThread(const ZipVariant &v, int filterId, Kind kind) +{ + if (!v.fLHC4) { + ClearCallOverrides(); + return; + } + R__SetLHC4CallCodec(v.fLHC4Codec); + ApplyFilterTrialOnThread(filterId); + if (v.fProfilePerColumn) + R__SetLHC4CallProfile(ProfileForKind(kind)); + else + R__SetLHC4CallProfile(-1); +} + +#endif + +std::vector AllVariants(int lhc4Level) +{ + std::vector v; + const int zstd = AlgoLevel(ROOT::RCompressionSetting::EAlgorithm::kZSTD, kDefaultZstdLevel); + const int lzma7 = AlgoLevel(ROOT::RCompressionSetting::EAlgorithm::kLZMA, kDefaultLzmaLevel); + const int lzma4 = AlgoLevel(ROOT::RCompressionSetting::EAlgorithm::kLZMA, 4); + v.push_back({"native_zstd", zstd, false, 0, false, false}); + v.push_back({"native_lzma", lzma7, false, 0, false, false}); + v.push_back({"native_lzma4", lzma4, false, 0, false, false}); +#ifdef R__HAS_LHC4CODEC + const int lhc4 = AlgoLevel(ROOT::RCompressionSetting::EAlgorithm::kLHC4, lhc4Level); + v.push_back({"lhc4_zstd", lhc4, true, kLHC4CodecZstd, true, false}); + v.push_back({"lhc4_beam", lhc4, true, kLHC4CodecBeam, true, false}); + v.push_back({"lhc4_crystal", lhc4, true, kLHC4CodecCrystal, true, false}); + v.push_back({"lhc4_crystal_profile", lhc4, true, kLHC4CodecCrystal, true, true}); + v.push_back({"lhc4_laser", lhc4, true, kLHC4CodecLaser, true, false}); + v.push_back({"lhc4_grating", lhc4, true, kLHC4CodecGrating, true, false}); + v.push_back({"lhc4_flash", lhc4, true, kLHC4CodecFlash, true, false}); + v.push_back({"lhc4_mosaic", lhc4, true, kLHC4CodecMosaic, true, false}); + v.push_back({"lhc4_prism", lhc4, true, kLHC4CodecBwt, true, false}); + v.push_back({"lhc4_auto", lhc4, true, kLHC4CodecAuto, true, false}); +#else + (void)lhc4Level; +#endif + return v; +} + +const ZipVariant *FindVariant(const std::vector &all, const std::string &label) +{ + for (const auto &v : all) { + if (v.fLabel == label) + return &v; + } + // lhc4_crystal5 / lhc4_laser9 -> strip trailing digits + std::string family = label; + while (!family.empty() && std::isdigit(static_cast(family.back()))) + family.pop_back(); + if (family.empty() || family == label) + return nullptr; + for (const auto &v : all) { + if (v.fLabel == family) + return &v; + } + return nullptr; +} + +std::vector SelectVariants(const RunOptions &opts) +{ + const auto all = AllVariants(opts.fLevel); + std::vector want = opts.fOnly; + if (want.empty()) + want = {"native_zstd", "native_lzma", "lhc4_crystal", "lhc4_laser", "lhc4_grating"}; + std::vector out; + for (const auto &name : want) { + const auto *v = FindVariant(all, name); + if (!v) { + std::ostringstream os; + os << "unknown variant '" << name << "'; available:"; + for (const auto &a : all) + os << ' ' << a.fLabel; + throw std::runtime_error(os.str()); + } +#ifdef R__HAS_LHC4CODEC + if (v->fLHC4 && v->fLHC4Codec != kLHC4CodecAuto && !R__LHC4CodecAvailable(v->fLHC4Codec)) { + std::cerr << "Skipping " << v->fLabel << ": backend not linked\n"; + continue; + } +#endif + out.push_back(*v); + } + if (out.empty()) + throw std::runtime_error("no usable variants"); + if (opts.fNoFilters) { + for (auto &v : out) + v.fFilters = false; + } + return out; +} + +void PrintVariantList(int level) +{ + std::cout << "Available variants (--level " << level << " for LHC4):\n"; + for (const auto &v : AllVariants(level)) + std::cout << " " << v.fLabel << '\n'; +} + +RunOptions ParseArgs(int argc, char **argv) +{ + RunOptions opts; + std::vector positional; + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "-h" || arg == "--help") { + std::cout << kUsage; + std::exit(0); + } + if (arg == "--list-variants") { + opts.fListVariants = true; + continue; + } + if (arg == "--csv") { + opts.fCsv = true; + continue; + } + if (arg == "--verbose" || arg == "-v") { + opts.fVerbose = true; + continue; + } + if (arg == "--verify") { + opts.fVerify = true; + continue; + } + if (arg == "--all-columns") { + opts.fAllColumns = true; + continue; + } + if (arg == "--top") { + if (i + 1 >= argc) + throw std::runtime_error("--top requires an argument"); + opts.fTop = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--top=", 0) == 0) { + opts.fTop = std::stoi(arg.substr(6)); + continue; + } + if (arg == "--pages") { + if (i + 1 >= argc) + throw std::runtime_error("--pages requires an argument"); + opts.fPages = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--pages=", 0) == 0) { + opts.fPages = std::stoi(arg.substr(8)); + continue; + } + if (arg == "--level") { + if (i + 1 >= argc) + throw std::runtime_error("--level requires an argument"); + opts.fLevel = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--level=", 0) == 0) { + opts.fLevel = std::stoi(arg.substr(8)); + continue; + } + if (arg == "--only-variant") { + if (i + 1 >= argc) + throw std::runtime_error("--only-variant requires an argument"); + opts.fOnly = SplitComma(argv[++i]); + continue; + } + if (arg.rfind("--only-variant=", 0) == 0) { + opts.fOnly = SplitComma(arg.substr(15)); + continue; + } + if (arg == "--keep-column-encoding") { + opts.fKeepColumnEncoding = true; + continue; + } + if (arg == "--no-filters") { + opts.fNoFilters = true; + continue; + } + if (arg == "--brute-filters") { + opts.fBruteFilters = true; + continue; + } + if (arg == "--brute-filters=all") { + opts.fBruteFilters = true; + opts.fBruteAll = true; + continue; + } + if (arg == "--threads") { + if (i + 1 >= argc) + throw std::runtime_error("--threads requires an argument"); + opts.fThreads = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--threads=", 0) == 0) { + opts.fThreads = std::stoi(arg.substr(10)); + continue; + } + if (!arg.empty() && arg[0] == '-') + throw std::runtime_error("unknown option: " + arg); + positional.push_back(arg); + } + if (opts.fListVariants) + return opts; + if (positional.empty()) + throw std::runtime_error("missing input file (see --help)"); + if (positional.size() > 2) + throw std::runtime_error("too many positional arguments (see --help)"); + opts.fInputFile = positional[0]; + if (positional.size() == 2) + opts.fNTupleName = positional[1]; + if (opts.fTop < 1) + throw std::runtime_error("--top must be >= 1"); + if (opts.fPages < 0) + throw std::runtime_error("--pages must be >= 0"); + if (opts.fLevel < 1 || opts.fLevel > 11) + throw std::runtime_error("--level must be 1..11"); + if (opts.fThreads < 0) + throw std::runtime_error("--threads must be >= 0"); + return opts; +} + +std::vector CollectPages(const ROOT::RNTupleDescriptor &desc, const ColRow &col, int maxPages) +{ + std::vector all; + all.reserve(static_cast(std::min(col.fNPages, 1024))); + for (const auto &clusterDesc : desc.GetClusterIterable()) { + bool present = false; + for (const auto &colRange : clusterDesc.GetColumnRangeIterable()) { + if (colRange.GetPhysicalColumnId() == col.fId && !colRange.IsSuppressed()) { + present = true; + break; + } + } + if (!present) + continue; + const auto &pageRange = clusterDesc.GetPageRange(col.fId); + for (const auto &pageInfo : pageRange.GetPageInfos()) { + const auto &loc = pageInfo.GetLocator(); + if (loc.GetType() == ROOT::RNTupleLocator::kTypePageZero) + continue; + const auto sealed = loc.GetNBytesOnStorage(); + const auto packed = (static_cast(col.fBits) * pageInfo.GetNElements() + 7) / 8; + if (sealed == 0 || packed == 0) + continue; + PageLoc p; + p.fOffset = loc.GetPosition(); + p.fSealed = sealed; + p.fPacked = packed; + p.fNElements = pageInfo.GetNElements(); + all.push_back(p); + } + } + if (maxPages <= 0 || static_cast(all.size()) <= maxPages) + return all; + std::vector sample; + sample.reserve(static_cast(maxPages)); + for (int i = 0; i < maxPages; ++i) { + const std::size_t idx = (static_cast(i) * all.size()) / static_cast(maxPages); + sample.push_back(all[idx]); + } + return sample; +} + +struct InflatedPage { + std::vector fRaw; + /// ROOT split/delta/zigzag page, used when probing native zstd/lzma. + std::vector fPackedNative; + std::uint64_t fSealed = 0; +}; + +std::vector InflatePages(PageReader &reader, const std::vector &pages, + const RColumnElementBase &elem, bool unpackEncodings, + const RColumnElementBase *nativeElem, int *nFail) +{ + std::vector out; + out.reserve(pages.size()); + std::vector sealed; + std::vector packed; + std::vector logical; + const bool unpack = unpackEncodings && IsSplitColumnType(elem.GetIdentifier().fOnDiskType); + for (const auto &p : pages) { + if (sealed.size() < p.fSealed) + sealed.resize(p.fSealed); + if (!reader.Read(p.fOffset, sealed.data(), static_cast(p.fSealed))) { + ++(*nFail); + continue; + } + InflatedPage inf; + inf.fSealed = p.fSealed; + try { + packed.resize(p.fPacked); + RNTupleDecompressor::Unzip(sealed.data(), static_cast(p.fSealed), p.fPacked, packed.data()); + if (unpack) { + inf.fRaw.resize(elem.GetSize() * p.fNElements); + elem.Unpack(inf.fRaw.data(), packed.data(), p.fNElements); + } else { + inf.fRaw = packed; + } + if (nativeElem) { + const unsigned char *src = packed.data(); + if (unpack) { + src = inf.fRaw.data(); + } else if (!elem.IsMappable()) { + logical.resize(elem.GetSize() * p.fNElements); + elem.Unpack(logical.data(), packed.data(), p.fNElements); + src = logical.data(); + } + inf.fPackedNative.resize(nativeElem->GetPackedSize(p.fNElements)); + nativeElem->Pack(inf.fPackedNative.data(), src, p.fNElements); + } + } catch (const std::exception &) { + ++(*nFail); + continue; + } + out.push_back(std::move(inf)); + } + return out; +} + +struct RecodeJob { + std::size_t fVariant = 0; + std::size_t fCol = 0; + std::size_t fPage = 0; + int fFilterId = -1; +}; + +void ZipRecodeJobs(const std::vector &variants, const std::vector &cols, + const std::vector> &inflated, const std::vector &jobs, + std::vector &jobZip, std::atomic *cpuNs, unsigned nThreads) +{ + std::atomic next{0}; + auto worker = [&] { + std::vector zipped; + for (;;) { + const std::size_t j = next.fetch_add(1); + if (j >= jobs.size()) + break; + const auto &job = jobs[j]; + const auto &variant = variants[job.fVariant]; + const auto &page = inflated[job.fCol][job.fPage]; +#ifdef R__HAS_LHC4CODEC + ApplyVariantOnThread(variant, job.fFilterId, cols[job.fCol].fKind); +#endif + const auto &src = (!variant.fLHC4 && !page.fPackedNative.empty()) ? page.fPackedNative : page.fRaw; + if (zipped.size() < src.size()) + zipped.resize(src.size()); + const auto t0 = std::chrono::steady_clock::now(); + jobZip[j] = RNTupleCompressor::Zip(src.data(), src.size(), variant.fCompression, zipped.data()); + if (cpuNs) { + const auto ns = + std::chrono::duration_cast(std::chrono::steady_clock::now() - t0).count(); + cpuNs[job.fVariant].fetch_add(static_cast(ns), std::memory_order_relaxed); + } + } +#ifdef R__HAS_LHC4CODEC + ClearCallOverrides(); +#endif + }; + if (nThreads <= 1) { + worker(); + return; + } + std::vector pool; + pool.reserve(nThreads); + for (unsigned t = 0; t < nThreads; ++t) + pool.emplace_back(worker); + for (auto &th : pool) + th.join(); +} + +} // namespace + +int main(int argc, char **argv) +{ + try { + auto opts = ParseArgs(argc, argv); + if (opts.fListVariants) { + PrintVariantList(opts.fLevel); + return 0; + } + + FileStat_t st{}; + if (gSystem->GetPathInfo(opts.fInputFile.c_str(), st) != 0) + throw std::runtime_error("cannot stat " + opts.fInputFile); + + std::unique_ptr quiet; + if (!opts.fVerbose) + quiet = std::make_unique(); + + const auto catalog = DescribeAllRNTuples(opts.fInputFile); + opts.fNTupleName = SelectRNTuple(catalog, opts.fNTupleName); + auto inspector = RNTupleInspector::Create(opts.fNTupleName, opts.fInputFile); + const auto &desc = inspector->GetDescriptor(); + const auto variants = SelectVariants(opts); + + std::cout << "File: " << opts.fInputFile << " (" << FormatBytes(st.fSize) << ")\n"; + PrintRNTupleCatalog(catalog, opts.fNTupleName); + std::cout << "RNTuple: " << opts.fNTupleName << '\n'; + std::cout << "Entries: " << desc.GetNEntries() << " columns: " << desc.GetNPhysicalColumns() << '\n'; + std::cout << "On disk: " << inspector->GetCompressionSettingsAsString() << " payload " + << FormatBytes(inspector->GetCompressedSize()) << " / " + << FormatBytes(inspector->GetUncompressedSize()) << " uncompressed (" << std::fixed + << std::setprecision(2) << inspector->GetCompressionFactor() << "x)\n"; + std::cout << "Mode: page recode in memory (no dictionaries, " + << (opts.fKeepColumnEncoding ? "encodings kept" : "unpack split/delta/zigzag") << ")\n\n"; + + std::vector cols; + for (const auto &colDesc : desc.GetColumnIterable()) { + if (colDesc.IsAliasColumn()) + continue; + const auto id = colDesc.GetPhysicalId(); + const auto &ci = inspector->GetColumnInspector(id); + ColRow row; + row.fId = id; + row.fField = desc.GetQualifiedFieldName(colDesc.GetFieldId()); + if (colDesc.GetIndex() != 0) + row.fField += "[" + std::to_string(colDesc.GetIndex()) + "]"; + row.fTypeName = RColumnElementBase::GetColumnTypeName(colDesc.GetType()); + row.fKind = Classify(colDesc.GetType()); + row.fType = colDesc.GetType(); + row.fBits = colDesc.GetBitsOnStorage(); + row.fNPages = ci.GetNPages(); + row.fComp = ci.GetCompressedSize(); + row.fUncomp = ci.GetUncompressedSize(); + cols.push_back(std::move(row)); + } + std::sort(cols.begin(), cols.end(), [](const ColRow &a, const ColRow &b) { return a.fComp > b.fComp; }); + + int nSel = opts.fAllColumns ? static_cast(cols.size()) + : std::min(opts.fTop, static_cast(cols.size())); + cols.resize(static_cast(nSel)); + + std::uint64_t selectedOrig = 0; + for (const auto &c : cols) + selectedOrig += c.fComp; + const auto totalOrig = inspector->GetCompressedSize(); + const auto restOrig = totalOrig > selectedOrig ? totalOrig - selectedOrig : 0; + + std::cout << "Selected " << cols.size() << " column" << (cols.size() == 1 ? "" : "s") << " covering " + << FormatBytes(selectedOrig) << " / " << FormatBytes(totalOrig) << " payload (" + << std::setprecision(1) << (totalOrig ? 100.0 * selectedOrig / totalOrig : 0) << "%).\n"; + if (opts.fPages > 0) + std::cout << "Sampling " << opts.fPages << " page(s) per column and scaling to that column's on-disk size.\n"; + else + std::cout << "Recoding every page of the selected columns.\n"; + std::cout << "Untouched remainder stays at " << FormatBytes(restOrig) << " in the projected file size.\n\n"; + + RNTupleCompressor::SetVerifyRoundtrip(opts.fVerify); + PageReader reader(opts.fInputFile); + + int nInflateFail = 0; + std::vector> inflated; + inflated.reserve(cols.size()); + int nSamplePages = 0; + for (const auto &c : cols) { + auto pages = CollectPages(desc, c, opts.fPages); + auto elem = RColumnElementBase::Generate(c.fType); + elem->SetBitsOnStorage(c.fBits); + std::unique_ptr nativeElem; + if (!opts.fKeepColumnEncoding) + nativeElem = MakeNativePacker(*elem); + auto inf = InflatePages(reader, pages, *elem, !opts.fKeepColumnEncoding, nativeElem.get(), &nInflateFail); + nSamplePages += static_cast(inf.size()); + inflated.push_back(std::move(inf)); + } + std::cout << "Pages queued: " << nSamplePages; + if (nInflateFail) + std::cout << " (" << nInflateFail << " unzip failures)"; + std::cout << '\n'; + if (!opts.fKeepColumnEncoding) { + std::cout << "Unpacking RNTuple column encodings (split/delta/zigzag) before recode.\n"; + std::cout << "Native variants re-apply ROOT split/delta/zigzag encodings before zip.\n"; + } + if (opts.fBruteFilters) + std::cout << "Brute filters: per page, keep the best of none/auto/" + << (opts.fBruteAll ? "every id except bitround" : "width-matched numeric") + << "; unzipped pages are reused; " + << ChooseThreads(opts.fThreads, static_cast(-1) / 2) << " threads.\n"; + if (opts.fNoFilters) + std::cout << "LHC4 byte filters are off.\n"; + + struct VariantRow { + std::string fLabel; + std::uint64_t fSampleNew = 0; + std::uint64_t fSampleOrig = 0; + std::uint64_t fSampleUncomp = 0; + std::uint64_t fScaledSel = 0; + std::uint64_t fProjected = 0; + double fSec = 0; + int fOk = 0; + int fFail = 0; + std::vector fColScaled; + }; + std::vector rows; + struct FilterWin { + std::uint64_t fPages = 0; + std::uint64_t fSample = 0; + }; + struct CodecWins { + std::string fCodec; + std::vector fWins; + std::uint64_t fPages = 0; + }; + std::vector bruteWins; + + auto makeRowFromZips = [&](const std::string &label, + const std::vector> &zipByCol) { + VariantRow row; + row.fLabel = label; + row.fColScaled.resize(cols.size(), 0); + for (std::size_t i = 0; i < cols.size(); ++i) { + if (inflated[i].empty()) { + row.fColScaled[i] = cols[i].fComp; + row.fScaledSel += cols[i].fComp; + continue; + } + std::uint64_t sampleNew = 0; + std::uint64_t sampleOrig = 0; + std::uint64_t sampleUncomp = 0; + for (std::size_t p = 0; p < inflated[i].size(); ++p) { + sampleNew += zipByCol[i][p]; + sampleOrig += inflated[i][p].fSealed; + sampleUncomp += inflated[i][p].fRaw.size(); + ++row.fOk; + } + row.fSampleNew += sampleNew; + row.fSampleOrig += sampleOrig; + row.fSampleUncomp += sampleUncomp; + if (sampleOrig > 0) { + const double scale = static_cast(cols[i].fComp) / static_cast(sampleOrig); + row.fColScaled[i] = static_cast(sampleNew * scale + 0.5); + } else { + row.fColScaled[i] = cols[i].fComp; + } + row.fScaledSel += row.fColScaled[i]; + } + row.fProjected = row.fScaledSel + restOrig; + return row; + }; + + auto recodeOne = [&](const ZipVariant &variant, const std::string &label, int filterId, unsigned nThreads) { + std::vector jobs; + for (std::size_t i = 0; i < cols.size(); ++i) { + for (std::size_t p = 0; p < inflated[i].size(); ++p) + jobs.push_back({0, i, p, filterId}); + } + std::vector jobZip(jobs.size(), 0); + std::vector one{variant}; + const auto t0 = std::chrono::steady_clock::now(); + ZipRecodeJobs(one, cols, inflated, jobs, jobZip, nullptr, nThreads); + std::vector> zipByCol(cols.size()); + for (std::size_t i = 0; i < cols.size(); ++i) + zipByCol[i].assign(inflated[i].size(), 0); + for (std::size_t j = 0; j < jobs.size(); ++j) + zipByCol[jobs[j].fCol][jobs[j].fPage] = jobZip[j]; + auto row = makeRowFromZips(label, zipByCol); + row.fSec = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + std::cout << "Recoding " << label << " ... " << FormatBytes(row.fScaledSel) << " selected (" + << std::setprecision(1) << std::fixed << row.fSec << " s)\n"; + rows.push_back(std::move(row)); + }; + +#ifdef R__HAS_LHC4CODEC + ResetLHC4(); +#endif + + if (opts.fBruteFilters) { +#ifndef R__HAS_LHC4CODEC + throw std::runtime_error("--brute-filters requires lhc4codec"); +#endif + for (const auto &variant : variants) { + const unsigned nPageThreads = ChooseThreads(opts.fThreads, static_cast(nSamplePages)); + if (!variant.fLHC4) { + recodeOne(variant, variant.fLabel, variant.fFilters ? -1 : 0, nPageThreads); + continue; + } + recodeOne(variant, variant.fLabel + "/none", 0, nPageThreads); + recodeOne(variant, variant.fLabel + "/auto", -1, nPageThreads); + + CodecWins wins; + wins.fCodec = variant.fLabel; + wins.fWins.resize(std::size(kFilterSpecs)); + struct FilterJob { + std::size_t fCol = 0; + std::size_t fPage = 0; + std::size_t fFilter = 0; + }; + std::vector jobs; + for (std::size_t i = 0; i < cols.size(); ++i) { + for (std::size_t p = 0; p < inflated[i].size(); ++p) { + for (std::size_t fi = 0; fi < std::size(kFilterSpecs); ++fi) { + const auto &fs = kFilterSpecs[fi]; + if (fs.fId == 35 || fs.fId == 36) + continue; + if (!FilterMatchesColumn(fs, cols[i].fBits, opts.fBruteAll)) + continue; + jobs.push_back({i, p, fi}); + } + } + } + std::vector jobZip(jobs.size(), static_cast(-1)); + const unsigned nThreads = ChooseThreads(opts.fThreads, jobs.size()); + const auto t0 = std::chrono::steady_clock::now(); + VariantRow oracle; + oracle.fLabel = variant.fLabel + "/oracle"; + oracle.fColScaled.resize(cols.size(), 0); + std::cout << "Recoding " << oracle.fLabel << " (best lossless filter per page, " << nThreads + << " thread" << (nThreads == 1 ? "" : "s") << ") ..." << std::flush; + std::atomic next{0}; + auto worker = [&] { + std::vector zipped; + for (;;) { + const std::size_t j = next.fetch_add(1); + if (j >= jobs.size()) + break; + const auto &job = jobs[j]; + const auto &page = inflated[job.fCol][job.fPage]; +#ifdef R__HAS_LHC4CODEC + ApplyVariantOnThread(variant, kFilterSpecs[job.fFilter].fId, cols[job.fCol].fKind); +#endif + if (zipped.size() < page.fRaw.size()) + zipped.resize(page.fRaw.size()); + jobZip[j] = RNTupleCompressor::Zip(page.fRaw.data(), page.fRaw.size(), variant.fCompression, + zipped.data()); + } +#ifdef R__HAS_LHC4CODEC + ClearCallOverrides(); +#endif + }; + if (nThreads <= 1) { + worker(); + } else { + std::vector pool; + pool.reserve(nThreads); + for (unsigned t = 0; t < nThreads; ++t) + pool.emplace_back(worker); + for (auto &th : pool) + th.join(); + } + oracle.fOk = static_cast(jobs.size()); + std::vector> bestZip(cols.size()); + std::vector> bestIdx(cols.size()); + for (std::size_t i = 0; i < cols.size(); ++i) { + bestZip[i].assign(inflated[i].size(), static_cast(-1)); + bestIdx[i].assign(inflated[i].size(), 0); + } + for (std::size_t j = 0; j < jobs.size(); ++j) { + const auto &job = jobs[j]; + if (jobZip[j] < bestZip[job.fCol][job.fPage]) { + bestZip[job.fCol][job.fPage] = jobZip[j]; + bestIdx[job.fCol][job.fPage] = job.fFilter; + } + } + for (std::size_t i = 0; i < cols.size(); ++i) { + if (inflated[i].empty()) { + oracle.fColScaled[i] = cols[i].fComp; + oracle.fScaledSel += cols[i].fComp; + continue; + } + std::uint64_t sampleNew = 0; + std::uint64_t sampleOrig = 0; + std::uint64_t sampleUncomp = 0; + for (std::size_t p = 0; p < inflated[i].size(); ++p) { + sampleNew += bestZip[i][p]; + sampleOrig += inflated[i][p].fSealed; + sampleUncomp += inflated[i][p].fRaw.size(); + wins.fWins[bestIdx[i][p]].fPages++; + wins.fWins[bestIdx[i][p]].fSample += bestZip[i][p]; + wins.fPages++; + } + oracle.fSampleNew += sampleNew; + oracle.fSampleOrig += sampleOrig; + oracle.fSampleUncomp += sampleUncomp; + if (sampleOrig > 0) { + const double scale = static_cast(cols[i].fComp) / static_cast(sampleOrig); + oracle.fColScaled[i] = static_cast(sampleNew * scale + 0.5); + } else { + oracle.fColScaled[i] = cols[i].fComp; + } + oracle.fScaledSel += oracle.fColScaled[i]; + } + oracle.fProjected = oracle.fScaledSel + restOrig; + oracle.fSec = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + std::cout << ' ' << FormatBytes(oracle.fScaledSel) << " selected (" << std::setprecision(1) << std::fixed + << oracle.fSec << " s)\n"; + rows.push_back(std::move(oracle)); + bruteWins.push_back(std::move(wins)); + } + } else { + std::vector jobs; + jobs.reserve(static_cast(nSamplePages) * variants.size()); + std::vector>> zipByVar(variants.size()); + for (std::size_t vi = 0; vi < variants.size(); ++vi) { + const int filterId = variants[vi].fFilters ? -1 : 0; + zipByVar[vi].resize(cols.size()); + for (std::size_t i = 0; i < cols.size(); ++i) { + zipByVar[vi][i].assign(inflated[i].size(), 0); + for (std::size_t p = 0; p < inflated[i].size(); ++p) + jobs.push_back({vi, i, p, filterId}); + } + } + const unsigned nThreads = ChooseThreads(opts.fThreads, jobs.size()); + auto cpuNs = std::make_unique[]>(variants.size()); + for (std::size_t vi = 0; vi < variants.size(); ++vi) + cpuNs[vi].store(0); + std::cout << "Recoding " << variants.size() << " codec" << (variants.size() == 1 ? "" : "s") << " × " + << nSamplePages << " page" << (nSamplePages == 1 ? "" : "s") << " on " << nThreads << " thread" + << (nThreads == 1 ? "" : "s") << " ..." << std::flush; + std::vector jobZip(jobs.size(), 0); + const auto t0 = std::chrono::steady_clock::now(); + ZipRecodeJobs(variants, cols, inflated, jobs, jobZip, cpuNs.get(), nThreads); + const double wall = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + std::cout << ' ' << std::setprecision(1) << std::fixed << wall << " s wall\n"; + for (std::size_t j = 0; j < jobs.size(); ++j) { + const auto &job = jobs[j]; + zipByVar[job.fVariant][job.fCol][job.fPage] = jobZip[j]; + } + for (std::size_t vi = 0; vi < variants.size(); ++vi) { + auto row = makeRowFromZips(variants[vi].fLabel, zipByVar[vi]); + row.fSec = static_cast(cpuNs[vi].load()) / 1e9; + std::cout << " " << row.fLabel << " ... " << FormatBytes(row.fScaledSel) << " selected (" + << std::setprecision(1) << std::fixed << row.fSec << " s CPU)\n"; + rows.push_back(std::move(row)); + } + } +#ifdef R__HAS_LHC4CODEC + ResetLHC4(); +#endif + + std::cout << "\n=== projected Events payload ===\n"; + const int nameW = opts.fBruteFilters ? 36 : 22; + std::cout << std::left << std::setw(nameW) << "codec" << std::right << std::setw(14) << "selected" + << std::setw(10) << "vs orig" << std::setw(14) << "file" << std::setw(10) << "vs file" + << std::setw(10) << "sec" << '\n'; + auto printRow = [&](const std::string &label, std::uint64_t selected, std::uint64_t file, double sec, bool timed) { + const double vsSel = selectedOrig ? 100.0 * (static_cast(selected) / selectedOrig - 1.0) : 0; + const double vsFile = totalOrig ? 100.0 * (static_cast(file) / totalOrig - 1.0) : 0; + std::cout << std::left << std::setw(nameW) << label << std::right << std::setw(14) << FormatBytes(selected) + << std::setw(9) << std::fixed << std::setprecision(1) << vsSel << "%" << std::setw(14) + << FormatBytes(file) << std::setw(9) << vsFile << "%"; + if (timed) + std::cout << std::setw(10) << std::setprecision(1) << sec; + else + std::cout << std::setw(10) << "-"; + std::cout << '\n'; + }; + printRow("on-disk", selectedOrig, totalOrig, 0, false); + for (const auto &r : rows) + printRow(r.fLabel, r.fScaledSel, r.fProjected, r.fSec, true); + + for (const auto &cw : bruteWins) { + std::vector order; + for (std::size_t fi = 0; fi < cw.fWins.size(); ++fi) { + if (cw.fWins[fi].fPages) + order.push_back(fi); + } + std::sort(order.begin(), order.end(), [&](std::size_t a, std::size_t b) { + return cw.fWins[a].fSample > cw.fWins[b].fSample; + }); + std::cout << "\n=== " << cw.fCodec << ": winning lossless filter per page ===\n"; + std::cout << std::left << std::setw(28) << "filter" << std::right << std::setw(10) << "pages" + << std::setw(14) << "sample" << '\n'; + for (std::size_t fi : order) { + std::cout << std::left << std::setw(28) << kFilterSpecs[fi].fName << std::right << std::setw(10) + << cw.fWins[fi].fPages << std::setw(14) << FormatBytes(cw.fWins[fi].fSample) << '\n'; + } + std::cout << std::left << std::setw(28) << "sampled pages" << std::right << std::setw(10) << cw.fPages + << '\n'; + } + + int nFail = 0, nOk = 0; + for (const auto &r : rows) { + nFail += r.fFail; + nOk += r.fOk; + } + if (nFail) + std::cout << "\nSkipped " << nFail << " page(s) that failed to unzip (" << nOk << " ok).\n"; + + std::vector colTable; + for (const auto &r : rows) { + if (!opts.fBruteFilters) { + colTable.push_back(&r); + continue; + } + if (r.fLabel.find("/none") != std::string::npos || r.fLabel.find("/auto") != std::string::npos || + r.fLabel.find("/oracle") != std::string::npos) + colTable.push_back(&r); + } + std::cout << "\n=== selected columns (scaled) ===\n"; + std::cout << std::left << std::setw(42) << "field" << std::setw(14) << "type" << std::right << std::setw(12) + << "on disk"; + for (const auto *r : colTable) + std::cout << std::setw(16) << r->fLabel; + std::cout << '\n'; + for (std::size_t i = 0; i < cols.size(); ++i) { + std::cout << std::left << std::setw(42) << ShortField(cols[i].fField) << std::setw(14) << cols[i].fTypeName + << std::right << std::setw(12) << FormatBytes(cols[i].fComp); + for (const auto *r : colTable) + std::cout << std::setw(16) << FormatBytes(r->fColScaled[i]); + std::cout << '\n'; + } + + if (opts.fCsv) { + std::cout << "\nfield,type,kind,on_disk"; + for (const auto &r : rows) + std::cout << ',' << r.fLabel; + std::cout << '\n'; + for (std::size_t i = 0; i < cols.size(); ++i) { + std::cout << '"' << cols[i].fField << '"' << ',' << cols[i].fTypeName << ',' << KindName(cols[i].fKind) + << ',' << cols[i].fComp; + for (const auto &r : rows) + std::cout << ',' << r.fColScaled[i]; + std::cout << '\n'; + } + } + return 0; + } catch (const std::exception &e) { + std::cerr << "rntupletryzip: " << e.what() << '\n'; + std::cerr << kUsage; + return 1; + } +} diff --git a/main/src/tree2ntuple.cxx b/main/src/tree2ntuple.cxx new file mode 100644 index 0000000000000..1d44e8728e986 --- /dev/null +++ b/main/src/tree2ntuple.cxx @@ -0,0 +1,1651 @@ +/// \file tree2ntuple.cxx +/// \brief Convert a TTree into several RNTuple output files for compression studies. + +#include +#include +#include +#include + +#include "RNTupleBench.hxx" + +#include +#include +#include +#include +#include +#include + +#ifdef R__HAS_LHC4CODEC +#include +#else +// Worker-result keys compile even when ROOT was built without lhc4codec. +enum { + kLHC4CodecBeam = 0, + kLHC4CodecLz = 0, + kLHC4CodecBwt = 1, + kLHC4CodecZstd = 2, + kLHC4CodecBzip3 = 3, + kLHC4CodecLzma = 4, + kLHC4CodecAuto = 5, + kLHC4CodecMosaic = 6, + kLHC4CodecOracle = 7, + kLHC4CodecCrystal = 8, + kLHC4CodecFlash = 9, + kLHC4CodecLaser = 10, + kLHC4CodecGrating = 11, + kLHC4CodecCount = 12, +}; +#define kLHC4CodecBit(c) (1u << (unsigned)(c)) +#define kLHC4AutoCodecsRoot \ + (kLHC4CodecBit(kLHC4CodecZstd) | kLHC4CodecBit(kLHC4CodecBeam) | kLHC4CodecBit(kLHC4CodecCrystal)) +#define kLHC4AutoCodecsAll \ + (kLHC4AutoCodecsRoot | kLHC4CodecBit(kLHC4CodecMosaic) | kLHC4CodecBit(kLHC4CodecBwt) | \ + kLHC4CodecBit(kLHC4CodecLaser) | kLHC4CodecBit(kLHC4CodecLzma) | kLHC4CodecBit(kLHC4CodecBzip3)) +enum { + kLHC4ProfileNone = 0, + kLHC4ProfileFloatKinematics = 1, + kLHC4ProfilePoissonCounts = 2, + kLHC4ProfileIntMonotonic = 3, + kLHC4ProfileText = 4, + kLHC4ProfileCustom = 5, +}; +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#endif + +namespace { + +using ROOT::Experimental::RNTupleImporter; +using ROOT::RNTupleWriteOptions; + +constexpr const char *kUsage = R"(Usage: tree2ntuple [options] input.root [treeName] [outputBase] + +Options: + --keep-column-encoding Keep ROOT RNTuple split/delta/zigzag column encodings when using + lhc4codec as the page compressor (default: plain columns only). + --page-size N Set max unzipped page size in bytes (RNTupleWriteOptions default: 1 MiB). + Columns start with small pages and grow up to this limit. + --initial-page-size N Advanced: set the initial unzipped page size in bytes (default: 256 B). + Only needed for micro-benchmarks; large values require much more memory. + --level N Compression level for LHC4 variants (1-11, default 5). Native ZSTD/LZMA + stay at 5/7. Grating has no levels. lhc4_laser9 is the same as + --level 9 --only-variant lhc4_laser5. + --auto-min-gain N Min % size gain for lhc4codec Auto to prefer a slower decoder + (default: 1). 0 = always pick the smallest payload. + --auto-max-level For lhc4codec Auto, race each codec at its maximum effort + instead of the RNTuple compression level (slower encode). + --auto-codecs LIST Codecs Auto may race: root (default: zstd,beam,crystal), + all, or a comma list (zstd,beam,crystal,mosaic,prism,laser,lzma,bzip3). + --profile NAME On lhc4_crystal_profile5, force one profile on every column: + float, poisson, monotonic, text, or custom. + Default: that variant picks per column. lhc4_crystal5 stays dynamic. + --profile-id N Profile id (0 uses the --profile enum value; custom ids are >= 5). + --bench Run decompression benchmarks (algorithm + single/multi-core event + reader) on output RNTuple files after conversion. + --bench-threads N Number of threads for multi-core event reader benchmark + (default: hardware concurrency). + --bench-iters N Number of passes for pure page decompression benchmark (default: 5). + --bench-entries N Number of entries to read in event reader benchmark (default: 0 = all). + --bench-percent N Probe the first N percent only (default: 100). Stops the page + unzip after that fraction of compressed bytes, in cluster + order, and LoadEntry after the same fraction of entries. + Example: --bench-percent 5. --bench-entries overrides the + entry count. + --jobs N Run up to N variants in parallel (default: 1). Use 0 for auto + (number of CPU threads, capped by variant count). Requires fork(). + --quiet Suppress per-variant progress (used for parallel workers). + --no-out Run conversions without keeping output files; print summary only. + --verify After each page is compressed, decompress it and compare with the + raw input buffer (catches codec round-trip corruption). + --only-variant LABEL Run only the given format (see labels below). + --worker-result PATH Internal: write one-line key/value result metrics to PATH. + -h, --help Show this help. + +Convert a TTree into RNTuple files: + + _native.root native RNTuple column encodings + ZSTD (level 5) + _native_lzma.root native RNTuple column encodings + LZMA (level 7) + LHC4 variants use --level (default 5) in the file suffix (lhc4_beam5, lhc4_laser9, ...). + _lhc4_zstd.root lhc4codec filters + zstd + _lhc4_bzip3.root lhc4codec filters + bzip3 + _lhc4_beamN.root lhc4codec filters + Beam (lhc4_lz5 is a legacy alias) + _lhc4_mosaicN.root lhc4codec filters + Mosaic + _lhc4_prismN.root lhc4codec filters + Prism + _lhc4_crystalN.root lhc4codec filters + Crystal (dynamic model and byte + filters, same kind of compressor as Beam) + _lhc4_crystal_profileN.root lhc4codec Crystal with pretrained per-column + profiles (Real32=float, Index32/UInt32=poisson, Char=text) + _lhc4_oracleN.root lhc4codec filters + Oracle (not raced by Auto) + _lhc4_flashN.root lhc4codec filters + Flash (byte-aligned LZ without an + entropy stage, fastest decoder; not raced by Auto) + _lhc4_laserN.root lhc4codec filters + Laser (LZMA2-class range coder + with a decoupled decoder, raced by --auto-codecs all) + _lhc4_grating.root lhc4codec filters + Grating (order-0 integer rANS; has no + levels; not raced by Auto) + _lhc4_auto.root lhc4codec filters + Auto codec race (see --auto-min-gain, + --auto-max-level, --auto-codecs) + When --level is left at 5, extra Crystal/learned variants at 7 and 9 are also emitted. + +By default, LHC4 variants disable ROOT column encodings (plain columns) and enable +lhc4codec byte filters (shuffle/delta/zigzag/dict/RLE as applicable). Use +--keep-column-encoding to stack ROOT encodings on top of the lhc4codec backend. + +Parallel mode launches one process per format so lhc4codec global settings and +compression statistics stay isolated. + +If treeName is omitted, the first TTree in the input file is used. +If outputBase is omitted, it defaults to "_ntuple". +)"; + +struct RunOptions { + bool fKeepColumnEncoding = false; + bool fNoOut = false; + bool fVerify = false; + bool fQuiet = false; + int fJobs = 1; + std::optional fPageSize; + std::optional fInitialPageSize; + int fLevel = 5; + bool fLevelExplicit = false; + std::optional fAutoMinGainPct; + bool fAutoMaxLevel = false; + std::optional fAutoCodecs; + int fProfile = kLHC4ProfileNone; + unsigned fProfileId = 0; + std::string fOnlyVariant; + std::string fWorkerResult; + std::string fInputFile; + std::string fTreeName; + std::string fOutputBase; + + bool fBench = false; + int fBenchThreads = 0; + int fBenchIters = 5; + std::uint64_t fBenchEntries = 0; + double fBenchPercent = 100.0; +}; + +struct FilterStatsSummary { + bool fValid = false; + bool fPageRatioValid = false; + double fPageRatio = 0.0; + double fFilterOnPct = 0.0; + double fFilterOffPct = 0.0; + double fRawWonPct = 0.0; + double fNoTransformPct = 0.0; +}; + +struct AutoCodecStatsSummary { + bool fValid = false; + std::uint64_t fSelections = 0; + std::uint64_t fCodecHits[kLHC4CodecCount] = {}; +}; + +struct VariantResult { + std::string fLabel; + std::uint64_t fOutputBytes = 0; + double fElapsedSec = 0.0; + FilterStatsSummary fFilters; + AutoCodecStatsSummary fAutoCodec; +}; + +std::string BasenameNoExt(std::string path) +{ + const auto slash = path.find_last_of("/\\"); + if (slash != std::string::npos) + path.erase(0, slash + 1); + const auto dot = path.rfind('.'); + if (dot != std::string::npos) + path.erase(dot); + return path; +} + +std::string JoinPath(const std::string &base, const std::string &suffix) +{ + return base + suffix + ".root"; +} + +void RemoveIfExists(const std::string &path) +{ + if (!gSystem->AccessPathName(path.c_str())) + gSystem->Unlink(path.c_str()); +} + +std::string MakeTempRootPath() +{ + TString tmp; + FILE *f = gSystem->TempFileName(tmp, nullptr, ".root"); + if (!f) + throw std::runtime_error("failed to create temporary output file"); + std::fclose(f); + return tmp.Data(); +} + +std::string ResolveVariantOutputPath(const RunOptions &opts, const std::string &suffix) +{ + if (opts.fNoOut) + return MakeTempRootPath(); + return JoinPath(opts.fOutputBase, suffix); +} + +std::optional FileSizeBytes(const std::string &path) +{ + FileStat_t st{}; + if (gSystem->GetPathInfo(path.c_str(), st) != 0) + return std::nullopt; + return static_cast(st.fSize); +} + +std::string FormatBytes(std::uint64_t nbytes) +{ + const char *suffix = "B"; + double v = static_cast(nbytes); + if (v >= 1024.0 * 1024.0 * 1024.0) { + v /= 1024.0 * 1024.0 * 1024.0; + suffix = "GiB"; + } else if (v >= 1024.0 * 1024.0) { + v /= 1024.0 * 1024.0; + suffix = "MiB"; + } else if (v >= 1024.0) { + v /= 1024.0; + suffix = "KiB"; + } + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(v >= 100.0 ? 0 : (v >= 10.0 ? 1 : 2)); + os << v << ' ' << suffix; + return os.str(); +} + +std::string FormatSeconds(double sec) +{ + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(sec >= 100.0 ? 0 : 1); + os << sec << 's'; + return os.str(); +} + +std::string FormatRatio(std::uint64_t outputBytes, std::uint64_t inputBytes) +{ + if (inputBytes == 0) + return "n/a"; + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(3); + os << static_cast(outputBytes) / static_cast(inputBytes); + return os.str(); +} + +std::string FormatPageRatio(const FilterStatsSummary &filters) +{ + if (!filters.fPageRatioValid) + return "n/a"; + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(3); + os << filters.fPageRatio; + return os.str(); +} + +std::string FormatPct(double pct) +{ + std::ostringstream os; + os.setf(std::ios::fixed); + os.precision(1); + os << pct << '%'; + return os.str(); +} + +bool ParseAutoCodecName(const std::string &name, unsigned &bit) +{ + if (name == "beam" || name == "lz") { + bit = kLHC4CodecBit(kLHC4CodecBeam); + return true; + } + if (name == "bwt" || name == "prism") { + bit = kLHC4CodecBit(kLHC4CodecBwt); + return true; + } + if (name == "zstd") { + bit = kLHC4CodecBit(kLHC4CodecZstd); + return true; + } + if (name == "bzip3") { + bit = kLHC4CodecBit(kLHC4CodecBzip3); + return true; + } + if (name == "lzma" || name == "xz") { + bit = kLHC4CodecBit(kLHC4CodecLzma); + return true; + } + if (name == "mosaic") { + bit = kLHC4CodecBit(kLHC4CodecMosaic); + return true; + } + if (name == "crystal") { + bit = kLHC4CodecBit(kLHC4CodecCrystal); + return true; + } + if (name == "laser") { + bit = kLHC4CodecBit(kLHC4CodecLaser); + return true; + } + // Flash and Grating are forced-only backends; Auto never races them. + return false; +} + +unsigned ParseAutoCodecMask(const std::string &list) +{ + if (list == "all") + return kLHC4AutoCodecsAll; + if (list == "root" || list.empty()) + return kLHC4AutoCodecsRoot; + + unsigned mask = 0; + std::size_t pos = 0; + while (pos <= list.size()) { + const auto end = list.find(',', pos); + const auto tok = list.substr(pos, (end == std::string::npos ? list.size() : end) - pos); + if (!tok.empty()) { + unsigned bit = 0; + if (!ParseAutoCodecName(tok, bit)) + throw std::runtime_error("unknown or non-raceable --auto-codecs token: " + tok); + mask |= bit; + } + if (end == std::string::npos) + break; + pos = end + 1; + } + if (mask == 0) + throw std::runtime_error("--auto-codecs list is empty"); + return mask; +} + +int ParseProfileName(const std::string &name) +{ + if (name == "none" || name == "off" || name == "dynamic") + return kLHC4ProfileNone; + if (name == "float" || name == "float-kinematics" || name == "kinematics") + return kLHC4ProfileFloatKinematics; + if (name == "poisson" || name == "poisson-counts" || name == "counts") + return kLHC4ProfilePoissonCounts; + if (name == "monotonic" || name == "int-monotonic" || name == "int") + return kLHC4ProfileIntMonotonic; + if (name == "text" || name == "json") + return kLHC4ProfileText; + if (name == "custom") + return kLHC4ProfileCustom; + throw std::runtime_error("unknown --profile: " + name + " (none, float, poisson, monotonic, text, custom)"); +} + +const char *FormatProfileName(int profile) +{ + switch (profile) { + case kLHC4ProfileFloatKinematics: return "float"; + case kLHC4ProfilePoissonCounts: return "poisson"; + case kLHC4ProfileIntMonotonic: return "monotonic"; + case kLHC4ProfileText: return "text"; + case kLHC4ProfileCustom: return "custom"; + default: return "none"; + } +} + +std::string FormatCrystalProfileStatus(const RunOptions &opts) +{ + std::string profileFlavour = "per column"; + if (opts.fProfile != kLHC4ProfileNone) { + profileFlavour = FormatProfileName(opts.fProfile); + if (opts.fProfileId != 0) + profileFlavour += " (id " + std::to_string(opts.fProfileId) + ")"; + profileFlavour += " (all columns)"; + } + return "lhc4_crystal5 dynamic; lhc4_crystal_profile5 " + profileFlavour; +} + +std::string FormatAutoCodecMask(unsigned mask) +{ + if (mask == kLHC4AutoCodecsRoot) + return "root (zstd,beam,crystal)"; + if (mask == kLHC4AutoCodecsAll) + return "all"; + + struct Row { + const char *fName; + unsigned fBit; + }; + static constexpr Row kRows[] = {{"zstd", kLHC4CodecBit(kLHC4CodecZstd)}, + {"beam", kLHC4CodecBit(kLHC4CodecBeam)}, + {"crystal", kLHC4CodecBit(kLHC4CodecCrystal)}, + {"mosaic", kLHC4CodecBit(kLHC4CodecMosaic)}, + {"prism", kLHC4CodecBit(kLHC4CodecBwt)}, + {"laser", kLHC4CodecBit(kLHC4CodecLaser)}, + {"lzma", kLHC4CodecBit(kLHC4CodecLzma)}, + {"bzip3", kLHC4CodecBit(kLHC4CodecBzip3)}}; + std::string out; + for (const auto &row : kRows) { + if (!(mask & row.fBit)) + continue; + if (!out.empty()) + out += ','; + out += row.fName; + } + return out.empty() ? "none" : out; +} + +std::string FindFirstTreeName(const std::string &fileName) +{ + auto file = std::unique_ptr(TFile::Open(fileName.c_str())); + if (!file || file->IsZombie()) + throw std::runtime_error("cannot open input file: " + fileName); + + TIter next(file->GetListOfKeys()); + while (auto key = static_cast(next())) { + if (std::strcmp(key->GetClassName(), "TTree") == 0) + return key->GetName(); + } + throw std::runtime_error("no TTree found in " + fileName); +} + +bool ParseBoolValue(const std::string &value) +{ + return value == "1" || value == "true" || value == "yes"; +} + +void WriteWorkerResult(const std::string &path, const VariantResult &result) +{ + std::ofstream out(path); + if (!out) + throw std::runtime_error("cannot write worker result: " + path); + out << "label=" << result.fLabel << '\n'; + out << "output_bytes=" << result.fOutputBytes << '\n'; + out << "elapsed_sec=" << std::setprecision(10) << result.fElapsedSec << '\n'; + out << "filter_valid=" << (result.fFilters.fValid ? 1 : 0) << '\n'; + out << "filter_on_pct=" << result.fFilters.fFilterOnPct << '\n'; + out << "filter_off_pct=" << result.fFilters.fFilterOffPct << '\n'; + out << "raw_won_pct=" << result.fFilters.fRawWonPct << '\n'; + out << "no_transform_pct=" << result.fFilters.fNoTransformPct << '\n'; + out << "page_ratio_valid=" << (result.fFilters.fPageRatioValid ? 1 : 0) << '\n'; + out << "page_ratio=" << std::setprecision(10) << result.fFilters.fPageRatio << '\n'; + out << "auto_valid=" << (result.fAutoCodec.fValid ? 1 : 0) << '\n'; + out << "auto_selections=" << result.fAutoCodec.fSelections << '\n'; + out << "auto_zstd=" << result.fAutoCodec.fCodecHits[kLHC4CodecZstd] << '\n'; + out << "auto_beam=" << result.fAutoCodec.fCodecHits[kLHC4CodecBeam] << '\n'; + out << "auto_lz=" << result.fAutoCodec.fCodecHits[kLHC4CodecBeam] << '\n'; + out << "auto_mosaic=" << result.fAutoCodec.fCodecHits[kLHC4CodecMosaic] << '\n'; + out << "auto_bwt=" << result.fAutoCodec.fCodecHits[kLHC4CodecBwt] << '\n'; + out << "auto_lzma=" << result.fAutoCodec.fCodecHits[kLHC4CodecLzma] << '\n'; + out << "auto_bzip3=" << result.fAutoCodec.fCodecHits[kLHC4CodecBzip3] << '\n'; + out << "auto_crystal=" << result.fAutoCodec.fCodecHits[kLHC4CodecCrystal] << '\n'; + out << "auto_laser=" << result.fAutoCodec.fCodecHits[kLHC4CodecLaser] << '\n'; +} + +VariantResult ReadWorkerResult(const std::string &path) +{ + std::ifstream in(path); + if (!in) + throw std::runtime_error("cannot read worker result: " + path); + + VariantResult result; + std::string line; + while (std::getline(in, line)) { + const auto eq = line.find('='); + if (eq == std::string::npos) + continue; + const std::string key = line.substr(0, eq); + const std::string value = line.substr(eq + 1); + if (key == "label") + result.fLabel = value; + else if (key == "output_bytes") + result.fOutputBytes = std::stoull(value); + else if (key == "elapsed_sec") + result.fElapsedSec = std::stod(value); + else if (key == "filter_valid") + result.fFilters.fValid = ParseBoolValue(value); + else if (key == "filter_on_pct") + result.fFilters.fFilterOnPct = std::stod(value); + else if (key == "filter_off_pct") + result.fFilters.fFilterOffPct = std::stod(value); + else if (key == "raw_won_pct") + result.fFilters.fRawWonPct = std::stod(value); + else if (key == "no_transform_pct") + result.fFilters.fNoTransformPct = std::stod(value); + else if (key == "page_ratio_valid") + result.fFilters.fPageRatioValid = ParseBoolValue(value); + else if (key == "page_ratio") + result.fFilters.fPageRatio = std::stod(value); + else if (key == "auto_valid") + result.fAutoCodec.fValid = ParseBoolValue(value); + else if (key == "auto_selections") + result.fAutoCodec.fSelections = std::stoull(value); + else if (key == "auto_zstd") + result.fAutoCodec.fCodecHits[kLHC4CodecZstd] = std::stoull(value); + else if (key == "auto_beam" || key == "auto_lz") + result.fAutoCodec.fCodecHits[kLHC4CodecBeam] = std::stoull(value); + else if (key == "auto_mosaic") + result.fAutoCodec.fCodecHits[kLHC4CodecMosaic] = std::stoull(value); + else if (key == "auto_bwt") + result.fAutoCodec.fCodecHits[kLHC4CodecBwt] = std::stoull(value); + else if (key == "auto_lzma") + result.fAutoCodec.fCodecHits[kLHC4CodecLzma] = std::stoull(value); + else if (key == "auto_bzip3") + result.fAutoCodec.fCodecHits[kLHC4CodecBzip3] = std::stoull(value); + else if (key == "auto_crystal") + result.fAutoCodec.fCodecHits[kLHC4CodecCrystal] = std::stoull(value); + else if (key == "auto_laser") + result.fAutoCodec.fCodecHits[kLHC4CodecLaser] = std::stoull(value); + } + return result; +} + +constexpr int kDefaultLHC4Level = 5; +constexpr int kMaxLHC4Level = 11; // lhc4codec::kMaxLevel + +int ParseLHC4Level(const std::string &text) +{ + int level = 0; + try { + std::size_t idx = 0; + level = std::stoi(text, &idx); + if (idx != text.size()) + throw std::invalid_argument("trailing characters"); + } catch (const std::exception &) { + throw std::runtime_error("--level expects an integer from 1 to " + std::to_string(kMaxLHC4Level)); + } + if (level < 1 || level > kMaxLHC4Level) + throw std::runtime_error("--level must be between 1 and " + std::to_string(kMaxLHC4Level)); + return level; +} + +int TrailingVariantLevel(const std::string &label) +{ + if (label.empty() || !std::isdigit(static_cast(label.back()))) + return 0; + std::size_t i = label.size(); + while (i > 0 && std::isdigit(static_cast(label[i - 1]))) + --i; + if (i == 0 || i == label.size()) + return 0; + try { + std::size_t idx = 0; + const std::string digits = label.substr(i); + const int n = std::stoi(digits, &idx); + if (idx != digits.size() || n < 1 || n > kMaxLHC4Level) + return 0; + return n; + } catch (const std::exception &) { + return 0; + } +} + +bool IsDedicatedLevelVariant(const std::string &label) +{ + return label == "lhc4_crystal7" || label == "lhc4_crystal9"; +} + +std::string TrimCopy(const std::string &text) +{ + const auto start = text.find_first_not_of(" \t"); + const auto end = text.find_last_not_of(" \t"); + if (start == std::string::npos || end == std::string::npos) + return {}; + return text.substr(start, end - start + 1); +} + +void InferLevelFromVariantLabels(RunOptions &opts) +{ + if (opts.fLevelExplicit || opts.fOnlyVariant.empty()) + return; + std::optional inferred; + std::stringstream ss(opts.fOnlyVariant); + std::string item; + while (std::getline(ss, item, ',')) { + item = TrimCopy(item); + if (item.empty() || IsDedicatedLevelVariant(item)) + continue; + const int n = TrailingVariantLevel(item); + if (n == 0) + continue; + if (!inferred) + inferred = n; + else if (*inferred != n) + throw std::runtime_error("conflicting levels in --only-variant; pass --level N"); + } + if (inferred) + opts.fLevel = *inferred; +} + +RunOptions ParseArgs(int argc, char **argv) +{ + RunOptions opts; + std::vector positional; + + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "-h" || arg == "--help") { + std::cout << kUsage; + std::exit(0); + } + if (arg == "--keep-column-encoding") { + opts.fKeepColumnEncoding = true; + continue; + } + if (arg == "--page-size") { + if (i + 1 >= argc) + throw std::runtime_error("--page-size requires an argument"); + opts.fPageSize = static_cast(std::stoull(argv[++i])); + continue; + } + if (arg.rfind("--page-size=", 0) == 0) { + opts.fPageSize = static_cast(std::stoull(arg.substr(12))); + continue; + } + if (arg == "--initial-page-size") { + if (i + 1 >= argc) + throw std::runtime_error("--initial-page-size requires an argument"); + opts.fInitialPageSize = static_cast(std::stoull(argv[++i])); + continue; + } + if (arg.rfind("--initial-page-size=", 0) == 0) { + opts.fInitialPageSize = static_cast(std::stoull(arg.substr(20))); + continue; + } + if (arg == "--level") { + if (i + 1 >= argc) + throw std::runtime_error("--level requires an argument"); + opts.fLevel = ParseLHC4Level(argv[++i]); + opts.fLevelExplicit = true; + continue; + } + if (arg.rfind("--level=", 0) == 0) { + opts.fLevel = ParseLHC4Level(arg.substr(8)); + opts.fLevelExplicit = true; + continue; + } + if (arg == "--auto-min-gain") { + if (i + 1 >= argc) + throw std::runtime_error("--auto-min-gain requires an argument"); + opts.fAutoMinGainPct = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--auto-min-gain=", 0) == 0) { + opts.fAutoMinGainPct = std::stoi(arg.substr(16)); + continue; + } + if (arg == "--auto-max-level") { + opts.fAutoMaxLevel = true; + continue; + } + if (arg == "--auto-codecs") { + if (i + 1 >= argc) + throw std::runtime_error("--auto-codecs requires an argument"); + opts.fAutoCodecs = ParseAutoCodecMask(argv[++i]); + continue; + } + if (arg.rfind("--auto-codecs=", 0) == 0) { + opts.fAutoCodecs = ParseAutoCodecMask(arg.substr(14)); + continue; + } + if (arg == "--profile") { + if (i + 1 >= argc) + throw std::runtime_error("--profile requires an argument"); + opts.fProfile = ParseProfileName(argv[++i]); + continue; + } + if (arg.rfind("--profile=", 0) == 0) { + opts.fProfile = ParseProfileName(arg.substr(10)); + continue; + } + if (arg == "--profile-id") { + if (i + 1 >= argc) + throw std::runtime_error("--profile-id requires an argument"); + opts.fProfileId = static_cast(std::stoul(argv[++i])); + continue; + } + if (arg.rfind("--profile-id=", 0) == 0) { + opts.fProfileId = static_cast(std::stoul(arg.substr(13))); + continue; + } + if (arg == "--bench") { + opts.fBench = true; + continue; + } + if (arg == "--bench-threads") { + if (i + 1 >= argc) + throw std::runtime_error("--bench-threads requires an argument"); + opts.fBenchThreads = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--bench-threads=", 0) == 0) { + opts.fBenchThreads = std::stoi(arg.substr(16)); + continue; + } + if (arg == "--bench-iters") { + if (i + 1 >= argc) + throw std::runtime_error("--bench-iters requires an argument"); + opts.fBenchIters = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--bench-iters=", 0) == 0) { + opts.fBenchIters = std::stoi(arg.substr(14)); + continue; + } + if (arg == "--bench-entries") { + if (i + 1 >= argc) + throw std::runtime_error("--bench-entries requires an argument"); + opts.fBenchEntries = std::stoull(argv[++i]); + continue; + } + if (arg.rfind("--bench-entries=", 0) == 0) { + opts.fBenchEntries = std::stoull(arg.substr(16)); + continue; + } + if (arg == "--bench-percent") { + if (i + 1 >= argc) + throw std::runtime_error("--bench-percent requires an argument"); + opts.fBenchPercent = std::stod(argv[++i]); + if (!(opts.fBenchPercent > 0.0 && opts.fBenchPercent <= 100.0)) + throw std::runtime_error("--bench-percent must be in (0, 100]"); + continue; + } + if (arg.rfind("--bench-percent=", 0) == 0) { + opts.fBenchPercent = std::stod(arg.substr(16)); + if (!(opts.fBenchPercent > 0.0 && opts.fBenchPercent <= 100.0)) + throw std::runtime_error("--bench-percent must be in (0, 100]"); + continue; + } + // Deprecated alias kept for compatibility with the first implementation. + if (arg == "--max-page-size") { + if (i + 1 >= argc) + throw std::runtime_error("--max-page-size requires an argument"); + opts.fPageSize = static_cast(std::stoull(argv[++i])); + continue; + } + if (arg.rfind("--max-page-size=", 0) == 0) { + opts.fPageSize = static_cast(std::stoull(arg.substr(16))); + continue; + } + if (arg == "--quiet") { + opts.fQuiet = true; + continue; + } + if (arg == "--no-out") { + opts.fNoOut = true; + continue; + } + if (arg == "--verify") { + opts.fVerify = true; + continue; + } + if (arg == "--jobs") { + if (i + 1 >= argc) + throw std::runtime_error("--jobs requires an argument"); + opts.fJobs = std::stoi(argv[++i]); + continue; + } + if (arg.rfind("--jobs=", 0) == 0) { + opts.fJobs = std::stoi(arg.substr(7)); + continue; + } + if (arg == "--only-variant" || arg == "--only-variants" || arg == "--variants") { + if (i + 1 >= argc) + throw std::runtime_error("--only-variant requires an argument"); + opts.fOnlyVariant = argv[++i]; + continue; + } + if (arg.rfind("--only-variant=", 0) == 0) { + opts.fOnlyVariant = arg.substr(15); + continue; + } + if (arg.rfind("--only-variants=", 0) == 0) { + opts.fOnlyVariant = arg.substr(16); + continue; + } + if (arg.rfind("--variants=", 0) == 0) { + opts.fOnlyVariant = arg.substr(11); + continue; + } + if (arg == "--worker-result") { + if (i + 1 >= argc) + throw std::runtime_error("--worker-result requires an argument"); + opts.fWorkerResult = argv[++i]; + continue; + } + if (arg.rfind("--worker-result=", 0) == 0) { + opts.fWorkerResult = arg.substr(16); + continue; + } + if (!arg.empty() && arg[0] == '-') { + throw std::runtime_error("unknown option: " + arg); + } + positional.push_back(arg); + } + + if (positional.empty()) + throw std::runtime_error("missing input file (see --help)"); + + opts.fInputFile = positional[0]; + if (positional.size() >= 2) + opts.fTreeName = positional[1]; + else + opts.fTreeName = FindFirstTreeName(opts.fInputFile); + + if (positional.size() >= 3) + opts.fOutputBase = positional[2]; + else + opts.fOutputBase = BasenameNoExt(opts.fInputFile) + "_ntuple"; + + if (positional.size() > 3) + throw std::runtime_error("too many positional arguments (see --help)"); + + if (opts.fPageSize && *opts.fPageSize == 0) + throw std::runtime_error("--page-size must be > 0"); + if (opts.fInitialPageSize && *opts.fInitialPageSize == 0) + throw std::runtime_error("--initial-page-size must be > 0"); + if (opts.fPageSize && opts.fInitialPageSize && *opts.fInitialPageSize > *opts.fPageSize) + throw std::runtime_error("--initial-page-size must not exceed --page-size"); + if (opts.fAutoMinGainPct && *opts.fAutoMinGainPct < 0) + throw std::runtime_error("--auto-min-gain must be >= 0"); + + InferLevelFromVariantLabels(opts); + + return opts; +} + +int ResolveAutoMinGainPct(const RunOptions &opts) +{ + return opts.fAutoMinGainPct ? *opts.fAutoMinGainPct : 1; +} + +bool ResolveAutoMaxLevel(const RunOptions &opts) +{ + return opts.fAutoMaxLevel; +} + +unsigned ResolveAutoCodecs(const RunOptions &opts) +{ + return opts.fAutoCodecs ? *opts.fAutoCodecs : kLHC4AutoCodecsRoot; +} + +void ApplyPageSizeOptions(RNTupleWriteOptions &writeOpts, const RunOptions &runOpts) +{ + if (runOpts.fPageSize) + writeOpts.SetMaxUnzippedPageSize(*runOpts.fPageSize); + + if (runOpts.fInitialPageSize) { + writeOpts.SetInitialUnzippedPageSize(*runOpts.fInitialPageSize); + + // ReservePage() allocates one initial buffer per column. Scale the auto budget only when + // the user deliberately raises the initial page size above the ROOT default (256 B). + constexpr std::size_t kDefaultInitialPageSize = 256; + if (*runOpts.fInitialPageSize > kDefaultInitialPageSize) { + std::size_t autoBudget = writeOpts.GetApproxZippedClusterSize(); + if (writeOpts.GetCompression() != 0) + autoBudget += writeOpts.GetApproxZippedClusterSize(); + writeOpts.SetPageBufferBudget(autoBudget * (*runOpts.fInitialPageSize) / kDefaultInitialPageSize); + } + } +} + +std::string FormatPageSizeSettings(const RunOptions &opts) +{ + if (!opts.fPageSize && !opts.fInitialPageSize) + return "default (initial=256 B, max=1 MiB)"; + + const auto initial = + opts.fInitialPageSize ? FormatBytes(*opts.fInitialPageSize) : std::string("256 B (default)"); + const auto max = opts.fPageSize ? FormatBytes(*opts.fPageSize) : std::string("1 MiB (default)"); + return "initial=" + initial + ", max=" + max; +} + +void AppendPageSizeArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fPageSize) { + args.emplace_back("--page-size"); + args.push_back(std::to_string(*opts.fPageSize)); + } + if (opts.fInitialPageSize) { + args.emplace_back("--initial-page-size"); + args.push_back(std::to_string(*opts.fInitialPageSize)); + } +} + +void AppendLevelArgs(std::vector &args, const RunOptions &opts) +{ + args.emplace_back("--level"); + args.push_back(std::to_string(opts.fLevel)); +} + +void AppendAutoMinGainArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fAutoMinGainPct) { + args.emplace_back("--auto-min-gain"); + args.push_back(std::to_string(*opts.fAutoMinGainPct)); + } +} + +void AppendAutoMaxLevelArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fAutoMaxLevel) + args.emplace_back("--auto-max-level"); +} + +void AppendAutoCodecsArgs(std::vector &args, const RunOptions &opts) +{ + if (!opts.fAutoCodecs) + return; + args.emplace_back("--auto-codecs"); + if (*opts.fAutoCodecs == kLHC4AutoCodecsAll) + args.emplace_back("all"); + else if (*opts.fAutoCodecs == kLHC4AutoCodecsRoot) + args.emplace_back("root"); + else + args.push_back(FormatAutoCodecMask(*opts.fAutoCodecs)); +} + +void AppendProfileArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fProfile == kLHC4ProfileNone && opts.fProfileId == 0) + return; + args.emplace_back("--profile"); + args.emplace_back(FormatProfileName(opts.fProfile)); + if (opts.fProfileId != 0) { + args.emplace_back("--profile-id"); + args.push_back(std::to_string(opts.fProfileId)); + } +} + +void AppendNoOutArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fNoOut) + args.emplace_back("--no-out"); +} + +void AppendVerifyArgs(std::vector &args, const RunOptions &opts) +{ + if (opts.fVerify) + args.emplace_back("--verify"); +} + +struct ZipVerifyGuard { + explicit ZipVerifyGuard(bool enable) { ROOT::Internal::RNTupleCompressor::SetVerifyRoundtrip(enable); } + ~ZipVerifyGuard() { ROOT::Internal::RNTupleCompressor::SetVerifyRoundtrip(false); } + ZipVerifyGuard(const ZipVerifyGuard &) = delete; + ZipVerifyGuard &operator=(const ZipVerifyGuard &) = delete; +}; + +#ifdef R__HAS_LHC4CODEC +void ResetLHC4Globals(int autoMinGainPct, bool autoMaxLevel, unsigned autoCodecs) +{ + R__SetLHC4Codec(kLHC4CodecBeam); + R__SetLHC4Filters(0); + R__SetLHC4FilterFallback(1); + R__SetLHC4FilterRle(1); + R__SetLHC4FilterDict(1); + R__SetLHC4AutoMinGainPct(autoMinGainPct); + R__SetLHC4AutoMaxLevel(autoMaxLevel ? 1 : 0); + R__SetLHC4AutoCodecs(autoCodecs); + R__SetLHC4Profile(kLHC4ProfileNone); + R__SetLHC4ProfileId(0); + R__SetLHC4ProfilePerColumn(0); +} + +RNTupleWriteOptions MakeLHC4WriteOptions(int level, bool keepColumnEncoding) +{ + RNTupleWriteOptions opts; + opts.SetEnableColumnEncoding(keepColumnEncoding); + opts.SetCompression(ROOT::RCompressionSetting::EAlgorithm::kLHC4, level); + return opts; +} + +void ConfigureLHC4FilterGlobals(int codec, bool enableByteFilters, int autoMinGainPct, bool autoMaxLevel, + unsigned autoCodecs, int profile, unsigned profileId, bool profilePerColumn) +{ + ResetLHC4Globals(autoMinGainPct, autoMaxLevel, autoCodecs); + R__SetLHC4Codec(codec); + if (enableByteFilters) + R__SetLHC4Filters(1); + // Standard Crystal stays dynamic. The profile flavour opts into static models. + if (codec == kLHC4CodecCrystal && profilePerColumn) { + if (profile != kLHC4ProfileNone) { + R__SetLHC4Profile(profile); + R__SetLHC4ProfileId(profileId); + } else { + R__SetLHC4ProfilePerColumn(1); + } + } +} + +FilterStatsSummary ReadFilterStatsSummary() +{ + R__LHC4CompressStatsSummary stats{}; + R__GetLHC4CompressStatsSummary(&stats); + FilterStatsSummary out; + if (stats.num_pages == 0) + return out; + out.fValid = true; + const double denom = static_cast(stats.num_pages); + out.fFilterOnPct = 100.0 * static_cast(stats.filtered_won_pages) / denom; + const auto filterOff = stats.raw_won_pages + stats.no_transform_pages + stats.stored_fallback_pages + + stats.plain_pages; + out.fFilterOffPct = 100.0 * static_cast(filterOff) / denom; + out.fRawWonPct = 100.0 * static_cast(stats.raw_won_pages) / denom; + out.fNoTransformPct = 100.0 * static_cast(stats.no_transform_pages) / denom; + if (stats.uncompressed_bytes > 0) { + out.fPageRatioValid = true; + out.fPageRatio = static_cast(stats.compressed_bytes) / + static_cast(stats.uncompressed_bytes); + } + return out; +} + +AutoCodecStatsSummary ReadAutoCodecStatsSummary() +{ + R__LHC4AutoCodecStatsSummary stats{}; + R__GetLHC4AutoCodecStatsSummary(&stats); + AutoCodecStatsSummary out; + if (stats.auto_selections == 0) + return out; + out.fValid = true; + out.fSelections = stats.auto_selections; + for (int i = 0; i < kLHC4CodecCount; ++i) + out.fCodecHits[i] = stats.codec_hits[i]; + return out; +} +#endif + +struct Variant { + const char *fLabel = nullptr; + std::string fSuffix; + RNTupleWriteOptions fOptions; + bool fUsesLHC4Globals = false; + bool fEnableLHC4ByteFilters = false; + int fLHC4Codec = 0; + bool fRequireCodecAvailable = false; + bool fLHC4ProfilePerColumn = false; +}; + +const Variant *FindVariant(const std::vector &variants, const std::string &label); + +RNTupleWriteOptions MakeNativeWriteOptions(ROOT::RCompressionSetting::EAlgorithm::EValues algorithm, int level) +{ + RNTupleWriteOptions opts; + opts.SetCompression(algorithm, level); + return opts; +} + +std::vector MakeVariants(const RunOptions &runOpts) +{ + std::vector variants; + + variants.push_back({"native_zstd", "_native", + MakeNativeWriteOptions(ROOT::RCompressionSetting::EAlgorithm::kZSTD, + ROOT::RCompressionSetting::ELevel::kDefaultZSTD), + false, false, 0, false}); + variants.push_back({"native_lzma", "_native_lzma", + MakeNativeWriteOptions(ROOT::RCompressionSetting::EAlgorithm::kLZMA, + ROOT::RCompressionSetting::ELevel::kDefaultLZMA), + false, false, 0, false}); + +#ifdef R__HAS_LHC4CODEC + const int kLevel = runOpts.fLevel; + const auto lhc4Opts = MakeLHC4WriteOptions(kLevel, runOpts.fKeepColumnEncoding); + const std::string lv = std::to_string(kLevel); + + variants.push_back({"lhc4_zstd", "_lhc4_zstd", lhc4Opts, true, true, kLHC4CodecZstd, true}); + variants.push_back({"lhc4_bzip3", "_lhc4_bzip3", lhc4Opts, true, true, kLHC4CodecBzip3, true}); + variants.push_back({"lhc4_beam5", "_lhc4_beam" + lv, lhc4Opts, true, true, kLHC4CodecBeam, false}); + variants.push_back({"lhc4_mosaic5", "_lhc4_mosaic" + lv, lhc4Opts, true, true, kLHC4CodecMosaic, false}); + variants.push_back({"lhc4_prism5", "_lhc4_prism" + lv, lhc4Opts, true, true, kLHC4CodecBwt, false}); + variants.push_back({"lhc4_crystal5", "_lhc4_crystal" + lv, lhc4Opts, true, true, kLHC4CodecCrystal, false}); + variants.push_back({"lhc4_crystal_profile5", "_lhc4_crystal_profile" + lv, lhc4Opts, true, true, kLHC4CodecCrystal, + false, true}); + + // Extra Crystal 7/9 comparison variants only at the default level so suffixes do not collide. + if (kLevel == kDefaultLHC4Level) { + const auto lhc4Opts7 = MakeLHC4WriteOptions(7, runOpts.fKeepColumnEncoding); + variants.push_back({"lhc4_crystal7", "_lhc4_crystal7", lhc4Opts7, true, true, kLHC4CodecCrystal, false}); + + const auto lhc4Opts9 = MakeLHC4WriteOptions(9, runOpts.fKeepColumnEncoding); + variants.push_back({"lhc4_crystal9", "_lhc4_crystal9", lhc4Opts9, true, true, kLHC4CodecCrystal, false}); + } + + variants.push_back({"lhc4_oracle5", "_lhc4_oracle" + lv, lhc4Opts, true, true, kLHC4CodecOracle, false}); + variants.push_back({"lhc4_flash5", "_lhc4_flash" + lv, lhc4Opts, true, true, kLHC4CodecFlash, false}); + variants.push_back({"lhc4_laser5", "_lhc4_laser" + lv, lhc4Opts, true, true, kLHC4CodecLaser, false}); + // Grating has no levels (the model does not search); the RNTuple level is ignored. + variants.push_back({"lhc4_grating", "_lhc4_grating", lhc4Opts, true, true, kLHC4CodecGrating, false}); + variants.push_back({"lhc4_auto", "_lhc4_auto", lhc4Opts, true, true, kLHC4CodecAuto, false}); +#endif + + for (auto &variant : variants) + ApplyPageSizeOptions(variant.fOptions, runOpts); + + if (!runOpts.fOnlyVariant.empty() && runOpts.fWorkerResult.empty()) { + std::unordered_set requested; + std::stringstream ss(runOpts.fOnlyVariant); + std::string item; + while (std::getline(ss, item, ',')) { + item = TrimCopy(item); + if (!item.empty()) + requested.insert(item); + } + std::unordered_set requestedCanon; + for (const auto &req : requested) { + const Variant *match = FindVariant(variants, req); + if (!match) + throw std::runtime_error("unknown variant label: " + req); + requestedCanon.insert(match->fLabel); + } + std::vector filtered; + for (const auto &v : variants) { + if (requestedCanon.count(v.fLabel)) + filtered.push_back(v); + } + return filtered; + } + + return variants; +} + +const Variant *FindVariant(const std::vector &variants, const std::string &label) +{ + std::string resolved = label; + if (resolved == "lhc4_lz5" || resolved == "lhc4_lz" || resolved == "lhc4_beam") + resolved = "lhc4_beam5"; + for (const auto &variant : variants) { + if (resolved == variant.fLabel) + return &variant; + } + const int n = TrailingVariantLevel(resolved); + std::string family = resolved; + if (n > 0) + family = resolved.substr(0, resolved.size() - std::to_string(n).size()); + if (family.empty()) + return nullptr; + const std::string fallback = family + std::to_string(kDefaultLHC4Level); + for (const auto &variant : variants) { + if (fallback == variant.fLabel) + return &variant; + } + for (const auto &variant : variants) { + if (family == variant.fLabel) + return &variant; + } + return nullptr; +} + +std::optional ImportVariant(const std::string &inputFile, const std::string &treeName, + const std::string &outputFile, const Variant &variant, bool quiet, + int autoMinGainPct, bool autoMaxLevel, unsigned autoCodecs, int profile, + unsigned profileId, bool discardOutput, bool verify) +{ +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals) { + if (variant.fRequireCodecAvailable && !R__LHC4CodecAvailable(variant.fLHC4Codec)) { + if (!quiet) + std::cerr << "Skipping " << variant.fLabel << ": lhc4codec backend is not available in this build\n"; + return std::nullopt; + } + ConfigureLHC4FilterGlobals(variant.fLHC4Codec, variant.fEnableLHC4ByteFilters, autoMinGainPct, autoMaxLevel, + autoCodecs, profile, profileId, variant.fLHC4ProfilePerColumn); + } else { + ResetLHC4Globals(autoMinGainPct, autoMaxLevel, autoCodecs); + } +#else + if (variant.fUsesLHC4Globals) { + if (!quiet) + std::cerr << "Skipping " << variant.fLabel << ": ROOT was built without lhc4codec support\n"; + return std::nullopt; + } +#endif + + RemoveIfExists(outputFile); + if (!quiet) { + if (discardOutput) + std::cout << "Converting " << variant.fLabel << " (--no-out)" << std::endl; + else + std::cout << "Writing " << variant.fLabel << " -> " << outputFile << std::endl; + } + +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals) + R__ResetLHC4CompressStats(); +#endif + + const auto t0 = std::chrono::steady_clock::now(); + + ZipVerifyGuard verifyGuard(verify); + auto importer = RNTupleImporter::Create(inputFile, treeName, outputFile); + importer->SetWriteOptions(variant.fOptions); + if (quiet) + importer->SetIsQuiet(true); + importer->Import(); + + const auto t1 = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = t1 - t0; + + const auto report = importer->GetLastImportReport(); + +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals && !quiet) { + std::cout << "Compression statistics (" << variant.fLabel << "):\n"; + R__PrintLHC4CompressStats(); + } +#endif + + if (!quiet) + std::cout << " done\n"; + + VariantResult result; + result.fLabel = variant.fLabel; + result.fElapsedSec = elapsed.count(); + result.fOutputBytes = report.fFileBytesOnDisk; + if (result.fOutputBytes == 0) { + if (const auto size = FileSizeBytes(outputFile)) + result.fOutputBytes = *size; + } +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals) + result.fFilters = ReadFilterStatsSummary(); + if (std::strcmp(variant.fLabel, "lhc4_auto") == 0) + result.fAutoCodec = ReadAutoCodecStatsSummary(); +#endif + if (discardOutput) + RemoveIfExists(outputFile); + return result; +} + +std::string FormatGainVsNativeZstd(const VariantResult &row, std::uint64_t nativeZstdBytes) +{ + if (row.fLabel == "native_zstd") + return "0.0%"; + if (nativeZstdBytes == 0) + return "n/a"; + const double gain = 100.0 * + (static_cast(nativeZstdBytes) - static_cast(row.fOutputBytes)) / + static_cast(nativeZstdBytes); + return FormatPct(gain); +} + +std::uint64_t FindNativeZstdOutputBytes(const std::vector &results) +{ + for (const auto &row : results) { + if (row.fLabel == "native_zstd") + return row.fOutputBytes; + } + return 0; +} + +void PrintAutoCodecSummary(const AutoCodecStatsSummary &stats) +{ + if (!stats.fValid) + return; + + std::cout << "\nlhc4_auto backend mix (" << stats.fSelections << " page" + << (stats.fSelections == 1 ? "" : "s") << "):\n"; + + struct Row { + const char *fName; + int fCodec; + }; + static constexpr Row kOrder[] = {{"zstd", kLHC4CodecZstd}, {"beam", kLHC4CodecBeam}, + {"mosaic", kLHC4CodecMosaic}, {"prism", kLHC4CodecBwt}, + {"laser", kLHC4CodecLaser}, {"lzma", kLHC4CodecLzma}, + {"bzip3", kLHC4CodecBzip3}, {"crystal", kLHC4CodecCrystal}}; + + for (const auto &row : kOrder) { + const auto n = stats.fCodecHits[row.fCodec]; + if (n == 0) + continue; + const double pct = 100.0 * static_cast(n) / static_cast(stats.fSelections); + std::cout << " " << std::left << std::setw(8) << row.fName << std::right << std::setw(8) << n << " (" + << std::fixed << std::setprecision(1) << pct << "%)\n"; + } +} + +void PrintSummaryTable(const RunOptions &opts, std::uint64_t inputBytes, + const std::vector &results) +{ + std::cout << "\n=== tree2ntuple summary ===\n"; + std::cout << "Input: " << opts.fInputFile << " [" << opts.fTreeName << "]\n"; + std::cout << "Input size: " << FormatBytes(inputBytes) << '\n'; + if (opts.fNoOut) + std::cout << "Output: (none, --no-out)\n"; + else + std::cout << "Output base: " << opts.fOutputBase << '\n'; + std::cout << "Page size: " << FormatPageSizeSettings(opts) << '\n'; + std::cout << "LHC4 level: " << opts.fLevel << '\n'; + std::cout << "Auto min gain: " << ResolveAutoMinGainPct(opts) << "%\n"; + if (ResolveAutoMaxLevel(opts)) + std::cout << "Auto max level: on\n"; + std::cout << "Auto codecs: " << FormatAutoCodecMask(ResolveAutoCodecs(opts)) << '\n'; + std::cout << "Crystal: " << FormatCrystalProfileStatus(opts) << "\n\n"; + + const std::uint64_t nativeZstdBytes = FindNativeZstdOutputBytes(results); + + constexpr int kLabelWidth = 24; + std::cout << std::left << std::setw(kLabelWidth) << "Format" << std::right << std::setw(12) << "Output" + << std::setw(10) << "Ratio" << std::setw(11) << "Page ratio" << std::setw(10) << "Gain" + << std::setw(10) << "Time" << std::setw(12) << "Filter on" << std::setw(13) << "Filter off" + << std::setw(12) << "Raw won" << std::setw(14) << "No transform" << '\n'; + std::cout << std::string(kLabelWidth + 12 + 10 + 11 + 10 + 10 + 12 + 13 + 12 + 14, '-') << '\n'; + + for (const auto &row : results) { + std::cout << std::left << std::setw(kLabelWidth) << row.fLabel << std::right << std::setw(12) + << FormatBytes(row.fOutputBytes) << std::setw(10) << FormatRatio(row.fOutputBytes, inputBytes) + << std::setw(11) << FormatPageRatio(row.fFilters) << std::setw(10) + << FormatGainVsNativeZstd(row, nativeZstdBytes) << std::setw(10) + << FormatSeconds(row.fElapsedSec); + + if (row.fFilters.fValid) { + std::cout << std::setw(12) << FormatPct(row.fFilters.fFilterOnPct) << std::setw(13) + << FormatPct(row.fFilters.fFilterOffPct) << std::setw(12) + << FormatPct(row.fFilters.fRawWonPct) << std::setw(14) + << FormatPct(row.fFilters.fNoTransformPct); + } else { + std::cout << std::setw(12) << "n/a" << std::setw(13) << "n/a" << std::setw(12) << "n/a" << std::setw(14) + << "n/a"; + } + std::cout << '\n'; + } + + std::cout << "\nRatio = RNTuple output size / TTree input file size (lower is smaller).\n"; + std::cout << "Page ratio = lhc4codec compressed payload / uncompressed page bytes (lhc4_* only).\n"; + std::cout << "Gain = space saved vs native_zstd output (positive = smaller file, native_zstd is 0%).\n"; + std::cout << "Filter on = lhc4codec byte transform kept on wire (filtered won).\n"; + std::cout << "Filter off = raw won + no transform + stored fallback + plain pages.\n"; + + for (const auto &row : results) { + if (row.fLabel == "lhc4_auto") + PrintAutoCodecSummary(row.fAutoCodec); + } +} + +std::vector RunSequential(const RunOptions &opts) +{ + std::vector results; + for (const auto &variant : MakeVariants(opts)) { + if (auto row = ImportVariant(opts.fInputFile, opts.fTreeName, ResolveVariantOutputPath(opts, variant.fSuffix), + variant, opts.fQuiet, ResolveAutoMinGainPct(opts), ResolveAutoMaxLevel(opts), + ResolveAutoCodecs(opts), opts.fProfile, opts.fProfileId, opts.fNoOut, + opts.fVerify)) { + results.push_back(*row); + } + } + return results; +} + +#if !defined(_WIN32) +std::vector BuildWorkerArgv(const char *executable, const RunOptions &opts, const Variant &variant, + const std::string &resultPath) +{ + std::vector args; + args.emplace_back(executable); + args.emplace_back("--only-variant"); + args.push_back(variant.fLabel); + args.emplace_back("--worker-result"); + args.push_back(resultPath); + args.emplace_back("--quiet"); + if (opts.fKeepColumnEncoding) + args.emplace_back("--keep-column-encoding"); + AppendLevelArgs(args, opts); + AppendPageSizeArgs(args, opts); + AppendAutoMinGainArgs(args, opts); + AppendAutoMaxLevelArgs(args, opts); + AppendAutoCodecsArgs(args, opts); + AppendProfileArgs(args, opts); + AppendNoOutArgs(args, opts); + AppendVerifyArgs(args, opts); + args.push_back(opts.fInputFile); + args.push_back(opts.fTreeName); + args.push_back(opts.fOutputBase); + return args; +} + +[[noreturn]] void ExecWorker(const std::vector &args) +{ + std::vector argv; + argv.reserve(args.size() + 1); + for (const auto &arg : args) + argv.push_back(const_cast(arg.c_str())); + argv.push_back(nullptr); + execvp(argv[0], argv.data()); + std::cerr << "tree2ntuple: failed to exec worker: " << args[0] << " (" << std::strerror(errno) << ")\n"; + _exit(127); +} + +unsigned ResolveJobCount(int jobs, std::size_t numVariants) +{ + if (jobs == 0) { + const unsigned hw = std::max(1u, std::thread::hardware_concurrency()); + return static_cast(std::min(hw, numVariants)); + } + if (jobs < 0) + throw std::runtime_error("--jobs must be >= 0"); + return static_cast(std::min(static_cast(jobs), numVariants)); +} + +std::vector RunParallel(const RunOptions &opts, const char *executable) +{ + const auto variants = MakeVariants(opts); + const unsigned jobCount = ResolveJobCount(opts.fJobs, variants.size()); + if (jobCount <= 1) + return RunSequential(opts); + + std::cout << "Running " << variants.size() << " variants with up to " << jobCount + << " parallel jobs (one process per format)\n"; + + const std::string resultDir = + std::string("/tmp/tree2ntuple-") + std::to_string(static_cast(getpid())); + gSystem->mkdir(resultDir.c_str(), true); + + struct RunningJob { + pid_t fPid = -1; + std::string fResultPath; + std::string fLabel; + }; + + std::vector running; + running.reserve(jobCount); + std::vector results; + results.reserve(variants.size()); + std::size_t nextVariant = 0; + int failedWorkers = 0; + + auto reapOne = [&]() { + int status = 0; + const pid_t done = wait(&status); + if (done <= 0) + throw std::runtime_error("wait() failed while collecting parallel workers"); + + auto it = std::find_if(running.begin(), running.end(), + [done](const RunningJob &job) { return job.fPid == done; }); + if (it == running.end()) + throw std::runtime_error("unexpected worker pid " + std::to_string(done)); + + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + std::cerr << "Worker failed for variant " << it->fLabel << '\n'; + ++failedWorkers; + } else { + results.push_back(ReadWorkerResult(it->fResultPath)); + } + gSystem->Unlink(it->fResultPath.c_str()); + running.erase(it); + }; + + auto launchOne = [&](const Variant &variant) { + RunningJob job; + job.fLabel = variant.fLabel; + job.fResultPath = resultDir + "/" + variant.fLabel + ".result"; + RemoveIfExists(job.fResultPath); + + const pid_t pid = fork(); + if (pid < 0) + throw std::runtime_error(std::string("fork() failed for variant ") + variant.fLabel); + + if (pid == 0) { + const auto workerArgs = BuildWorkerArgv(executable, opts, variant, job.fResultPath); + ExecWorker(workerArgs); + } + + job.fPid = pid; + running.push_back(std::move(job)); + }; + + while (nextVariant < variants.size() || !running.empty()) { + while (running.size() < jobCount && nextVariant < variants.size()) { + launchOne(variants[nextVariant]); + ++nextVariant; + } + if (!running.empty()) + reapOne(); + } + + gSystem->Unlink(resultDir.c_str()); + + if (failedWorkers > 0) + throw std::runtime_error(std::to_string(failedWorkers) + " parallel worker(s) failed"); + + std::sort(results.begin(), results.end(), [&variants](const VariantResult &a, const VariantResult &b) { + auto rank = [&variants](const std::string &label) { + for (std::size_t i = 0; i < variants.size(); ++i) { + if (variants[i].fLabel == label) + return i; + } + return variants.size(); + }; + return rank(a.fLabel) < rank(b.fLabel); + }); + + return results; +} +#endif + +int RunWorkerMode(const RunOptions &opts) +{ + const auto variants = MakeVariants(opts); + const Variant *variant = FindVariant(variants, opts.fOnlyVariant); + if (!variant) + throw std::runtime_error("unknown variant label: " + opts.fOnlyVariant); + + ROOT::EnableImplicitMT(); + + const bool quiet = !opts.fWorkerResult.empty(); + auto result = ImportVariant(opts.fInputFile, opts.fTreeName, ResolveVariantOutputPath(opts, variant->fSuffix), + *variant, quiet, ResolveAutoMinGainPct(opts), ResolveAutoMaxLevel(opts), + ResolveAutoCodecs(opts), opts.fProfile, opts.fProfileId, opts.fNoOut, + opts.fVerify); + if (!result) + return 1; + + if (!opts.fWorkerResult.empty()) { + WriteWorkerResult(opts.fWorkerResult, *result); + return 0; + } + + const auto inputSize = FileSizeBytes(opts.fInputFile); + if (inputSize) + PrintSummaryTable(opts, *inputSize, {*result}); + return 0; +} + +int RunMain(const RunOptions &opts, const char *executable) +{ + if (!opts.fWorkerResult.empty()) + return RunWorkerMode(opts); + + const auto inputSize = FileSizeBytes(opts.fInputFile); + if (!inputSize) + throw std::runtime_error("cannot stat input file: " + opts.fInputFile); + + if (opts.fKeepColumnEncoding && !opts.fQuiet) + std::cout << "Using ROOT column encodings + lhc4codec backend for LHC4 variants\n"; + if ((opts.fPageSize || opts.fInitialPageSize) && !opts.fQuiet) + std::cout << "Page size: " << FormatPageSizeSettings(opts) << '\n'; + if (!opts.fQuiet) + std::cout << "LHC4 level: " << opts.fLevel << '\n'; + if (!opts.fQuiet) + std::cout << "Auto min gain: " << ResolveAutoMinGainPct(opts) << "%\n"; + if (ResolveAutoMaxLevel(opts) && !opts.fQuiet) + std::cout << "Auto max level: on\n"; + if (!opts.fQuiet) + std::cout << "Auto codecs: " << FormatAutoCodecMask(ResolveAutoCodecs(opts)) << '\n'; + if (!opts.fQuiet) + std::cout << "Crystal: " << FormatCrystalProfileStatus(opts) << '\n'; + if (opts.fVerify && !opts.fQuiet) + std::cout << "Verify: enabled (compress/decompress round-trip per page)\n"; + + std::vector results; +#if defined(_WIN32) + if (opts.fJobs != 1 && !opts.fQuiet) + std::cerr << "tree2ntuple: parallel mode is not supported on Windows; running sequentially\n"; + ROOT::EnableImplicitMT(); + results = RunSequential(opts); +#else + if (opts.fJobs == 1) { + ROOT::EnableImplicitMT(); + results = RunSequential(opts); + } else { + results = RunParallel(opts, executable); + } +#endif + + if (!opts.fQuiet) + PrintSummaryTable(opts, *inputSize, results); + + if (opts.fBench && !opts.fNoOut) { + const auto variants = MakeVariants(opts); + std::vector> filesToBench; + for (const auto &row : results) { + const auto *v = FindVariant(variants, row.fLabel); + if (v) { + std::string outPath = ResolveVariantOutputPath(opts, v->fSuffix); + filesToBench.push_back({row.fLabel, outPath}); + } + } + std::vector benchResults; + for (const auto &[label, path] : filesToBench) { + benchResults.push_back(RNTupleBench::RunBenchmarkForFile( + label, path, "", opts.fBenchThreads, opts.fBenchIters, opts.fBenchEntries, opts.fBenchPercent)); + } + RNTupleBench::PrintBenchmarkSummary(benchResults); + } + + return 0; +} + +} // anonymous namespace + +int main(int argc, char **argv) +{ + try { + const auto opts = ParseArgs(argc, argv); + return RunMain(opts, argv[0]); + } catch (const std::exception &e) { + std::cerr << "tree2ntuple: " << e.what() << '\n'; + std::cerr << kUsage; + return 1; + } +} diff --git a/tree/ntuple/inc/ROOT/RFieldBase.hxx b/tree/ntuple/inc/ROOT/RFieldBase.hxx index 70880e3e3cfda..867ce5dfe515e 100644 --- a/tree/ntuple/inc/ROOT/RFieldBase.hxx +++ b/tree/ntuple/inc/ROOT/RFieldBase.hxx @@ -427,8 +427,8 @@ protected: const ColumnRepresentation_t & EnsureCompatibleColumnTypes(const ROOT::RNTupleDescriptor &desc, std::uint16_t representationIndex) const; /// When connecting a field to a page sink, the field's default column representation is subject - /// to adjustment according to the write options. E.g., if compression is turned off, encoded columns - /// are changed to their unencoded counterparts. + /// to adjustment according to the write options. E.g., if compression is turned off or column encoding + /// is disabled, encoded columns are changed to their unencoded counterparts. void AutoAdjustColumnTypes(const ROOT::RNTupleWriteOptions &options); /// Called by Clone(), which additionally copies the on-disk ID diff --git a/tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx b/tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx index 721562b89c66f..504b303b723d1 100644 --- a/tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx +++ b/tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx @@ -1775,6 +1775,10 @@ public: /// This resets the builder's descriptor. void SetSchemaFromExisting(const RNTupleDescriptor &descriptor); + /// Replace split/delta/zigzag on-disk column types with their plain counterparts. + /// Packed page size is unchanged; only the on-disk encoding stored in the descriptor is demoted. + void DemoteSplitColumnTypes(); + void SetVersion(std::uint16_t versionEpoch, std::uint16_t versionMajor, std::uint16_t versionMinor, std::uint16_t versionPatch); void SetVersionForWriting(); diff --git a/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx b/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx index aeb00fd8f90ac..3df419e522ba7 100644 --- a/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx +++ b/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx @@ -175,6 +175,18 @@ Requires `EnablePageChecksums` and will throw if previously disabled. + +`EnableColumnEncoding` +`bool` +`true` + +If set, columns use RNTuple's on-storage encodings (split, zigzag, delta) where applicable. +If disabled, columns are stored in plain physical layout. When compression uses the LHC4 +algorithm, lhc4codec byte filters are enabled automatically (see ZipLHC4.h: filters, +filter RLE, and dict toggles; codec defaults to Beam). + + + */ // clang-format on @@ -205,6 +217,7 @@ protected: EImplicitMT fUseImplicitMT = EImplicitMT::kDefault; bool fEnablePageChecksums = true; bool fEnableSamePageMerging = true; + bool fEnableColumnEncoding = true; /// Specifies the max size of a payload storeable into a single TKey. When writing an RNTuple to a ROOT file, /// any payload whose size exceeds this will be split into multiple keys. std::uint64_t fMaxKeySize = kDefaultMaxKeySize; @@ -261,6 +274,9 @@ public: bool GetEnableSamePageMerging() const { return fEnableSamePageMerging; } void SetEnableSamePageMerging(bool val); + bool GetEnableColumnEncoding() const { return fEnableColumnEncoding; } + void SetEnableColumnEncoding(bool val) { fEnableColumnEncoding = val; } + std::uint64_t GetMaxKeySize() const { return fMaxKeySize; } friend bool operator==(const RNTupleWriteOptions &lhs, const RNTupleWriteOptions &rhs) @@ -272,7 +288,8 @@ public: lhs.fUseBufferedWrite == rhs.fUseBufferedWrite && lhs.fUseDirectIO == rhs.fUseDirectIO && lhs.fWriteBufferSize == rhs.fWriteBufferSize && lhs.fUseImplicitMT == rhs.fUseImplicitMT && lhs.fEnablePageChecksums == rhs.fEnablePageChecksums && - lhs.fEnableSamePageMerging == rhs.fEnableSamePageMerging && lhs.fMaxKeySize == rhs.fMaxKeySize; + lhs.fEnableSamePageMerging == rhs.fEnableSamePageMerging && + lhs.fEnableColumnEncoding == rhs.fEnableColumnEncoding && lhs.fMaxKeySize == rhs.fMaxKeySize; } friend bool operator!=(const RNTupleWriteOptions &lhs, const RNTupleWriteOptions &rhs) { return !(lhs == rhs); } diff --git a/tree/ntuple/inc/ROOT/RNTupleZip.hxx b/tree/ntuple/inc/ROOT/RNTupleZip.hxx index a613f30bb46c8..8eeb29c29d76e 100644 --- a/tree/ntuple/inc/ROOT/RNTupleZip.hxx +++ b/tree/ntuple/inc/ROOT/RNTupleZip.hxx @@ -17,6 +17,11 @@ #include #include +#include + +#ifdef R__HAS_LHC4CODEC +#include +#endif #include #include @@ -28,61 +33,6 @@ namespace ROOT { namespace Internal { -// clang-format off -/** -\class ROOT::Internal::RNTupleCompressor -\ingroup NTuple -\brief Helper class to compress data blocks in the ROOT compression frame format -*/ -// clang-format on -class RNTupleCompressor { -public: - RNTupleCompressor() = delete; - RNTupleCompressor(const RNTupleCompressor &other) = delete; - RNTupleCompressor &operator=(const RNTupleCompressor &other) = delete; - RNTupleCompressor(RNTupleCompressor &&other) = delete; - RNTupleCompressor &operator=(RNTupleCompressor &&other) = delete; - - /// Returns the size of the compressed data, written into the provided output buffer. - static std::size_t Zip(const void *from, std::size_t nbytes, int compression, void *to) - { - R__ASSERT(from != nullptr); - R__ASSERT(to != nullptr); - auto cxLevel = compression % 100; - if (cxLevel == 0) { - memcpy(to, from, nbytes); - return nbytes; - } - - auto cxAlgorithm = static_cast(compression / 100); - unsigned int nZipBlocks = 1 + (nbytes - 1) / kMAXZIPBUF; - const char *source = static_cast(from); - int szTarget = nbytes; - char *target = reinterpret_cast(to); - int szOutBlock = 0; - int szRemaining = nbytes; - size_t szZipData = 0; - for (unsigned int i = 0; i < nZipBlocks; ++i) { - int szSource = std::min(static_cast(kMAXZIPBUF), szRemaining); - R__zipMultipleAlgorithm(cxLevel, &szSource, source, &szTarget, target, &szOutBlock, cxAlgorithm); - R__ASSERT(szOutBlock >= 0); - if ((szOutBlock == 0) || (szOutBlock >= szSource)) { - // Uncompressible block, we have to store the entire input data stream uncompressed - memcpy(to, from, nbytes); - return nbytes; - } - - szZipData += szOutBlock; - source += szSource; - target += szOutBlock; - szRemaining -= szSource; - } - R__ASSERT(szRemaining == 0); - R__ASSERT(szZipData < nbytes); - return szZipData; - } -}; - // clang-format off /** \class ROOT::Internal::RNTupleDecompressor @@ -146,6 +96,118 @@ public: } }; +inline void VerifyZipRoundtrip(const void *from, std::size_t nbytes, const void *to, std::size_t szZipData) +{ + if (nbytes == szZipData) { + if (std::memcmp(from, to, nbytes) != 0) + throw ROOT::RException(R__FAIL("zip verify: uncompressed page buffer mismatch")); + return; + } + +#ifdef R__HAS_LHC4CODEC + auto dumpLhc4VerifyFailure = [&](const char *stage, const char *status) { + if (szZipData < 2) + return; + const auto *hdr = static_cast(to); + if (hdr[0] != 'L' || hdr[1] != 'C') + return; + R__LHC4FailureDumpInfo dump{}; + dump.fStage = stage; + dump.fUncompressed = from; + dump.fUncompressedSize = nbytes; + dump.fCompressedLc = to; + dump.fCompressedLcSize = szZipData; + dump.fCxLevel = -1; + dump.fStatusMessage = status; + R__DumpLHC4Failure(&dump); + }; +#endif + + const auto roundtrip = std::make_unique(nbytes); + try { + RNTupleDecompressor::Unzip(to, szZipData, nbytes, roundtrip.get()); + } catch (const ROOT::RException &err) { +#ifdef R__HAS_LHC4CODEC + dumpLhc4VerifyFailure("verify_unzip", err.what()); +#endif + throw; + } + if (std::memcmp(from, roundtrip.get(), nbytes) != 0) { +#ifdef R__HAS_LHC4CODEC + dumpLhc4VerifyFailure("verify_mismatch", "decompressed page buffer mismatch"); +#endif + throw ROOT::RException(R__FAIL("zip verify: decompressed page buffer mismatch")); + } +} + +// clang-format off +/** +\class ROOT::Internal::RNTupleCompressor +\ingroup NTuple +\brief Helper class to compress data blocks in the ROOT compression frame format +*/ +// clang-format on +class RNTupleCompressor { +public: + RNTupleCompressor() = delete; + RNTupleCompressor(const RNTupleCompressor &other) = delete; + RNTupleCompressor &operator=(const RNTupleCompressor &other) = delete; + RNTupleCompressor(RNTupleCompressor &&other) = delete; + RNTupleCompressor &operator=(RNTupleCompressor &&other) = delete; + + /// When enabled, every Zip() round-trips through Unzip() and compares page bytes. + static void SetVerifyRoundtrip(bool enable) { fVerifyRoundtrip = enable; } + static bool GetVerifyRoundtrip() { return fVerifyRoundtrip; } + + /// Returns the size of the compressed data, written into the provided output buffer. + static std::size_t Zip(const void *from, std::size_t nbytes, int compression, void *to) + { + R__ASSERT(from != nullptr); + R__ASSERT(to != nullptr); + auto cxLevel = compression % 100; + if (cxLevel == 0) { + memcpy(to, from, nbytes); + if (fVerifyRoundtrip) + VerifyZipRoundtrip(from, nbytes, to, nbytes); + return nbytes; + } + + auto cxAlgorithm = static_cast(compression / 100); + unsigned int nZipBlocks = 1 + (nbytes - 1) / kMAXZIPBUF; + const char *source = static_cast(from); + int szTarget = nbytes; + char *target = reinterpret_cast(to); + int szOutBlock = 0; + int szRemaining = nbytes; + size_t szZipData = 0; + for (unsigned int i = 0; i < nZipBlocks; ++i) { + int szSource = std::min(static_cast(kMAXZIPBUF), szRemaining); + R__zipMultipleAlgorithm(cxLevel, &szSource, source, &szTarget, target, &szOutBlock, cxAlgorithm); + R__ASSERT(szOutBlock >= 0); + if ((szOutBlock == 0) || (szOutBlock >= szSource)) { + // Uncompressible block, we have to store the entire input data stream uncompressed + memcpy(to, from, nbytes); + if (fVerifyRoundtrip) + VerifyZipRoundtrip(from, nbytes, to, nbytes); + return nbytes; + } + + szZipData += szOutBlock; + source += szSource; + target += szOutBlock; + szRemaining -= szSource; + } + R__ASSERT(szRemaining == 0); + R__ASSERT(szZipData < nbytes); + if (fVerifyRoundtrip) + VerifyZipRoundtrip(from, nbytes, to, szZipData); + return szZipData; + } + +private: + inline static bool fVerifyRoundtrip = false; +}; + } // namespace Internal } // namespace ROOT diff --git a/tree/ntuple/inc/ROOT/RPageStorage.hxx b/tree/ntuple/inc/ROOT/RPageStorage.hxx index dfcf2d11d411c..45c07f4889fa0 100644 --- a/tree/ntuple/inc/ROOT/RPageStorage.hxx +++ b/tree/ntuple/inc/ROOT/RPageStorage.hxx @@ -541,10 +541,12 @@ public: /// Initialize sink based on an existing descriptor and fill into the descriptor builder, optionally copying over /// the descriptor's clusters to this sink's descriptor. - /// \return The model created from the new sink's descriptor. This model should be kept alive - /// for at least as long as the sink. + /// If `dropColumnEncoding` is true, split/delta/zigzag column types are demoted to plain types and no C++ model + /// is reconstructed (so streamer fields do not require dictionaries). + /// \return The model created from the new sink's descriptor, or `nullptr` when `dropColumnEncoding` is true. + /// A non-null model should be kept alive for at least as long as the sink. [[nodiscard]] std::unique_ptr - InitFromDescriptor(const ROOT::RNTupleDescriptor &descriptor, bool copyClusters); + InitFromDescriptor(const ROOT::RNTupleDescriptor &descriptor, bool copyClusters, bool dropColumnEncoding = false); /// Adds a new column representation to the given field. It is NOT marked as suppressed. /// The first element index of the added column will be computed as the sum of the first element index of the diff --git a/tree/ntuple/src/RFieldBase.cxx b/tree/ntuple/src/RFieldBase.cxx index 44ef16b781d3f..592d2dfcd30a1 100644 --- a/tree/ntuple/src/RFieldBase.cxx +++ b/tree/ntuple/src/RFieldBase.cxx @@ -911,33 +911,42 @@ void ROOT::RFieldBase::RemoveReadCallback(size_t idx) fIsSimple = (fTraits & kTraitMappable) && !fIsArtificial && fReadCallbacks.empty(); } +namespace { + +void DemoteSplitColumnTypes(ROOT::RFieldBase::ColumnRepresentation_t &rep) +{ + for (auto &colType : rep) { + switch (colType) { + case ROOT::ENTupleColumnType::kSplitIndex64: colType = ROOT::ENTupleColumnType::kIndex64; break; + case ROOT::ENTupleColumnType::kSplitIndex32: colType = ROOT::ENTupleColumnType::kIndex32; break; + case ROOT::ENTupleColumnType::kSplitReal64: colType = ROOT::ENTupleColumnType::kReal64; break; + case ROOT::ENTupleColumnType::kSplitReal32: colType = ROOT::ENTupleColumnType::kReal32; break; + case ROOT::ENTupleColumnType::kSplitInt64: colType = ROOT::ENTupleColumnType::kInt64; break; + case ROOT::ENTupleColumnType::kSplitInt32: colType = ROOT::ENTupleColumnType::kInt32; break; + case ROOT::ENTupleColumnType::kSplitInt16: colType = ROOT::ENTupleColumnType::kInt16; break; + case ROOT::ENTupleColumnType::kSplitUInt64: colType = ROOT::ENTupleColumnType::kUInt64; break; + case ROOT::ENTupleColumnType::kSplitUInt32: colType = ROOT::ENTupleColumnType::kUInt32; break; + case ROOT::ENTupleColumnType::kSplitUInt16: colType = ROOT::ENTupleColumnType::kUInt16; break; + default: break; + } + } +} + +} // anonymous namespace + void ROOT::RFieldBase::AutoAdjustColumnTypes(const ROOT::RNTupleWriteOptions &options) { - if ((options.GetCompression() == 0) && HasDefaultColumnRepresentative()) { + const bool usePlainColumns = !options.GetEnableColumnEncoding() || (options.GetCompression() == 0); + + if (usePlainColumns && HasDefaultColumnRepresentative()) { ColumnRepresentation_t rep = GetColumnRepresentations().GetSerializationDefault(); - for (auto &colType : rep) { - switch (colType) { - case ROOT::ENTupleColumnType::kSplitIndex64: colType = ROOT::ENTupleColumnType::kIndex64; break; - case ROOT::ENTupleColumnType::kSplitIndex32: colType = ROOT::ENTupleColumnType::kIndex32; break; - case ROOT::ENTupleColumnType::kSplitReal64: colType = ROOT::ENTupleColumnType::kReal64; break; - case ROOT::ENTupleColumnType::kSplitReal32: colType = ROOT::ENTupleColumnType::kReal32; break; - case ROOT::ENTupleColumnType::kSplitInt64: colType = ROOT::ENTupleColumnType::kInt64; break; - case ROOT::ENTupleColumnType::kSplitInt32: colType = ROOT::ENTupleColumnType::kInt32; break; - case ROOT::ENTupleColumnType::kSplitInt16: colType = ROOT::ENTupleColumnType::kInt16; break; - case ROOT::ENTupleColumnType::kSplitUInt64: colType = ROOT::ENTupleColumnType::kUInt64; break; - case ROOT::ENTupleColumnType::kSplitUInt32: colType = ROOT::ENTupleColumnType::kUInt32; break; - case ROOT::ENTupleColumnType::kSplitUInt16: colType = ROOT::ENTupleColumnType::kUInt16; break; - default: break; - } - } + DemoteSplitColumnTypes(rep); SetColumnRepresentatives({rep}); } if (fTypeAlias == "Double32_t") { - if (options.GetCompression() != 0) - SetColumnRepresentatives({{ROOT::ENTupleColumnType::kSplitReal32}}); - else - SetColumnRepresentatives({{ROOT::ENTupleColumnType::kReal32}}); + SetColumnRepresentatives({{usePlainColumns ? ROOT::ENTupleColumnType::kReal32 + : ROOT::ENTupleColumnType::kSplitReal32}}); } } diff --git a/tree/ntuple/src/RNTupleDescriptor.cxx b/tree/ntuple/src/RNTupleDescriptor.cxx index 6a20fc3605d06..a51f89cd57880 100644 --- a/tree/ntuple/src/RNTupleDescriptor.cxx +++ b/tree/ntuple/src/RNTupleDescriptor.cxx @@ -1356,6 +1356,26 @@ void ROOT::Internal::RNTupleDescriptorBuilder::SetSchemaFromExisting(const RNTup fDescriptor = descriptor.CloneSchema(); } +void ROOT::Internal::RNTupleDescriptorBuilder::DemoteSplitColumnTypes() +{ + for (auto &[id, col] : fDescriptor.fColumnDescriptors) { + (void)id; + switch (col.fType) { + case ENTupleColumnType::kSplitIndex64: col.fType = ENTupleColumnType::kIndex64; break; + case ENTupleColumnType::kSplitIndex32: col.fType = ENTupleColumnType::kIndex32; break; + case ENTupleColumnType::kSplitReal64: col.fType = ENTupleColumnType::kReal64; break; + case ENTupleColumnType::kSplitReal32: col.fType = ENTupleColumnType::kReal32; break; + case ENTupleColumnType::kSplitInt64: col.fType = ENTupleColumnType::kInt64; break; + case ENTupleColumnType::kSplitInt32: col.fType = ENTupleColumnType::kInt32; break; + case ENTupleColumnType::kSplitInt16: col.fType = ENTupleColumnType::kInt16; break; + case ENTupleColumnType::kSplitUInt64: col.fType = ENTupleColumnType::kUInt64; break; + case ENTupleColumnType::kSplitUInt32: col.fType = ENTupleColumnType::kUInt32; break; + case ENTupleColumnType::kSplitUInt16: col.fType = ENTupleColumnType::kUInt16; break; + default: break; + } + } +} + void ROOT::Internal::RNTupleDescriptorBuilder::BeginHeaderExtension() { if (!fDescriptor.fHeaderExtension) diff --git a/tree/ntuple/src/RPageStorage.cxx b/tree/ntuple/src/RPageStorage.cxx index 026e2ead49dd8..1e988552660ea 100644 --- a/tree/ntuple/src/RPageStorage.cxx +++ b/tree/ntuple/src/RPageStorage.cxx @@ -34,6 +34,10 @@ #include #include +#ifdef R__HAS_LHC4CODEC +#include "ZipLHC4.h" +#endif + #include #include #include @@ -829,10 +833,75 @@ ROOT::Internal::RPageSink::RPageSink(std::string_view name, const ROOT::RNTupleW : RPageStorage(name), fOptions(options.Clone()), fWritePageMemoryManager(options.GetPageBufferBudget()) { ROOT::Internal::EnsureValidNameForRNTuple(name, "RNTuple").ThrowOnError(); + +#ifdef R__HAS_LHC4CODEC + if (!fOptions->GetEnableColumnEncoding()) { + const auto algorithm = ROOT::RCompressionSetting::AlgorithmFromCompressionSettings(fOptions->GetCompression()); + if (algorithm == ROOT::RCompressionSetting::EAlgorithm::kLHC4) + R__SetLHC4Filters(1); + } +#endif } ROOT::Internal::RPageSink::~RPageSink() {} +#ifdef R__HAS_LHC4CODEC +namespace { + +/// Pretrained Crystal profile for one column layout. Stride-4 models match 32-bit +/// columns; text matches byte columns. Other layouts stay on the dynamic model. +int LHC4ProfileForColumnType(ROOT::ENTupleColumnType type) +{ + switch (type) { + case ROOT::ENTupleColumnType::kReal32: + return kLHC4ProfileFloatKinematics; + case ROOT::ENTupleColumnType::kIndex32: + case ROOT::ENTupleColumnType::kUInt32: + return kLHC4ProfilePoissonCounts; + case ROOT::ENTupleColumnType::kChar: + return kLHC4ProfileText; + default: + return kLHC4ProfileNone; + } +} + +/// Sets the zip profile for this thread and restores it when the page seal returns. +struct LHC4CallProfileGuard { + int fPrevious{-1}; + bool fActive{false}; + explicit LHC4CallProfileGuard(int profile) + { + if (profile < 0) + return; + fPrevious = R__GetLHC4CallProfile(); + R__SetLHC4CallProfile(profile); + fActive = true; + } + ~LHC4CallProfileGuard() + { + if (fActive) + R__SetLHC4CallProfile(fPrevious); + } + LHC4CallProfileGuard(const LHC4CallProfileGuard &) = delete; + LHC4CallProfileGuard &operator=(const LHC4CallProfileGuard &) = delete; +}; + +/// Column profile for the profile flavour. Standard Crystal leaves this inactive so +/// byte filters and the dynamic model stay in use. +int LHC4CallProfileForPage(const ROOT::Internal::RColumnElementBase &element, std::uint32_t compressionSettings) +{ + if (!R__GetLHC4ProfilePerColumn() || R__GetLHC4Codec() != kLHC4CodecCrystal || + R__GetLHC4Profile() != kLHC4ProfileNone) + return -1; + const auto algorithm = ROOT::RCompressionSetting::AlgorithmFromCompressionSettings(compressionSettings); + if (algorithm != ROOT::RCompressionSetting::EAlgorithm::kLHC4) + return -1; + return LHC4ProfileForColumnType(element.GetIdentifier().fOnDiskType); +} + +} // namespace +#endif + ROOT::Internal::RPageStorage::RSealedPage ROOT::Internal::RPageSink::SealPage(const RSealPageConfig &config) { assert(config.fPage); @@ -852,6 +921,9 @@ ROOT::Internal::RPageStorage::RSealedPage ROOT::Internal::RPageSink::SealPage(co } auto nBytesZipped = nBytesPacked; +#ifdef R__HAS_LHC4CODEC + LHC4CallProfileGuard columnProfile(LHC4CallProfileForPage(*config.fElement, config.fCompressionSettings)); +#endif if ((config.fCompressionSettings != 0) || !config.fElement->IsMappable() || !config.fAllowAlias || config.fWriteChecksum) { nBytesZipped = @@ -1104,10 +1176,13 @@ void ROOT::Internal::RPagePersistentSink::InitImpl(ROOT::RNTupleModel &model) } std::unique_ptr -ROOT::Internal::RPagePersistentSink::InitFromDescriptor(const ROOT::RNTupleDescriptor &srcDescriptor, bool copyClusters) +ROOT::Internal::RPagePersistentSink::InitFromDescriptor(const ROOT::RNTupleDescriptor &srcDescriptor, bool copyClusters, + bool dropColumnEncoding) { // Create new descriptor fDescriptorBuilder.SetSchemaFromExisting(srcDescriptor); + if (dropColumnEncoding) + fDescriptorBuilder.DemoteSplitColumnTypes(); // This is needed to be able to use GetTypeNameForComparison() fDescriptorBuilder.SetVersionForWriting(); const auto &descriptor = fDescriptorBuilder.GetDescriptor(); @@ -1152,15 +1227,18 @@ ROOT::Internal::RPagePersistentSink::InitFromDescriptor(const ROOT::RNTupleDescr } } - // Create model - auto modelOpts = ROOT::RNTupleDescriptor::RCreateModelOptions(); - modelOpts.SetReconstructProjections(true); - // We want to emulate unknown types to allow merging RNTuples containing types that we lack dictionaries for. - modelOpts.SetEmulateUnknownTypes(true); - auto model = descriptor.CreateModel(modelOpts); - if (!copyClusters) { - auto &projectedFields = ROOT::Internal::GetProjectedFieldsOfModel(*model); - projectedFields.GetFieldZero().SetOnDiskId(model->GetConstFieldZero().GetOnDiskId()); + // Recode-without-model skips C++ field reconstruction (streamer fields would otherwise need dictionaries). + std::unique_ptr model; + if (!dropColumnEncoding) { + auto modelOpts = ROOT::RNTupleDescriptor::RCreateModelOptions(); + modelOpts.SetReconstructProjections(true); + // We want to emulate unknown types to allow merging RNTuples containing types that we lack dictionaries for. + modelOpts.SetEmulateUnknownTypes(true); + model = descriptor.CreateModel(modelOpts); + if (!copyClusters) { + auto &projectedFields = ROOT::Internal::GetProjectedFieldsOfModel(*model); + projectedFields.GetFieldZero().SetOnDiskId(model->GetConstFieldZero().GetOnDiskId()); + } } // Serialize header and init from it diff --git a/tree/ntuple/test/ntuple_types.cxx b/tree/ntuple/test/ntuple_types.cxx index fea3b38764d06..c595ba8e3b52c 100644 --- a/tree/ntuple/test/ntuple_types.cxx +++ b/tree/ntuple/test/ntuple_types.cxx @@ -3,6 +3,10 @@ #include "ROOT/TestSupport.hxx" #include "TInterpreter.h" +#ifdef R__HAS_LHC4CODEC +#include "ZipLHC4.h" +#endif + #include #include #include @@ -2305,3 +2309,46 @@ TEST(RNTuple, ContextDependentTypes) } } } + +TEST(RNTuple, DisableColumnEncoding) +{ +#ifdef R__HAS_LHC4CODEC + R__SetLHC4Filters(0); +#endif + + FileRaii fileGuard("test_ntuple_disable_column_encoding.root"); + + auto model = RNTupleModel::Create(); + model->AddField(std::make_unique>("i")); + model->AddField(std::make_unique>("f")); + model->AddField(std::make_unique>>("v")); + + auto options = RNTupleWriteOptions(); + options.SetEnableColumnEncoding(false); + options.SetCompression(606); + + { + auto writer = RNTupleWriter::Recreate(std::move(model), "ntuple", fileGuard.GetPath(), options); + auto e = writer->CreateEntry(); + *e->GetPtr("i") = 42; + *e->GetPtr("f") = 3.14f; + e->GetPtr>("v")->assign({1.f, 2.f, 3.f}); + writer->Fill(*e); + } + + auto reader = RNTupleReader::Open("ntuple", fileGuard.GetPath()); + const auto &desc = reader->GetDescriptor(); + EXPECT_EQ(ROOT::ENTupleColumnType::kInt32, (*desc.GetColumnIterable(desc.FindFieldId("i")).begin()).GetType()); + EXPECT_EQ(ROOT::ENTupleColumnType::kReal32, (*desc.GetColumnIterable(desc.FindFieldId("f")).begin()).GetType()); + EXPECT_EQ(ROOT::ENTupleColumnType::kIndex64, + (*desc.GetColumnIterable(desc.FindFieldId("v")).begin()).GetType()); + + reader->LoadEntry(0); + EXPECT_EQ(42, *reader->GetModel().GetDefaultEntry().GetPtr("i")); + EXPECT_FLOAT_EQ(3.14f, *reader->GetModel().GetDefaultEntry().GetPtr("f")); + EXPECT_EQ(std::vector({1.f, 2.f, 3.f}), *reader->GetModel().GetDefaultEntry().GetPtr>("v")); + +#ifdef R__HAS_LHC4CODEC + EXPECT_EQ(1, R__GetLHC4Filters()); +#endif +} diff --git a/tree/ntuple/test/ntuple_zip.cxx b/tree/ntuple/test/ntuple_zip.cxx index d1a2da7e91ae8..667ed3ffd68e2 100644 --- a/tree/ntuple/test/ntuple_zip.cxx +++ b/tree/ntuple/test/ntuple_zip.cxx @@ -1,5 +1,13 @@ #include "ntuple_test.hxx" +#ifdef R__HAS_LHC4CODEC +#include "ZipLHC4.h" + +#include +#include +#include +#endif + TEST(RNTupleZip, Basics) { std::string data = "xxxxxxxxxxxxxxxxxxxxxxxx"; @@ -69,3 +77,226 @@ TEST(RNTupleZip, CorruptedInput) EXPECT_THROW(RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, data.length(), unzipBuffer.get()), ROOT::RException); } + +#ifdef R__HAS_LHC4CODEC +TEST(RNTupleZip, LHC4) +{ + std::string data = "xxxxxxxxxxxxxxxxxxxxxxxx"; + const int compression = ROOT::CompressionSettings(ROOT::RCompressionSetting::EAlgorithm::kLHC4, + ROOT::RCompressionSetting::ELevel::kDefaultLHC4); + auto zipBuffer = std::unique_ptr(new char[data.length()]); + auto szZipped = RNTupleCompressor::Zip(data.data(), data.length(), compression, zipBuffer.get()); + EXPECT_LT(szZipped, data.length()); + auto unzipBuffer = std::unique_ptr(new char[data.length()]); + RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, data.length(), unzipBuffer.get()); + EXPECT_EQ(data, std::string_view(unzipBuffer.get(), data.length())); +} + +TEST(RNTupleZip, LHC4Filters) +{ + R__SetLHC4Filters(1); + R__SetLHC4Bwt(0); + std::vector column(1024); + for (std::size_t i = 0; i < column.size(); ++i) + column[i] = static_cast(i); + const auto *data = reinterpret_cast(column.data()); + const auto dataLen = column.size() * sizeof(std::uint32_t); + const int compression = ROOT::CompressionSettings(ROOT::RCompressionSetting::EAlgorithm::kLHC4, 6); + auto zipBuffer = std::unique_ptr(new char[dataLen]); + auto szZipped = RNTupleCompressor::Zip(data, dataLen, compression, zipBuffer.get()); + EXPECT_LT(szZipped, dataLen); + auto unzipBuffer = std::unique_ptr(new char[dataLen]); + RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, dataLen, unzipBuffer.get()); + EXPECT_EQ(0, std::memcmp(data, unzipBuffer.get(), dataLen)); + R__SetLHC4Filters(0); +} + +TEST(RNTupleZip, LHC4Bwt) +{ + R__SetLHC4Filters(0); + R__SetLHC4Codec(kLHC4CodecBwt); + std::string data(4096, 'a'); + const int compression = ROOT::CompressionSettings(ROOT::RCompressionSetting::EAlgorithm::kLHC4, 6); + auto zipBuffer = std::unique_ptr(new char[data.length()]); + auto szZipped = RNTupleCompressor::Zip(data.data(), data.length(), compression, zipBuffer.get()); + EXPECT_LT(szZipped, data.length()); + auto unzipBuffer = std::unique_ptr(new char[data.length()]); + RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, data.length(), unzipBuffer.get()); + EXPECT_EQ(data, std::string_view(unzipBuffer.get(), data.length())); + R__SetLHC4Codec(kLHC4CodecBeam); +} + +TEST(RNTupleZip, LHC4FilterOptions) +{ + R__SetLHC4Codec(kLHC4CodecBeam); + R__SetLHC4Filters(1); + R__SetLHC4FilterFallback(0); + R__SetLHC4FilterRle(1); + R__SetLHC4FilterDict(0); + std::vector column(1024); + for (std::size_t i = 0; i < column.size(); ++i) + column[i] = static_cast(i); + const auto *data = reinterpret_cast(column.data()); + const auto dataLen = column.size() * sizeof(std::uint32_t); + const int compression = ROOT::CompressionSettings(ROOT::RCompressionSetting::EAlgorithm::kLHC4, 6); + auto zipBuffer = std::unique_ptr(new char[dataLen]); + auto szZipped = RNTupleCompressor::Zip(data, dataLen, compression, zipBuffer.get()); + EXPECT_LT(szZipped, dataLen); + auto unzipBuffer = std::unique_ptr(new char[dataLen]); + RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, dataLen, unzipBuffer.get()); + EXPECT_EQ(0, std::memcmp(data, unzipBuffer.get(), dataLen)); + R__SetLHC4Filters(0); + R__SetLHC4FilterFallback(1); + R__SetLHC4FilterDict(1); +} + +TEST(RNTupleZip, LHC4ExternalCodec) +{ + if (!R__LHC4CodecAvailable(kLHC4CodecZstd)) + GTEST_SKIP() << "lhc4codec zstd backend not available"; + + R__SetLHC4Codec(kLHC4CodecZstd); + R__SetLHC4Filters(0); + std::string data(4096, 'x'); + const int compression = ROOT::CompressionSettings(ROOT::RCompressionSetting::EAlgorithm::kLHC4, 6); + auto zipBuffer = std::unique_ptr(new char[data.length()]); + auto szZipped = RNTupleCompressor::Zip(data.data(), data.length(), compression, zipBuffer.get()); + EXPECT_LT(szZipped, data.length()); + auto unzipBuffer = std::unique_ptr(new char[data.length()]); + RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, data.length(), unzipBuffer.get()); + EXPECT_EQ(data, std::string_view(unzipBuffer.get(), data.length())); + R__SetLHC4Codec(kLHC4CodecBeam); +} + +TEST(RNTupleZip, LHC4NativeCodecs) +{ + const int codecs[] = {kLHC4CodecMosaic, kLHC4CodecCrystal, kLHC4CodecOracle, + kLHC4CodecFlash, kLHC4CodecLaser, kLHC4CodecGrating}; + std::string data(4096, 'x'); + const int compression = ROOT::CompressionSettings(ROOT::RCompressionSetting::EAlgorithm::kLHC4, 5); + for (int codec : codecs) { + ASSERT_TRUE(R__LHC4CodecAvailable(codec)); + R__SetLHC4Codec(codec); + R__SetLHC4Filters(1); + auto zipBuffer = std::unique_ptr(new char[data.length()]); + auto szZipped = RNTupleCompressor::Zip(data.data(), data.length(), compression, zipBuffer.get()); + EXPECT_LT(szZipped, data.length()) << "codec " << codec; + auto unzipBuffer = std::unique_ptr(new char[data.length()]); + RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, data.length(), unzipBuffer.get()); + EXPECT_EQ(data, std::string_view(unzipBuffer.get(), data.length())) << "codec " << codec; + } + R__SetLHC4Codec(kLHC4CodecBeam); + R__SetLHC4Filters(0); +} + +TEST(RNTupleZip, LHC4Auto) +{ + R__SetLHC4Codec(kLHC4CodecAuto); + R__SetLHC4AutoMinGainPct(1); + R__SetLHC4Filters(1); + std::vector column(1024); + for (std::size_t i = 0; i < column.size(); ++i) + column[i] = static_cast(i); + const auto *data = reinterpret_cast(column.data()); + const auto dataLen = column.size() * sizeof(std::uint32_t); + const int compression = ROOT::CompressionSettings(ROOT::RCompressionSetting::EAlgorithm::kLHC4, 5); + auto zipBuffer = std::unique_ptr(new char[dataLen]); + auto szZipped = RNTupleCompressor::Zip(data, dataLen, compression, zipBuffer.get()); + EXPECT_LT(szZipped, dataLen); + auto unzipBuffer = std::unique_ptr(new char[dataLen]); + RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, dataLen, unzipBuffer.get()); + EXPECT_EQ(0, std::memcmp(data, unzipBuffer.get(), dataLen)); + R__SetLHC4Codec(kLHC4CodecBeam); + R__SetLHC4Filters(0); +} + +TEST(RNTupleZip, LHC4AutoMaxLevel) +{ + R__SetLHC4Codec(kLHC4CodecAuto); + R__SetLHC4AutoMinGainPct(1); + R__SetLHC4AutoMaxLevel(1); + R__SetLHC4Filters(1); + std::vector column(1024); + for (std::size_t i = 0; i < column.size(); ++i) + column[i] = static_cast(i); + const auto *data = reinterpret_cast(column.data()); + const auto dataLen = column.size() * sizeof(std::uint32_t); + const int compression = ROOT::CompressionSettings(ROOT::RCompressionSetting::EAlgorithm::kLHC4, 5); + auto zipBuffer = std::unique_ptr(new char[dataLen]); + auto szZipped = RNTupleCompressor::Zip(data, dataLen, compression, zipBuffer.get()); + EXPECT_LT(szZipped, dataLen); + auto unzipBuffer = std::unique_ptr(new char[dataLen]); + RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, dataLen, unzipBuffer.get()); + EXPECT_EQ(0, std::memcmp(data, unzipBuffer.get(), dataLen)); + R__SetLHC4Codec(kLHC4CodecBeam); + R__SetLHC4AutoMaxLevel(0); + R__SetLHC4Filters(0); +} + +TEST(RNTupleZip, LHC4AutoCodecs) +{ + EXPECT_EQ(kLHC4AutoCodecsRoot, R__GetLHC4AutoCodecs()); + + R__SetLHC4Codec(kLHC4CodecAuto); + R__SetLHC4AutoMinGainPct(0); + R__SetLHC4AutoCodecs(kLHC4CodecBit(kLHC4CodecBeam)); + R__SetLHC4Filters(1); + R__ResetLHC4CompressStats(); + + std::vector column(1024); + for (std::size_t i = 0; i < column.size(); ++i) + column[i] = static_cast(i); + const auto *data = reinterpret_cast(column.data()); + const auto dataLen = column.size() * sizeof(std::uint32_t); + const int compression = ROOT::CompressionSettings(ROOT::RCompressionSetting::EAlgorithm::kLHC4, 5); + auto zipBuffer = std::unique_ptr(new char[dataLen]); + auto szZipped = RNTupleCompressor::Zip(data, dataLen, compression, zipBuffer.get()); + EXPECT_LT(szZipped, dataLen); + auto unzipBuffer = std::unique_ptr(new char[dataLen]); + RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, dataLen, unzipBuffer.get()); + EXPECT_EQ(0, std::memcmp(data, unzipBuffer.get(), dataLen)); + + R__LHC4AutoCodecStatsSummary stats{}; + R__GetLHC4AutoCodecStatsSummary(&stats); + EXPECT_GT(stats.auto_selections, 0u); + EXPECT_EQ(stats.codec_hits[kLHC4CodecBeam], stats.auto_selections); + for (int i = 0; i < kLHC4CodecCount; ++i) { + if (i != kLHC4CodecBeam) + EXPECT_EQ(0u, stats.codec_hits[i]) << "codec " << i; + } + + R__SetLHC4Codec(kLHC4CodecBeam); + R__SetLHC4AutoCodecs(0); + R__SetLHC4AutoMinGainPct(1); + R__SetLHC4Filters(0); + EXPECT_EQ(kLHC4AutoCodecsRoot, R__GetLHC4AutoCodecs()); +} + +TEST(RNTupleZip, LHC4Profile) +{ + EXPECT_EQ(kLHC4ProfileNone, R__GetLHC4Profile()); + + R__SetLHC4Codec(kLHC4CodecCrystal); + R__SetLHC4Filters(0); + R__SetLHC4Profile(kLHC4ProfileIntMonotonic); + R__SetLHC4ProfileId(0); + + std::vector column(4096); + for (std::size_t i = 0; i < column.size(); ++i) + column[i] = static_cast(i); + const auto *data = reinterpret_cast(column.data()); + const auto dataLen = column.size() * sizeof(std::uint32_t); + const int compression = ROOT::CompressionSettings(ROOT::RCompressionSetting::EAlgorithm::kLHC4, 5); + auto zipBuffer = std::unique_ptr(new char[dataLen]); + auto szZipped = RNTupleCompressor::Zip(data, dataLen, compression, zipBuffer.get()); + EXPECT_LT(szZipped, dataLen); + auto unzipBuffer = std::unique_ptr(new char[dataLen]); + RNTupleDecompressor::Unzip(zipBuffer.get(), szZipped, dataLen, unzipBuffer.get()); + EXPECT_EQ(0, std::memcmp(data, unzipBuffer.get(), dataLen)); + + R__SetLHC4Codec(kLHC4CodecBeam); + R__SetLHC4Profile(kLHC4ProfileNone); + R__SetLHC4ProfileId(0); + EXPECT_EQ(kLHC4ProfileNone, R__GetLHC4Profile()); +} +#endif From 0cbd2e3dbb5fdc39549657544da3a9d1b906c708 Mon Sep 17 00:00:00 2001 From: Andreas Joachim Peters Date: Wed, 14 Oct 2026 13:13:10 +0200 Subject: [PATCH 5/5] [ntuple] Learned column compression profiles for LHC4 Crystal. Train one static Crystal model per column from its first pages and store it once in the column descriptor instead of shipping a ~1.5 KiB model with every page. Format: kFeatureFlag_CompressionContext (1) in the header feature flags and column record flag 0x04 carrying an RCompressionContext (algorithm, type, size, payload); RNTupleDescriptor and the serializer read and write it, with a unit test. Writer: RNTupleWriteOptions::SetEnableLearnedProfiles, the training sample size (256 KiB per column, capped across columns) and the train level. The persistent sink buffers a column's uncompressed pages until the sample is complete, trains and registers the model, seals later pages with that profile id, trains the still-pending columns before the header is committed, and defers the header while profiles are enabled. RPageSinkBuf forwards the profile id to the parallel zip tasks. Pages written before the sample is full stay dynamic; a page the profile loses escapes to a dynamic block in the codec. Reader: RPageSource registers the models from the descriptor on attach. core/lhc4codec: R__LHC4TrainProfileModel[Level], R__LHC4RegisterProfileModel, R__LHC4ExportProfileModel, a thread-local call profile id, and the Crystal profile block / escape counters in the compression statistics. Tools: lhc4_crystal_learned5/7/9 variants and the profile escape report in tree2ntuple and rntuple2rntuple. tree/ntuple/doc/LearnedCompressionProfiles.md documents the format, the codec side (precomputed decode tables, drifting-column transforms, per-page lane modes on profile blocks) and the measurements: at 1 MiB pages the learned file is 0.4% larger than dynamic Crystal, decodes 3-4% faster and halves the encode CPU; at 16 KiB pages it is 2.4% smaller and writes five times faster. --- core/lhc4codec/README.md | 15 + core/lhc4codec/inc/ZipLHC4.h | 21 + core/lhc4codec/src/ZipLHC4.cxx | 40 +- main/src/rntuple2rntuple.cxx | 10 +- main/src/tree2ntuple.cxx | 34 +- tree/ntuple/doc/LearnedCompressionProfiles.md | 653 ++++++++++++++++++ tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx | 66 ++ tree/ntuple/inc/ROOT/RNTupleSerialize.hxx | 1 + tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx | 21 +- tree/ntuple/inc/ROOT/RPageSinkBuf.hxx | 4 + tree/ntuple/inc/ROOT/RPageStorage.hxx | 36 +- tree/ntuple/src/RNTupleDescriptor.cxx | 9 +- tree/ntuple/src/RNTupleSerialize.cxx | 30 + tree/ntuple/src/RPageSinkBuf.cxx | 17 +- tree/ntuple/src/RPageStorage.cxx | 245 ++++++- tree/ntuple/test/ntuple_serialize.cxx | 62 ++ 16 files changed, 1246 insertions(+), 18 deletions(-) create mode 100644 tree/ntuple/doc/LearnedCompressionProfiles.md diff --git a/core/lhc4codec/README.md b/core/lhc4codec/README.md index 705b0656a2f1c..08b3cf3562af3 100644 --- a/core/lhc4codec/README.md +++ b/core/lhc4codec/README.md @@ -268,6 +268,21 @@ must be >= 5 and come from `R__LHC4TrainAndRegisterProfile()`. choice. `--profile float|poisson|monotonic|text|custom` forces that one model on `lhc4_crystal_profile5` only. +### Learned column compression profiles (`lhc4_crystal_learned`) + +Rather than relying on hard-coded heuristics or paying the overhead of repeating dynamic +frequency tables on every page (~1.5 KiB/page), RNTuple supports data-learned column +compression profiles: +- **Empirical Training**: Trained from initial column data (Cluster 0) using + `R__LHC4TrainProfileModelLevel(sample, n, stride, level, ...)` with configurable + levels (5, 7, 9) and order-1/2 context predictors (`C1`, `C2`, `Lat1`, `Trend`, `RecEq`). +- **Metadata Persistence**: The trained model (130–550 bytes) is stored in the RNTuple + Header via `RCompressionContext` (Feature Flag `kFeatureFlag_CompressionContext`, + record flag `0x04`), eliminating per-page table headers. +- **Precomputed Decoder Tables**: Decode tables (`dec_tab`, `q_scaled`, `DecLane`) are + precomputed once during profile registration and cached in `StaticProfileHolder`. + This delivers line-rate SIMD rANS decompression without per-page reconstruction overhead. + Decompression does **not** need these flags; the inner frame type and filter path are restored automatically on read. diff --git a/core/lhc4codec/inc/ZipLHC4.h b/core/lhc4codec/inc/ZipLHC4.h index 3972d9ab71f49..ae00fb43a950d 100644 --- a/core/lhc4codec/inc/ZipLHC4.h +++ b/core/lhc4codec/inc/ZipLHC4.h @@ -141,6 +141,24 @@ unsigned R__GetLHC4ProfileId(void); /// Train a custom static profile from columnar sample bytes and register it. /// `profileId` must be >= 5. `stride` is the element width in bytes. Returns 1 on success. int R__LHC4TrainAndRegisterProfile(unsigned profileId, const void *sample, size_t sampleSize, int stride); +int R__LHC4TrainAndRegisterProfileLevel(unsigned profileId, const void *sample, size_t sampleSize, int stride, int level); + +/// Train a static Crystal profile model from sample columnar data. +/// If outModel is NULL, writes required size to *outModelSize and returns 1. +/// Returns 1 on success, 0 on failure. +int R__LHC4TrainProfileModel(const void *sample, size_t sampleSize, int stride, + void *outModel, size_t *outModelSize); +int R__LHC4TrainProfileModelLevel(const void *sample, size_t sampleSize, int stride, int level, + void *outModel, size_t *outModelSize); + +/// Register a serialized profile model under profileId (>= 5). +/// Returns 1 on success, 0 on failure. +int R__LHC4RegisterProfileModel(unsigned profileId, const void *modelBytes, size_t modelSize); + +/// Export an already-registered or builtin profile model to serialized bytes. +/// If outModel is NULL, writes required size to *outModelSize and returns 1. +/// Returns 1 on success, 0 on failure. +int R__LHC4ExportProfileModel(unsigned profileId, void *outModel, size_t *outModelSize); /// Legacy convenience: enable BWT block mode (sets codec to Bwt/Beam). void R__SetLHC4Bwt(int enable); @@ -161,6 +179,9 @@ struct R__LHC4CompressStatsSummary { unsigned long long no_transform_pages; unsigned long long stored_fallback_pages; unsigned long long plain_pages; + /// Crystal blocks requested with a static profile: coded with it / escaped to a dynamic block + unsigned long long crystal_profile_blocks; + unsigned long long crystal_profile_escapes; }; void R__GetLHC4CompressStatsSummary(struct R__LHC4CompressStatsSummary *out); diff --git a/core/lhc4codec/src/ZipLHC4.cxx b/core/lhc4codec/src/ZipLHC4.cxx index 8674826fe8466..9fdfaf962bbc3 100644 --- a/core/lhc4codec/src/ZipLHC4.cxx +++ b/core/lhc4codec/src/ZipLHC4.cxx @@ -11,6 +11,7 @@ #include "ZipLHC4.h" #include "lhc4codec/lhc4codec.hpp" +#include "lhc4codec/lhc4codec_c.h" #include #include @@ -358,15 +359,42 @@ static int EffectiveLHC4Profile() } extern "C" int R__LHC4TrainAndRegisterProfile(unsigned profileId, const void *sample, size_t sampleSize, int stride) +{ + return R__LHC4TrainAndRegisterProfileLevel(profileId, sample, sampleSize, stride, 5); +} + +extern "C" int R__LHC4TrainAndRegisterProfileLevel(unsigned profileId, const void *sample, size_t sampleSize, int stride, int level) { if (!sample || sampleSize == 0) return 0; return lhc4codec::train_and_register_profile( - profileId, std::span(static_cast(sample), sampleSize), stride) + profileId, std::span(static_cast(sample), sampleSize), stride, level) ? 1 : 0; } +extern "C" int R__LHC4TrainProfileModel(const void *sample, size_t sampleSize, int stride, + void *outModel, size_t *outModelSize) +{ + return R__LHC4TrainProfileModelLevel(sample, sampleSize, stride, 5, outModel, outModelSize); +} + +extern "C" int R__LHC4TrainProfileModelLevel(const void *sample, size_t sampleSize, int stride, int level, + void *outModel, size_t *outModelSize) +{ + return lhc4codec_train_profile_model_level(sample, sampleSize, stride, level, outModel, outModelSize); +} + +extern "C" int R__LHC4RegisterProfileModel(unsigned profileId, const void *modelBytes, size_t modelSize) +{ + return lhc4codec_register_profile_model(profileId, modelBytes, modelSize); +} + +extern "C" int R__LHC4ExportProfileModel(unsigned profileId, void *outModel, size_t *outModelSize) +{ + return lhc4codec_export_profile_model(profileId, outModel, outModelSize); +} + extern "C" void R__SetLHC4Bwt(int enable) { if (enable) @@ -400,8 +428,12 @@ static lhc4codec::CompressParams MakeLHC4CompressParams(int cxlevel) params.auto_max_level = R__LHC4AutoMaxLevel != 0; params.auto_codecs = R__LHC4AutoCodecs; params.profile = static_cast(EffectiveLHC4Profile()); - // A per-call profile is a builtin id. The process-wide id applies only to the forced profile. - params.profile_id = R__LHC4CallProfile >= 0 ? 0u : R__LHC4ProfileId; + // A per-call profile is a builtin id unless call profile ID is set. The process-wide id applies only to the forced profile. + if (R__LHC4CallProfile >= 0) { + params.profile_id = R__LHC4CallProfileId ? R__LHC4CallProfileId : static_cast(R__LHC4CallProfile); + } else { + params.profile_id = R__LHC4ProfileId; + } return params; } @@ -542,6 +574,8 @@ extern "C" void R__GetLHC4CompressStatsSummary(R__LHC4CompressStatsSummary *out) out->no_transform_pages = stats.no_transform_pages; out->stored_fallback_pages = stats.stored_fallback_pages; out->plain_pages = stats.plain_pages; + out->crystal_profile_blocks = stats.crystal_profile_blocks; + out->crystal_profile_escapes = stats.crystal_profile_escapes; } extern "C" void R__GetLHC4AutoCodecStatsSummary(R__LHC4AutoCodecStatsSummary *out) diff --git a/main/src/rntuple2rntuple.cxx b/main/src/rntuple2rntuple.cxx index 3329992747ecf..3253ec152ebcf 100644 --- a/main/src/rntuple2rntuple.cxx +++ b/main/src/rntuple2rntuple.cxx @@ -155,6 +155,8 @@ Re-encode an RNTuple with different page compressors: filters, same kind of compressor as Beam) _lhc4_crystal_profileN.root lhc4codec Crystal with pretrained per-column profiles (Real32=float, Index32/UInt32=poisson, Char=text) + _lhc4_crystal_learnedN.root lhc4codec Crystal with learned column + profiles stored once in metadata (header/footer) _lhc4_oracleN.root lhc4codec filters + Oracle (not raced by Auto) _lhc4_flashN.root lhc4codec filters + Flash (byte-aligned LZ without an entropy stage, fastest decoder; not raced by Auto) @@ -1084,6 +1086,7 @@ struct Variant { int fLHC4Codec = 0; bool fRequireCodecAvailable = false; bool fLHC4ProfilePerColumn = false; + bool fLearnedProfiles = false; }; RNTupleWriteOptions MakeNativeWriteOptions(ROOT::RCompressionSetting::EAlgorithm::EValues algorithm, int level) @@ -1119,6 +1122,10 @@ std::vector MakeVariants(const RunOptions &runOpts) variants.push_back({"lhc4_crystal5", "_lhc4_crystal" + lv, lhc4Opts, true, true, kLHC4CodecCrystal, false}); variants.push_back({"lhc4_crystal_profile5", "_lhc4_crystal_profile" + lv, lhc4Opts, true, true, kLHC4CodecCrystal, false, true}); + auto learnedOpts = lhc4Opts; + learnedOpts.SetEnableLearnedProfiles(true); + variants.push_back({"lhc4_crystal_learned5", "_lhc4_crystal_learned" + lv, learnedOpts, true, true, + kLHC4CodecCrystal, false, true, true}); variants.push_back({"lhc4_oracle5", "_lhc4_oracle" + lv, lhc4Opts, true, true, kLHC4CodecOracle, false}); variants.push_back({"lhc4_flash5", "_lhc4_flash" + lv, lhc4Opts, true, true, kLHC4CodecFlash, false}); variants.push_back({"lhc4_laser5", "_lhc4_laser" + lv, lhc4Opts, true, true, kLHC4CodecLaser, false}); @@ -1318,8 +1325,9 @@ std::optional ReencodeVariant(const std::string &inputFile, const std::cerr << "Skipping " << variant.fLabel << ": lhc4codec backend is not available in this build\n"; return std::nullopt; } + int effectiveProfile = variant.fLearnedProfiles ? kLHC4ProfileNone : profile; ConfigureLHC4FilterGlobals(variant.fLHC4Codec, variant.fEnableLHC4ByteFilters, autoMinGainPct, autoMaxLevel, - autoCodecs, profile, profileId, variant.fLHC4ProfilePerColumn); + autoCodecs, effectiveProfile, profileId, variant.fLHC4ProfilePerColumn); } else { ResetLHC4Globals(autoMinGainPct, autoMaxLevel, autoCodecs); } diff --git a/main/src/tree2ntuple.cxx b/main/src/tree2ntuple.cxx index 1d44e8728e986..83352993c3252 100644 --- a/main/src/tree2ntuple.cxx +++ b/main/src/tree2ntuple.cxx @@ -136,6 +136,8 @@ Convert a TTree into RNTuple files: filters, same kind of compressor as Beam) _lhc4_crystal_profileN.root lhc4codec Crystal with pretrained per-column profiles (Real32=float, Index32/UInt32=poisson, Char=text) + _lhc4_crystal_learnedN.root lhc4codec Crystal with learned column + profiles stored once in metadata (header/footer) _lhc4_oracleN.root lhc4codec filters + Oracle (not raced by Auto) _lhc4_flashN.root lhc4codec filters + Flash (byte-aligned LZ without an entropy stage, fastest decoder; not raced by Auto) @@ -601,7 +603,8 @@ int TrailingVariantLevel(const std::string &label) bool IsDedicatedLevelVariant(const std::string &label) { - return label == "lhc4_crystal7" || label == "lhc4_crystal9"; + return label == "lhc4_crystal7" || label == "lhc4_crystal9" || label == "lhc4_crystal_learned7" || + label == "lhc4_crystal_learned9"; } std::string TrimCopy(const std::string &text) @@ -1084,6 +1087,7 @@ struct Variant { int fLHC4Codec = 0; bool fRequireCodecAvailable = false; bool fLHC4ProfilePerColumn = false; + bool fLearnedProfiles = false; }; const Variant *FindVariant(const std::vector &variants, const std::string &label); @@ -1121,14 +1125,26 @@ std::vector MakeVariants(const RunOptions &runOpts) variants.push_back({"lhc4_crystal5", "_lhc4_crystal" + lv, lhc4Opts, true, true, kLHC4CodecCrystal, false}); variants.push_back({"lhc4_crystal_profile5", "_lhc4_crystal_profile" + lv, lhc4Opts, true, true, kLHC4CodecCrystal, false, true}); + auto learnedOpts5 = lhc4Opts; + learnedOpts5.SetEnableLearnedProfiles(true); + variants.push_back({"lhc4_crystal_learned5", "_lhc4_crystal_learned" + lv, learnedOpts5, true, true, + kLHC4CodecCrystal, false, true, true}); // Extra Crystal 7/9 comparison variants only at the default level so suffixes do not collide. if (kLevel == kDefaultLHC4Level) { const auto lhc4Opts7 = MakeLHC4WriteOptions(7, runOpts.fKeepColumnEncoding); variants.push_back({"lhc4_crystal7", "_lhc4_crystal7", lhc4Opts7, true, true, kLHC4CodecCrystal, false}); + auto learnedOpts7 = lhc4Opts7; + learnedOpts7.SetEnableLearnedProfiles(true); + variants.push_back({"lhc4_crystal_learned7", "_lhc4_crystal_learned7", learnedOpts7, true, true, kLHC4CodecCrystal, + false, true, true}); const auto lhc4Opts9 = MakeLHC4WriteOptions(9, runOpts.fKeepColumnEncoding); variants.push_back({"lhc4_crystal9", "_lhc4_crystal9", lhc4Opts9, true, true, kLHC4CodecCrystal, false}); + auto learnedOpts9 = lhc4Opts9; + learnedOpts9.SetEnableLearnedProfiles(true); + variants.push_back({"lhc4_crystal_learned9", "_lhc4_crystal_learned9", learnedOpts9, true, true, kLHC4CodecCrystal, + false, true, true}); } variants.push_back({"lhc4_oracle5", "_lhc4_oracle" + lv, lhc4Opts, true, true, kLHC4CodecOracle, false}); @@ -1208,8 +1224,9 @@ std::optional ImportVariant(const std::string &inputFile, const s std::cerr << "Skipping " << variant.fLabel << ": lhc4codec backend is not available in this build\n"; return std::nullopt; } + int effectiveProfile = variant.fLearnedProfiles ? kLHC4ProfileNone : profile; ConfigureLHC4FilterGlobals(variant.fLHC4Codec, variant.fEnableLHC4ByteFilters, autoMinGainPct, autoMaxLevel, - autoCodecs, profile, profileId, variant.fLHC4ProfilePerColumn); + autoCodecs, effectiveProfile, profileId, variant.fLHC4ProfilePerColumn); } else { ResetLHC4Globals(autoMinGainPct, autoMaxLevel, autoCodecs); } @@ -1257,6 +1274,19 @@ std::optional ImportVariant(const std::string &inputFile, const s if (!quiet) std::cout << " done\n"; +#ifdef R__HAS_LHC4CODEC + if (!quiet && variant.fUsesLHC4Globals) { + R__LHC4CompressStatsSummary stats{}; + R__GetLHC4CompressStatsSummary(&stats); + if (const auto total = stats.crystal_profile_blocks + stats.crystal_profile_escapes; total > 0) { + std::cout << " Crystal profile blocks: " << stats.crystal_profile_blocks << " coded with profile, " + << stats.crystal_profile_escapes << " escaped to dynamic (" + << FormatPct(100.0 * static_cast(stats.crystal_profile_escapes) / + static_cast(total)) + << ")\n"; + } + } +#endif VariantResult result; result.fLabel = variant.fLabel; diff --git a/tree/ntuple/doc/LearnedCompressionProfiles.md b/tree/ntuple/doc/LearnedCompressionProfiles.md new file mode 100644 index 0000000000000..9fa499809c7f3 --- /dev/null +++ b/tree/ntuple/doc/LearnedCompressionProfiles.md @@ -0,0 +1,653 @@ +# Learned Column Compression Profiles in RNTuple: Architecture, Implementation, and Performance Analysis + +## 1. Executive Summary & Problem Analysis + +RNTuple is ROOT's next-generation columnar storage format, engineered for high-throughput analysis on modern NVMe and networked storage. High-performance columnar codecs like **Crystal** (part of `lhc4codec`) rely on lane-wise linear predictors, dynamic context modeling, and finite-state entropy (rANS) probability models. + +### 1.1 The Failure Mode of Hard-Coded Priors +In earlier versions, Crystal compression in RNTuple utilized hard-coded column-type heuristics: + +```cpp +int LHC4ProfileForColumnType(ROOT::ENTupleColumnType type) +{ + switch (type) { + case ROOT::ENTupleColumnType::kReal32: + return kLHC4ProfileFloatKinematics; + case ROOT::ENTupleColumnType::kIndex32: + case ROOT::ENTupleColumnType::kUInt32: + return kLHC4ProfilePoissonCounts; + case ROOT::ENTupleColumnType::kChar: + return kLHC4ProfileText; + default: + return kLHC4ProfileNone; + } +} +``` + +While intended to avoid per-page model headers, hard-coded priors frequently fail on real High-Energy Physics (HEP) data: +- **Distribution Mismatch**: A single-precision float column in real physics ntuples might represent an invariant mass distribution peaked at $91.2\text{ GeV}$ or $125\text{ GeV}$, spatial detector coordinates ($\eta \in [-2.5, 2.5]$, $\phi \in [-\pi, \pi]$), normalized machine learning classifier scores / B-tagging probabilities $\in [0.0, 1.0]$, or calorimeter cell energies spanning six orders of magnitude. +- **Entropy Coding Penalty**: Coding symbols against mismatched probability tables costs up to $+8\text{ to }+16\text{ bits per byte}$. The codec detects expansion beyond raw size and falls back to storing raw uncompressed bytes. + +### 1.2 The Cost of Per-Page Dynamic Models +When fallback occurs, or when using dynamic models without profiles, Crystal re-estimates frequency distributions on every single page: +- **Redundant Model Table Overhead**: A dynamic Crystal model table weighs $\approx 1.0\text{ to }1.5\text{ KiB}$. On a typical column with $5{,}000$ pages ($64\text{ to }256\text{ KiB}$ each), repeating the table on each page consumes: + $$\text{Redundant Overhead} = 1.5\text{ KiB} \times 5{,}000\text{ pages} \approx 7.5\text{ MB per column}$$ + In a file with $100$ physics columns, $50\text{ to }75\text{ MB}$ of storage is wasted on duplicate frequency tables. For small pages ($4\text{ to }16\text{ KiB}$), this table tax accounts for $20\%\text{ to }50\%$ of the entire file. +- **Decompression Throughput Bottleneck**: Every page decode must parse arithmetic model headers, allocate symbol tables, and reconstruct cumulative frequency trees (~256–640 KiB allocation and rebuild per page). + +### 1.3 The Learned Global Profile Architecture +Storing the model **once** in the column metadata resolves both problems: +1. **$99.98\%$ Table Overhead Eliminated**: Storing $130\text{ to }550\text{ bytes}$ once in the column descriptor eliminates redundant page headers across all clusters. +2. **True Empirical Fit**: Training the model on actual column sample bytes captures the exact exponent, mantissa, and sign distributions. +3. **Precomputed Decoding Tables**: The reader initializes the model and compiles the decoding lookup tables once upon opening the column, bypassing table construction during page decompression. + +--- + +## 2. On-Disk Binary Format Specification Updates + +The RNTuple format specification (`tree/ntuple/doc/BinaryFormatSpecification.md`) has been extended with feature flags and column compression contexts. + +### 2.1 Feature Flags +To prevent older readers that lack support for column-level compression contexts from attempting to read profile-encoded pages: + +| Flag Bit | Name | Meaning | +| :--- | :--- | :--- | +| **`1`** | **`kFeatureFlag_CompressionContext`** | Signals that at least one column descriptor contains an empirical compression context/dictionary. | + +In `tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx`: +```cpp +enum EFeatureFlags { + kFeatureFlag_NestedDeferredColumns = 0, + kFeatureFlag_CompressionContext = 1, + kFeatureFlag_COUNT, + kFeatureFlag_Test = 137 +}; +``` + +### 2.2 Column Description Record Frame Extension (`0x04`) +The **Column Description** record frame in the Header and Footer envelopes is extended with an optional compression context block: + +``` + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Type | Bits on Storage | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Field ID | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Flags | Representation Index | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | ++ First element index (if flag 0x01 is set) + +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | ++ Min value (if flag 0x02 is set) + +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | ++ Max value (if flag 0x02 is set) + +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Context Algorithm | Context Type | ◄── [if flag 0x04] ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Context Length | ◄── [if flag 0x04] ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Context Payload Bytes | ◄── [if flag 0x04] +| ... | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +#### Column Record Flags: +- `0x01`: `kFlagDeferredColumn` — Index of first element is non-zero. +- `0x02`: `kFlagHasValueRange` — Min and max values are present. +- **`0x04`**: **`kFlagHasCompressionContext`** — Column carries a trained context (e.g., Crystal profile model, Zstd dictionary). + +#### Context Header Fields: +- **`Context Algorithm`** (`UInt16`): The compression algorithm identifier (`0x05` for LHC4, `0x04` for Zstd). +- **`Context Type`** (`UInt16`): Context encoding type: + - `0x0001`: `kLHC4CrystalProfile` — Serialized Crystal `Model` (lane feature selectors, group mappings, quantized rANS frequencies). + - `0x0002`: `kZstdDictionary` — Standard Zstandard trained dictionary blob. +- **`Context Length`** (`UInt32`): Uncompressed byte length of the context payload ($130\text{ to }550\text{ bytes}$ for Crystal). +- **`Context Payload`**: Raw serialized model/dictionary bytes. + +--- + +## 3. Codec Engine Implementation (`cerncodec`) + +All engine enhancements were implemented in `cerncodec/src/crystal_static.cpp` and exposed through `include/lhc4codec/lhc4codec.hpp`. + +### 3.1 Public C++ API +```cpp +namespace lhc4codec { + +[[nodiscard]] std::vector train_profile_model( + std::span sample, int stride, int level = 5); + +[[nodiscard]] bool register_profile_model( + std::uint32_t profile_id, std::span model_bytes) noexcept; + +[[nodiscard]] std::vector export_profile_model(std::uint32_t profile_id); + +} // namespace lhc4codec +``` + +### 3.2 Precomputed Decoder Tables in `StaticProfileHolder` +To eliminate per-page table parsing, `StaticProfileHolder` precomputes and caches all lookup tables upon profile registration: +```cpp +struct StaticProfileHolder { + Model model; + Filter transform = Filter::None; // element transform applied to every page (section 3.6) + std::vector esym; + + // Precomputed decoder tables & structures (read-only after finalize_profile) + bool compact = false; + bool trend = false; + bool req = false; + std::vector dec_tab; + std::vector q_scaled; + std::array lanes{}; +}; +``` +In `finalize_profile(StaticProfileHolder& h)`: +1. `build_dec_tables(m, h.dec_tab, h.compact)` pre-populates rank-to-symbol and frequency lookup tables. +2. `h.q_scaled` pre-multiplies context group scales by `ctx_bytes_for(compact)`. +3. `DecLane` descriptors are pre-constructed with shift masks and table pointers. + +### 3.3 Decoupled Stream Decoder Dispatch +The stream decompression routine was decoupled: +```cpp +Status decode_crystal_streams(const Model& m, bool compact, const u8* tab, + const DecLane* lanes, bool trend, bool req, + int k, ByteReader& r, u8* dst, usize n); +``` +`decode_crystal_profile` passes pointers directly from `h->dec_tab.data()` and `h->lanes.data()`, completely bypassing runtime table generation and dynamic allocation. + +### 3.4 Buffer Reuse and Selective Zeroing +In `decode_crystal_streams`: +- Multi-stream padded buffer allocation was changed from `assign(total, 0)` (which cleared hundreds of KiB of memory per page) to capacity checking `resize(total)`. +- Zeroing is strictly limited to the trailing lookahead window per stream: + ```cpp + std::memset(t.padded.data() + off + lens[s], 0, kRansPad); + ``` + +### 3.5 Thread-Safe Profile Registry +Profile lookups are guarded by a read-write lock: +```cpp +static std::shared_mutex g_custom_profiles_mutex; +static std::unordered_map> g_custom_profiles; +``` +Concurrent worker threads decode pages simultaneously under `std::shared_lock`, while new profiles are registered with `std::unique_lock`. + +### 3.6 Drifting Columns: Element Transform and Support Prior + +A static profile models byte values. The pages of an offset or index column (RNTuple `Index32/64`, cumulative +collection sizes) break that assumption: every page starts where the previous one ended, so the byte values of +the training sample say nothing about the byte values of later pages, while the first differences (the collection +sizes) are stationary. The same holds for timestamps, event numbers and slowly changing run/lumi numbers. Before +this section's changes, 66% of the blocks on the CMS sample escaped to the per-page model, for this reason and +for the cost of the pseudo-counts described below. + +**Transform trained into the profile.** `train_profile` (crystal_static.cpp) trains the plain model and, for +2/4/8-byte elements, the `Delta` and `ZigzagDelta` candidates of that width (`transform.hpp` filters). A candidate +is trained only when a per-lane order-0 estimate of the transformed sample is below the plain one (floats and +small counts fail this gate and cost a single training); the choice between trained candidates is by the exact +coded size of the sample. The exception is a *drifting* sample, detected by `sample_drifts`: the two halves of the +sample share at most half of some lane's byte mass (total variation distance $\ge 1/2$). Offsets move their high +lanes this way; floats, counts and small signed integers keep the same lane histograms throughout. On a drifting +sample the plain model's sample cost is not predictive (it is reached with bases relative to the previous record +and contexts on byte values that do not recur), so the transform is taken when it costs at most 2% more on the +sample. + +The transform is part of the model bytes: the leading byte carries the lattice stride in bits 0-4 and the transform +code in bits 5-7 (0 = none, so models written before this change read unchanged). Nothing changes on the RNTuple +side, the model bytes in `RCompressionContext` are opaque. Every page coded with the profile goes through +`apply_filter` before the context pass and `invert_filter` (in place) after decoding. The first element of a +transformed page is the page's absolute start, which the profile cannot know: it travels raw in the block header +(after the stream count) and is zero for the coder. + +**Support prior.** Symbols the sample did not show need a frequency too, and the smallest one ($1/4096$) is a real +cost: 255 unseen symbols take 6% of a context's mass, 0.09 bits per byte, which on a constant lane or a count +column is 10% of the page. This was the hidden reason why many float and count pages escaped by a few percent. +The pseudo-count now goes only to the unseen symbols inside the sample's *support*: the ring interval spanned by +the seen symbols plus a margin of two on both sides (the gaps of a count's tail, the next values of a growing one, +the `0xFF` of a negative delta next to the `0x00` of a positive one). Minor contexts (under 4096 observations in +the sample, e.g. the stream starts) get every unseen symbol, since the pseudo-counts cost next to nothing there +against the header of an escape. A page that needs a symbol outside the support is not codable with the profile +and takes the dynamic path; the escape costs it the per-page model header, ~1.5 KiB. + +**Stride.** The profile keeps the column's element stride. `sanitize_stride` (which trades per-lane tables against +their header cost on a single block) no longer applies to profiles: the header is shipped once, and merging the +lanes would spread each lane's pseudo-counts over the others' symbols (on a lumi-like column this quadrupled the +profile's cost). + +Effect on the CMS sample (1 MiB pages): the escaped bytes fell from 808 MB to 265 MB of the 1,281 MB of Crystal +input (48.5% of the blocks, most of them 32 KiB boolean/char pages whose output is negligible); the `Index64` +columns are coded with `Delta64` on the profile (175 of 198 blocks). The piecewise-constant `int32` columns (run, +lumi) train `ZigzagDelta32` and still escape on the pages where the value jumps outside the support; with +$\approx 2$ KiB of output per 1 MiB page that is immaterial. The learned file is 0.4% larger than dynamic instead +of 1.2%. The remaining gap is the static tables on the float columns, where the +per-page tables fit the page (+0.46% over the per-page estimate on 299 MB of coded output) plus the headers of the +escaped blocks. Decode speed is unchanged (section 5.1): the inverse delta is one pass at memory speed over a +small fraction of the pages. + +### 3.7 Per-Page Lane Modes on Profile Blocks + +A dynamic block skips the coder for two kinds of lane (lhc4codec `1d70fb1`): a lane that is constant over the +block ships one value byte in the model, and a lane whose coded size would reach its byte count (noise to the +model) ships its bytes verbatim in a section after the model. Both leave the rANS loop on either side: the decoder +fills them before the lockstep pass and only shifts their bytes into the byte history (3 instructions per byte +instead of ~30). The profile could not do this by itself, since it serves pages its sample never saw, so the +profile path fell 16-18% behind dynamic in decode speed when those modes arrived. + +The modes are now a property of the page, carried in the block header of a new block kind (`kCryProfileLanes`, 6; +kind 5 blocks stay decodable and are still written when every lane is coded): 2 bits per lane, one value byte per +constant lane, then the raw lanes' bytes lane by lane, then the streams. `build_crystal_profile` decides per lane +from the page: constant if all of its bytes are equal (checked on the transformed page, so a delta-transformed +counter has every lane constant and the block is 11 bytes); raw if the page's cost under the profile's tables +reaches 8 bits per byte, **or if the lane meets a symbol outside the profile's support** (frequency 0). The latter +is what used to send the whole page to the dynamic path with its ~1.5 KiB model header; now that lane alone goes +raw and the escape decision compares, per lane, the profile's exact cost against the trainer's estimate of a +per-page model (the smaller of that estimate and the raw size for a raw lane, so a lane that a per-page model +would compress still escapes). The skipped lanes' symbols take a sentinel frequency row (full probability, the +coder state is unchanged) so the stream encoder needs no special case; the decoder overrides the profile's +precomputed lane descriptors (`DecLane::mv`) for the page and otherwise runs the same loop. + +Effect on the CMS sample (1 MiB pages, level 5): the escaped blocks fell from 2,389 to 2,138 of 4,955 (48% to +43%), the file is 44 KB smaller, and the profile path decodes 3-4% faster than dynamic again (section 5.1; it was +18% slower without the lane modes). The round trip is verified by the `tree2ntuple --verify` path on every page +and by the api test's lane-mode block. + +--- + +## 4. ROOT Core and RNTuple Integration + +### 4.1 C Bridge in Core (`core/lhc4codec`) +In `core/lhc4codec/inc/ZipLHC4.h` and `src/ZipLHC4.cxx`: +- `R__LHC4TrainProfileModelLevel(...)` trains a profile model on uncompressed sample data. +- `R__LHC4RegisterProfileModel(...)` deserializes and registers the model in `cerncodec`. +- `R__LHC4ExportProfileModel(...)` exports a registered profile model. + +### 4.2 In-Memory Metadata: `RCompressionContext` +In `tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx`: +```cpp +class RCompressionContext { +public: + enum class EType : std::uint16_t { + kUnknown = 0, + kLHC4CrystalProfile = 1, + kZstdDictionary = 2, + }; + + std::uint32_t GetProfileId() const { + if (fPayload.empty()) return 0; + std::uint32_t hash = 2166136261u; + for (auto b : fPayload) { + hash ^= b; + hash *= 16777619u; + } + return 256u + (hash & 0x7FFFFFFFu); + } +}; +``` +Content-addressed Profile IDs (FNV-1a hash offset by 256) ensure that concurrent files and threads never suffer from profile ID collisions. + +### 4.3 Reader Pipeline (`tree/ntuple/src/RPageStorage.cxx`) +When an RNTuple file is opened, `RPageSource::RegisterCompressionProfiles(const RNTupleDescriptor &desc)` iterates through all columns. For each column with an LHC4 Crystal compression context, it invokes `R__LHC4RegisterProfileModel()` once. When pages are unsealed, `R__unzipLHC4` decodes directly using the pre-registered profile ID. + +### 4.4 Writer Pipeline (`tree/ntuple/src/RPageStorage.cxx`) +In `RPagePersistentSink::EnsureColumnProfile()`, called for every committed page while the column has no profile yet, +if `GetWriteOptions().GetEnableLearnedProfiles()` is true: + 1. The column data stride is determined from `ENTupleColumnType`. + 2. The page bytes are added to the column's training sample until it holds + `GetWriteOptions().GetLearnedProfileSampleSize()` bytes (default 256 KiB). A page that is already that large is + used directly. Pages committed before the sample is complete are written with the per-page (dynamic) Crystal + model. The memory held by partial samples is bounded (64 MiB across all columns); above that, columns train on + the sample they have. + 3. `R__LHC4TrainProfileModelLevel` trains the profile model at + `max(GetLearnedProfileTrainLevel(), write level)` (default train level 6). The search runs once per column + and the model is reused for every page, so it is decoupled from the write level. On the CMS OpenData sample + the levels 7-9 (exhaustive pair search) add ~60 s of training for the whole file (1,700 columns) and no + measurable size gain over level 5/6, hence the default. The training also picks the profile's element + transform (section 3.6): a drifting sample (offsets, timestamps) trains on the element deltas. + 4. The model is registered in `cerncodec` and saved into the column's `RCompressionContext`. + 5. The descriptor is tagged with `kFeatureFlag_CompressionContext`. The contexts are serialized with the header, + which the sink commits with the first cluster (`CommitHeader`, deferred while learned profiles are enabled). + Just before that, `TrainPendingColumnProfiles` trains every column whose sample is still incomplete on what it + has (at least 16 elements), and from then on `EnsureColumnProfile` returns 0 for columns without a profile: + a model trained after the header is written would be known to the writer only, and its pages unreadable. + (The sample accumulation had opened exactly that hole for columns whose sample completed, or whose training + was forced by the 64 MiB budget, in a later cluster; on the CMS sample 482 of 860 column contexts were lost + this way, and the pages of those columns decoded only while the escape sent them to the per-page model.) + 6. The sample page and all subsequent pages of the column are compressed using this profile ID with 0 bytes of + model header per page. The encoder keeps a per-page escape: a page whose estimated cost under the profile + exceeds the estimated per-page model cost by more than a few percent is written as a dynamic Crystal block + instead (the block kind is per page, so the reader needs no extra information). + +--- + +## 5. Comprehensive Benchmark Results + +Evaluated on the CMS OpenData sample (`Events` tree, 1,049,500 events, 5,740-5,944 sealed pages, 1.436 GB +uncompressed) on Apple Silicon (single core and 8-thread IMT). Measured 13 Oct 2026 with lhc4codec `2772ef8` +(drifting-column transform and support prior of section 3.6, the faster static encoder of `89bae1c`, the format v2 +encoder/decoder review and the lane modes of `1d70fb1`, where constant and raw lanes of a dynamic block bypass +the coder, and the per-page lane modes on profile blocks of section 3.7) and ROOT `bd2015a49ef`. The seven files +were written one after the other (`tree2ntuple --jobs 1 --variants native_zstd,lhc4_crystal5,..., +lhc4_crystal_learned9`) and the three benchmarks below were taken in one sequential +`rntuple2rntuple --bench-only --bench-iters 5 --bench-threads 8` run with nothing else of ours running, so the +rows are comparable with each other. The machine was not idle (a virtual machine held one core throughout); +against a quieter run of the same morning every row is ~12% lower (zstd 2252 vs 2558 MB/s, dynamic Crystal 873 vs +1023 MB/s), and the relative figures are what the tables are for. + +Written sizes: `native_zstd` 406 MiB, `lhc4_crystal{5,7,9}` 357 MiB, `lhc4_crystal_learned{5,7,9}` 359 MiB +(374.7 MB vs 376.3 MB, +0.4%). With the lane modes of section 3.7 the learned variants code 2,817 of 4,955 +Crystal blocks with the profile and escape 2,138 (43%; 2,389 or 48% before, most of them 32 KiB boolean/char +pages); before section 3.6 the escaped share was 63% of the bytes. + +### 5.1 Pure In-Memory Page Decompression Benchmark (Single Core, 5 Iterations) +*Measures raw codec decompression throughput without file I/O or RNTuple object deserialization.* + +| Variant | Compressed Size | Uncompressed Size | Time per pass | Throughput | vs Dynamic | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **`native_zstd`** (level 5) | 409.4 MB | 1436.7 MB | 0.638 s | 2252.3 MB/s | — | +| **`lhc4_crystal5`** (dynamic) | 360.4 MB | 1436.7 MB | 1.646 s | 872.9 MB/s | baseline | +| **`lhc4_crystal_learned5`** (profiled) | 361.8 MB | 1436.7 MB | **1.577 s** | **911.1 MB/s** | **+4.4%** | +| **`lhc4_crystal7`** (dynamic) | 360.1 MB | 1436.7 MB | 1.780 s | 807.2 MB/s | baseline | +| **`lhc4_crystal_learned7`** (profiled) | 361.5 MB | 1436.7 MB | **1.707 s** | **841.9 MB/s** | **+4.3%** | +| **`lhc4_crystal9`** (dynamic) | 360.1 MB | 1436.7 MB | 1.803 s | 797.0 MB/s | baseline | +| **`lhc4_crystal_learned9`** (profiled) | 361.5 MB | 1436.7 MB | **1.749 s** | **821.4 MB/s** | **+3.1%** | + +### 5.2 End-to-End `RNTupleReader::LoadEntry` Benchmark (Single Core, All Entries) +*Measures real analysis workload reading all 1.05M events entry-by-entry.* + +| Variant | Read Wall Time | Total MB/s | Entries / sec | Decomp Wall Time | Decomp Throughput | vs Dynamic | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| **`native_zstd`** | 9.271 s | 204.2 MB/s | 113,242 ent/s | 0.942 s | 2010.8 MB/s | — | +| **`lhc4_crystal5`** (dynamic) | 9.369 s | 202.1 MB/s | 112,058 ent/s | 1.776 s | 1066.4 MB/s | baseline | +| **`lhc4_crystal_learned5`** (profiled) | 9.685 s | 195.5 MB/s | 108,405 ent/s | 1.844 s | 1026.8 MB/s | -3.7% | +| **`lhc4_crystal7`** (dynamic) | 9.603 s | 197.2 MB/s | 109,329 ent/s | 1.966 s | 963.0 MB/s | baseline | +| **`lhc4_crystal_learned7`** (profiled) | 9.793 s | 193.3 MB/s | 107,205 ent/s | 1.932 s | 979.8 MB/s | +1.7% | +| **`lhc4_crystal9`** (dynamic) | 9.569 s | 197.9 MB/s | 109,714 ent/s | 1.978 s | 957.3 MB/s | baseline | +| **`lhc4_crystal_learned9`** (profiled) | **9.447 s** | **200.4 MB/s** | **111,137 ent/s** | **1.887 s** | **1003.3 MB/s** | **+4.8%** | + +### 5.3 Multi-Threaded `RNTupleReader::LoadEntry` Benchmark (8 Threads IMT) +| Variant | Read Wall Time | Total MB/s | Entries / sec | Decomp Wall Time | Decomp Throughput | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **`native_zstd`** | 8.374 s | 226.1 MB/s | 125,371 ent/s | 0.183 s | 10347.4 MB/s | +| **`lhc4_crystal5`** (dynamic) | 7.912 s | 239.3 MB/s | 132,690 ent/s | 0.426 s | 4442.3 MB/s | +| **`lhc4_crystal_learned5`** (profiled) | 7.954 s | 238.1 MB/s | 131,997 ent/s | 0.416 s | 4547.6 MB/s | +| **`lhc4_crystal7`** (dynamic) | 8.072 s | 234.6 MB/s | 130,059 ent/s | 0.467 s | 4052.6 MB/s | +| **`lhc4_crystal_learned7`** (profiled) | 8.030 s | 235.8 MB/s | 130,739 ent/s | 0.477 s | 3970.2 MB/s | +| **`lhc4_crystal9`** (dynamic) | 7.962 s | 237.8 MB/s | 131,869 ent/s | 0.470 s | 4029.8 MB/s | +| **`lhc4_crystal_learned9`** (profiled) | 7.958 s | 237.9 MB/s | 131,933 ent/s | 0.437 s | 4333.9 MB/s | + +With 8 threads the decompression is 2-6% of the read wall time for every variant; the remaining time is +deserialization, which is the same for all of them, so the end-to-end numbers converge (the six Crystal variants +are within 2% of each other here). + +**Two byte counts.** The "Decomp Throughput" of tables 5.2 and 5.3 is higher than the pure decode of table 5.1 +(1,066 vs 873 MB/s for `lhc4_crystal5`) because the two benchmarks count different bytes over different +timers. The pure page benchmark (`RNTupleBench.hxx`) counts *stored* bytes, bits on storage × elements, 1,437 MB +for this file, and times `RNTupleDecompressor::Unzip` alone. The reader metric `szUnzip` +(`RPageStorage.cxx`, `UnsealPage` callers) counts *in-memory* bytes, `element->GetSize()` × elements, 1,893 MB for +the same file, since a 32-bit index column unpacks to a 64-bit offset and a bool bit to a byte; its timer +`timeWallUnzip` wraps `UnsealPage`, i.e. unzip plus that unpack. Scaled to stored bytes the reader's 1,066 MB/s is +~810 MB/s, consistent with the pure decode. With IMT the timer wraps the whole parallel cluster unzip, so table +5.3 reports aggregate throughput. "Total MB/s" is in-memory bytes over the whole read, deserialization included. + +### 5.4 Reading the Results + +- **Size**: learned profiles cost 0.4% over dynamic Crystal (361.8 MB vs 360.4 MB) and stay 12% below zstd. The + gap is the static tables on the float columns, which the per-page tables fit better (+0.5% on the 299 MB of + profile-coded output), plus the per-page headers of the escaped blocks; the drifting integer columns, which + made up the earlier 1.2% gap, now code on the profile (section 3.6). +- **Speed**: the lane-specialised scalar decoder brought dynamic Crystal from ~515 MB/s to ~700 MB/s in pure page + decode, and the lane modes of lhc4codec `1d70fb1` (a lane that is constant over the block, or that no context + compresses, is stored as such and skips the rANS loop) take it to 870-1020 MB/s on this sample, depending on + the machine's state. Lane modes are a per-block decision, and when they arrived the profile path lacked them: + measured on the same files that morning, `lhc4_crystal_learned5` was 18% behind `lhc4_crystal5` in pure decode + (839 vs 1023 MB/s, confirmed in swapped order at 815 vs 970) and 12-15% behind in single-threaded + `LoadEntry`. With the per-page lane modes of section 3.7 the profile path is 3-4% ahead in pure decode at + every level (table 5.1) and within ±5% in `LoadEntry` (table 5.2, where the sign changes with the level), which + is where it stood before `1d70fb1`: the profile's tables are a little larger than a per-page level-5 model + (more cache misses) and smaller than a per-page level-7/9 model. +- **Size** with the lane modes: the learned file lost 44 KB at level 5 (376,332,059 B) and 11 KB at level 7/9 + (376,092,242 B); the dynamic files are unchanged. +- The earlier figures in this section (dynamic 316 MB/s, learned +73% end-to-end) predate the lane-specialised + decoder, the training sample and the per-page escape, and were taken with other runs in parallel; they are + superseded by the tables above. + +### 5.5 Page Size Dependence and When to Use Learned Profiles + +Every advantage of a learned profile is a fixed cost per page that it removes: the ~1.5 KiB per-page model, the +decoder table build, and the per-page model search on the writer. Whether that pays depends on the page size. +Same sample, `tree2ntuple --no-out --jobs 1 --page-size N`, one variant at a time: + +| Max page size | `lhc4_crystal5` (dynamic) | `lhc4_crystal_learned5` | Size | Encode wall time | +| :--- | :--- | :--- | :--- | :--- | +| 1 MiB (default) | 357 MiB | 359 MiB | dynamic 0.6% smaller | 34.7 s vs 32.8 s | +| 64 KiB | 364 MiB | 362 MiB | learned 0.5% smaller | 53.7 s vs 30.8 s | +| 16 KiB | 375 MiB | 366 MiB | **learned 2.4% smaller** | **160 s vs 32.3 s** | + +Reading the same TTree into `native_zstd` alone takes 29.5 s in this run (23 s on the quieter machine of the +morning), which is the floor of the encode column (TTree deserialisation and RNTuple page filling): the learned +variant sits on that floor at every page size, the dynamic variant's model search grows with the page count. +Before the drifting-column transform and the support prior (section 3.6) the same table read 362 / 367 / 372 MiB +for the learned variant: dynamic 1.2% smaller at 1 MiB, par at 64 KiB, learned 2% smaller at 16 KiB; before the +lane modes of `1d70fb1` the dynamic column read 357 / 365 / 380 MiB. + +**Encode cost.** The wall times are bounded by the TTree read: `tree2ntuple` enables IMT and the sink compresses +pages in parallel tasks, so at 1 MiB pages the two variants differ by two seconds of wall time while the CPU time +differs by a factor of two: run alone, `lhc4_crystal5` takes 34.3 s wall / 86.8 s CPU and `lhc4_crystal_learned5` +32.3 s wall / 42.3 s CPU (~25 s CPU of that is the TTree read and page filling in both cases). On a page coded +with the profile the writer runs the context index pass, a count-based cost estimate for the lane modes and the +escape decision, and the rANS encoder, about 2.5 ns per byte; the dynamic path runs the per-page model search at +~20 ns per byte (level 5, after the encoder speed-up of `89bae1c`). A `sample` profile of the learned run splits +its Crystal CPU as: training (256 KiB per column at level 6, once) ~25%, the escaped pages coded dynamically ~50%, +and the pages coded with the profile ~25%, the last including the profile attempt that every escaped page also +pays. With 1 MiB pages the training is the largest fixed cost; at 16 KiB pages the model search runs 60 times +more often while the training stays the same, hence 160 s against 32 s wall. + +Conclusions for the default RNTuple configuration (1 MiB pages, large files, bulk reads): dynamic Crystal remains +the default. It is 0.6% smaller, decodes as fast (section 3.7), and needs no training step, descriptor extension +or profile registry. `SetEnableLearnedProfiles` stays opt-in. + +The learned path earns its keep where pages are small, with the crossover now between 64 KiB and 1 MiB: +- many-column ntuples with few entries, where most pages never grow to the maximum; +- the first pages of every column and the pages flushed at cluster boundaries; +- random or low-latency access, where a single page decode is the unit of work; +- memory-constrained writers that cannot hold a 1 MiB page buffer per column; +- write-side cost: the profile encode takes half the CPU at 1 MiB pages and a fifth of the wall time at 16 KiB + pages, since the model search runs once per column instead of once per page. + +What would widen that regime further are profiles trained once per data tier and shared across files, which the +per-page model cannot do by construction; that does not exist yet. The remaining 0.6% at 1 MiB pages is the price +of static tables on float columns, where the per-page tables fit the page. + +--- + +## 6. Performance Deep Dive: Why is ZSTD Faster and Why Crystal Decodes at ~1 GB/s? + +A common question is: *Why does Crystal decode at ~0.9-1 GB/s in pure page decompression (~700 MB/s before the lane modes) rather than 1.5 GB/s+, and why does Zstandard achieve ~2.2-2.5 GB/s?* + +The answer lies in the fundamental algorithmic and architectural differences between LZ77 dictionary coding and Order-1/2 Markov Context rANS entropy coding. + +### 6.1 Architectural Difference: LZ77 Match Copying vs Per-Byte Context Modeling + +#### How Zstandard Achieves 1.75+ GB/s: +1. **Bulk Vector Match Copying**: + - ZSTD is primarily an **LZ77** sliding-window dictionary compressor combined with Finite State Entropy (FSE) / Huffman (Huff0) for raw literals. + - When repeated patterns or common values occur (e.g. repeated floats, zeros, structural offsets), LZ77 emits `(match_distance, match_length)` references. + - The decompressor satisfies matches using unrolled 16- or 32-byte SIMD vector loads and stores (`_mm256_storeu_si256` or `vld1q_u8` / `vst1q_u8`). + - Copying 16–64 bytes takes **1 clock cycle** ($\approx 15\text{–}30\text{ GB/s}$ copy throughput). + - In columnar data, up to $80\%\text{–}90\%$ of total bytes are expanded via these instant memory copies. Only the remaining $10\%\text{–}20\%$ (the non-matching literals) pass through the entropy stage. +2. **Order-0 Independence**: + - The Huffman/FSE stage in Zstandard is Order-0 (context-free). Literals are decoded in parallel streams without referencing neighboring bytes or previous rows. + +#### Why Crystal Must Process Every Single Byte in the Entropy State Machine: +1. **Pure Context-Predictive Coder**: + - Crystal is not an LZ77 compressor. It is a **Markov context-mixing lattice model coder** (similar to PAQ/CALIC, optimized with rANS). + - It does not emit `(distance, length)` copy tokens. Instead, **every single byte of the 1.436 GB payload** must pass through the full context predictor and the rANS arithmetic state machine. +2. **The Per-Byte Decoding Pipeline (`decode_one_lane`)**: + The lane (the feature choice) is the same for all interleaved streams of a lattice step, so the decoder dispatches + once per step to a routine specialised on the lane's features (`LaneBody` in `crystal_static.cpp`). For *each + individual byte* that routine executes: + ```cpp + // 1. Lattice history fetch, only when a feature of this lane reads it + lat1 = p[-S]; + lat2 = p[-2 * S]; + + // 2. Context from the lane's two features (resolved at compile time: + // an order-0 lane does no feature work, a C1 lane one mask) + c = L.q[feat_a] + L.q[256 + feat_b]; // 2 context table lookups + + // 3. Probability slot lookup + slot = st.d.slot(); // rANS state masking + SlotInfo si = slot_lookup(c, slot); // 2 dependent table memory loads + + // 4. rANS arithmetic state update & stream refill + st.d.advance(si.freq, si.cum, slot); // multiply, shift, subtract, refill + + // 5. Output & history register update + *p = si.sym + base; + st.hist = (st.hist << 8) | *p; + ``` +3. **Instruction Count and Cycle Limits**: + - The routine above is **29 to 36 instructions per byte on aarch64** (34 to 40 on x86-64), including 3–5 + dependent memory loads (lattice history, context group offsets, slot symbol, cumulative frequency). The + previous generic step, which selected the features at run time from a packed word with variable shifts, was + 40 instructions on aarch64 and 57 on x86-64 with register spills. + - The 16 interleaved streams keep the dependent loads of different streams in flight, so the loop is bound by + instruction issue rather than load latency: decode throughput stops scaling at 8 streams. + - On a ~3.5 GHz core the **~840 MB/s** measured for the profile path when every byte still went through this + loop is **~4.2 cycles per byte**, i.e. ~7-8 instructions per cycle across the interleaved streams, close to + the issue width of the core. The 0.9-1 GB/s of both paths today come from bytes that skip the loop: with the + lane modes of lhc4codec `1d70fb1` (per block) and section 3.7 (per page on profile blocks) a lane that is + constant over the block is restored by a fill, and a lane that no context compresses is copied, so only the + remaining lanes pay the per-byte cost above. + +--- + +### 6.2 Hardware Vectorization: AVX2 vs ARM64 NEON + +In `cerncodec/src/crystal_static.cpp`: +- The 8-wide / 16-wide vectorized lockstep decoder (`decode_streams_v8`) is explicitly implemented for **x86-64 AVX2**: + ```cpp + #if defined(__x86_64__) && (defined(__GNUC__) || defined(__clang__)) + #define CRY_V8 1 + #include + #define CRY_V8_TARGET __attribute__((target("avx2,bmi2,popcnt"))) + #else + #define CRY_V8 0 + #endif + ``` +- The AVX2 vector path utilizes `_mm256_i32gather_epi32` (`vpgatherdd`) to gather 8 independent rANS table lookups simultaneously across 8 interleaved streams. +- On **ARM64 (Apple Silicon / Darwin)**: + - Standard ARM NEON does not feature 32-bit hardware vector gather instructions. + - Consequently, `CRY_V8` is disabled (`CRY_V8 == 0`), and the engine runs the scalar lockstep body + (`LaneBody`, or `decode_streams_body` for lanes it does not cover). + - Thus, on Apple Silicon, the benchmark measures pure scalar execution performance. + +--- + +### 6.3 Performance Expectations on x86_64 Platforms + +On x86_64 platforms (such as modern Intel Core/Xeon or AMD Zen 3/4/5), hardware AVX2 vectorization is active. The performance characteristics differ significantly from ARM64 scalar execution: + +#### A. Expected Throughput Across CPU Microarchitectures +| Architecture | Code Path | Cycles / Byte | Pure Decomp Rate (Single Core) | RNTuple End-to-End (`LoadEntry`) | +| :--- | :--- | :--- | :--- | :--- | +| **ARM64 (Apple Silicon M-series)**, measured | Scalar (`LaneBody`) | ~4.2 (coded bytes) | **~870 – 1,020 MB/s** with lane bypass (dynamic and profile) | ~1,000 – 1,150 MB/s | +| **AMD Zen 3 (AVX2)** | Vector (`decode_streams_v8`) | ~6.5 – 7.5 | **~500 – 580 MB/s** | ~550 – 620 MB/s | +| **AMD Zen 4 / Zen 5 (AVX-512 / AVX2)** | Vector (`decode_streams_v8`) | ~5.0 – 6.0 | **~620 – 750 MB/s** | ~680 – 800 MB/s | +| **Intel Golden Cove / Raptor Lake / Xeon**| Vector (`decode_streams_v8`) | ~4.5 – 5.5 | **~650 – 850 MB/s** | ~700 – 900 MB/s | +| **Zstandard (Modern x86_64)** | Unrolled AVX2 Byte Copy | ~1.5 – 2.0 | **~2,000 – 2,500 MB/s** | ~1,500 – 1,800 MB/s | + +The x86_64 rows are projections from the cycle-per-byte model below (the vector body is not measured on the +platform of section 5); the ARM64 row is measured. Note that the x86-64 scalar path, used for blocks with fewer +than 8 streams (pages below 32 KiB), with compact tables, or on CPUs without AVX2, benefits from the same +lane-specialised body: 57 (53 with BMI2) → 34–40 instructions per byte with the stack spills largely gone. + +#### B. Why the x86_64 vector body is expected to be faster than ARM64 scalar +On x86_64 with AVX2: +1. **Parallel Vector Register State**: For blocks $\ge 64\text{ KiB}$, $k = 16$ interleaved streams are split into two 8-wide AVX2 vector groups. The state $x$, stream offset $p$, and output history $h$ of 8 streams reside directly in 256-bit `__m256i` registers. +2. **SIMD Context Gathers**: Frequency table and context lookups use hardware vector gathers (`vpgatherdd`). +3. **Register-Resident Latency Hiding**: For strides $\le 8$ (floats, doubles, integers), output history ($p[-S]$ and $p[-2S]$) is shifted within four 256-bit vector registers (`_mm256_slli_epi32`), avoiding round-trip loads through memory. +4. **Interleaved Dependency Chains**: Issuing the two 8-wide vector groups back-to-back overlaps memory access latencies across the two chains. +The vector body is projected at **~4.5–7 cycles/byte**, which is in the same range as the measured scalar +**~4.2 cycles/byte** on Apple Silicon; whether it still pays off against the lane-specialised scalar body on a +given x86-64 core is a measurement to make (`LHC4_CRYSTAL_X86=scalar` selects the scalar body for an A/B). + +#### C. Microarchitectural Ceilings: Why 1.5 GB/s is Unreachable on a Single Core +Reaching $1.5\text{ GB/s}$ at a $4.0\text{ GHz}$ core clock requires consuming $\le 2.67\text{ clock cycles per byte}$: +$$\text{Cycles per byte} = \frac{4.0\text{ GHz}}{1.5\text{ GB/s}} \approx 2.67\text{ cycles/byte}$$ + +Why `decode_streams_v8` cannot sustain $2.6\text{ cycles/byte}$: +1. **Gather Port Contention**: While standard vector loads (`_mm256_loadu_si256`) execute in 1 cycle, a vector gather decomposes into 8 individual address generation and cache tag lookups. On Zen 3, a 256-bit gather occupies the gather execution unit for ~19 cycles. On Zen 4 and Intel Golden Cove, gathers execute in ~4–8 cycles, but remain micro-op intensive. +2. **High Memory Operation Density**: Decoding 8 bytes requires **5 distinct gathers** (context features $a$ and $b$, table slot, cumulative frequency, and stream bit refill) — totaling **40 cache operations per 8 bytes decoded** (5 memory operations per byte). Even with 100% L1 cache hits, the CPU's load execution pipelines become saturated at **650–850 MB/s**. +3. **Contrast with Zstandard's LZ77 Match Engine**: Zstandard does not perform gathers. It writes repeated data with a single instruction (`_mm256_storeu_si256`), copying **32 bytes in 1 clock cycle** ($\approx 30\text{–}40\text{ GB/s}$). Because columnar physics data contains many repeated floats, zeros, and structural indices, 80%+ of the bytes bypass entropy arithmetic entirely. + +#### D. Multi-Threaded Scaling on x86_64 +Because the precomputed profile tables (`dec_tab`, `q_scaled`, `DecLane`) are immutable and fit into L1/L2 caches, multi-threaded RNTuple scaling is virtually linear: +- **8 Threads (AMD Zen 4 / Intel Xeon)**: $\approx \mathbf{4.5\text{–}5.5\text{ GB/s}}$ aggregate decompression throughput. +- **16 Threads**: $\approx \mathbf{8\text{–}10\text{ GB/s}}$ aggregate decompression throughput. + +In typical analysis batch processing, 8–16 threads running Crystal Learned Profiles easily saturate modern PCIe Gen 4/Gen 5 NVMe storage while preserving a **10% to 15% file size advantage** over Zstandard. + +--- + +### 6.4 Where the Time Goes: Table Caching Impact + +Table precomputation cannot reduce the instruction count of the rANS entropy loop itself; what it removes is the +per-page model parse and decoder table build. With 1 MiB pages that is a small fraction of the decode: + +1. **Per-page table build**: before the lane modes, `lhc4_crystal5` and `lhc4_crystal_learned5` decoded the same + 1.44 GB in 1.981 s and 1.985 s, so the per-page model parse plus table build of the dynamic path is below the + measurement noise at this page size. The learned path's remaining cost is its larger models (a 256 KiB training + sample at level 6 yields more contexts than a per-page level-5 model, hence more table cache misses). The + lane bypass of `1d70fb1`, which the profile path lacked for a while (1.405 s vs 1.712 s per pass on the same + files), is a per-page property of profile blocks too since section 3.7, and the profile path is 3-4% ahead + in table 5.1. +2. **Higher levels**: `lhc4_crystal7` and `lhc4_crystal9` decode 8-9% slower than level 5 (807/797 vs 873 MB/s): + the deeper model search picks larger context sets per page. The learned variants, trained once at level 6, + follow the same slope (911 / 842 / 821 MB/s), since the spread between their levels comes from the escaped + pages (43% of the blocks), which are coded dynamically at the write level. +3. **Storage Compression Ratio Advantage**: + - While Zstandard decompresses faster due to bulk LZ77 copies, it achieves lower compression on scientific floating-point data: + - `native_zstd` (level 5): **409.4 MB** + - `lhc4_crystal5`: **360.4 MB** (49 MB smaller, ~12% higher compression ratio) + - `lhc4_crystal_learned5`: **361.8 MB** + - Crystal captures complex continuous distributions and order-1/2 derivatives that LZ77 cannot match. + +--- + +## 7. Summary & Future Directions + +| Dimension | Zstandard (Native) | Crystal Dynamic | Crystal Learned Profile (Implemented) | +| :--- | :--- | :--- | :--- | +| **Model Type** | LZ77 + Order-0 Huffman/FSE | Order-1/2 Context Lattice rANS | Order-1/2 Context Lattice rANS | +| **Model Storage** | None (dynamic per frame) | Per-page ($\approx 1.5\text{ KiB/page}$) | **Single metadata blob in Header** (77–750 B), per-page lane modes (1–4 B), per-page escape to a dynamic block | +| **Page Table Overhead** | None | 256–640 KiB rebuild / page | **0 bytes rebuilt / page** (precomputed) on profile blocks | +| **Lane bypass (constant / raw lanes)** | — | per block | **per page** (section 3.7) | +| **Compressed Size (CMS)**| 409.4 MB | 360.1–360.4 MB | **361.5–361.8 MB** | +| **Pure Decomp Throughput**| 2,252 MB/s | 797–873 MB/s | **821–911 MB/s** (+3-4% per level) | +| **RNTuple Decomp Throughput (1 thread)**| 2,011 MB/s | 957–1,066 MB/s | **980–1,027 MB/s** | +| **Encode CPU (1 MiB pages, level 5)**| — | 86.8 s | **42.3 s** (of which ~25 s TTree read in both) | +| **Encode wall (16 KiB pages, level 5)**| — | 160 s | **32 s** | + +### Potential Next Optimizations: +1. **Profile size cap**: + Profiles trained on a 256 KiB sample have more contexts than a per-page model; capping the context count for + profiles (or weighting the table cost higher during training) would trade a little ratio for decode speed. +2. **NEON / SVE Vectorization for ARM64**: + Implement vector interleaving or lookup emulation for ARM NEON/SVE for architectures without native gather. +3. **Fast-Path LZ Pre-Filter**: + For columns exhibiting long zero-runs or static repeats, integrating a lightweight byte-level RLE/LZ stage before the context rANS stage would allow skipping the entropy machine for large data stretches, approaching multi-gigabyte throughput while retaining lattice density. On the CMS sample this is also what the remaining escapes need: the boolean/char pages and the piecewise-constant `int32` columns, where a static table cannot represent a probability below $1/4096$ for the rare symbol. + +Done since the first version of this list: the transform for drifting integer columns and the support prior +(section 3.6), which took the escaped bytes from 63% to 21% of the Crystal input and the size gap at 1 MiB pages +from 1.2% to 0.4% (file bytes; 357 vs 359 MiB in the summary table); and the per-page lane modes on profile +blocks (section 3.7), which restored decode-speed parity with dynamic Crystal after its lane modes and took the +escaped blocks from 48% to 43%. The remaining escapes are pages where a per-page model beats the profile's tables +by more than the 3% margin on a coded lane; a lane that leaves the support no longer forces one. diff --git a/tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx b/tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx index 504b303b723d1..2e97a08d864df 100644 --- a/tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx +++ b/tree/ntuple/inc/ROOT/RNTupleDescriptor.hxx @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -197,6 +198,55 @@ public: bool IsSoACollection() const { return fIsSoACollection; } }; +// clang-format off +/** +\class ROOT::RCompressionContext +\ingroup NTuple +\brief Compression context (trained profile model or dictionary) for a column +*/ +// clang-format on +class RCompressionContext { +public: + enum class EType : std::uint16_t { + kUnknown = 0, + kLHC4CrystalProfile = 1, + kZstdDictionary = 2, + }; + +private: + std::uint16_t fAlgorithm = 0; + EType fContextType = EType::kUnknown; + std::vector fPayload; + +public: + RCompressionContext() = default; + RCompressionContext(std::uint16_t algo, EType type, std::vector payload) + : fAlgorithm(algo), fContextType(type), fPayload(std::move(payload)) {} + + std::uint16_t GetAlgorithm() const { return fAlgorithm; } + EType GetContextType() const { return fContextType; } + std::span GetPayload() const { return fPayload; } + std::size_t GetSize() const { return fPayload.size(); } + + std::uint32_t GetProfileId() const + { + if (fPayload.empty()) + return 0; + std::uint32_t hash = 2166136261u; + for (auto b : fPayload) { + hash ^= b; + hash *= 16777619u; + } + return 256u + (hash & 0x7FFFFFFFu); + } + + bool operator==(const RCompressionContext &other) const + { + return fAlgorithm == other.fAlgorithm && fContextType == other.fContextType && fPayload == other.fPayload; + } + bool operator!=(const RCompressionContext &other) const { return !(*this == other); } +}; + // clang-format off /** \class ROOT::RColumnDescriptor @@ -243,6 +293,8 @@ private: ROOT::ENTupleColumnType fType = ROOT::ENTupleColumnType::kUnknown; /// Optional value range (used e.g. by quantized real fields) std::optional fValueRange; + /// Optional compression context (trained profile model or dictionary) + std::optional fCompressionContext; public: RColumnDescriptor() = default; @@ -264,6 +316,7 @@ public: std::uint16_t GetBitsOnStorage() const { return fBitsOnStorage; } ROOT::ENTupleColumnType GetType() const { return fType; } std::optional GetValueRange() const { return fValueRange; } + const std::optional &GetCompressionContext() const { return fCompressionContext; } bool IsAliasColumn() const { return fPhysicalColumnId != fLogicalColumnId; } bool IsDeferredColumn() const { return fFirstElementIndex != 0; } bool IsSuppressedDeferredColumn() const { return fFirstElementIndex < 0; } @@ -784,6 +837,8 @@ public: /// in the first ntuple and by Real32 in the second. /// Added in version 1.1.0.0 of the binary format. kFeatureFlag_NestedDeferredColumns = 0, + /// Signals that the RNTuple contains at least one column with a compression context (e.g. trained Crystal profile or Zstd dictionary). + kFeatureFlag_CompressionContext = 1, // Insert new feature flags here, with contiguous values. If at any point a "hole" appears in the valid feature // flags values, the check in RNTupleSerialize must be updated. @@ -1490,6 +1545,16 @@ public: fColumn.fValueRange = valueRange; return *this; } + RColumnDescriptorBuilder &CompressionContext(RCompressionContext context) + { + fColumn.fCompressionContext = std::move(context); + return *this; + } + RColumnDescriptorBuilder &CompressionContext(std::optional context) + { + fColumn.fCompressionContext = std::move(context); + return *this; + } ROOT::DescriptorId_t GetFieldId() const { return fColumn.fFieldId; } ROOT::DescriptorId_t GetRepresentationIndex() const { return fColumn.fRepresentationIndex; } /// Attempt to make a column descriptor. This may fail if the column @@ -1800,6 +1865,7 @@ public: // The field that the column belongs to has to be already available. For fields with multiple columns, // the columns need to be added in order of the column index RResult AddColumn(RColumnDescriptor &&columnDesc); + void SetColumnCompressionContext(ROOT::DescriptorId_t columnId, RCompressionContext ctx); RResult AddClusterGroup(RClusterGroupDescriptor &&clusterGroup); RResult AddCluster(RClusterDescriptor &&clusterDesc); diff --git a/tree/ntuple/inc/ROOT/RNTupleSerialize.hxx b/tree/ntuple/inc/ROOT/RNTupleSerialize.hxx index 0f80b0e8c170d..8cde0f4346e85 100644 --- a/tree/ntuple/inc/ROOT/RNTupleSerialize.hxx +++ b/tree/ntuple/inc/ROOT/RNTupleSerialize.hxx @@ -78,6 +78,7 @@ public: static constexpr std::uint16_t kFlagDeferredColumn = 0x01; static constexpr std::uint16_t kFlagHasValueRange = 0x02; + static constexpr std::uint16_t kFlagHasCompressionContext = 0x04; static constexpr ROOT::DescriptorId_t kZeroFieldId = std::uint64_t(-2); diff --git a/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx b/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx index 3df419e522ba7..338fc1f3df3b3 100644 --- a/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx +++ b/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx @@ -218,6 +218,13 @@ protected: bool fEnablePageChecksums = true; bool fEnableSamePageMerging = true; bool fEnableColumnEncoding = true; + bool fEnableLearnedProfiles = false; + /// Uncompressed bytes of a column collected before its learned profile is trained. Pages written before the + /// sample is complete use the per-page (dynamic) Crystal model. + std::size_t fLearnedProfileSampleSize = 256 * 1024; + /// Lane-search level used to train learned profiles; the effective level is the maximum of this value and the + /// write level. Levels >= 7 run the exhaustive pair search (~10x the training time of level 6). + int fLearnedProfileTrainLevel = 6; /// Specifies the max size of a payload storeable into a single TKey. When writing an RNTuple to a ROOT file, /// any payload whose size exceeds this will be split into multiple keys. std::uint64_t fMaxKeySize = kDefaultMaxKeySize; @@ -277,6 +284,15 @@ public: bool GetEnableColumnEncoding() const { return fEnableColumnEncoding; } void SetEnableColumnEncoding(bool val) { fEnableColumnEncoding = val; } + bool GetEnableLearnedProfiles() const { return fEnableLearnedProfiles; } + void SetEnableLearnedProfiles(bool val) { fEnableLearnedProfiles = val; } + + std::size_t GetLearnedProfileSampleSize() const { return fLearnedProfileSampleSize; } + void SetLearnedProfileSampleSize(std::size_t val) { fLearnedProfileSampleSize = val; } + + int GetLearnedProfileTrainLevel() const { return fLearnedProfileTrainLevel; } + void SetLearnedProfileTrainLevel(int val) { fLearnedProfileTrainLevel = val; } + std::uint64_t GetMaxKeySize() const { return fMaxKeySize; } friend bool operator==(const RNTupleWriteOptions &lhs, const RNTupleWriteOptions &rhs) @@ -289,7 +305,10 @@ public: lhs.fWriteBufferSize == rhs.fWriteBufferSize && lhs.fUseImplicitMT == rhs.fUseImplicitMT && lhs.fEnablePageChecksums == rhs.fEnablePageChecksums && lhs.fEnableSamePageMerging == rhs.fEnableSamePageMerging && - lhs.fEnableColumnEncoding == rhs.fEnableColumnEncoding && lhs.fMaxKeySize == rhs.fMaxKeySize; + lhs.fEnableColumnEncoding == rhs.fEnableColumnEncoding && + lhs.fEnableLearnedProfiles == rhs.fEnableLearnedProfiles && + lhs.fLearnedProfileSampleSize == rhs.fLearnedProfileSampleSize && + lhs.fLearnedProfileTrainLevel == rhs.fLearnedProfileTrainLevel && lhs.fMaxKeySize == rhs.fMaxKeySize; } friend bool operator!=(const RNTupleWriteOptions &lhs, const RNTupleWriteOptions &rhs) { return !(lhs == rhs); } diff --git a/tree/ntuple/inc/ROOT/RPageSinkBuf.hxx b/tree/ntuple/inc/ROOT/RPageSinkBuf.hxx index 07fefd77305f1..88f4bf540b60d 100644 --- a/tree/ntuple/inc/ROOT/RPageSinkBuf.hxx +++ b/tree/ntuple/inc/ROOT/RPageSinkBuf.hxx @@ -152,6 +152,10 @@ public: RPage ReservePage(ColumnHandle_t columnHandle, std::size_t nElements) final; + std::uint32_t GetColumnProfileId(ROOT::DescriptorId_t physicalColumnId) const override; + std::uint32_t EnsureColumnProfile(ROOT::DescriptorId_t physicalColumnId, const ROOT::Internal::RPage &page, + const ROOT::Internal::RColumnElementBase &element) override; + std::unique_ptr CloneAsHidden(std::string_view name, const RNTupleWriteOptions &opts) const final; }; // RPageSinkBuf diff --git a/tree/ntuple/inc/ROOT/RPageStorage.hxx b/tree/ntuple/inc/ROOT/RPageStorage.hxx index 45c07f4889fa0..c69726f5b626b 100644 --- a/tree/ntuple/inc/ROOT/RPageStorage.hxx +++ b/tree/ntuple/inc/ROOT/RPageStorage.hxx @@ -282,7 +282,8 @@ protected: /// compressionSetting is 0 (uncompressed) and the page is mappable and not checksummed, the returned sealed page /// will point directly to the input page buffer. Otherwise, the sealed page references fSealPageBuffer. Thus, /// the buffer pointed to by the RSealedPage should never be freed. - RSealedPage SealPage(const ROOT::Internal::RPage &page, const ROOT::Internal::RColumnElementBase &element); + RSealedPage SealPage(const ROOT::Internal::RPage &page, const ROOT::Internal::RColumnElementBase &element, + std::uint32_t profileId = 0); private: std::vector fOnDatasetCommitCallbacks; @@ -308,6 +309,14 @@ public: bool IsInitialized() const { return fIsInitialized; } + /// Returns the custom compression profile id (if any) for the specified column + virtual std::uint32_t GetColumnProfileId(ROOT::DescriptorId_t /*physicalColumnId*/) const { return 0; } + virtual std::uint32_t EnsureColumnProfile(ROOT::DescriptorId_t physicalColumnId, const ROOT::Internal::RPage & /*page*/, + const ROOT::Internal::RColumnElementBase & /*element*/) + { + return GetColumnProfileId(physicalColumnId); + } + /// Return the RNTupleDescriptor being constructed. virtual const ROOT::RNTupleDescriptor &GetDescriptor() const = 0; @@ -341,6 +350,8 @@ public: /// If false, the output buffer must not point to the input page buffer, which would otherwise be an option /// if the page is mappable and should not be compressed bool fAllowAlias = false; + /// Custom compression profile ID (for learned profiles in LHC4 / Crystal) + std::uint32_t fCompressionProfileId = 0; /// Location for sealed output. The memory buffer has to be large enough. void *fBuffer = nullptr; }; @@ -469,6 +480,14 @@ protected: RFeatures fFeatures; ROOT::Internal::RNTupleDescriptorBuilder fDescriptorBuilder; + bool fHeaderCommitted = false; + std::unique_ptr fProfileMutex = std::make_unique(); + /// Learned compression profiles: uncompressed page bytes of a column collected until the training sample + /// (RNTupleWriteOptions::GetLearnedProfileSampleSize()) is complete. Indexed by physical column id; an entry is + /// released once the column's profile is trained. + std::unordered_map> fProfileSamples; + /// Sum of the bytes held in fProfileSamples + std::size_t fProfileSampleBytes = 0; /// Default I/O performance counters that get registered in fMetrics struct RCounters { @@ -575,6 +594,18 @@ public: void CommitStagedClusters(std::span clusters) final; void CommitClusterGroup() final; void CommitAttributeSet(std::string_view attrSetName, const RNTupleLink &attrAnchorInfo) final; + + std::uint32_t GetColumnProfileId(ROOT::DescriptorId_t physicalColumnId) const override; + std::uint32_t EnsureColumnProfile(ROOT::DescriptorId_t physicalColumnId, const ROOT::Internal::RPage &page, + const ROOT::Internal::RColumnElementBase &element) override; + /// Trains and registers the learned profile of a column from `sample`; returns the profile id (0 if no model). + /// Requires fProfileMutex to be held and the header to be uncommitted. + std::uint32_t TrainColumnProfile(ROOT::DescriptorId_t physicalColumnId, const unsigned char *sample, + std::size_t sampleSize, int stride); + /// Trains the columns whose samples are still incomplete on what they have. Called before the header is + /// committed: the column compression contexts live in the header, so a profile has to exist by then or never. + void TrainPendingColumnProfiles(); + void CommitHeader(); }; // class RPagePersistentSink // clang-format off @@ -908,6 +939,9 @@ public: /// connecting streamer fields so that emulated classes can be read. void RegisterStreamerInfos(); + /// Registers learned compression profiles (LHC4 Crystal) in the codec runtime. + static void RegisterCompressionProfiles(const ROOT::RNTupleDescriptor &desc); + /// Creates a new PageSource using the same underlying file as this but referring to a different RNTuple, /// described by `anchorLink`. virtual std::unique_ptr OpenWithDifferentAnchor(const ROOT::Internal::RNTupleLink &anchorLink, diff --git a/tree/ntuple/src/RNTupleDescriptor.cxx b/tree/ntuple/src/RNTupleDescriptor.cxx index a51f89cd57880..4787a0ffec4b5 100644 --- a/tree/ntuple/src/RNTupleDescriptor.cxx +++ b/tree/ntuple/src/RNTupleDescriptor.cxx @@ -155,7 +155,7 @@ bool ROOT::RColumnDescriptor::operator==(const RColumnDescriptor &other) const return fLogicalColumnId == other.fLogicalColumnId && fPhysicalColumnId == other.fPhysicalColumnId && fBitsOnStorage == other.fBitsOnStorage && fType == other.fType && fFieldId == other.fFieldId && fIndex == other.fIndex && fRepresentationIndex == other.fRepresentationIndex && - fValueRange == other.fValueRange; + fValueRange == other.fValueRange && fCompressionContext == other.fCompressionContext; } ROOT::RColumnDescriptor ROOT::RColumnDescriptor::Clone() const @@ -170,6 +170,7 @@ ROOT::RColumnDescriptor ROOT::RColumnDescriptor::Clone() const clone.fFirstElementIndex = fFirstElementIndex; clone.fRepresentationIndex = fRepresentationIndex; clone.fValueRange = fValueRange; + clone.fCompressionContext = fCompressionContext; return clone; } @@ -1142,6 +1143,12 @@ void ROOT::Internal::RNTupleDescriptorBuilder::SetFeature(unsigned int flag) fDescriptor.fFeatureFlags.insert(flag); } +void ROOT::Internal::RNTupleDescriptorBuilder::SetColumnCompressionContext(ROOT::DescriptorId_t columnId, + RCompressionContext ctx) +{ + fDescriptor.fColumnDescriptors.at(columnId).fCompressionContext = std::move(ctx); +} + ROOT::RResult ROOT::Experimental::Internal::RNTupleAttrSetDescriptorBuilder::MoveDescriptor() { diff --git a/tree/ntuple/src/RNTupleSerialize.cxx b/tree/ntuple/src/RNTupleSerialize.cxx index d30c61d98b08a..02b6e91d3d5f0 100644 --- a/tree/ntuple/src/RNTupleSerialize.cxx +++ b/tree/ntuple/src/RNTupleSerialize.cxx @@ -254,6 +254,8 @@ ROOT::RResult SerializePhysicalColumn(const ROOT::RColumnDescript flags |= RNTupleSerializer::kFlagDeferredColumn; if (columnDesc.GetValueRange().has_value()) flags |= RNTupleSerializer::kFlagHasValueRange; + if (columnDesc.GetCompressionContext().has_value()) + flags |= RNTupleSerializer::kFlagHasCompressionContext; std::int64_t firstElementIdx = columnDesc.GetFirstElementIndex(); if (columnDesc.IsSuppressedDeferredColumn()) firstElementIdx = -firstElementIdx; @@ -270,6 +272,16 @@ ROOT::RResult SerializePhysicalColumn(const ROOT::RColumnDescript pos += RNTupleSerializer::SerializeUInt64(intMin, *where); pos += RNTupleSerializer::SerializeUInt64(intMax, *where); } + if (flags & RNTupleSerializer::kFlagHasCompressionContext) { + const auto &ctx = *columnDesc.GetCompressionContext(); + pos += RNTupleSerializer::SerializeUInt16(ctx.GetAlgorithm(), *where); + pos += RNTupleSerializer::SerializeUInt16(static_cast(ctx.GetContextType()), *where); + pos += RNTupleSerializer::SerializeUInt32(ctx.GetSize(), *where); + if (*where != nullptr) { + memcpy(pos, ctx.GetPayload().data(), ctx.GetSize()); + } + pos += ctx.GetSize(); + } if (auto res = RNTupleSerializer::SerializeFramePostscript(buffer ? base : nullptr, pos - base)) { pos += res.Unwrap(); @@ -375,6 +387,24 @@ DeserializeColumn(const void *buffer, std::uint64_t bufSize, ROOT::Internal::RCo memcpy(&max, &maxInt, sizeof(max)); columnDesc.ValueRange(min, max); } + if (flags & RNTupleSerializer::kFlagHasCompressionContext) { + if (fnFrameSizeLeft() < 2 * sizeof(std::uint16_t) + sizeof(std::uint32_t)) + return R__FAIL("column record frame too short"); + std::uint16_t algo; + std::uint16_t ctxType; + std::uint32_t ctxSize; + bytes += RNTupleSerializer::DeserializeUInt16(bytes, algo); + bytes += RNTupleSerializer::DeserializeUInt16(bytes, ctxType); + bytes += RNTupleSerializer::DeserializeUInt32(bytes, ctxSize); + if (fnFrameSizeLeft() < ctxSize) + return R__FAIL("column record frame too short"); + std::vector payload(ctxSize); + if (ctxSize > 0) { + memcpy(payload.data(), bytes, ctxSize); + bytes += ctxSize; + } + columnDesc.CompressionContext(ROOT::RCompressionContext(algo, static_cast(ctxType), std::move(payload))); + } columnDesc.FieldId(fieldId).BitsOnStorage(bitsOnStorage).Type(type).RepresentationIndex(representationIndex); columnDesc.FirstElementIndex(std::abs(firstElementIdx)); diff --git a/tree/ntuple/src/RPageSinkBuf.cxx b/tree/ntuple/src/RPageSinkBuf.cxx index 7720239f1cb83..e1477d600e731 100644 --- a/tree/ntuple/src/RPageSinkBuf.cxx +++ b/tree/ntuple/src/RPageSinkBuf.cxx @@ -158,6 +158,7 @@ void ROOT::Internal::RPageSinkBuf::CommitPage(ColumnHandle_t columnHandle, const { auto colId = columnHandle.fPhysicalId; const auto &element = *columnHandle.fColumn->GetElement(); + std::uint32_t profileId = EnsureColumnProfile(colId, page, element); // Safety: References are guaranteed to be valid until the element is destroyed. In other words, all buffered page // elements are valid until DropBufferedPages(). @@ -195,6 +196,7 @@ void ROOT::Internal::RPageSinkBuf::CommitPage(ColumnHandle_t columnHandle, const config.fPage = &page; config.fElement = &element; config.fCompressionSettings = GetWriteOptions().GetCompression(); + config.fCompressionProfileId = profileId; config.fWriteChecksum = GetWriteOptions().GetEnablePageChecksums(); config.fAllowAlias = false; config.fBuffer = zipItem.fBuf.get(); @@ -221,7 +223,7 @@ void ROOT::Internal::RPageSinkBuf::CommitPage(ColumnHandle_t columnHandle, const fCounters->fParallelZip.SetValue(1); // Thread safety: Each thread works on a distinct zipItem which owns its // compression buffer. - fTaskScheduler->AddTask([this, &zipItem, &sealedPage, &element, allocateBuf, shrinkSealedPage] { + fTaskScheduler->AddTask([this, &zipItem, &sealedPage, &element, allocateBuf, shrinkSealedPage, profileId] { // The task will consume the uncompressed page. Decrease the atomic counter early so that more work has arrived // when we are done. fBufferedUncompressed -= zipItem.fPage.GetNBytes(); @@ -231,6 +233,7 @@ void ROOT::Internal::RPageSinkBuf::CommitPage(ColumnHandle_t columnHandle, const config.fPage = &zipItem.fPage; config.fElement = &element; config.fCompressionSettings = GetWriteOptions().GetCompression(); + config.fCompressionProfileId = profileId; config.fWriteChecksum = GetWriteOptions().GetEnablePageChecksums(); // Make sure the page buffer is not aliased so that we can free the uncompressed page. config.fAllowAlias = false; @@ -339,3 +342,15 @@ void ROOT::Internal::RPageSinkBuf::CommitAttributeSet(std::string_view attrSetNa { fInnerSink->CommitAttributeSet(attrSetName, attrAnchorInfo); } + +std::uint32_t ROOT::Internal::RPageSinkBuf::GetColumnProfileId(ROOT::DescriptorId_t physicalColumnId) const +{ + return fInnerSink ? fInnerSink->GetColumnProfileId(physicalColumnId) : 0; +} + +std::uint32_t ROOT::Internal::RPageSinkBuf::EnsureColumnProfile(ROOT::DescriptorId_t physicalColumnId, + const ROOT::Internal::RPage &page, + const ROOT::Internal::RColumnElementBase &element) +{ + return fInnerSink ? fInnerSink->EnsureColumnProfile(physicalColumnId, page, element) : 0; +} diff --git a/tree/ntuple/src/RPageStorage.cxx b/tree/ntuple/src/RPageStorage.cxx index 1e988552660ea..d96e192517d8e 100644 --- a/tree/ntuple/src/RPageStorage.cxx +++ b/tree/ntuple/src/RPageStorage.cxx @@ -242,6 +242,8 @@ void ROOT::Internal::RPageSource::Attach(RNTupleSerializer::EDescriptorDeseriali descGuard.MoveIn(AttachImpl()); fStructureBuffer.Reset(); + RegisterCompressionProfiles(*descGuard); + std::vector buffer; for (const auto &cgDesc : descGuard->GetClusterGroupIterable()) { buffer.resize(cgDesc.GetPageListLength() + cgDesc.GetPageListLocator().GetNBytesOnStorage()); @@ -742,6 +744,26 @@ void ROOT::Internal::RPageSource::RegisterStreamerInfos() fHasStreamerInfosRegistered = true; } +void ROOT::Internal::RPageSource::RegisterCompressionProfiles(const ROOT::RNTupleDescriptor &desc) +{ +#ifdef R__HAS_LHC4CODEC + for (const auto &col : desc.GetColumnIterable()) { + if (const auto &ctxOpt = col.GetCompressionContext(); ctxOpt.has_value()) { + const auto &ctx = *ctxOpt; + if (ctx.GetAlgorithm() == ROOT::RCompressionSetting::EAlgorithm::kLHC4 && + ctx.GetContextType() == ROOT::RCompressionContext::EType::kLHC4CrystalProfile) { + auto profileId = ctx.GetProfileId(); + if (profileId != 0 && ctx.GetSize() > 0) { + R__LHC4RegisterProfileModel(profileId, ctx.GetPayload().data(), ctx.GetSize()); + } + } + } + } +#else + (void)desc; +#endif +} + //------------------------------------------------------------------------------ bool ROOT::Internal::RWritePageMemoryManager::RColumnInfo::operator>(const RColumnInfo &other) const @@ -865,22 +887,71 @@ int LHC4ProfileForColumnType(ROOT::ENTupleColumnType type) } } +/// Stride in bytes for empirical profile training based on column layout. +unsigned DetermineStrideForColumnType(ROOT::ENTupleColumnType type) +{ + switch (type) { + case ROOT::ENTupleColumnType::kReal32: + case ROOT::ENTupleColumnType::kIndex32: + case ROOT::ENTupleColumnType::kUInt32: + case ROOT::ENTupleColumnType::kInt32: + case ROOT::ENTupleColumnType::kSplitInt32: + case ROOT::ENTupleColumnType::kSplitUInt32: + case ROOT::ENTupleColumnType::kSplitReal32: + case ROOT::ENTupleColumnType::kSplitIndex32: + return 4; + case ROOT::ENTupleColumnType::kReal64: + case ROOT::ENTupleColumnType::kIndex64: + case ROOT::ENTupleColumnType::kUInt64: + case ROOT::ENTupleColumnType::kInt64: + case ROOT::ENTupleColumnType::kSplitInt64: + case ROOT::ENTupleColumnType::kSplitUInt64: + case ROOT::ENTupleColumnType::kSplitReal64: + case ROOT::ENTupleColumnType::kSplitIndex64: + return 8; + case ROOT::ENTupleColumnType::kInt16: + case ROOT::ENTupleColumnType::kUInt16: + case ROOT::ENTupleColumnType::kReal16: + case ROOT::ENTupleColumnType::kSplitInt16: + case ROOT::ENTupleColumnType::kSplitUInt16: + return 2; + case ROOT::ENTupleColumnType::kByte: + case ROOT::ENTupleColumnType::kChar: + case ROOT::ENTupleColumnType::kInt8: + case ROOT::ENTupleColumnType::kUInt8: + case ROOT::ENTupleColumnType::kBit: + return 1; + default: + return 0; + } +} + /// Sets the zip profile for this thread and restores it when the page seal returns. struct LHC4CallProfileGuard { int fPrevious{-1}; + unsigned fPreviousId{0}; bool fActive{false}; - explicit LHC4CallProfileGuard(int profile) + explicit LHC4CallProfileGuard(int profile, unsigned profileId = 0) { - if (profile < 0) + if (profile < 0 && profileId == 0) return; fPrevious = R__GetLHC4CallProfile(); - R__SetLHC4CallProfile(profile); + fPreviousId = R__GetLHC4CallProfileId(); + if (profileId != 0) { + R__SetLHC4CallProfile(kLHC4ProfileCustom); + R__SetLHC4CallProfileId(profileId); + } else { + R__SetLHC4CallProfile(profile); + R__SetLHC4CallProfileId(0); + } fActive = true; } ~LHC4CallProfileGuard() { - if (fActive) + if (fActive) { R__SetLHC4CallProfile(fPrevious); + R__SetLHC4CallProfileId(fPreviousId); + } } LHC4CallProfileGuard(const LHC4CallProfileGuard &) = delete; LHC4CallProfileGuard &operator=(const LHC4CallProfileGuard &) = delete; @@ -922,7 +993,9 @@ ROOT::Internal::RPageStorage::RSealedPage ROOT::Internal::RPageSink::SealPage(co auto nBytesZipped = nBytesPacked; #ifdef R__HAS_LHC4CODEC - LHC4CallProfileGuard columnProfile(LHC4CallProfileForPage(*config.fElement, config.fCompressionSettings)); + unsigned profileId = config.fCompressionProfileId; + int stdProfile = profileId ? kLHC4ProfileCustom : LHC4CallProfileForPage(*config.fElement, config.fCompressionSettings); + LHC4CallProfileGuard columnProfile(stdProfile, profileId); #endif if ((config.fCompressionSettings != 0) || !config.fElement->IsMappable() || !config.fAllowAlias || config.fWriteChecksum) { @@ -943,7 +1016,8 @@ ROOT::Internal::RPageStorage::RSealedPage ROOT::Internal::RPageSink::SealPage(co } ROOT::Internal::RPageStorage::RSealedPage -ROOT::Internal::RPageSink::SealPage(const ROOT::Internal::RPage &page, const RColumnElementBase &element) +ROOT::Internal::RPageSink::SealPage(const ROOT::Internal::RPage &page, const RColumnElementBase &element, + std::uint32_t profileId) { const auto nBytes = page.GetNBytes() + GetWriteOptions().GetEnablePageChecksums() * kNBytesPageChecksum; if (fSealPageBuffer.size() < nBytes) @@ -953,6 +1027,7 @@ ROOT::Internal::RPageSink::SealPage(const ROOT::Internal::RPage &page, const RCo config.fPage = &page; config.fElement = &element; config.fCompressionSettings = GetWriteOptions().GetCompression(); + config.fCompressionProfileId = profileId; config.fWriteChecksum = GetWriteOptions().GetEnablePageChecksums(); config.fAllowAlias = true; config.fBuffer = fSealPageBuffer.data(); @@ -1150,7 +1225,6 @@ void ROOT::Internal::RPagePersistentSink::InitImpl(ROOT::RNTupleModel &model) { fDescriptorBuilder.SetNTuple(fNTupleName, model.GetDescription()); fDescriptorBuilder.SetVersionForWriting(); - const auto &descriptor = fDescriptorBuilder.GetDescriptor(); auto &fieldZero = ROOT::Internal::GetFieldZeroOfModel(model); fDescriptorBuilder.AddField(RFieldDescriptorBuilder::FromField(fieldZero).FieldId(0).MakeDescriptor().Unwrap()); @@ -1167,11 +1241,28 @@ void ROOT::Internal::RPagePersistentSink::InitImpl(ROOT::RNTupleModel &model) initialChangeset.fAddedProjectedFields.emplace_back(f); UpdateSchema(initialChangeset, 0U); + if (GetWriteOptions().GetEnableLearnedProfiles()) { + fHeaderCommitted = false; + } else { + CommitHeader(); + } +} + +void ROOT::Internal::RPagePersistentSink::CommitHeader() +{ + if (fHeaderCommitted) + return; + + // Last chance for learned profiles: their models go into the header's column records. + TrainPendingColumnProfiles(); + + const auto &descriptor = fDescriptorBuilder.GetDescriptor(); fSerializationContext = RNTupleSerializer::SerializeHeader(nullptr, descriptor).Unwrap(); auto buffer = MakeUninitArray(fSerializationContext.GetHeaderSize()); fSerializationContext = RNTupleSerializer::SerializeHeader(buffer.get(), descriptor).Unwrap(); InitImpl(buffer.get(), fSerializationContext.GetHeaderSize()); + fHeaderCommitted = true; fDescriptorBuilder.BeginHeaderExtension(); } @@ -1371,10 +1462,13 @@ void ROOT::Internal::RPagePersistentSink::CommitPage(ColumnHandle_t columnHandle fOpenColumnRanges.at(columnHandle.fPhysicalId).IncrementNElements(page.GetNElements()); auto element = columnHandle.fColumn->GetElement(); + auto colId = columnHandle.fPhysicalId; + std::uint32_t profileId = EnsureColumnProfile(colId, page, *element); + RPageStorage::RSealedPage sealedPage; { RNTupleAtomicTimer timer(fCounters->fTimeWallZip, fCounters->fTimeCpuZip); - sealedPage = SealPage(page, *element); + sealedPage = SealPage(page, *element, profileId); } fCounters->fSzZip.Add(page.GetNBytes()); @@ -1385,6 +1479,137 @@ void ROOT::Internal::RPagePersistentSink::CommitPage(ColumnHandle_t columnHandle fOpenPageRanges.at(columnHandle.fPhysicalId).GetPageInfos().emplace_back(pageInfo); } +std::uint32_t ROOT::Internal::RPagePersistentSink::EnsureColumnProfile( + ROOT::DescriptorId_t physicalColumnId, const ROOT::Internal::RPage &page, + const ROOT::Internal::RColumnElementBase &element) +{ + std::lock_guard lock(*fProfileMutex); + std::uint32_t profileId = GetColumnProfileId(physicalColumnId); +#ifdef R__HAS_LHC4CODEC + if (profileId != 0) + return profileId; + // The column compression contexts are serialized with the header, which is committed with the first cluster + // (TrainPendingColumnProfiles runs just before). A profile trained later would be known to this writer only, so + // columns without a profile by then stay on the per-page model. + if (fHeaderCommitted) + return 0; + if (!(GetWriteOptions().GetEnableLearnedProfiles() || R__GetLHC4Profile() == kLHC4ProfileCustom)) + return 0; + const auto algo = ROOT::RCompressionSetting::AlgorithmFromCompressionSettings(GetWriteOptions().GetCompression()); + if (algo != ROOT::RCompressionSetting::EAlgorithm::kLHC4) + return 0; + const auto stride = DetermineStrideForColumnType(element.GetIdentifier().fOnDiskType); + if (stride <= 0) + return 0; + + // The profile is trained once per column on a sample of the column's uncompressed bytes. Pages committed + // before the sample is complete are written with the per-page (dynamic) Crystal model, so the profile is + // trained on real data instead of the small first page of the column. + const std::size_t minSample = static_cast(stride) * 16; + const std::size_t target = std::max(GetWriteOptions().GetLearnedProfileSampleSize(), minSample); + // Memory held by partial samples across all columns; above this, columns train on the sample they have. + constexpr std::size_t kSampleBudget = 64 * 1024 * 1024; + + const auto *pageBytes = static_cast(page.GetBuffer()); + const unsigned char *sample = pageBytes; + std::size_t sampleSize = page.GetNBytes(); + auto itSample = fProfileSamples.find(physicalColumnId); + const bool haveBuffered = (itSample != fProfileSamples.end()) && !itSample->second.empty(); + if (haveBuffered || sampleSize < target) { + auto &buf = fProfileSamples[physicalColumnId]; + const std::size_t take = std::min(page.GetNBytes(), target - std::min(target, buf.size())); + buf.insert(buf.end(), pageBytes, pageBytes + take); + fProfileSampleBytes += take; + const bool complete = buf.size() >= target; + const bool forced = (fProfileSampleBytes >= kSampleBudget) && (buf.size() >= minSample); + if (!complete && !forced) + return 0; + sample = buf.data(); + sampleSize = buf.size(); + } + if (sampleSize < minSample) + return 0; + + profileId = TrainColumnProfile(physicalColumnId, sample, sampleSize, stride); + if (auto it = fProfileSamples.find(physicalColumnId); it != fProfileSamples.end()) { + fProfileSampleBytes -= std::min(fProfileSampleBytes, it->second.size()); + fProfileSamples.erase(it); + } +#endif + return profileId; +} + +std::uint32_t ROOT::Internal::RPagePersistentSink::TrainColumnProfile(ROOT::DescriptorId_t physicalColumnId, + const unsigned char *sample, + std::size_t sampleSize, int stride) +{ +#ifdef R__HAS_LHC4CODEC + // The lane search level is decoupled from the write level: the profile is trained once per column and reused + // for every page, so a deeper search than the per-page encode is affordable. The write level is the floor. + const int trainLevel = + std::max(GetWriteOptions().GetLearnedProfileTrainLevel(), static_cast(GetWriteOptions().GetCompression() % 100)); + std::vector modelBytes(64 * 1024); + std::size_t modelSize = modelBytes.size(); + bool trained = R__LHC4TrainProfileModelLevel(sample, sampleSize, stride, trainLevel, modelBytes.data(), &modelSize); + if (!trained) { + // The initial buffer may have been too small for the model + modelSize = 0; + if (R__LHC4TrainProfileModelLevel(sample, sampleSize, stride, trainLevel, nullptr, &modelSize) && + modelSize > modelBytes.size()) { + modelBytes.resize(modelSize); + trained = R__LHC4TrainProfileModelLevel(sample, sampleSize, stride, trainLevel, modelBytes.data(), &modelSize); + } + } + if (!trained || modelSize == 0) + return 0; + modelBytes.resize(modelSize); + ROOT::RCompressionContext ctx(ROOT::RCompressionSetting::EAlgorithm::kLHC4, + ROOT::RCompressionContext::EType::kLHC4CrystalProfile, std::move(modelBytes)); + const std::uint32_t profileId = ctx.GetProfileId(); + R__LHC4RegisterProfileModel(profileId, ctx.GetPayload().data(), ctx.GetSize()); + fDescriptorBuilder.SetFeature(ROOT::RNTupleDescriptor::kFeatureFlag_CompressionContext); + fDescriptorBuilder.SetColumnCompressionContext(physicalColumnId, std::move(ctx)); + return profileId; +#else + (void)physicalColumnId; + (void)sample; + (void)sampleSize; + (void)stride; + return 0; +#endif +} + +void ROOT::Internal::RPagePersistentSink::TrainPendingColumnProfiles() +{ + std::lock_guard lock(*fProfileMutex); +#ifdef R__HAS_LHC4CODEC + if (!fHeaderCommitted) { + const auto &descriptor = fDescriptorBuilder.GetDescriptor(); + for (auto &[columnId, sample] : fProfileSamples) { + if (GetColumnProfileId(columnId) != 0 || columnId >= descriptor.GetNPhysicalColumns()) + continue; + const auto stride = DetermineStrideForColumnType(descriptor.GetColumnDescriptor(columnId).GetType()); + if (stride <= 0 || sample.size() < static_cast(stride) * 16) + continue; + TrainColumnProfile(columnId, sample.data(), sample.size(), stride); + } + } +#endif + fProfileSamples.clear(); + fProfileSampleBytes = 0; +} + +std::uint32_t ROOT::Internal::RPagePersistentSink::GetColumnProfileId(ROOT::DescriptorId_t physicalColumnId) const +{ + if (physicalColumnId < fDescriptorBuilder.GetDescriptor().GetNPhysicalColumns()) { + const auto &colDesc = fDescriptorBuilder.GetDescriptor().GetColumnDescriptor(physicalColumnId); + if (const auto &ctx = colDesc.GetCompressionContext(); ctx.has_value()) { + return ctx->GetProfileId(); + } + } + return 0; +} + void ROOT::Internal::RPagePersistentSink::CommitSealedPage(ROOT::DescriptorId_t physicalColumnId, const RPageStorage::RSealedPage &sealedPage) { @@ -1486,6 +1711,8 @@ void ROOT::Internal::RPagePersistentSink::CommitSealedPageV(std::span fakePayload = {0x04, 0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB}; + ROOT::RCompressionContext compCtx( + ROOT::RCompressionSetting::EAlgorithm::kLHC4, + ROOT::RCompressionContext::EType::kLHC4CrystalProfile, + fakePayload + ); + EXPECT_GE(compCtx.GetProfileId(), 256u); + + builder.AddColumn(RColumnDescriptorBuilder() + .LogicalColumnId(0) + .PhysicalColumnId(0) + .FieldId(1) + .BitsOnStorage(32) + .Type(ROOT::ENTupleColumnType::kReal32) + .Index(0) + .CompressionContext(compCtx) + .MakeDescriptor() + .Unwrap()); + + auto desc = builder.MoveDescriptor(); + auto context = RNTupleSerializer::SerializeHeader(nullptr, desc).Unwrap(); + EXPECT_GT(context.GetHeaderSize(), 0); + auto buffer = MakeUninitArray(context.GetHeaderSize()); + context = RNTupleSerializer::SerializeHeader(buffer.get(), desc).Unwrap(); + + RNTupleDescriptorBuilder readBuilder; + readBuilder.SetVersionForWriting(); + RNTupleSerializer::DeserializeHeader(buffer.get(), context.GetHeaderSize(), readBuilder); + + auto readDesc = readBuilder.MoveDescriptor(); + auto readCol = readDesc.GetColumnDescriptor(0); + EXPECT_TRUE(readCol.GetCompressionContext().has_value()); + EXPECT_EQ(readCol.GetCompressionContext()->GetAlgorithm(), ROOT::RCompressionSetting::EAlgorithm::kLHC4); + EXPECT_EQ(readCol.GetCompressionContext()->GetContextType(), ROOT::RCompressionContext::EType::kLHC4CrystalProfile); + EXPECT_EQ(readCol.GetCompressionContext()->GetSize(), fakePayload.size()); + EXPECT_EQ(readCol.GetCompressionContext()->GetPayload().size(), fakePayload.size()); + EXPECT_EQ(std::vector(readCol.GetCompressionContext()->GetPayload().begin(), + readCol.GetCompressionContext()->GetPayload().end()), + fakePayload); + EXPECT_EQ(readCol.GetCompressionContext()->GetProfileId(), compCtx.GetProfileId()); +} + TEST(RNTuple, SerializeFooter) { RNTupleDescriptorBuilder builder;