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..08b3cf3562af3 --- /dev/null +++ b/core/lhc4codec/README.md @@ -0,0 +1,344 @@ +# 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. + +### 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. + +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..ae00fb43a950d --- /dev/null +++ b/core/lhc4codec/inc/ZipLHC4.h @@ -0,0 +1,214 @@ +// 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); +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); +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; + /// 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); + +/// 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..9fdfaf962bbc3 --- /dev/null +++ b/core/lhc4codec/src/ZipLHC4.cxx @@ -0,0 +1,592 @@ +// 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 "lhc4codec/lhc4codec_c.h" + +#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) +{ + 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, 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) + 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 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; +} + +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; + out->crystal_profile_blocks = stats.crystal_profile_blocks; + out->crystal_profile_escapes = stats.crystal_profile_escapes; +} + +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..3253ec152ebcf --- /dev/null +++ b/main/src/rntuple2rntuple.cxx @@ -0,0 +1,1793 @@ +/// \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_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) + _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; + bool fLearnedProfiles = 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}); + 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}); + // 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; + } + int effectiveProfile = variant.fLearnedProfiles ? kLHC4ProfileNone : profile; + ConfigureLHC4FilterGlobals(variant.fLHC4Codec, variant.fEnableLHC4ByteFilters, autoMinGainPct, autoMaxLevel, + autoCodecs, effectiveProfile, 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..83352993c3252 --- /dev/null +++ b/main/src/tree2ntuple.cxx @@ -0,0 +1,1681 @@ +/// \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_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) + _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" || label == "lhc4_crystal_learned7" || + label == "lhc4_crystal_learned9"; +} + +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; + bool fLearnedProfiles = 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}); + 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}); + 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; + } + int effectiveProfile = variant.fLearnedProfiles ? kLHC4ProfileNone : profile; + ConfigureLHC4FilterGlobals(variant.fLHC4Codec, variant.fEnableLHC4ByteFilters, autoMinGainPct, autoMaxLevel, + autoCodecs, effectiveProfile, 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"; +#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; + 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/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/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..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 @@ -1775,6 +1840,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(); @@ -1796,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 aeb00fd8f90ac..338fc1f3df3b3 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,14 @@ protected: EImplicitMT fUseImplicitMT = EImplicitMT::kDefault; 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; @@ -261,6 +281,18 @@ public: bool GetEnableSamePageMerging() const { return fEnableSamePageMerging; } void SetEnableSamePageMerging(bool val); + 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) @@ -272,7 +304,11 @@ 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.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/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/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 dfcf2d11d411c..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 { @@ -541,10 +560,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 @@ -573,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 @@ -906,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/RFieldBase.cxx b/tree/ntuple/src/RFieldBase.cxx index 42b33d86d3a7d..592d2dfcd30a1 100644 --- a/tree/ntuple/src/RFieldBase.cxx +++ b/tree/ntuple/src/RFieldBase.cxx @@ -911,30 +911,43 @@ 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") - SetColumnRepresentatives({{ROOT::ENTupleColumnType::kSplitReal32}}); + if (fTypeAlias == "Double32_t") { + SetColumnRepresentatives({{usePlainColumns ? ROOT::ENTupleColumnType::kReal32 + : ROOT::ENTupleColumnType::kSplitReal32}}); + } } void ROOT::RFieldBase::ConnectPageSink(ROOT::Internal::RPageSink &pageSink, ROOT::NTupleSize_t firstEntry) diff --git a/tree/ntuple/src/RNTupleDescriptor.cxx b/tree/ntuple/src/RNTupleDescriptor.cxx index 6a20fc3605d06..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() { @@ -1356,6 +1363,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/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 026e2ead49dd8..d96e192517d8e 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 @@ -238,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()); @@ -738,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 @@ -829,10 +855,124 @@ 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; + } +} + +/// 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, unsigned profileId = 0) + { + if (profile < 0 && profileId == 0) + return; + fPrevious = R__GetLHC4CallProfile(); + fPreviousId = R__GetLHC4CallProfileId(); + if (profileId != 0) { + R__SetLHC4CallProfile(kLHC4ProfileCustom); + R__SetLHC4CallProfileId(profileId); + } else { + R__SetLHC4CallProfile(profile); + R__SetLHC4CallProfileId(0); + } + fActive = true; + } + ~LHC4CallProfileGuard() + { + if (fActive) { + R__SetLHC4CallProfile(fPrevious); + R__SetLHC4CallProfileId(fPreviousId); + } + } + 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 +992,11 @@ ROOT::Internal::RPageStorage::RSealedPage ROOT::Internal::RPageSink::SealPage(co } auto nBytesZipped = nBytesPacked; +#ifdef R__HAS_LHC4CODEC + 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) { nBytesZipped = @@ -871,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) @@ -881,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(); @@ -1078,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()); @@ -1095,19 +1241,39 @@ 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(); } 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 +1318,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 @@ -1293,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()); @@ -1307,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) { @@ -1408,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; diff --git a/tree/ntuple/test/ntuple_types.cxx b/tree/ntuple/test/ntuple_types.cxx index e206d29a36cc8..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 @@ -1814,6 +1818,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"); @@ -2282,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 diff --git a/tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx b/tree/ntupleutil/inc/ROOT/RNTupleImporter.hxx index ab68dd70faef4..0032188f2a395 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: @@ -191,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 @@ -219,6 +235,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 +282,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..14bb7001c2eca 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"; + } } }; @@ -360,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) @@ -394,15 +444,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 +458,54 @@ 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 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); + } } } - } - 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); }