diff --git a/builtins/lhc4codec/CMakeLists.txt b/builtins/lhc4codec/CMakeLists.txt new file mode 100644 index 0000000000000..d896be9bbc25e --- /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** +# 16 Sep 2026, https://gitlab.cern.ch/apeters/lhc4codec.git @ 922d272 (packaging/CI, bzip3 pin) +set(ROOT_LHC4CODEC_VERSION 2.3.0) +set(ROOT_LHC4CODEC_GIT_REPOSITORY "https://gitlab.cern.ch/apeters/lhc4codec.git") +set(ROOT_LHC4CODEC_GIT_TAG "922d2729da39cb393c4f109d393ffbaca3424a57") + +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..434aab4db5b61 --- /dev/null +++ b/core/lhc4codec/CMakeLists.txt @@ -0,0 +1,26 @@ +############################################################################ +# 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 $ +) + +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..4ce1a4a6ea35b --- /dev/null +++ b/core/lhc4codec/README.md @@ -0,0 +1,281 @@ +# 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 | + +Query availability with `R__LHC4CodecAvailable(codec)`. Beam, Bwt, Mosaic, Oracle, +Crystal, 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/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 | +| `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. +- **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,...`. + +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 + +When LHC4 compression or decompression fails, ROOT writes diagnostic artifacts to +`/tmp/root-lhc4-failure-/`: + +| 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..1fbcc1f7c068e --- /dev/null +++ b/core/lhc4codec/inc/ZipLHC4.h @@ -0,0 +1,132 @@ +// 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, + kLHC4CodecCount = 9, +}; + +/// Bit in an Auto codec mask: `1u << kLHC4Codec*`. Auto and Oracle 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 is never included). +#define kLHC4AutoCodecsAll \ + (kLHC4AutoCodecsRoot | kLHC4CodecBit(kLHC4CodecMosaic) | kLHC4CodecBit(kLHC4CodecBwt) | \ + kLHC4CodecBit(kLHC4CodecLzma) | kLHC4CodecBit(kLHC4CodecBzip3)) + +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). 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. +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); + +/// Legacy convenience: enable BWT block mode (sets codec to Bwt/Beam). +void R__SetLHC4Bwt(int enable); +int R__GetLHC4Bwt(void); + +/// Reset aggregated lhc4codec compression statistics. +void R__ResetLHC4CompressStats(void); +/// Print aggregated lhc4codec compression statistics to stdout. +void R__PrintLHC4CompressStats(void); + +/// Page-outcome counters from the most recent compress-stats window. +struct R__LHC4CompressStatsSummary { + unsigned long long num_pages; + unsigned long long uncompressed_bytes; + unsigned long long compressed_bytes; + unsigned long long filtered_won_pages; + unsigned long long raw_won_pages; + unsigned long long no_transform_pages; + unsigned long long stored_fallback_pages; + unsigned long long plain_pages; +}; +void R__GetLHC4CompressStatsSummary(struct R__LHC4CompressStatsSummary *out); + +/// Auto-mode winner histogram (indexed by kLHC4Codec*; only populated for lhc4_auto runs). +/// Slots for Auto and Oracle are unused: Auto is the racer, Oracle is not raced. +struct R__LHC4AutoCodecStatsSummary { + unsigned long long auto_selections; + unsigned long long codec_hits[kLHC4CodecCount]; +}; +void R__GetLHC4AutoCodecStatsSummary(struct R__LHC4AutoCodecStatsSummary *out); + +/// Optional inputs for R__DumpLHC4Failure (null pointers / zero sizes are omitted from dump files). +struct R__LHC4FailureDumpInfo { + const char *fStage; ///< e.g. "zip", "unzip", "verify_unzip", "verify_mismatch" + const void *fUncompressed; ///< raw page bytes before compression (may be null) + size_t fUncompressedSize; + const void *fCompressedLc; ///< full ROOT LC frame (9-byte header + lhc4codec payload) + size_t fCompressedLcSize; + int fCxLevel; ///< compression level if known, else -1 + const char *fStatusMessage; ///< human-readable error (may be null) +}; +/// Write fUncompressed / fCompressedLc payloads and a .meta sidecar under /tmp/root-lhc4-failure-/. +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..492387401e096 --- /dev/null +++ b/core/lhc4codec/src/ZipLHC4.cxx @@ -0,0 +1,420 @@ +// Original Author: ROOT Team + +/************************************************************************* + * Copyright (C) 1995-2026, Rene Brun and Fons Rademakers. * + * All rights reserved. * + * * + * For the licensing terms see $ROOTSYS/LICENSE. * + * For the list of contributors see $ROOTSYS/README/CREDITS. * + *************************************************************************/ + +#include "ZipLHC4.h" + +#include "lhc4codec/lhc4codec.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; + +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 int R__LHC4AutoMinGainPct = 1; +static int R__LHC4AutoMaxLevel = 0; +static unsigned R__LHC4AutoCodecs = kLHC4AutoCodecsRoot; +static int R__LHC4LastCxLevel = -1; + +namespace { + +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"; + 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 << "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'; + } +} + +} // namespace + +extern "C" void R__DumpLHC4Failure(const R__LHC4FailureDumpInfo *info) +{ + 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"; +} + +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; + 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__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; + if (cxlevel < lhc4codec::kMinLevel) + cxlevel = lhc4codec::kMinLevel; + if (cxlevel > lhc4codec::kMaxLevel) + cxlevel = lhc4codec::kMaxLevel; + params.codec = ToLHC4Codec(R__LHC4Codec); + params.level = cxlevel; + params.filters = R__LHC4Filters != 0; + params.filter_fallback = R__LHC4FilterFallback != 0; + params.filter_rle = R__LHC4FilterRle != 0; + params.filter_dict = R__LHC4FilterDict != 0; + params.auto_min_gain_pct = R__LHC4AutoMinGainPct; + params.auto_max_level = R__LHC4AutoMaxLevel != 0; + params.auto_codecs = R__LHC4AutoCodecs; + 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; + + const auto params = MakeLHC4CompressParams(cxlevel); + if (R__unlikely(!lhc4codec::codec_available(params.codec))) { + std::cerr << "Error in zip LHC4: requested codec is not available in this build" << std::endl; + DumpZipFailure(cxlevel, *srcsize, src, "requested codec is not available in this build"); + return; + } + + const auto result = lhc4codec::compress( + std::span(reinterpret_cast(src), static_cast(*srcsize)), + std::span(reinterpret_cast(&tgt[kHeaderSize]), + static_cast(*tgtsize - kHeaderSize)), + params); + + if (R__unlikely(!result.ok())) { + const std::string msg(lhc4codec::status_message(result.status)); + if (R__unlikely(result.status != lhc4codec::Status::BufferTooSmall)) { + std::cerr << "Error in zip LHC4: " << msg << std::endl; + DumpZipFailure(cxlevel, *srcsize, src, msg.c_str()); + } + return; + } + + const size_t written = result.value; + *irep = static_cast(written + kHeaderSize); + + const size_t deflate_size = written; + const size_t inflate_size = static_cast(*srcsize); + tgt[0] = 'L'; + tgt[1] = 'C'; + tgt[2] = static_cast(kLHC4Version); + tgt[3] = deflate_size & 0xff; + tgt[4] = (deflate_size >> 8) & 0xff; + tgt[5] = (deflate_size >> 16) & 0xff; + tgt[6] = inflate_size & 0xff; + tgt[7] = (inflate_size >> 8) & 0xff; + tgt[8] = (inflate_size >> 16) & 0xff; +} + +void R__unzipLHC4(int *srcsize, const unsigned char *src, int *tgtsize, unsigned char *tgt, int *irep) +{ + *irep = 0; + + if (R__unlikely(src[0] != 'L' || src[1] != 'C')) { + std::cerr << "R__unzipLHC4: algorithm run against buffer with incorrect header (got " << src[0] << src[1] + << "; expected LC)." << std::endl; + R__LHC4FailureDumpInfo dump{}; + dump.fStage = "unzip"; + dump.fCompressedLc = src; + dump.fCompressedLcSize = static_cast(*srcsize); + dump.fUncompressedSize = static_cast(*tgtsize); + dump.fCxLevel = R__LHC4LastCxLevel; + dump.fStatusMessage = "incorrect LC header"; + R__DumpLHC4Failure(&dump); + return; + } + + if (R__unlikely(src[2] != kLHC4Version)) { + std::cerr << "R__unzipLHC4: incompatible LHC4 on-disk version (got " << static_cast(src[2]) << "; expected " + << static_cast(kLHC4Version) << ")" << std::endl; + R__LHC4FailureDumpInfo dump{}; + dump.fStage = "unzip"; + dump.fCompressedLc = src; + dump.fCompressedLcSize = static_cast(*srcsize); + dump.fUncompressedSize = static_cast(*tgtsize); + dump.fCxLevel = R__LHC4LastCxLevel; + dump.fStatusMessage = "incompatible LHC4 on-disk version"; + R__DumpLHC4Failure(&dump); + return; + } + + const auto result = lhc4codec::decompress( + std::span(reinterpret_cast(&src[kHeaderSize]), + static_cast(*srcsize - kHeaderSize)), + std::span(reinterpret_cast(tgt), static_cast(*tgtsize))); + + if (R__unlikely(!result.ok())) { + const std::string msg(lhc4codec::status_message(result.status)); + if (R__unlikely(result.status != lhc4codec::Status::BufferTooSmall)) { + std::cerr << "Error in unzip LHC4: " << msg << std::endl; + R__LHC4FailureDumpInfo dump{}; + dump.fStage = "unzip"; + dump.fCompressedLc = src; + dump.fCompressedLcSize = static_cast(*srcsize); + dump.fUncompressedSize = static_cast(*tgtsize); + dump.fCxLevel = R__LHC4LastCxLevel; + dump.fStatusMessage = msg.c_str(); + R__DumpLHC4Failure(&dump); + } + return; + } + + *irep = static_cast(result.value); +} + +extern "C" void R__ResetLHC4CompressStats(void) +{ + lhc4codec::reset_compress_stats(); +} + +extern "C" void R__PrintLHC4CompressStats(void) +{ + const auto stats = lhc4codec::get_compress_stats(); + std::cout << lhc4codec::format_compress_stats(stats); +} + +extern "C" void R__GetLHC4CompressStatsSummary(R__LHC4CompressStatsSummary *out) +{ + if (!out) + return; + const auto stats = lhc4codec::get_compress_stats(); + out->num_pages = stats.num_pages; + out->uncompressed_bytes = stats.uncompressed_bytes; + out->compressed_bytes = stats.compressed_bytes; + out->filtered_won_pages = stats.filtered_won_pages; + out->raw_won_pages = stats.raw_won_pages; + out->no_transform_pages = stats.no_transform_pages; + out->stored_fallback_pages = stats.stored_fallback_pages; + out->plain_pages = stats.plain_pages; +} + +extern "C" void R__GetLHC4AutoCodecStatsSummary(R__LHC4AutoCodecStatsSummary *out) +{ + if (!out) + return; + const auto stats = lhc4codec::get_compress_stats(); + out->auto_selections = stats.auto_selections; + for (int i = 0; i < kLHC4CodecCount; ++i) + out->codec_hits[i] = stats.auto_codec_hits[i]; +} diff --git a/core/zip/inc/Compression.h b/core/zip/inc/Compression.h index 62301753b686c..2b02f13f126a7 100644 --- a/core/zip/inc/Compression.h +++ b/core/zip/inc/Compression.h @@ -42,6 +42,11 @@ 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 options are toggled globally via R__SetLHC4Codec(), +/// R__SetLHC4Filters(), R__SetLHC4AutoCodecs(), 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 +85,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 +108,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..73c4b36e82a2e 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -45,6 +45,12 @@ 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) + #---CreateHaddCommandLineOptions------------------------------------------------------------------ generateHeader(hadd ${CMAKE_SOURCE_DIR}/main/src/hadd-argparse.py diff --git a/main/src/rntuple2rntuple.cxx b/main/src/rntuple2rntuple.cxx new file mode 100644 index 0000000000000..2ddb4dc6ae430 --- /dev/null +++ b/main/src/rntuple2rntuple.cxx @@ -0,0 +1,1240 @@ +/// \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 + +#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, + kLHC4CodecCount = 9, +}; +#define kLHC4CodecBit(c) (1u << (unsigned)(c)) +#define kLHC4AutoCodecsRoot \ + (kLHC4CodecBit(kLHC4CodecZstd) | kLHC4CodecBit(kLHC4CodecBeam) | kLHC4CodecBit(kLHC4CodecCrystal)) +#define kLHC4AutoCodecsAll \ + (kLHC4AutoCodecsRoot | kLHC4CodecBit(kLHC4CodecMosaic) | kLHC4CodecBit(kLHC4CodecBwt) | \ + kLHC4CodecBit(kLHC4CodecLzma) | kLHC4CodecBit(kLHC4CodecBzip3)) +#endif + +#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::RPageSinkFile; +using ROOT::Internal::RPageSource; +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 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. + --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,bwt,lzma,bzip3). + --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_zstd.root lhc4codec filters + zstd (level 5) + _lhc4_bzip3.root lhc4codec filters + bzip3 (level 5) + _lhc4_beam5.root lhc4codec filters + Beam (level 5; lhc4_lz5 is a legacy alias) + _lhc4_mosaic5.root lhc4codec filters + Mosaic (level 5) + _lhc4_bwt5.root lhc4codec filters + BWT (level 5) + _lhc4_crystal5.root lhc4codec filters + Crystal (level 5) + _lhc4_oracle5.root lhc4codec filters + Oracle (level 5; not raced by Auto) + _lhc4_auto.root lhc4codec filters + Auto codec race (level 5; see --auto-min-gain, + --auto-max-level, --auto-codecs) + +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 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; + std::optional fAutoMinGainPct; + bool fAutoMaxLevel = false; + std::optional fAutoCodecs; + std::string fOnlyVariant; + std::string fWorkerResult; + std::string fInputFile; + std::string fNTupleName; + std::string fOutputBase; +}; + +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; + } + 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; +} + +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)}, + {"bwt", kLHC4CodecBit(kLHC4CodecBwt)}, + {"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'; +} + +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); + } + return result; +} + +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 == "--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 == "--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.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"); + + 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 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 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); +} + +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) +{ + ResetLHC4Globals(autoMinGainPct, autoMaxLevel, autoCodecs); + R__SetLHC4Codec(codec); + if (enableByteFilters) + R__SetLHC4Filters(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; + const char *fSuffix = nullptr; + RNTupleWriteOptions fOptions; + bool fUsesLHC4Globals = false; + bool fEnableLHC4ByteFilters = false; + int fLHC4Codec = 0; + bool fRequireCodecAvailable = 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 + constexpr int kLevel = 5; + const auto lhc4Opts = MakeLHC4WriteOptions(kLevel, runOpts.fKeepColumnEncoding); + + 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_beam5", lhc4Opts, true, true, kLHC4CodecBeam, false}); + variants.push_back({"lhc4_mosaic5", "_lhc4_mosaic5", lhc4Opts, true, true, kLHC4CodecMosaic, false}); + variants.push_back({"lhc4_bwt5", "_lhc4_bwt5", lhc4Opts, true, true, kLHC4CodecBwt, false}); + variants.push_back({"lhc4_crystal5", "_lhc4_crystal5", lhc4Opts, true, true, kLHC4CodecCrystal, false}); + variants.push_back({"lhc4_oracle5", "_lhc4_oracle5", lhc4Opts, true, true, kLHC4CodecOracle, 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) +{ + const std::string resolved = (label == "lhc4_lz5") ? "lhc4_beam5" : label; + for (const auto &variant : variants) { + if (resolved == variant.fLabel) + return &variant; + } + return nullptr; +} + +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, + bool discardOutput) +{ +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals) { + if (variant.fRequireCodecAvailable && !R__LHC4CodecAvailable(variant.fLHC4Codec)) { + if (!quiet) + std::cerr << "Skipping " << variant.fLabel << ": lhc4codec backend is not available in this build\n"; + return std::nullopt; + } + ConfigureLHC4FilterGlobals(variant.fLHC4Codec, variant.fEnableLHC4ByteFilters, autoMinGainPct, autoMaxLevel, + autoCodecs); + } 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{}); + std::vector sources{source.get()}; + + const auto writeOpts = variant.fOptions; + auto destination = std::make_unique(ntupleName, outputFile, writeOpts); + + 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}, {"bwt", kLHC4CodecBwt}, + {"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 << "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 << '\n'; + + const std::uint64_t nativeZstdBytes = FindNativeZstdOutputBytes(results); + + std::cout << std::left << std::setw(14) << "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(14 + 12 + 10 + 11 + 10 + 10 + 12 + 13 + 12 + 14, '-') << '\n'; + + for (const auto &row : results) { + std::cout << std::left << std::setw(14) << 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.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"); + AppendPageSizeArgs(args, opts); + AppendAutoMinGainArgs(args, opts); + AppendAutoMaxLevelArgs(args, opts); + AppendAutoCodecsArgs(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.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.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 << "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'; + + 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); + 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/tree2ntuple.cxx b/main/src/tree2ntuple.cxx new file mode 100644 index 0000000000000..b74f728025e51 --- /dev/null +++ b/main/src/tree2ntuple.cxx @@ -0,0 +1,1260 @@ +/// \file tree2ntuple.cxx +/// \brief Convert a TTree into several RNTuple output files for compression studies. + +#include +#include +#include +#include + +#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, + kLHC4CodecCount = 9, +}; +#define kLHC4CodecBit(c) (1u << (unsigned)(c)) +#define kLHC4AutoCodecsRoot \ + (kLHC4CodecBit(kLHC4CodecZstd) | kLHC4CodecBit(kLHC4CodecBeam) | kLHC4CodecBit(kLHC4CodecCrystal)) +#define kLHC4AutoCodecsAll \ + (kLHC4AutoCodecsRoot | kLHC4CodecBit(kLHC4CodecMosaic) | kLHC4CodecBit(kLHC4CodecBwt) | \ + kLHC4CodecBit(kLHC4CodecLzma) | kLHC4CodecBit(kLHC4CodecBzip3)) +#endif + +#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. + --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,bwt,lzma,bzip3). + --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_zstd.root lhc4codec filters + zstd (level 5) + _lhc4_bzip3.root lhc4codec filters + bzip3 (level 5) + _lhc4_beam5.root lhc4codec filters + Beam (level 5; lhc4_lz5 is a legacy alias) + _lhc4_mosaic5.root lhc4codec filters + Mosaic (level 5) + _lhc4_bwt5.root lhc4codec filters + BWT (level 5) + _lhc4_crystal5.root lhc4codec filters + Crystal (level 5) + _lhc4_oracle5.root lhc4codec filters + Oracle (level 5; not raced by Auto) + _lhc4_auto.root lhc4codec filters + Auto codec race (level 5; see --auto-min-gain, + --auto-max-level, --auto-codecs) + +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; + std::optional fAutoMinGainPct; + bool fAutoMaxLevel = false; + std::optional fAutoCodecs; + std::string fOnlyVariant; + std::string fWorkerResult; + std::string fInputFile; + std::string fTreeName; + std::string fOutputBase; +}; + +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; + } + 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; +} + +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)}, + {"bwt", kLHC4CodecBit(kLHC4CodecBwt)}, + {"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'; +} + +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); + } + return result; +} + +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 == "--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; + } + // 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") { + 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.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"); + + 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 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 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); +} + +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) +{ + ResetLHC4Globals(autoMinGainPct, autoMaxLevel, autoCodecs); + R__SetLHC4Codec(codec); + if (enableByteFilters) + R__SetLHC4Filters(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; + const char *fSuffix = nullptr; + RNTupleWriteOptions fOptions; + bool fUsesLHC4Globals = false; + bool fEnableLHC4ByteFilters = false; + int fLHC4Codec = 0; + bool fRequireCodecAvailable = 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 + constexpr int kLevel = 5; + const auto lhc4Opts = MakeLHC4WriteOptions(kLevel, runOpts.fKeepColumnEncoding); + + 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_beam5", lhc4Opts, true, true, kLHC4CodecBeam, false}); + variants.push_back({"lhc4_mosaic5", "_lhc4_mosaic5", lhc4Opts, true, true, kLHC4CodecMosaic, false}); + variants.push_back({"lhc4_bwt5", "_lhc4_bwt5", lhc4Opts, true, true, kLHC4CodecBwt, false}); + variants.push_back({"lhc4_crystal5", "_lhc4_crystal5", lhc4Opts, true, true, kLHC4CodecCrystal, false}); + variants.push_back({"lhc4_oracle5", "_lhc4_oracle5", lhc4Opts, true, true, kLHC4CodecOracle, 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) +{ + const std::string resolved = (label == "lhc4_lz5") ? "lhc4_beam5" : label; + for (const auto &variant : variants) { + if (resolved == 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, + bool discardOutput, bool verify) +{ +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals) { + if (variant.fRequireCodecAvailable && !R__LHC4CodecAvailable(variant.fLHC4Codec)) { + if (!quiet) + std::cerr << "Skipping " << variant.fLabel << ": lhc4codec backend is not available in this build\n"; + return std::nullopt; + } + ConfigureLHC4FilterGlobals(variant.fLHC4Codec, variant.fEnableLHC4ByteFilters, autoMinGainPct, autoMaxLevel, + autoCodecs); + } else { + ResetLHC4Globals(autoMinGainPct, autoMaxLevel, autoCodecs); + } +#else + if (variant.fUsesLHC4Globals) { + if (!quiet) + std::cerr << "Skipping " << variant.fLabel << ": ROOT was built without lhc4codec support\n"; + return std::nullopt; + } +#endif + + RemoveIfExists(outputFile); + if (!quiet) { + if (discardOutput) + std::cout << "Converting " << variant.fLabel << " (--no-out)" << std::endl; + else + std::cout << "Writing " << variant.fLabel << " -> " << outputFile << std::endl; + } + +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals) + R__ResetLHC4CompressStats(); +#endif + + const auto t0 = std::chrono::steady_clock::now(); + + ZipVerifyGuard verifyGuard(verify); + auto importer = RNTupleImporter::Create(inputFile, treeName, outputFile); + importer->SetWriteOptions(variant.fOptions); + if (quiet) + importer->SetIsQuiet(true); + importer->Import(); + + const auto t1 = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = t1 - t0; + + const auto report = importer->GetLastImportReport(); + +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals && !quiet) { + std::cout << "Compression statistics (" << variant.fLabel << "):\n"; + R__PrintLHC4CompressStats(); + } +#endif + + if (!quiet) + std::cout << " done\n"; + + VariantResult result; + result.fLabel = variant.fLabel; + result.fElapsedSec = elapsed.count(); + result.fOutputBytes = report.fFileBytesOnDisk; + if (result.fOutputBytes == 0) { + if (const auto size = FileSizeBytes(outputFile)) + result.fOutputBytes = *size; + } +#ifdef R__HAS_LHC4CODEC + if (variant.fUsesLHC4Globals) + result.fFilters = ReadFilterStatsSummary(); + if (std::strcmp(variant.fLabel, "lhc4_auto") == 0) + result.fAutoCodec = ReadAutoCodecStatsSummary(); +#endif + if (discardOutput) + RemoveIfExists(outputFile); + return result; +} + +std::string FormatGainVsNativeZstd(const VariantResult &row, std::uint64_t nativeZstdBytes) +{ + if (row.fLabel == "native_zstd") + return "0.0%"; + if (nativeZstdBytes == 0) + return "n/a"; + const double gain = 100.0 * + (static_cast(nativeZstdBytes) - static_cast(row.fOutputBytes)) / + static_cast(nativeZstdBytes); + return FormatPct(gain); +} + +std::uint64_t FindNativeZstdOutputBytes(const std::vector &results) +{ + for (const auto &row : results) { + if (row.fLabel == "native_zstd") + return row.fOutputBytes; + } + return 0; +} + +void PrintAutoCodecSummary(const AutoCodecStatsSummary &stats) +{ + if (!stats.fValid) + return; + + std::cout << "\nlhc4_auto backend mix (" << stats.fSelections << " page" + << (stats.fSelections == 1 ? "" : "s") << "):\n"; + + struct Row { + const char *fName; + int fCodec; + }; + static constexpr Row kOrder[] = {{"zstd", kLHC4CodecZstd}, {"beam", kLHC4CodecBeam}, + {"mosaic", kLHC4CodecMosaic}, {"bwt", kLHC4CodecBwt}, + {"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 << "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 << '\n'; + + const std::uint64_t nativeZstdBytes = FindNativeZstdOutputBytes(results); + + std::cout << std::left << std::setw(14) << "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(14 + 12 + 10 + 11 + 10 + 10 + 12 + 13 + 12 + 14, '-') << '\n'; + + for (const auto &row : results) { + std::cout << std::left << std::setw(14) << 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.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"); + AppendPageSizeArgs(args, opts); + AppendAutoMinGainArgs(args, opts); + AppendAutoMaxLevelArgs(args, opts); + AppendAutoCodecsArgs(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.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.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 << "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.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); + return 0; +} + +} // anonymous namespace + +int main(int argc, char **argv) +{ + try { + const auto opts = ParseArgs(argc, argv); + return RunMain(opts, argv[0]); + } catch (const std::exception &e) { + std::cerr << "tree2ntuple: " << e.what() << '\n'; + std::cerr << kUsage; + return 1; + } +} diff --git a/tree/ntuple/inc/ROOT/RFieldBase.hxx b/tree/ntuple/inc/ROOT/RFieldBase.hxx index 70880e3e3cfda..867ce5dfe515e 100644 --- a/tree/ntuple/inc/ROOT/RFieldBase.hxx +++ b/tree/ntuple/inc/ROOT/RFieldBase.hxx @@ -427,8 +427,8 @@ protected: const ColumnRepresentation_t & EnsureCompatibleColumnTypes(const ROOT::RNTupleDescriptor &desc, std::uint16_t representationIndex) const; /// When connecting a field to a page sink, the field's default column representation is subject - /// to adjustment according to the write options. E.g., if compression is turned off, encoded columns - /// are changed to their unencoded counterparts. + /// to adjustment according to the write options. E.g., if compression is turned off or column encoding + /// is disabled, encoded columns are changed to their unencoded counterparts. void AutoAdjustColumnTypes(const ROOT::RNTupleWriteOptions &options); /// Called by Clone(), which additionally copies the on-disk ID diff --git a/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx b/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx index aeb00fd8f90ac..3df419e522ba7 100644 --- a/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx +++ b/tree/ntuple/inc/ROOT/RNTupleWriteOptions.hxx @@ -175,6 +175,18 @@ Requires `EnablePageChecksums` and will throw if previously disabled. + +`EnableColumnEncoding` +`bool` +`true` + +If set, columns use RNTuple's on-storage encodings (split, zigzag, delta) where applicable. +If disabled, columns are stored in plain physical layout. When compression uses the LHC4 +algorithm, lhc4codec byte filters are enabled automatically (see ZipLHC4.h: filters, +filter RLE, and dict toggles; codec defaults to Beam). + + + */ // clang-format on @@ -205,6 +217,7 @@ protected: EImplicitMT fUseImplicitMT = EImplicitMT::kDefault; bool fEnablePageChecksums = true; bool fEnableSamePageMerging = true; + bool fEnableColumnEncoding = true; /// Specifies the max size of a payload storeable into a single TKey. When writing an RNTuple to a ROOT file, /// any payload whose size exceeds this will be split into multiple keys. std::uint64_t fMaxKeySize = kDefaultMaxKeySize; @@ -261,6 +274,9 @@ public: bool GetEnableSamePageMerging() const { return fEnableSamePageMerging; } void SetEnableSamePageMerging(bool val); + bool GetEnableColumnEncoding() const { return fEnableColumnEncoding; } + void SetEnableColumnEncoding(bool val) { fEnableColumnEncoding = val; } + std::uint64_t GetMaxKeySize() const { return fMaxKeySize; } friend bool operator==(const RNTupleWriteOptions &lhs, const RNTupleWriteOptions &rhs) @@ -272,7 +288,8 @@ public: lhs.fUseBufferedWrite == rhs.fUseBufferedWrite && lhs.fUseDirectIO == rhs.fUseDirectIO && lhs.fWriteBufferSize == rhs.fWriteBufferSize && lhs.fUseImplicitMT == rhs.fUseImplicitMT && lhs.fEnablePageChecksums == rhs.fEnablePageChecksums && - lhs.fEnableSamePageMerging == rhs.fEnableSamePageMerging && lhs.fMaxKeySize == rhs.fMaxKeySize; + lhs.fEnableSamePageMerging == rhs.fEnableSamePageMerging && + lhs.fEnableColumnEncoding == rhs.fEnableColumnEncoding && lhs.fMaxKeySize == rhs.fMaxKeySize; } friend bool operator!=(const RNTupleWriteOptions &lhs, const RNTupleWriteOptions &rhs) { return !(lhs == rhs); } diff --git a/tree/ntuple/inc/ROOT/RNTupleZip.hxx b/tree/ntuple/inc/ROOT/RNTupleZip.hxx index a613f30bb46c8..8eeb29c29d76e 100644 --- a/tree/ntuple/inc/ROOT/RNTupleZip.hxx +++ b/tree/ntuple/inc/ROOT/RNTupleZip.hxx @@ -17,6 +17,11 @@ #include #include +#include + +#ifdef R__HAS_LHC4CODEC +#include +#endif #include #include @@ -28,61 +33,6 @@ namespace ROOT { namespace Internal { -// clang-format off -/** -\class ROOT::Internal::RNTupleCompressor -\ingroup NTuple -\brief Helper class to compress data blocks in the ROOT compression frame format -*/ -// clang-format on -class RNTupleCompressor { -public: - RNTupleCompressor() = delete; - RNTupleCompressor(const RNTupleCompressor &other) = delete; - RNTupleCompressor &operator=(const RNTupleCompressor &other) = delete; - RNTupleCompressor(RNTupleCompressor &&other) = delete; - RNTupleCompressor &operator=(RNTupleCompressor &&other) = delete; - - /// Returns the size of the compressed data, written into the provided output buffer. - static std::size_t Zip(const void *from, std::size_t nbytes, int compression, void *to) - { - R__ASSERT(from != nullptr); - R__ASSERT(to != nullptr); - auto cxLevel = compression % 100; - if (cxLevel == 0) { - memcpy(to, from, nbytes); - return nbytes; - } - - auto cxAlgorithm = static_cast(compression / 100); - unsigned int nZipBlocks = 1 + (nbytes - 1) / kMAXZIPBUF; - const char *source = static_cast(from); - int szTarget = nbytes; - char *target = reinterpret_cast(to); - int szOutBlock = 0; - int szRemaining = nbytes; - size_t szZipData = 0; - for (unsigned int i = 0; i < nZipBlocks; ++i) { - int szSource = std::min(static_cast(kMAXZIPBUF), szRemaining); - R__zipMultipleAlgorithm(cxLevel, &szSource, source, &szTarget, target, &szOutBlock, cxAlgorithm); - R__ASSERT(szOutBlock >= 0); - if ((szOutBlock == 0) || (szOutBlock >= szSource)) { - // Uncompressible block, we have to store the entire input data stream uncompressed - memcpy(to, from, nbytes); - return nbytes; - } - - szZipData += szOutBlock; - source += szSource; - target += szOutBlock; - szRemaining -= szSource; - } - R__ASSERT(szRemaining == 0); - R__ASSERT(szZipData < nbytes); - return szZipData; - } -}; - // clang-format off /** \class ROOT::Internal::RNTupleDecompressor @@ -146,6 +96,118 @@ public: } }; +inline void VerifyZipRoundtrip(const void *from, std::size_t nbytes, const void *to, std::size_t szZipData) +{ + if (nbytes == szZipData) { + if (std::memcmp(from, to, nbytes) != 0) + throw ROOT::RException(R__FAIL("zip verify: uncompressed page buffer mismatch")); + return; + } + +#ifdef R__HAS_LHC4CODEC + auto dumpLhc4VerifyFailure = [&](const char *stage, const char *status) { + if (szZipData < 2) + return; + const auto *hdr = static_cast(to); + if (hdr[0] != 'L' || hdr[1] != 'C') + return; + R__LHC4FailureDumpInfo dump{}; + dump.fStage = stage; + dump.fUncompressed = from; + dump.fUncompressedSize = nbytes; + dump.fCompressedLc = to; + dump.fCompressedLcSize = szZipData; + dump.fCxLevel = -1; + dump.fStatusMessage = status; + R__DumpLHC4Failure(&dump); + }; +#endif + + const auto roundtrip = std::make_unique(nbytes); + try { + RNTupleDecompressor::Unzip(to, szZipData, nbytes, roundtrip.get()); + } catch (const ROOT::RException &err) { +#ifdef R__HAS_LHC4CODEC + dumpLhc4VerifyFailure("verify_unzip", err.what()); +#endif + throw; + } + if (std::memcmp(from, roundtrip.get(), nbytes) != 0) { +#ifdef R__HAS_LHC4CODEC + dumpLhc4VerifyFailure("verify_mismatch", "decompressed page buffer mismatch"); +#endif + throw ROOT::RException(R__FAIL("zip verify: decompressed page buffer mismatch")); + } +} + +// clang-format off +/** +\class ROOT::Internal::RNTupleCompressor +\ingroup NTuple +\brief Helper class to compress data blocks in the ROOT compression frame format +*/ +// clang-format on +class RNTupleCompressor { +public: + RNTupleCompressor() = delete; + RNTupleCompressor(const RNTupleCompressor &other) = delete; + RNTupleCompressor &operator=(const RNTupleCompressor &other) = delete; + RNTupleCompressor(RNTupleCompressor &&other) = delete; + RNTupleCompressor &operator=(RNTupleCompressor &&other) = delete; + + /// When enabled, every Zip() round-trips through Unzip() and compares page bytes. + static void SetVerifyRoundtrip(bool enable) { fVerifyRoundtrip = enable; } + static bool GetVerifyRoundtrip() { return fVerifyRoundtrip; } + + /// Returns the size of the compressed data, written into the provided output buffer. + static std::size_t Zip(const void *from, std::size_t nbytes, int compression, void *to) + { + R__ASSERT(from != nullptr); + R__ASSERT(to != nullptr); + auto cxLevel = compression % 100; + if (cxLevel == 0) { + memcpy(to, from, nbytes); + if (fVerifyRoundtrip) + VerifyZipRoundtrip(from, nbytes, to, nbytes); + return nbytes; + } + + auto cxAlgorithm = static_cast(compression / 100); + unsigned int nZipBlocks = 1 + (nbytes - 1) / kMAXZIPBUF; + const char *source = static_cast(from); + int szTarget = nbytes; + char *target = reinterpret_cast(to); + int szOutBlock = 0; + int szRemaining = nbytes; + size_t szZipData = 0; + for (unsigned int i = 0; i < nZipBlocks; ++i) { + int szSource = std::min(static_cast(kMAXZIPBUF), szRemaining); + R__zipMultipleAlgorithm(cxLevel, &szSource, source, &szTarget, target, &szOutBlock, cxAlgorithm); + R__ASSERT(szOutBlock >= 0); + if ((szOutBlock == 0) || (szOutBlock >= szSource)) { + // Uncompressible block, we have to store the entire input data stream uncompressed + memcpy(to, from, nbytes); + if (fVerifyRoundtrip) + VerifyZipRoundtrip(from, nbytes, to, nbytes); + return nbytes; + } + + szZipData += szOutBlock; + source += szSource; + target += szOutBlock; + szRemaining -= szSource; + } + R__ASSERT(szRemaining == 0); + R__ASSERT(szZipData < nbytes); + if (fVerifyRoundtrip) + VerifyZipRoundtrip(from, nbytes, to, szZipData); + return szZipData; + } + +private: + inline static bool fVerifyRoundtrip = false; +}; + } // namespace Internal } // namespace ROOT diff --git a/tree/ntuple/inc/ROOT/RPageSinkBuf.hxx b/tree/ntuple/inc/ROOT/RPageSinkBuf.hxx index 07fefd77305f1..5c7f3247121dd 100644 --- a/tree/ntuple/inc/ROOT/RPageSinkBuf.hxx +++ b/tree/ntuple/inc/ROOT/RPageSinkBuf.hxx @@ -73,6 +73,7 @@ class RPageSinkBuf : public RPageSink { const RPageStorage::SealedPageSequence_t &GetSealedPages() const { return fSealedPages; } void DropBufferedPages(); + void ValidateReadyToCommit(bool requireChecksum) const; // The returned reference points to a default-constructed RSealedPage. It can be used // to fill in data after sealing. diff --git a/tree/ntuple/src/RFieldBase.cxx b/tree/ntuple/src/RFieldBase.cxx index 42b33d86d3a7d..0565616f1e1c6 100644 --- a/tree/ntuple/src/RFieldBase.cxx +++ b/tree/ntuple/src/RFieldBase.cxx @@ -911,30 +911,45 @@ 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") { + if (options.GetEnableColumnEncoding() && options.GetCompression() != 0) + SetColumnRepresentatives({{ROOT::ENTupleColumnType::kSplitReal32}}); + else + SetColumnRepresentatives({{ROOT::ENTupleColumnType::kReal32}}); + } } void ROOT::RFieldBase::ConnectPageSink(ROOT::Internal::RPageSink &pageSink, ROOT::NTupleSize_t firstEntry) diff --git a/tree/ntuple/src/RPageSinkBuf.cxx b/tree/ntuple/src/RPageSinkBuf.cxx index 7720239f1cb83..50f1ebfdfa225 100644 --- a/tree/ntuple/src/RPageSinkBuf.cxx +++ b/tree/ntuple/src/RPageSinkBuf.cxx @@ -20,6 +20,7 @@ #include #include +#include using ROOT::Experimental::Detail::RNTupleAtomicCounter; using ROOT::Experimental::Detail::RNTupleAtomicTimer; @@ -36,6 +37,34 @@ void ROOT::Internal::RPageSinkBuf::RColumnBuf::DropBufferedPages() fSealedPages.clear(); } +void ROOT::Internal::RPageSinkBuf::RColumnBuf::ValidateReadyToCommit(bool requireChecksum) const +{ + if (fBufferedPages.size() != fSealedPages.size()) { + throw RException(R__FAIL("column has " + std::to_string(fBufferedPages.size()) + + " buffered pages but " + std::to_string(fSealedPages.size()) + + " sealed pages (parallel compression incomplete?)")); + } + + for (std::size_t i = 0; i < fBufferedPages.size(); ++i) { + if (!fBufferedPages[i].IsSealed()) { + throw RException(R__FAIL("column page " + std::to_string(i) + " (" + + std::to_string(fBufferedPages[i].fPage.GetNBytes()) + + " B) was not sealed (compression task failed?)")); + } + } + + for (std::size_t i = 0; i < fSealedPages.size(); ++i) { + const auto &sp = fSealedPages[i]; + if (sp.GetBuffer() == nullptr) { + throw RException(R__FAIL("sealed page " + std::to_string(i) + " has a null buffer")); + } + if (requireChecksum && !sp.GetHasChecksum()) { + throw RException(R__FAIL("sealed page " + std::to_string(i) + " is missing a checksum (" + + std::to_string(sp.GetDataSize()) + " B payload)")); + } + } +} + ROOT::Internal::RPageSinkBuf::RPageSinkBuf(std::unique_ptr inner) : RPageSink(inner->GetNTupleName(), inner->GetWriteOptions()), fInnerSink(std::move(inner)) { @@ -268,6 +297,7 @@ void ROOT::Internal::RPageSinkBuf::FlushClusterImpl(const std::function toCommit; toCommit.reserve(fBufferedColumns.size()); for (auto &bufColumn : fBufferedColumns) { + bufColumn.ValidateReadyToCommit(GetWriteOptions().GetEnableSamePageMerging()); R__ASSERT(bufColumn.HasSealedPagesOnly()); const auto &sealedPages = bufColumn.GetSealedPages(); toCommit.emplace_back(bufColumn.GetHandle().fPhysicalId, sealedPages.cbegin(), sealedPages.cend()); diff --git a/tree/ntuple/src/RPageStorage.cxx b/tree/ntuple/src/RPageStorage.cxx index 026e2ead49dd8..43d1611b5e2ab 100644 --- a/tree/ntuple/src/RPageStorage.cxx +++ b/tree/ntuple/src/RPageStorage.cxx @@ -34,6 +34,10 @@ #include #include +#ifdef R__HAS_LHC4CODEC +#include "ZipLHC4.h" +#endif + #include #include #include @@ -829,6 +833,14 @@ 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() {} diff --git a/tree/ntuple/test/ntuple_types.cxx b/tree/ntuple/test/ntuple_types.cxx index e206d29a36cc8..9165e8f8b5295 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 @@ -2282,3 +2286,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..d1814f4e41d10 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,197 @@ 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}; + 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()); +} +#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..ff1cb08276c1b 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()); + c.fPackedLeaves.clear(); + 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); }