diff --git a/Framework/AODNative/CMakeLists.txt b/Framework/AODNative/CMakeLists.txt new file mode 100644 index 0000000000000..b4311f11f1bfd --- /dev/null +++ b/Framework/AODNative/CMakeLists.txt @@ -0,0 +1,93 @@ +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +# ROOT-free reader for AO2D files. Nothing in this directory may depend on +# ROOT, at build or run time: see the check at the end of this file. +o2_add_library(AODNative + SOURCES src/ByteSource.cxx + src/LocalByteSource.cxx + src/Container.cxx + src/Objects.cxx + src/Codecs.cxx + src/Decoder.cxx + src/ArrowTable.cxx + src/ReadPlan.cxx + TARGETVARNAME targetName + PRIVATE_INCLUDE_DIRECTORIES ${CMAKE_CURRENT_LIST_DIR}/src + PUBLIC_LINK_LIBRARIES Arrow::arrow_shared + PRIVATE_LINK_LIBRARIES PkgConfig::zstd PkgConfig::lz4 PkgConfig::lzma ZLIB::ZLIB) + +# Reading over root:// needs the XRootD client, which does not depend on ROOT. +if(XROOTD_FOUND AND XROOTD_CLIENT_FOUND) + target_sources(${targetName} PRIVATE src/XRootDByteSource.cxx) + target_include_directories(${targetName} SYSTEM PRIVATE ${XROOTD_INCLUDE_DIRS}) + target_link_libraries(${targetName} PRIVATE ${XROOTD_CLIENT_LIBRARIES}) + target_compile_definitions(${targetName} PRIVATE AODNATIVE_WITH_XROOTD) +endif() + +# Public HTTP(S) with libcurl. +if(TARGET CURL::libcurl) + target_sources(${targetName} PRIVATE src/HttpByteSource.cxx) + target_link_libraries(${targetName} PRIVATE CURL::libcurl) + target_compile_definitions(${targetName} PRIVATE AODNATIVE_WITH_CURL) +endif() + +o2_add_executable(inspect + COMPONENT_NAME aod-native + SOURCES tools/inspect.cxx + PUBLIC_LINK_LIBRARIES O2::AODNative fmt::fmt) + +o2_add_executable(dump + COMPONENT_NAME aod-native + SOURCES tools/dump.cxx + PUBLIC_LINK_LIBRARIES O2::AODNative) + +add_executable(o2-test-framework-aod-native + test/test_Codecs.cxx + test/test_Objects.cxx + test/test_Decoder.cxx + test/test_ReadPlan.cxx) +target_include_directories(o2-test-framework-aod-native PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src) +target_link_libraries(o2-test-framework-aod-native PRIVATE O2::AODNative O2::Catch2 PkgConfig::zstd PkgConfig::lz4 PkgConfig::lzma ZLIB::ZLIB) + +get_filename_component(outdir ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/../tests ABSOLUTE) +set_property(TARGET o2-test-framework-aod-native PROPERTY RUNTIME_OUTPUT_DIRECTORY ${outdir}) + +add_test(NAME framework:aod-native COMMAND o2-test-framework-aod-native) + +# Measures private memory while decoding the files given on the command line. +# It interposes glibc's malloc, so it is Linux only and cannot be built with sanitizers. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_executable(o2-test-framework-aod-native-memory test/test_Memory.cxx) + target_link_libraries(o2-test-framework-aod-native-memory PRIVATE O2::AODNative) + set_property(TARGET o2-test-framework-aod-native-memory PROPERTY RUNTIME_OUTPUT_DIRECTORY ${outdir}) +endif() + +# libFuzzer target for the container, object and decoder layers. Needs clang, +# and the whole library built with -fsanitize=address,undefined to be useful. +option(AODNATIVE_FUZZ "Build the AODNative libFuzzer target" OFF) +if(AODNATIVE_FUZZ) + add_executable(o2-fuzz-aod-native test/fuzz/fuzz_RootFile.cxx) + target_include_directories(o2-fuzz-aod-native PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src) + target_compile_options(o2-fuzz-aod-native PRIVATE -fsanitize=fuzzer) + target_link_options(o2-fuzz-aod-native PRIVATE -fsanitize=fuzzer) + target_link_libraries(o2-fuzz-aod-native PRIVATE O2::AODNative PkgConfig::zstd PkgConfig::lz4 PkgConfig::lzma ZLIB::ZLIB) +endif() + +# The reader core must not link ROOT, directly or through its dependencies. +foreach(property LINK_LIBRARIES INTERFACE_LINK_LIBRARIES) + get_target_property(libs ${targetName} ${property}) + foreach(lib IN LISTS libs) + if(lib MATCHES "ROOT::" OR lib MATCHES "O2::Framework") + message(FATAL_ERROR "O2::AODNative must not depend on ROOT, but links ${lib}") + endif() + endforeach() +endforeach() diff --git a/Framework/AODNative/DEVELOPMENT.md b/Framework/AODNative/DEVELOPMENT.md new file mode 100644 index 0000000000000..8482f51d218a5 --- /dev/null +++ b/Framework/AODNative/DEVELOPMENT.md @@ -0,0 +1,406 @@ +# ROOT-free AO2D reader: development notes + +Work in progress on a DPL plugin which reads Run 3 AO2D files into Arrow +tables without ROOT, falling back to the ROOT TTree plugin per file. + +Spec: "ROOT-free AO2D Reader Plugin — Implementation Spec", +https://claude.ai/artifact/SVBE64S6nZozEmFnAf4CXf (milestones, byte layouts, +I/O and memory strategy, DPL integration). These notes record what was +implemented against it, where it deviates, and what is verified. + +## Status by milestone + +| # | Milestone | State | +|---|---|---| +| 1 | Inspect tool: local ByteSource, container and object layers | Done, verified | +| 2 | Decode to heap Arrow: codecs, basket decoder, `o2-aod-native-dump` | Done, verified | +| 3 | Bounded memory | Done, verified (see the memory model notes) | +| 3b | `AODInputFile` abstraction in DataInputDirector | Done, existing tests pass | +| 4 | DPL plugin, `native\|root\|auto` selection, per-file fallback | Done, byte-identical to the TTree plugin on production AO2Ds, identical DPL results | +| 5 | Remote I/O: XRootD, HTTP, AliEn, read planning, prefetch, streaming | Done for XRootD, AliEn and public HTTP(S); authenticated HTTPS and S3 not started | +| 6 | Hardening, default switched to `auto` | Not started | + +"Verified" means against the uproot-written corpus and, since milestone 4, +byte-identical to the TTree plugin on production AO2Ds (see below). + +## Layout + +``` +Framework/AODNative/ O2::AODNative, ROOT-free (CMake fails if it links ROOT or O2::Framework) + include/AODNative/ + Errors.h FormatError (base), UnsupportedVersion, UnsupportedLayout, CorruptData, IoError + ByteSource.h Options, ReadRequest, ByteSource (+ willNeed/dontNeed hints) + Container.h FileHeader, KeyInfo, RootFile (keys, directories, DF ordering, payloads) + Objects.h BasketRef, BranchInfo, TreeInfo, supported class versions + Codecs.h PayloadDecompressor (incremental + prefix reads), decompressBounded, xxh64 + Decoder.h BranchDecoder: values, VLA values, booleans, offsets, into caller memory + ArrowTable.h physicalSchema/datasetSchema (TTreePlugin contract), ColumnOp planning, makeBatch + ReadPlan.h ReadPlan (per-DF coalesced reads), ReadBufferPool, PlannedByteSource + src/ LocalByteSource (mmap / pread), XRootDByteSource (XrdCl, if found), + HttpByteSource (libcurl), Cursor (bounds-checked BE reader), Endian, ... + tools/ o2-aod-native-inspect [--json], o2-aod-native-dump + test/ Catch2 unit tests (o2-test-framework-aod-native) + test_Memory.cxx o2-test-framework-aod-native-memory : private memory per DF (malloc interposed) + fuzz/ libFuzzer target, -DAODNATIVE_FUZZ=ON (clang) + tools/ make_corpus.py, reference_inspect.py (uproot reference), check_corpus.py + standalone/ CMake harness to build this directory without the O2 stack + +Framework/AnalysisSupport/test/ + compareNativeToRoot.cxx o2-test-framework-aod-native-vs-root : every tree + through both plugins, serialised as DPL does, byte compared + +Framework/AnalysisSupport/src/ + AODInputFile.{h,cxx} AODInputFile interface + RootAODInputFile (TFile) [3b] + NativeAOD.{h,cxx} NativeAODFileSystem, NativeAODInputFile, NativeTreeHandle [4] + NativeAODPlugin.cxx FrameworkAnalysisNativeAODSupport: format, fragment, + deferred output stream, implementation and capability [4] +``` + +Other changes: `DataInputDirector.{h,cxx}` and `Plugin.cxx` (DiscoverMetadataInAOD) +go through `AODInputFile`; `--aod-reader-backend`, `--aod-reader-threads`, +`--aod-reader-local-io` in `WorkflowHelpers.cxx` / `runDataProcessing.cxx`, +read in `AODJAlienReaderHelpers.cxx`; codec dependencies (ZLIB, and pkg-config +libzstd, liblz4 and liblzma) in `dependencies/O2Dependencies.cmake`. liblzma +goes through pkg-config too: `find_package(LibLZMA)` misses Homebrew's xz, +because the O2 recipe sets `CMAKE_IGNORE_PATH=/opt/homebrew/include`. + +## Deviations from the spec + +- Byte layouts, checked against uproot 5.7: + - TObject's referenced bit is `kIsReferenced = 0x10`, not `0x00010000`. + - TBranch v13 `fBasketSeek` is always 64-bit (`Q`); the flag byte is a speedbump. + - Embedded baskets are parsed using ROOT's TBasket flag byte (99 header only, + +10 buffer present, +40 displacements, 1 = entry offsets present). + - Pure object back-references have no byte count: the first word is the tag. +- XXH64 for the LZ4 checksum is implemented in `Codecs.cxx` (about 50 lines): + alidist has no xxhash package. zstd comes from the system via pkg-config: + alidist has no zstd package either. +- `IoError` derives from `FormatError` like the other errors, so in `auto` + mode unsupported URL schemes fall back to ROOT. +- `AODInputFile::topLevelKeys()` returns name + class pairs and there is an extra + `keysIn(dir)`: `getListOfTables` (metadata discovery) needs both. TMap + contents come back as `std::map`, so metadata is now iterated alphabetically. +- The native `RootObjectReadingCapability` lives in the plugin library + (`O2FrameworkAnalysisNativeAODSupport:NativeAODObjectReadingCapability`), not + in `Framework/Core/src/Plugin.cxx`: recognising a native handle needs + `NativeAODFileSystem`, which Core cannot depend on. It is loaded (first in the + list) only when `--aod-reader-backend` is not `root`, so the default path is + unchanged. +- `NativeAODInputFile::open` parses the trees of the first DF, so that version + or layout problems surface at open, when the file can still go to ROOT. + Problems in later DFs become `AODReadError{InvalidTimeframe}`. `CorruptData` + found inside the deferred `Write()` cannot be mapped (same as the TTree plugin). +- `TAttLine`, `TAttFill` and `TAttMarker` are skipped by byte count without + a version check: ROOT 6.36 writes `TAttMarker` v3, and nothing is read from them. +- VLA sizes come from the `_size` branch preceding the values, as in the + TTree plugin, not from the leaf count: Run 2 converted FV0/FT0 tables have + `fAmplitudeA` counted by `fChannelA_size` but preceded by `fAmplitudeA_size`. +- Metadata objects are refused above 256 MiB, and every length read from the + file is checked against the file size before allocating (found by fuzzing). + +## Memory model as measured (milestone 3) + +`o2-test-framework-aod-native-memory` (Linux only: it interposes glibc's malloc) decodes into MAP_SHARED memory (standing +in for shm) and tracks every heap allocation, including the codecs'. + +- Value and VLA columns: no basket-sized private buffer. zstd decodes partial + chunks with the buffer-less API straight into the destination (its history), + with a fixed 256 KiB scratch for the last blocks. Largest allocation: + zstd 0.25 MiB, zlib 0.03 MiB, LZ4 / uncompressed 0. Baskets of 402 MB decode + with 0.09 MiB private. +- liblzma needs its dictionary per worker (8 MiB at the presets ROOT uses). +- Booleans are not bounded by the 64 KiB window alone: their output is + bit-packed, so it cannot be the codec's history, and zstd / LZ4 keep up to one + window / chunk (0.39 MiB in the corpus). The test reports them separately. +- pread mode holds the compressed bytes of the DF being read (the read plan, + below); mmap mode holds nothing. + +## Verification done + +- Unit tests: 26 test cases (codecs incl. bounded/prefix reads at block and + chunk boundaries, truncation, corruption, LZ4 checksum; cursor bounds; + decoder with poisoned entry-offset tails and guard bytes, booleans across + basket and window boundaries, VLA, embedded and uncompressed baskets, threads). +- Corpus (make_corpus.py): zstd, zlib, lzma, lz4 and uncompressed files, 3 DFs + each in non-lexicographic order, 3 tables, every leaf type, fixed arrays (also + of booleans), VLAs, baskets of 0 to 70001 entries, incompressible baskets + stored uncompressed; plus a 2.5 GB zstd file (64-bit seeks, 440 MB baskets + over many 16 MiB chunks) and an RNTuple file. check_corpus.py: inspect JSON + and dumped Arrow tables identical to the uproot reference for all 45 trees and + the big file, IPC bytes independent of `--threads 4` / `--pread`, also under + ASan; clean refusals for RNTuple, a patched TBranch version, truncations and + non-ROOT files. +- Fuzzing: about 12 million inputs in total, ASan + UBSan (fatal); no open findings. + +Not covered by the corpus: `metaData` / `parentFiles` TMaps and embedded +baskets (uproot writes neither). + +- Production AO2Ds (`o2-test-framework-aod-native-vs-root`, macOS arm64): the + IPC bytes DPL would send are identical between the native reader and the + TTree plugin for every tree, with mmap and with `--threads 4 --pread`; + `parentFiles` / `metaData` and DF keys identical too: + - two Hyperloop derived files, ROOT 6.36, zstd: 264 DFs / 792 trees each + - a Hyperloop derived file, ROOT 6.32, zstd, 937 MB: 27 DFs / 567 trees + - a Run 2 converted AO2D, ROOT 6.24, zlib, 1.3 GB: 16 DFs / 384 trees + In total 7.2 GB serialised. Truncated files are refused with `CorruptData`. +- `test_DataInputDirector`, `test_NavigateToLevel` (default `root` backend) pass. +- LHC24ar apass1 AO2D (ROOT 6.32, 2.6 GB): 63 DFs / 1827 trees byte-identical. +- DPL: `o2-testworkflows-analysis-histograms` on that file (it picks the + `TracksIU` task from the discovered `aod-metadata-tables`): AnalysisResults.root + identical, all 5 histograms bin by bin, with `--aod-reader-backend` root, + native and auto. +- Fallback: 3 DFs of that file plus a tree with a `std::vector` branch + in the first DF (a `TBranchElement`, refused natively, never read by the + workflow). `auto` logs the fallback once with the reason and gives results + identical to `root`; `native` stops with the reason in the error. +- Not yet exercised in DPL: parent files, and the value of the TF file name + sent with derived output. + +## Read performance (2026-10-28) + +`o2-test-framework-aod-native-vs-root --bench root|native `: every tree of +every DF read and serialised as DPL does, one backend per process. macOS arm64, +warm page cache, median of 3 runs (spread about 1%). The reader alone, not DPL. + +| File | Backend | Wall s | User s | Sys s | Max RSS MiB | Read calls | +|---|---|---|---|---|---|---| +| Hyperloop derived, 6.36, zstd, 90 MB | root | 0.45 | 0.42 | 0.02 | 273 | 2379 | +| | native | 0.36 | 0.35 | 0.01 | 327 | 5023 | +| | native --threads 4 | 0.35 | 0.35 | 0.01 | 326 | 5023 | +| | native --pread | 0.35 | 0.34 | 0.01 | 241 | 5023 | +| Hyperloop derived, 6.32, zstd, 937 MB | root | 3.98 | 2.64 | 1.34 | 399 | 1164 | +| | native | 2.58 | 2.50 | 0.07 | 1204 | 86424 | +| | native --threads 4 | 0.90 | 2.74 | 0.26 | 1204 | 86424 | +| | native --pread | 2.61 | 2.47 | 0.14 | 310 | 86424 | +| Run 2 converted, 6.24, zlib, 1.3 GB | root | 2.99 | 2.69 | 0.30 | 519 | 789 | +| | native | 2.67 | 2.58 | 0.08 | 1589 | 4191 | +| | native --threads 4 | 2.10 | 2.62 | 0.10 | 1589 | 4191 | +| | native --pread | 2.72 | 2.58 | 0.13 | 365 | 4191 | + +- User time is the same (decompression dominates); the gain is ROOT's system + time (1.34 s on the 937 MB file, not profiled). +- Threads split the baskets of one branch, so only files with many baskets per + branch gain. +- With mmap, max RSS includes the mapped file. `madvise(MADV_DONTNEED)` after + each decoded basket was tried: on macOS it changes nothing (Darwin keeps + file-backed pages resident) and adds 0.1-0.3 s of system time, so it was not + kept. On Linux it should drop the pages of the read-only `MAP_PRIVATE` + mapping; to be measured there. pread is as fast and has the lowest RSS, so + it is the DPL default (`--aod-reader-local-io pread`); the library and the + tools keep mmap as their default, with `--pread` to switch. +- Many more read calls than ROOT (no TTreeCache): free locally, but remote + reading needs the coalesced per-DF read plan of milestone 5 (next section). + +## Read plan (milestone 5, step 1) + +DPL decodes each tree as soon as it is read (`f2b.release()` in +`DataInputDescriptor::readTree` serialises right away), so the plan cannot wait +for all the tables of a DF. Instead it learns, like TTreeCache: a reader asks +for the same tables in every DF. + +- `NativeAODFileSystem::planFor(path)`: when a tree of a new DF is asked for, a + new `ReadPlan` is made with every basket of the trees read in the previous DF + registered, plus this tree. The first lookup of a range not in memory fetches + all pending ranges with one `readvInto`, merging ranges less than + `Options::coalesceGap` (64 KiB) apart. The first DF of a file is read tree by tree. +- Each fragment decodes through a `PlannedByteSource` over the plan. With mmap + there is no plan: nothing needs reading. +- Fetch buffers come from a `ReadBufferPool` per file and go back to it when + the plan goes away, so DFs reuse one buffer instead of allocating and + faulting in a new one each time. + +Byte-identical on the 5 production files with `--pread` and `--pread --threads 4`; +DPL results identical. Read calls reading every tree (per basket before / with +the plan / ROOT): hy_3993289/0065 5023 / 2121 / 2379; hy_1318738 86424 / 1232 / +1164; AO2D_019 4191 / 497 / 789; LHC24ar 71799 / 2273 / 3783. Bytes read +0.07% +(merged gaps). In DPL (`analysis-histograms` on LHC24ar, a few tables): +8852 → 1209 read calls, ROOT 444; about 19 merged ranges per DF, because the +requested baskets are interleaved with those of unread trees. They go out in one +`readvInto` per DF, which is one vectored request for a remote source; locally +it is still one pread each. + +Cost, locally: wall time within 0-2% of pread without the plan. Max RSS grows by +about one DF of compressed bytes (macOS, `MallocLargeCache=0`: hy_1318738 +310 → 349 MiB, AO2D_019 364 → 444, LHC24ar 311 → 360), the same order as +ROOT's 50 MB TTreeCache. With the macOS allocator cache on, max RSS is higher +(378, 599, 435 MiB): it keeps freed large blocks resident; glibc returns them. + +## XRootD source (milestone 5, step 2) + +`XRootDByteSource` (root://, roots://, xroot://, xroots://), built when O2 finds +XRootD (optional in O2); otherwise those URLs get an IoError, so `auto` falls +back. Links only libXrdCl. `readvInto` sends a whole read plan at once: ranges +up to 2097136 bytes go in vectored reads of at most 1024 chunks, larger ones +in plain reads of at most 64 MiB, all asynchronous, then waits for all of them; +a short or failed reply is an IoError. `readCalls()` counts requests sent. + +Tested against a local server (Homebrew xrootd 6.2.0, `all.export ~/src/data +r/o`): byte-identical with both readers over root:// on hy_3993289/0065, +hy_1318738 and LHC24ar (7.8 GB). Over localhost, median of 3 (wall s, requests): + +| File | root | native | native --threads 4 | +|---|---|---|---| +| hy_3993289/0065 | 0.90, 2379 | 0.72, 2121 | 0.72 | +| hy_1318738 | 3.45, 1164 | 3.31, 727 | 1.36 | +| LHC24ar | 7.73, 3783 | 7.30, 2175 | 3.11 | + +No latency on localhost, so the request count is what transfers to a real +network. Of native's requests, the data takes 3-4 per DF; the rest are +metadata read one at a time (directory header, key list, one per TTree). + +Metadata, batched (step 3): +- `RootFile::keysOf(df)` reads the key list of a DF once (it was read twice + per DF: once by `NativeAODInputFile::keysIn` and once by the filesystem) and + fetches the stored bytes of all its TTrees with it, in one `readvInto`: + ranges less than 4 KiB apart are merged (64 KiB pulled basket data in, +16% + bytes on small DFs). They are parsed only when asked for, so a TTree the + native reader cannot parse still fails only if requested. About 30 kB per + DF on LHC24ar and hy_1318738, 2 kB on hy_3993289. +- The entry count of a last basket missing from `fBasketEntry` is computed + from the branch's entries; the decoder checks it against the basket header. + The header read stays as fallback. + +Requests per DF are now 4 sequential round trips: directory record, key list, +TTrees, data (a few requests in flight). Over root://localhost, every tree, +median of 3 (wall s, requests): + +| File | root | native | native --threads 4 | +|---|---|---|---| +| hy_3993289/0065 | 0.89, 2379 | 0.56, 1063 | 0.57 | +| hy_1318738 | 3.49, 1164 | 3.23, 131 | 1.25 | +| LHC24ar | 7.77, 3783 | 7.09, 283 | 2.84 | + +2.2x to 13x fewer requests than ROOT, bytes read within 0.1%. + +Only the TTrees the read plan learned (those read in the previous DF, set with +`RootFile::setTreesToPrefetch`) are prefetched; all of them for the first DF of +a file, none when the file is mapped. Fetching every TTree of the DF cost, in +DPL (analysis-histograms, LHC24ar, a few tables), 2564 preads instead of 1209 +and +0.3% bytes; with the learned set it is 1224 preads and the same bytes as +before. Over root:// the same workflow sends 319 requests (ROOT 444), with +identical results. + +## Next-DF prefetch (milestone 5, step 4) + +For a remote source (`ByteSource::isRemote()`, XRootD) and `prefetchDepth > 0` +(default 1), when a DF starts `NativeAODFileSystem` prepares the next one in a +background task: its keys, the TTrees learned in the current DF, and a read +plan with their baskets fetched (`ReadPlan::fetch()`). It works on its own copy +of `RootFile` over the same source, so `RootFile` is never shared across +threads. When the reader reaches that DF, the keys, trees and plan are adopted; +a lookahead for another DF, or one which failed, is dropped (the normal path +redoes the work and reports errors). Not started for the first DF of a file, +which has not shown yet which trees are read. + +The next DF is the one after the current in the read order DPL uses: +`DataInputDescriptor` passes its `listOfTimeFrameNumbers` through +`AODInputFile::setReadOrder()`. `RootFile::dataFrames()` could not stand in for +it: with a parentFiles map, DataInputDirector sorts by parent file with an +unstable sort, so DFs of the same parent can come in a different order. + +Also: `readStringMap()` results are cached (parentFiles was read twice at open). + +Over root://localhost, every tree, median of 3 (wall s): + +| File | root | native, no prefetch | native | --threads 4: no prefetch / prefetch | +|---|---|---|---|---| +| hy_3993289/0065 | 0.89 | 0.56 | 0.36 | 0.56 / 0.36 | +| hy_1318738 | 3.43 | 3.24 | 2.61 | 1.24 / 0.92 | +| LHC24ar | 7.75 | 7.11 | 5.38 | 2.91 / 2.30 | + +Over root:// native now runs at the speed of local pread (LHC24ar 5.48 s). +Cost: one more DF of compressed bytes (max RSS +34 MiB on hy_1318738, +81 MiB on +LHC24ar). Requests are about the same as without it (151 vs 130, 283 vs 283); +in DPL (analysis-histograms, LHC24ar) 380 vs 319, with identical results. + +## AliEn lookup (milestone 5, step 5) + +`alien://` files: `gridReplicas(lfn)` (AODInputFile.cxx, which may use ROOT) +sends `access read ` through `gGrid`, as `TJAlienFile::Open` does, and +turns each replica into `root:////?&authz=`. Files inside +an archive (`#member`, on the LFN or the replica) are refused, so `auto` falls +back to ROOT. `openNativeAOD()` tries the replicas in the order AliEn proposes, +moving on after an IoError only (a format problem is the same on every +replica); DPL's reader and the comparison tool both open files through it. +The file keeps its LFN as name: what DataInputDirector compares, what goes +with derived output, and what monitoring reports, with `se=` and +`open_time=` as for TJAlienFile. `XRootDByteSource` uses the full URL only to +open; its `url()` and error messages leave out the query, so the access +envelope does not reach logs or outputs. + +From a Mac at CERN, token from alien-token-init: +- hy_3993289/0065 (alien:///alice/cern.ch/user/a/alihyperloop/jobs/0399/...): + byte-identical with ROOT (TJAlienFile), 264 DFs / 792 trees. +- LHC24ar, first 10 DFs, `--bench`: root 25.15 s, 603 requests, 2.70 s user, + 382 MiB; native 19.93 s, 71 requests, 1.25 s user, 522 MiB. Both read about + 408 MB: bandwidth bound (16 vs 20 MB/s from CERN EOS), native keeps the + connection busier thanks to the lookahead. +- DPL, `o2-testworkflows-analysis-histograms --aod-file alien://.../LHC24ar/...AO2D.root`: + AnalysisResults.root identical (5 histograms) with root, native and auto. + Requests 444 (root) vs 380 (native), 628.5 vs 629.1 MB read. The open + line reads `lfn=alien://...,size=2630072561,se=ALICE::CERN::EOS,open_time=0.1`, + and no `authz` appears in the log. Wall times (root 49.4 s, native 48.7 s, + auto 35.2 s with the same native code) vary by 13 s from run to run over the + WAN: a timing comparison needs interleaved repeats, or a Hyperloop test. + +## HTTP(S) source (milestone 5, step 6) + +`HttpByteSource` (http://, https://), libcurl, public servers only (no +authentication yet). The size comes from a HEAD request; reads are ranged GETs, +ranges above 16 MiB split so that the pieces go out in parallel. `readvInto` +sends all its GETs at once on a curl multi handle: multiplexed on one +connection with HTTP/2 (`CURL_HTTP_VERSION_2TLS` by default, `httpVersion`), +otherwise over at most `httpConnections` (4) connections. Connections, TLS +sessions and DNS are shared by all the calls on a source (a curl share +handle), so the lookahead and the reader can fetch at the same time. +Redirects are followed; `X509_CERT_DIR` sets the CA path. A reply must be a 206 +with exactly the bytes asked for: a server which ignores the range is caught at +the first extra byte and refused ("the server ignored the byte range"), so +`auto` falls back to ROOT rather than downloading the whole file. + +Tested: `o2-aod-native-inspect https://root.cern/files/h1/dstarmb.root` (HEAD and +ranges over public HTTPS); Python's http.server, which ignores ranges, is +refused. Against a local range-capable test server (HTTP/1.1, single ranges), +native over HTTP vs ROOT on the local file (the tool takes +`=`): byte-identical on hy_3993289/0065, hy_1318738 and +LHC24ar, also with --threads 4. hy_1318738, localhost: 3.14 s without +prefetch, 2.65 s with, 0.97 s with --threads 4 (root:// 3.24, 2.61, 0.92). + +Public HTTP, alimonitor train test data (LHC15o Run 2 converted, ROOT 6.24, +zlib, 1.19 GB, http://alimonitor.cern.ch/train-workdir/testdata/LFN/alice/data/2015/LHC15o/000246392/pass2/PWGZZ/Run3_Conversion/320_20220701-0813/AOD/153/AO2D.root): +byte-identical with ROOT on a local copy (15 DFs, 360 trees, 3.7 GB +serialised). Bandwidth bound at about 26 MB/s, as wget (44.0 s for the file): +native 47.8 s without prefetch, 45.0 s with, 46.1 s with --threads 4; 3.2 s of +user CPU. This ROOT build cannot read http:// at all (the TWebFile plugin entry +fails in ROOT 6.40, and there is no Davix), so for these files `auto` has no +fallback and the native reader is the only way. + +## Building and testing + +In O2: built with the rest of Framework (aliBuild). Standalone, for iterating on +the core without the O2 stack: + +```sh +python3 -m venv .venv && .venv/bin/pip install cmake ninja uproot awkward numpy pyarrow xxhash +curl -sSLO https://raw.githubusercontent.com/catchorg/Catch2/v3.7.1/extras/catch_amalgamated.{hpp,cpp} # into $CATCH2 +PATH=$PWD/.venv/bin:$PATH cmake -S O2/Framework/AODNative/test/standalone -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo -DPYARROW_DIR=$PWD/.venv/lib/python3.X/site-packages/pyarrow -DCATCH2_DIR=$CATCH2 +ninja -C build +# add -DSANITIZE=ON for ASan/UBSan; CXX=clang++ -DSANITIZE=ON -DAODNATIVE_FUZZ=ON for the fuzzer +build/tests/o2-test-framework-aod-native +.venv/bin/python -I O2/Framework/AODNative/test/tools/make_corpus.py corpus [--big] +.venv/bin/python -I O2/Framework/AODNative/test/tools/check_corpus.py build/bin corpus work +build/tests/o2-test-framework-aod-native-memory [--threads N] [--pread] corpus/*.root +``` + +## Next steps + +1. alidist (`o2.sh` needs `lzma` and `lz4`; zstd from the system) and the + Linux build, with the memory test and `madvise` measured there. +2. A Hyperloop test train with `--aod-reader-backend auto` on the reader, + including merged files with embedded baskets and derived data with parent files. +3. Authenticated HTTPS (tokens, X.509) and S3, when needed. + +Build note for Arch Linux (kernel headers 7.x): alidist's Clang 20.1.7 fails in +compiler-rt on ``; adding `-DCOMPILER_RT_BUILD_SANITIZERS=OFF` to +the cmake call in `alidist/clang.sh` works around it. diff --git a/Framework/AODNative/include/AODNative/ArrowTable.h b/Framework/AODNative/include/AODNative/ArrowTable.h new file mode 100644 index 0000000000000..482d51a499743 --- /dev/null +++ b/Framework/AODNative/include/AODNative/ArrowTable.h @@ -0,0 +1,68 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_ARROWTABLE_H_ +#define O2_FRAMEWORK_AODNATIVE_ARROWTABLE_H_ + +#include "AODNative/Decoder.h" +#include "AODNative/Objects.h" +#include +#include +#include +#include +#include +#include + +namespace o2::aod::native +{ + +/// The physical schema of a tree, exactly as the TTree plugin's Inspect builds +/// it: one field per branch, in branch order. A branch X_size becomes an int32 +/// field and must be immediately followed by X, which becomes list(T). +std::shared_ptr physicalSchema(TreeInfo const& tree); + +/// The physical schema without the *_size fields, as DataInputDirector derives it. +std::shared_ptr datasetSchema(std::shared_ptr const& physical); + +/// One buffer to fill for a column of the dataset schema. The byte sizes are +/// those of the TTree plugin, so that the IPC stream is byte-identical. +struct ColumnOp { + enum struct Kind { Values, + Booleans, + VLA }; + Kind kind; + BranchInfo const* branch; + int datasetField; + size_t byteSize; + /// For VLA columns: the offsets, decoded eagerly because they size the values. + std::shared_ptr offsets; +}; + +/// Plans the reading of @a schema (a subset of the dataset schema) from @a tree, +/// decoding VLA offsets into buffers from @a pool. Checks the row counts with +/// the same errors as the TTree plugin. Returns the number of rows in @a rows. +std::vector planColumns(TreeInfo const& tree, std::shared_ptr const& schema, + BranchDecoder& decoder, arrow::MemoryPool* pool, int64_t& rows); + +/// Fills the buffer of @a op, which is exactly op.byteSize bytes. +void executeColumnOp(BranchDecoder& decoder, ColumnOp const& op, uint8_t* dst); + +/// Wraps the buffers of @a ops (one per op, in order) into a record batch. +std::shared_ptr makeBatch(std::shared_ptr const& schema, std::vector const& ops, + std::vector> const& buffers, int64_t rows); + +/// Reads a whole tree into heap memory: plan, execute, wrap. +arrow::Result> readTree(ByteSource& source, TreeInfo const& tree, Options const& options, + arrow::MemoryPool* pool = arrow::default_memory_pool()); + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_ARROWTABLE_H_ diff --git a/Framework/AODNative/include/AODNative/ByteSource.h b/Framework/AODNative/include/AODNative/ByteSource.h new file mode 100644 index 0000000000000..113bc3adaef93 --- /dev/null +++ b/Framework/AODNative/include/AODNative/ByteSource.h @@ -0,0 +1,80 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_BYTESOURCE_H_ +#define O2_FRAMEWORK_AODNATIVE_BYTESOURCE_H_ + +#include +#include +#include +#include +#include + +namespace o2::aod::native +{ + +/// All the reader settings, collected from the --aod-reader-* / --aod-http-* +/// workflow options. +struct Options { + enum struct LocalIO { MMap, + PRead }; + enum struct RemoteIO { Prefetch, + Stream }; + enum struct HttpVersion { Auto, + Http1_1, + Http2 }; + LocalIO localIO = LocalIO::MMap; + RemoteIO remoteIO = RemoteIO::Prefetch; + int threads = 1; + int prefetchDepth = 1; + size_t coalesceGap = 64 * 1024; + double spanFraction = 0.8; + int streamWindows = 4; + size_t streamWindowBytes = 2 * 1024 * 1024; + HttpVersion httpVersion = HttpVersion::Auto; + int httpConnections = 4; +}; + +struct ReadRequest { + uint64_t offset; + size_t length; + void* dst; +}; + +/// Random access to the bytes of a file. Everything above this is I/O-agnostic. +/// The caller always owns the destination. Implementations are thread-safe +/// for concurrent reads. +class ByteSource +{ + public: + virtual ~ByteSource(); + virtual uint64_t size() const = 0; + /// Throws IoError, or CorruptData when the range is past the end of the file. + virtual void readInto(uint64_t off, size_t len, void* dst) = 0; + virtual void readvInto(std::span requests) = 0; + /// A view of the bytes without copying them, if the backend can provide it. + virtual std::span tryMap(uint64_t off, size_t len) { return {}; } + /// Whether tryMap() provides every range, so that nothing needs reading. + virtual bool canMap() const { return false; } + /// Whether each read is a network round trip, worth hiding behind decoding. + virtual bool isRemote() const { return false; } + /// Hints that a range will be needed soon, or is no longer needed. + virtual void willNeed(uint64_t off, size_t len) {} + virtual void dontNeed(uint64_t off, size_t len) {} + virtual uint64_t bytesRead() const = 0; + virtual uint64_t readCalls() const = 0; + virtual std::string url() const = 0; + static std::unique_ptr open(std::string url, const Options&); +}; + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_BYTESOURCE_H_ diff --git a/Framework/AODNative/include/AODNative/Codecs.h b/Framework/AODNative/include/AODNative/Codecs.h new file mode 100644 index 0000000000000..30205c1b4eef5 --- /dev/null +++ b/Framework/AODNative/include/AODNative/Codecs.h @@ -0,0 +1,80 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_CODECS_H_ +#define O2_FRAMEWORK_AODNATIVE_CODECS_H_ + +#include +#include +#include +#include +#include +#include + +namespace o2::aod::native +{ + +/// Decompresses the chunks of a ROOT compressed payload incrementally into +/// caller-provided memory. Each read() continues where the previous one +/// stopped, so a caller can stop at the end of the value region without +/// producing (or holding) the rest. Only LZ4 needs scratch memory, and only +/// when a read ends in the middle of an LZ4 chunk. +class PayloadDecompressor +{ + public: + PayloadDecompressor(std::span src, uint32_t objLen, std::string const& file, uint64_t fileOffset); + ~PayloadDecompressor(); + PayloadDecompressor(PayloadDecompressor const&) = delete; + PayloadDecompressor& operator=(PayloadDecompressor const&) = delete; + + /// Writes exactly @a n bytes to @a dst. Throws CorruptData if the payload + /// ends first or contradicts its own headers. + void read(uint8_t* dst, size_t n); + /// Writes the first @a n bytes of the payload to @a dst, as the only read. + /// Since the output is contiguous and stays in place, zstd decodes into it + /// without a window buffer of its own. + void readPrefix(uint8_t* dst, size_t n); + /// Bytes produced so far. + size_t produced() const { return mProduced; } + + private: + struct Codec; + void nextChunk(); + size_t produceFromChunk(uint8_t* dst, size_t n); + size_t zstdPrefix(uint8_t* dst, size_t n); + + std::span mSrc; + uint32_t mObjLen; + std::string const& mFile; + uint64_t mFileOffset; + size_t mSrcPos = 0; + size_t mProduced = 0; + // Current chunk + char mTag[2] = {0, 0}; + std::span mChunkIn; + size_t mChunkOut = 0; // uncompressed size of the current chunk + size_t mChunkDone = 0; // produced from the current chunk + std::unique_ptr mCodec; + std::vector mScratch; // LZ4 chunk decoded ahead of a partial read, or the last zstd blocks of a prefix + bool mPrefixOnly = false; +}; + +/// Decompresses all chunks of a payload, writing at most maxOut bytes to dst. +/// Returns the number of bytes written, which is min(objLen, maxOut). +size_t decompressBounded(std::span src, uint32_t objLen, uint8_t* dst, size_t maxOut, + std::string const& file = "", uint64_t fileOffset = 0); + +/// XXH64 with seed 0, as used for the LZ4 chunk checksum. +uint64_t xxh64(std::span data); + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_CODECS_H_ diff --git a/Framework/AODNative/include/AODNative/Container.h b/Framework/AODNative/include/AODNative/Container.h new file mode 100644 index 0000000000000..567927542a8fc --- /dev/null +++ b/Framework/AODNative/include/AODNative/Container.h @@ -0,0 +1,103 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_CONTAINER_H_ +#define O2_FRAMEWORK_AODNATIVE_CONTAINER_H_ + +#include "AODNative/ByteSource.h" +#include "AODNative/Objects.h" +#include +#include +#include +#include +#include +#include + +namespace o2::aod::native +{ + +struct FileHeader { + int32_t version; + int32_t begin; + uint64_t end, seekFree; + int32_t nbytesFree, nfree, nbytesName; + uint8_t units; + int32_t compress; + uint64_t seekInfo; + int32_t nbytesInfo; +}; + +struct KeyInfo { + uint32_t nbytes; + int16_t version; + uint32_t objlen; + uint16_t keylen, cycle; + uint64_t seekKey, seekPdir; + std::string className, name, title; + bool compressed() const { return objlen != nbytes - keylen; } +}; + +struct DirectoryInfo { + int16_t version; + int32_t nbytesKeys, nbytesName; + uint64_t seekDir, seekParent, seekKeys; +}; + +/// The container layer: file header, keys and directories. Only reads what +/// is asked for, so opening a file costs a handful of small reads. +class RootFile +{ + public: + explicit RootFile(std::shared_ptr); + FileHeader const& header() const { return mHeader; } + ByteSource& source() { return *mSource; } + std::shared_ptr const& sourcePtr() const { return mSource; } + /// All the keys of the top directory, every cycle, in on-disk order. + const std::vector& topKeys(); + /// DF_* directory names, deduplicated, ordered numerically, or by parent + /// file path when a parentFiles map exists. Same order as DataInputDirector. + std::vector dataFrames(); + /// The keys inside DF @a df, highest cycle only, read once per DF. Unless + /// the file is mapped, the stored bytes of its TTrees are fetched along, in + /// one read: each read is a round trip for a remote file. + std::vector keysOf(const std::string& df); + /// The TTrees whose bytes keysOf() fetches along; all of them when empty. + void setTreesToPrefetch(std::vector names) { mTreesToPrefetch = std::move(names); } + TreeInfo readTree(const KeyInfo&); + /// Empty map if the key does not exist. Read once per key. + std::map readStringMap(const std::string& key); + /// The uncompressed payload of a key. + std::vector readPayload(const KeyInfo&); + + private: + KeyInfo parseKey(std::span buf, uint64_t fileOffset); + DirectoryInfo readDirectory(uint64_t offset); + std::vector readKeyList(DirectoryInfo const&); + std::optional findTopKey(std::string const& name); + std::vector readStored(const KeyInfo&); + + std::shared_ptr mSource; + std::string mUrl; + FileHeader mHeader; + std::optional> mTopKeys; + // Stored bytes of the TTrees of the last DF listed, by key offset, until read. + std::map> mPrefetched; + std::map> mKeysOf; + std::vector mTreesToPrefetch; + std::map> mStringMaps; +}; + +/// Keeps only the highest cycle of each name, preserving first-seen order. +std::vector highestCycles(std::vector const& keys); + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_CONTAINER_H_ diff --git a/Framework/AODNative/include/AODNative/Decoder.h b/Framework/AODNative/include/AODNative/Decoder.h new file mode 100644 index 0000000000000..c0c3d46a58f47 --- /dev/null +++ b/Framework/AODNative/include/AODNative/Decoder.h @@ -0,0 +1,68 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_DECODER_H_ +#define O2_FRAMEWORK_AODNATIVE_DECODER_H_ + +#include "AODNative/ByteSource.h" +#include "AODNative/Objects.h" +#include +#include +#include + +namespace o2::aod::native +{ + +/// Byteswaps @a count big-endian values of @a typeSize bytes in place. +void byteswapInPlace(uint8_t* data, size_t count, size_t typeSize); + +/// Decodes the baskets of a branch straight into caller-owned memory (a +/// shared memory slice, or a heap buffer in tools and tests). Values are +/// decompressed into the destination, stopping at the end of each basket's +/// value region, and byteswapped there: no intermediate decode buffer. +class BranchDecoder +{ + public: + /// Size of the boolean window, the only decode-side scratch memory. + static constexpr size_t kBooleanWindow = 64 * 1024; + + BranchDecoder(ByteSource& source, Options const& options); + std::string const& url() const { return mUrl; } + + /// Values of a scalar or fixed-size array branch, @a dst holding exactly + /// entries × fixedLen × element size bytes. + void decodeValues(BranchInfo const& branch, uint8_t* dst, size_t nbytes); + /// Values of a variable-length array branch. @a offsets are the entries + 1 + /// offsets from decodeOffsets() on its count branch. + void decodeVLAValues(BranchInfo const& branch, std::span offsets, uint8_t* dst, size_t nbytes); + /// Booleans (one byte each on disk) bit-packed into @a bitmap, which is zeroed + /// first. @a nbytes is the full size of the bitmap. + void decodeBooleans(BranchInfo const& branch, uint8_t* bitmap, size_t nbytes); + /// Decodes a _size branch into entries + 1 int32 offsets. + std::vector decodeOffsets(BranchInfo const& sizeBranch); + + private: + struct BasketValues; + /// Calls @a fn(basket index, reader) for each basket, where the reader yields + /// the value bytes of that basket in order. Baskets are independent, so this + /// fans out over threads when @a parallel is set and options allow it. + template + void forEachBasket(BranchInfo const& branch, bool parallel, F&& fn); + void checkBaskets(BranchInfo const& branch) const; + + ByteSource& mSource; + Options mOptions; + std::string mUrl; +}; + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_DECODER_H_ diff --git a/Framework/AODNative/include/AODNative/Errors.h b/Framework/AODNative/include/AODNative/Errors.h new file mode 100644 index 0000000000000..b5715bd1c0633 --- /dev/null +++ b/Framework/AODNative/include/AODNative/Errors.h @@ -0,0 +1,82 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_ERRORS_H_ +#define O2_FRAMEWORK_AODNATIVE_ERRORS_H_ + +#include +#include +#include + +namespace o2::aod::native +{ + +/// Base of every error raised by the native reader. In "auto" mode, any of +/// these during open or metadata parsing makes the file fall back to ROOT. +struct FormatError : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +/// A class version we have no hardcoded layout for. +struct UnsupportedVersion : FormatError { + UnsupportedVersion(std::string className_, int version_, std::string file_) + : FormatError("unsupported version " + std::to_string(version_) + " of class " + className_ + " in " + file_), + className(std::move(className_)), + version(version_), + file(std::move(file_)) + { + } + std::string className; + int version; + std::string file; +}; + +/// A valid ROOT construct outside of the AO2D subset (split objects, +/// unknown leaf types, legacy compression, ...). +struct UnsupportedLayout : FormatError { + explicit UnsupportedLayout(std::string what_) + : FormatError("unsupported layout: " + what_), + detail(std::move(what_)) + { + } + std::string detail; +}; + +/// Bytes which contradict themselves: truncated buffers, bad checksums, +/// sizes which do not add up. +struct CorruptData : FormatError { + CorruptData(std::string file_, uint64_t offset_, std::string what_) + : FormatError("corrupt data in " + file_ + " at offset " + std::to_string(offset_) + ": " + what_), + file(std::move(file_)), + offset(offset_), + detail(std::move(what_)) + { + } + std::string file; + uint64_t offset; + std::string detail; +}; + +/// Failure of the underlying transport. +struct IoError : FormatError { + IoError(std::string url_, std::string status_) + : FormatError("I/O error on " + url_ + ": " + status_), + url(std::move(url_)), + status(std::move(status_)) + { + } + std::string url; + std::string status; +}; + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_ERRORS_H_ diff --git a/Framework/AODNative/include/AODNative/Objects.h b/Framework/AODNative/include/AODNative/Objects.h new file mode 100644 index 0000000000000..97631fa80909c --- /dev/null +++ b/Framework/AODNative/include/AODNative/Objects.h @@ -0,0 +1,71 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_OBJECTS_H_ +#define O2_FRAMEWORK_AODNATIVE_OBJECTS_H_ + +#include +#include +#include +#include +#include + +namespace o2::aod::native +{ + +/// Where the bytes of one basket are. For an embedded basket (stored inside +/// the TBranch rather than as its own key) the uncompressed value bytes are +/// kept in @a embeddedPayload and @a seek / @a nbytes are 0. +struct BasketRef { + uint64_t seek; + uint32_t nbytes; + int64_t firstEntry; + int32_t nEntries; + bool embedded; + std::vector embeddedPayload; +}; + +/// One flat branch with a single scalar leaf. @a leafClass is the letter of +/// the TLeaf subclass (O, B, S, I, L, F, D). @a countBranch is the name of the +/// branch holding the sizes for a variable-length array, empty otherwise. +struct BranchInfo { + std::string name; + char leafClass; + bool isUnsigned; + int32_t fixedLen; + std::string countBranch; + int64_t entries, totBytes, zipBytes; + std::vector baskets; +}; + +struct TreeInfo { + std::string name; + int64_t entries, totBytes, zipBytes; + std::vector branches; +}; + +/// Size in bytes of one value of a leaf class on disk. +size_t leafElementSize(char leafClass); + +/// The supported versions of each class we deserialize. Extend it only with +/// versions found in the test corpus, each with its own layout function. +std::map> const& supportedClassVersions(); + +/// Deserializes a TTree from the uncompressed payload of its key. @a keylen is +/// needed because object references are relative to the start of the key. +TreeInfo readTreePayload(std::span payload, uint16_t keylen, std::string const& file, uint64_t fileOffset); + +/// Deserializes a TMap of TObjString to TObjString (metaData, parentFiles). +std::map readStringMapPayload(std::span payload, uint16_t keylen, std::string const& file, uint64_t fileOffset); + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_OBJECTS_H_ diff --git a/Framework/AODNative/include/AODNative/ReadPlan.h b/Framework/AODNative/include/AODNative/ReadPlan.h new file mode 100644 index 0000000000000..b018adbacaaaa --- /dev/null +++ b/Framework/AODNative/include/AODNative/ReadPlan.h @@ -0,0 +1,108 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_READPLAN_H_ +#define O2_FRAMEWORK_AODNATIVE_READPLAN_H_ + +#include "AODNative/ByteSource.h" +#include "AODNative/Objects.h" +#include +#include +#include +#include + +namespace o2::aod::native +{ + +/// Memory for the reads of ReadPlans, kept from one DF to the next: a new plan +/// takes the buffers of the plans which are gone instead of allocating, and +/// faulting in, fresh ones. +class ReadBufferPool +{ + public: + struct Buffer { + std::unique_ptr data; + size_t capacity = 0; + }; + /// The smallest free buffer of at least @a size bytes, or a new one. + Buffer get(size_t size); + void put(Buffer buffer); + + private: + static constexpr size_t kMaxFree = 2; + std::mutex mMutex; + std::vector mFree; +}; + +/// The baskets of the trees a DF is read from, fetched in as few reads as +/// possible: ranges are registered first, and the first lookup of one which is +/// not in memory yet fetches every pending range, merging those closer than +/// Options::coalesceGap. The fetched bytes are held until the plan goes away. +class ReadPlan +{ + public: + /// Without a @a pool, the plan allocates its own buffers. + ReadPlan(std::shared_ptr source, Options const& options, std::shared_ptr pool = nullptr); + ~ReadPlan(); + ReadPlan(ReadPlan const&) = delete; + ReadPlan& operator=(ReadPlan const&) = delete; + + void add(uint64_t offset, size_t length); + /// Registers every basket of @a tree which is not embedded in its TBranch. + void add(TreeInfo const& tree); + /// The bytes of [offset, offset + length), if a registered range covers + /// them, fetching the pending ranges first if needed. Empty otherwise. The + /// span stays valid as long as the plan. + std::span find(uint64_t offset, size_t length); + /// Fetches the pending ranges now, rather than on the first find(). + void fetch(); + size_t bytesHeld() const; + + private: + struct Chunk { + uint64_t offset; + uint8_t* data; + size_t size; + }; + std::span lookup(uint64_t offset, size_t length) const; + void fetchPending(); + + std::shared_ptr mSource; + std::shared_ptr mPool; + size_t mCoalesceGap; + std::vector> mPending; + std::vector mChunks; // sorted by offset, not overlapping + std::vector mBuffers; + mutable std::mutex mMutex; +}; + +/// A ByteSource which serves the ranges of a ReadPlan from memory and +/// everything else from the source underneath. +class PlannedByteSource : public ByteSource +{ + public: + PlannedByteSource(std::shared_ptr source, std::shared_ptr plan); + uint64_t size() const override { return mSource->size(); } + void readInto(uint64_t off, size_t len, void* dst) override; + void readvInto(std::span requests) override; + std::span tryMap(uint64_t off, size_t len) override; + uint64_t bytesRead() const override { return mSource->bytesRead(); } + uint64_t readCalls() const override { return mSource->readCalls(); } + std::string url() const override { return mSource->url(); } + + private: + std::shared_ptr mSource; + std::shared_ptr mPlan; +}; + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_READPLAN_H_ diff --git a/Framework/AODNative/src/ArrowTable.cxx b/Framework/AODNative/src/ArrowTable.cxx new file mode 100644 index 0000000000000..e18e621b2d398 --- /dev/null +++ b/Framework/AODNative/src/ArrowTable.cxx @@ -0,0 +1,208 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/ArrowTable.h" +#include "AODNative/Errors.h" +#include +#include +#include +#include +#include + +namespace o2::aod::native +{ + +namespace +{ +std::shared_ptr valueType(BranchInfo const& b) +{ + switch (b.leafClass) { + case 'O': + return arrow::boolean(); + case 'B': + return b.isUnsigned ? arrow::uint8() : arrow::int8(); + case 'S': + return b.isUnsigned ? arrow::uint16() : arrow::int16(); + case 'I': + return b.isUnsigned ? arrow::uint32() : arrow::int32(); + case 'L': + return b.isUnsigned ? arrow::uint64() : arrow::int64(); + case 'F': + return arrow::float32(); + case 'D': + return arrow::float64(); + } + throw UnsupportedLayout(std::string("leaf class TLeaf") + b.leafClass + " of branch " + b.name); +} + +BranchInfo const& branchNamed(TreeInfo const& tree, std::string const& name) +{ + for (auto& b : tree.branches) { + if (b.name == name) { + return b; + } + } + throw FormatError("tree " + tree.name + " has no branch " + name); +} + +std::string format(char const* fmt, auto... args) +{ + auto n = std::snprintf(nullptr, 0, fmt, args...); + std::string s(n, '\0'); + std::snprintf(s.data(), n + 1, fmt, args...); + return s; +} +} // namespace + +std::shared_ptr physicalSchema(TreeInfo const& tree) +{ + std::vector> fields; + bool prevIsSize = false; + for (auto& b : tree.branches) { + if (prevIsSize && fields.back()->name() != b.name + "_size") { + throw UnsupportedLayout(format("Unexpected layout for VLA container %s.", b.name.c_str())); + } + if (b.name.ends_with("_size")) { + fields.push_back(std::make_shared(b.name, arrow::int32())); + prevIsSize = true; + continue; + } + if (prevIsSize) { + fields.push_back(std::make_shared(b.name, arrow::list(valueType(b)))); + } else if (!b.countBranch.empty()) { + throw UnsupportedLayout("variable-length branch " + b.name + " is not preceded by its size branch"); + } else if (b.fixedLen > 1) { + fields.push_back(std::make_shared(b.name, arrow::fixed_size_list(valueType(b), b.fixedLen))); + } else { + fields.push_back(std::make_shared(b.name, valueType(b))); + } + prevIsSize = false; + } + if (!fields.empty() && fields.back()->name().ends_with("_size")) { + throw UnsupportedLayout(format("Missing values for VLA indices %s.", fields.back()->name().c_str())); + } + return std::make_shared(fields); +} + +std::shared_ptr datasetSchema(std::shared_ptr const& physical) +{ + std::vector> fields; + for (auto& f : physical->fields()) { + if (!f->name().ends_with("_size")) { + fields.push_back(f); + } + } + return std::make_shared(fields); +} + +std::vector planColumns(TreeInfo const& tree, std::shared_ptr const& schema, + BranchDecoder& decoder, arrow::MemoryPool* pool, int64_t& rows) +{ + std::vector ops; + rows = -1; + for (int fi = 0; fi < schema->num_fields(); ++fi) { + auto const& field = schema->field(fi); + auto const& branch = branchNamed(tree, field->name()); + auto type = field->type(); + ColumnOp op{ColumnOp::Kind::Values, &branch, fi, 0, nullptr}; + int64_t opRows = branch.entries; + if (type->id() == arrow::Type::LIST) { + auto const& sizeBranch = branchNamed(tree, branch.name + "_size"); + if (branch.entries != sizeBranch.entries) { + throw CorruptData(decoder.url(), 0, format("Branch %s has %lld entries, but its size branch %s has %lld.", branch.name.c_str(), (long long)branch.entries, sizeBranch.name.c_str(), (long long)sizeBranch.entries)); + } + auto offsets = decoder.decodeOffsets(sizeBranch); + auto buffer = arrow::AllocateBuffer(offsets.size() * sizeof(int32_t), pool).ValueOrDie(); + std::memcpy(buffer->mutable_data(), offsets.data(), offsets.size() * sizeof(int32_t)); + op.offsets = std::move(buffer); + op.kind = ColumnOp::Kind::VLA; + op.byteSize = (size_t)offsets.back() * leafElementSize(branch.leafClass); + opRows = sizeBranch.entries; + } else if (type->id() == arrow::Type::BOOL) { + op.kind = ColumnOp::Kind::Booleans; + op.byteSize = (branch.entries + 7) / 8; + } else if (type->id() == arrow::Type::FIXED_SIZE_LIST && type->field(0)->type()->id() == arrow::Type::BOOL) { + // Not the minimal size, but the TTree plugin's, for byte identity. + op.kind = ColumnOp::Kind::Booleans; + op.byteSize = (branch.entries * branch.fixedLen) / 8 + 1; + } else { + op.byteSize = branch.entries * branch.fixedLen * leafElementSize(branch.leafClass); + } + if (rows == -1) { + rows = opRows; + } else if (rows != opRows) { + throw CorruptData(decoder.url(), 0, format("Unmatching number of rows for branch %s. Expected %lli, found %lli", branch.name.c_str(), (long long)rows, (long long)opRows)); + } + ops.push_back(std::move(op)); + } + if (rows == -1) { + rows = 0; + } + return ops; +} + +void executeColumnOp(BranchDecoder& decoder, ColumnOp const& op, uint8_t* dst) +{ + switch (op.kind) { + case ColumnOp::Kind::Values: + decoder.decodeValues(*op.branch, dst, op.byteSize); + break; + case ColumnOp::Kind::Booleans: + decoder.decodeBooleans(*op.branch, dst, op.byteSize); + break; + case ColumnOp::Kind::VLA: { + auto const* offsets = (int32_t const*)op.offsets->data(); + decoder.decodeVLAValues(*op.branch, {offsets, (size_t)op.branch->entries + 1}, dst, op.byteSize); + } break; + } +} + +std::shared_ptr makeBatch(std::shared_ptr const& schema, std::vector const& ops, + std::vector> const& buffers, int64_t rows) +{ + std::vector> columns; + for (size_t i = 0; i < ops.size(); ++i) { + auto const& op = ops[i]; + auto type = schema->field(op.datasetField)->type(); + auto entries = op.branch->entries; + if (type->id() == arrow::Type::FIXED_SIZE_LIST) { + auto vdata = std::make_shared(type->field(0)->type(), entries * op.branch->fixedLen, + std::vector>{nullptr, buffers[i]}); + columns.push_back(std::make_shared(type, entries, arrow::MakeArray(vdata))); + } else if (op.kind == ColumnOp::Kind::VLA) { + auto total = ((int32_t const*)op.offsets->data())[entries]; + auto vdata = std::make_shared(type->field(0)->type(), total, + std::vector>{nullptr, buffers[i]}); + columns.push_back(std::make_shared(type, entries, op.offsets, arrow::MakeArray(vdata))); + } else { + auto data = std::make_shared(type, entries, std::vector>{nullptr, buffers[i]}); + columns.push_back(arrow::MakeArray(data)); + } + } + return arrow::RecordBatch::Make(schema, rows, columns); +} + +arrow::Result> readTree(ByteSource& source, TreeInfo const& tree, Options const& options, arrow::MemoryPool* pool) +{ + auto schema = datasetSchema(physicalSchema(tree)); + BranchDecoder decoder{source, options}; + int64_t rows; + auto ops = planColumns(tree, schema, decoder, pool, rows); + std::vector> buffers; + for (auto& op : ops) { + ARROW_ASSIGN_OR_RAISE(std::shared_ptr buffer, arrow::AllocateBuffer(op.byteSize, pool)); + executeColumnOp(decoder, op, buffer->mutable_data()); + buffers.push_back(std::move(buffer)); + } + return makeBatch(schema, ops, buffers, rows); +} + +} // namespace o2::aod::native diff --git a/Framework/AODNative/src/ByteSource.cxx b/Framework/AODNative/src/ByteSource.cxx new file mode 100644 index 0000000000000..adbf54d8d8954 --- /dev/null +++ b/Framework/AODNative/src/ByteSource.cxx @@ -0,0 +1,62 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/ByteSource.h" +#include "AODNative/Errors.h" +#include "LocalByteSource.h" +#ifdef AODNATIVE_WITH_XROOTD +#include "XRootDByteSource.h" +#endif +#ifdef AODNATIVE_WITH_CURL +#include "HttpByteSource.h" +#endif + +namespace o2::aod::native +{ + +ByteSource::~ByteSource() = default; + +std::unique_ptr ByteSource::open(std::string url, const Options& options) +{ + if (url.starts_with("file://")) { + return std::make_unique(url.substr(7), options); + } + for (auto scheme : {"root://", "roots://", "xroot://", "xroots://"}) { + if (url.starts_with(scheme)) { +#ifdef AODNATIVE_WITH_XROOTD + return std::make_unique(std::move(url), options); +#else + throw IoError(url, "the native reader was built without XRootD"); +#endif + } + } + for (auto scheme : {"http://", "https://"}) { + if (url.starts_with(scheme)) { +#ifdef AODNATIVE_WITH_CURL + return std::make_unique(std::move(url), options); +#else + throw IoError(url, "the native reader was built without curl"); +#endif + } + } + // alien:// names are resolved to root:// replicas by the caller. + for (auto scheme : {"alien://"}) { + if (url.starts_with(scheme)) { + throw IoError(url, "alien:// names must be resolved to a replica first"); + } + } + if (url.find("://") != std::string::npos) { + throw IoError(url, "unknown URL scheme"); + } + return std::make_unique(std::move(url), options); +} + +} // namespace o2::aod::native diff --git a/Framework/AODNative/src/Codecs.cxx b/Framework/AODNative/src/Codecs.cxx new file mode 100644 index 0000000000000..5b429d533c7a5 --- /dev/null +++ b/Framework/AODNative/src/Codecs.cxx @@ -0,0 +1,383 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/Codecs.h" +#include "AODNative/Errors.h" +#include "Endian.h" +#include +#include +#include +#include +#include +#include +// For the buffer-less decompression API, exported by the shared library too. +#define ZSTD_STATIC_LINKING_ONLY +#include + +namespace o2::aod::native +{ + +namespace +{ +constexpr size_t kChunkHeaderSize = 9; +constexpr size_t kLZ4ChecksumSize = 8; + +uint32_t le24(uint8_t const* p) { return p[0] | (p[1] << 8) | (p[2] << 16); } +} // namespace + +// One decoder state per codec, created on first use and reset for each chunk. +struct PayloadDecompressor::Codec { + ZSTD_DCtx* zstd = nullptr; + z_stream zlib{}; + bool zlibInit = false; + lzma_stream lzma = LZMA_STREAM_INIT; + bool lzmaInit = false; + bool lz4Partial = false; // the current LZ4 chunk was decoded partially, straight into the destination + + ~Codec() + { + if (zstd) { + ZSTD_freeDCtx(zstd); + } + if (zlibInit) { + inflateEnd(&zlib); + } + if (lzmaInit) { + lzma_end(&lzma); + } + } +}; + +PayloadDecompressor::PayloadDecompressor(std::span src, uint32_t objLen, std::string const& file, uint64_t fileOffset) + : mSrc(src), mObjLen(objLen), mFile(file), mFileOffset(fileOffset), mCodec(std::make_unique()) +{ +} + +PayloadDecompressor::~PayloadDecompressor() = default; + +void PayloadDecompressor::nextChunk() +{ + auto fail = [this](std::string const& what) { throw CorruptData(mFile, mFileOffset + mSrcPos, what); }; + if (mSrc.size() - mSrcPos < kChunkHeaderSize) { + fail("compressed payload ends after " + std::to_string(mProduced) + " of " + std::to_string(mObjLen) + " bytes"); + } + auto* h = mSrc.data() + mSrcPos; + mTag[0] = (char)h[0]; + mTag[1] = (char)h[1]; + size_t cSize = le24(h + 3); + mChunkOut = le24(h + 6); + mChunkDone = 0; + if (cSize > mSrc.size() - mSrcPos - kChunkHeaderSize) { + fail("compressed chunk of " + std::to_string(cSize) + " bytes past the end of the payload"); + } + if (mChunkOut == 0 || mChunkOut > mObjLen - mProduced) { + fail("chunk of " + std::to_string(mChunkOut) + " uncompressed bytes does not fit the object length"); + } + mChunkIn = mSrc.subspan(mSrcPos + kChunkHeaderSize, cSize); + auto& codec = *mCodec; + mScratch.clear(); + if (mTag[0] == 'Z' && mTag[1] == 'S') { + if (!codec.zstd) { + codec.zstd = ZSTD_createDCtx(); + } + ZSTD_DCtx_reset(codec.zstd, ZSTD_reset_session_only); + } else if (mTag[0] == 'Z' && mTag[1] == 'L') { + if (codec.zlibInit) { + inflateReset(&codec.zlib); + } else { + if (inflateInit(&codec.zlib) != Z_OK) { + fail("cannot initialise zlib"); + } + codec.zlibInit = true; + } + codec.zlib.next_in = (Bytef*)mChunkIn.data(); + codec.zlib.avail_in = mChunkIn.size(); + } else if (mTag[0] == 'X' && mTag[1] == 'Z') { + if (codec.lzmaInit) { + lzma_end(&codec.lzma); + } + codec.lzma = LZMA_STREAM_INIT; + if (lzma_stream_decoder(&codec.lzma, UINT64_MAX, 0) != LZMA_OK) { + fail("cannot initialise liblzma"); + } + codec.lzmaInit = true; + codec.lzma.next_in = mChunkIn.data(); + codec.lzma.avail_in = mChunkIn.size(); + } else if (mTag[0] == 'L' && mTag[1] == '4') { + if (cSize < kLZ4ChecksumSize) { + fail("LZ4 chunk too small for its checksum"); + } + uint64_t expected = 0; + for (size_t i = 0; i < kLZ4ChecksumSize; ++i) { + expected = (expected << 8) | mChunkIn[i]; + } + mChunkIn = mChunkIn.subspan(kLZ4ChecksumSize); + if (xxh64(mChunkIn) != expected) { + fail("LZ4 checksum mismatch"); + } + codec.lz4Partial = false; + } else { + throw UnsupportedLayout(std::string("compression algorithm \"") + mTag[0] + mTag[1] + "\" in " + mFile); + } + mSrcPos += kChunkHeaderSize + cSize; +} + +size_t PayloadDecompressor::produceFromChunk(uint8_t* dst, size_t n) +{ + auto fail = [this](std::string const& what) { throw CorruptData(mFile, mFileOffset + mSrcPos, what); }; + auto& codec = *mCodec; + bool wholeChunk = mChunkDone == 0 && n == mChunkOut; + if (mTag[0] == 'Z' && mTag[1] == 'S') { + if (wholeChunk) { + auto r = ZSTD_decompressDCtx(codec.zstd, dst, n, mChunkIn.data(), mChunkIn.size()); + if (ZSTD_isError(r) || r != n) { + fail(std::string("zstd: ") + (ZSTD_isError(r) ? ZSTD_getErrorName(r) : "size mismatch")); + } + return n; + } + if (mPrefixOnly && mChunkDone == 0) { + return zstdPrefix(dst, n); + } + // Resume where the previous read stopped: the input position is kept in mChunkIn. + // The streaming API keeps its own window buffer, sized by the writer's window. + ZSTD_outBuffer out{dst, n, 0}; + while (out.pos < out.size) { + ZSTD_inBuffer in{mChunkIn.data(), mChunkIn.size(), 0}; + auto r = ZSTD_decompressStream(codec.zstd, &out, &in); + mChunkIn = mChunkIn.subspan(in.pos); + if (ZSTD_isError(r)) { + fail(std::string("zstd: ") + ZSTD_getErrorName(r)); + } + if (out.pos < out.size && (r == 0 || (in.pos == 0 && mChunkIn.empty()))) { + fail("zstd chunk ended early"); + } + } + return n; + } + if (mTag[0] == 'Z' && mTag[1] == 'L') { + auto& z = codec.zlib; + z.next_out = dst; + z.avail_out = n; + while (z.avail_out > 0) { + auto r = inflate(&z, Z_NO_FLUSH); + if (r == Z_STREAM_END && z.avail_out > 0) { + fail("zlib chunk ended early"); + } + if (r != Z_OK && r != Z_STREAM_END) { + fail(std::string("zlib: ") + (z.msg ? z.msg : std::to_string(r))); + } + } + return n; + } + if (mTag[0] == 'X' && mTag[1] == 'Z') { + auto& s = codec.lzma; + s.next_out = dst; + s.avail_out = n; + while (s.avail_out > 0) { + auto r = lzma_code(&s, LZMA_RUN); + if (r == LZMA_STREAM_END && s.avail_out > 0) { + fail("xz chunk ended early"); + } + if (r != LZMA_OK && r != LZMA_STREAM_END) { + fail("liblzma error " + std::to_string(r)); + } + } + return n; + } + // LZ4 block format cannot be resumed. Decode partially straight into the + // destination when this read starts the chunk, which is the common case of + // stopping at the end of the value region. Should a later read need the + // rest, decode the whole chunk into scratch memory. + if (wholeChunk) { + // A whole chunk must use all of its input and produce exactly its size. + auto r = LZ4_decompress_safe((char const*)mChunkIn.data(), (char*)dst, mChunkIn.size(), n); + if (r < 0 || (size_t)r != n) { + fail("LZ4 chunk size mismatch"); + } + return n; + } + if (mScratch.empty() && mChunkDone == 0) { + auto r = LZ4_decompress_safe_partial((char const*)mChunkIn.data(), (char*)dst, mChunkIn.size(), n, n); + if (r < 0 || (size_t)r != n) { + fail("LZ4 decompression failed"); + } + codec.lz4Partial = true; + return n; + } + if (mScratch.empty()) { + mScratch.resize(mChunkOut); + auto r = LZ4_decompress_safe((char const*)mChunkIn.data(), (char*)mScratch.data(), mChunkIn.size(), mChunkOut); + if (r < 0 || (size_t)r != mChunkOut) { + fail("LZ4 decompression failed"); + } + } + std::memcpy(dst, mScratch.data() + mChunkDone, n); + return n; +} + +// Decodes the first @a n bytes of a zstd chunk straight into @a dst, with the +// buffer-less API: the destination itself is zstd's history, so no window +// buffer is allocated. Blocks are written in place while a whole block (at most +// ZSTD_BLOCKSIZE_MAX) still fits; the last ones go to a fixed scratch, contiguous +// with each other, and only the needed bytes are copied out. +size_t PayloadDecompressor::zstdPrefix(uint8_t* dst, size_t n) +{ + auto fail = [this](std::string const& what) { throw CorruptData(mFile, mFileOffset + mSrcPos, what); }; + auto* dctx = mCodec->zstd; + if (ZSTD_isError(ZSTD_decompressBegin(dctx))) { + fail("cannot initialise zstd"); + } + auto in = mChunkIn; + size_t written = 0; + bool inScratch = false; + size_t scratchUsed = 0; + while (written < n) { + auto need = ZSTD_nextSrcSizeToDecompress(dctx); + if (need == 0) { + fail("zstd chunk ended early"); + } + if (need > in.size()) { + fail("truncated zstd chunk"); + } + size_t r; + if (!inScratch && n - written >= ZSTD_BLOCKSIZE_MAX) { + r = ZSTD_decompressContinue(dctx, dst + written, n - written, in.data(), need); + if (!ZSTD_isError(r)) { + written += r; + } + } else { + if (!inScratch) { + mScratch.resize(2 * ZSTD_BLOCKSIZE_MAX); + inScratch = true; + } + r = ZSTD_decompressContinue(dctx, mScratch.data() + scratchUsed, mScratch.size() - scratchUsed, in.data(), need); + if (!ZSTD_isError(r)) { + scratchUsed += r; + if (written + scratchUsed >= n) { + std::memcpy(dst + written, mScratch.data(), n - written); + written = n; + } + } + } + if (ZSTD_isError(r)) { + fail(std::string("zstd: ") + ZSTD_getErrorName(r)); + } + in = in.subspan(need); + } + mScratch.clear(); + mScratch.shrink_to_fit(); + return n; +} + +void PayloadDecompressor::readPrefix(uint8_t* dst, size_t n) +{ + if (mProduced != 0) { + throw std::logic_error("readPrefix must be the first read of a payload"); + } + mPrefixOnly = true; + read(dst, n); +} + +void PayloadDecompressor::read(uint8_t* dst, size_t n) +{ + if (mPrefixOnly && mProduced != 0) { + throw std::logic_error("no read can follow readPrefix"); + } + if (n > mObjLen - mProduced) { + throw CorruptData(mFile, mFileOffset, "read of " + std::to_string(n) + " bytes past the object length " + std::to_string(mObjLen)); + } + while (n > 0) { + if (mChunkDone == mChunkOut) { + nextChunk(); + } + auto k = std::min(n, mChunkOut - mChunkDone); + produceFromChunk(dst, k); + dst += k; + n -= k; + mChunkDone += k; + mProduced += k; + } +} + +size_t decompressBounded(std::span src, uint32_t objLen, uint8_t* dst, size_t maxOut, + std::string const& file, uint64_t fileOffset) +{ + auto n = std::min(objLen, maxOut); + PayloadDecompressor d{src, objLen, file, fileOffset}; + d.readPrefix(dst, n); + return n; +} + +uint64_t xxh64(std::span data) +{ + constexpr uint64_t P1 = 11400714785074694791ULL; + constexpr uint64_t P2 = 14029467366897019727ULL; + constexpr uint64_t P3 = 1609587929392839161ULL; + constexpr uint64_t P4 = 9650029242287828579ULL; + constexpr uint64_t P5 = 2870177450012600261ULL; + auto read64 = [](uint8_t const* p) { + uint64_t v; + std::memcpy(&v, p, 8); + return std::endian::native == std::endian::little ? v : bswap(v); + }; + auto read32 = [](uint8_t const* p) { + uint32_t v; + std::memcpy(&v, p, 4); + return std::endian::native == std::endian::little ? v : bswap(v); + }; + auto round = [](uint64_t acc, uint64_t input) { + acc += input * P2; + return std::rotl(acc, 31) * P1; + }; + auto merge = [&round](uint64_t acc, uint64_t v) { + acc ^= round(0, v); + return acc * P1 + P4; + }; + + auto const* p = data.data(); + auto const* end = p + data.size(); + uint64_t h; + if (data.size() >= 32) { + uint64_t v1 = P1 + P2, v2 = P2, v3 = 0, v4 = -P1; + for (; end - p >= 32; p += 32) { + v1 = round(v1, read64(p)); + v2 = round(v2, read64(p + 8)); + v3 = round(v3, read64(p + 16)); + v4 = round(v4, read64(p + 24)); + } + h = std::rotl(v1, 1) + std::rotl(v2, 7) + std::rotl(v3, 12) + std::rotl(v4, 18); + h = merge(merge(merge(merge(h, v1), v2), v3), v4); + } else { + h = P5; + } + h += data.size(); + for (; end - p >= 8; p += 8) { + h ^= round(0, read64(p)); + h = std::rotl(h, 27) * P1 + P4; + } + if (end - p >= 4) { + h ^= (uint64_t)read32(p) * P1; + h = std::rotl(h, 23) * P2 + P3; + p += 4; + } + for (; p < end; ++p) { + h ^= *p * P5; + h = std::rotl(h, 11) * P1; + } + h ^= h >> 33; + h *= P2; + h ^= h >> 29; + h *= P3; + h ^= h >> 32; + return h; +} + +} // namespace o2::aod::native diff --git a/Framework/AODNative/src/Container.cxx b/Framework/AODNative/src/Container.cxx new file mode 100644 index 0000000000000..cf47a59f1da71 --- /dev/null +++ b/Framework/AODNative/src/Container.cxx @@ -0,0 +1,356 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/Container.h" +#include "AODNative/Codecs.h" +#include "AODNative/Errors.h" +#include "Cursor.h" +#include +#include +#include + +namespace o2::aod::native +{ + +namespace +{ +// Big enough for the file header and for a TDirectory record (including its UUID). +constexpr size_t kHeaderReadSize = 128; +constexpr size_t kDirectoryReadSize = 64; +// Largest gap between the TTrees of a DF still read together with them. +constexpr uint64_t kMetadataGap = 4 * 1024; +// Fixed part of a TBasket key, after which come the class name, name and title. +constexpr size_t kBasketHeaderTail = 19; +// Smallest possible key header: 32-bit seeks and three empty strings. +constexpr size_t kMinKeySize = 29; +// Metadata objects (trees, maps) in AO2Ds are a few MB at most. A larger +// object length can only come from a corrupted key, so refuse to allocate it. +constexpr uint32_t kMaxMetadataSize = 256 * 1024 * 1024; + +std::vector readRange(ByteSource& source, uint64_t off, size_t len) +{ + // Check before allocating: the length comes from the file. + if (off > source.size() || len > source.size() - off) { + throw CorruptData(source.url(), off, "range of " + std::to_string(len) + " bytes past the end of the file"); + } + std::vector buf(len); + source.readInto(off, len, buf.data()); + return buf; +} +} // namespace + +RootFile::RootFile(std::shared_ptr source) + : mSource(std::move(source)), + mUrl(mSource->url()) +{ + auto buf = readRange(*mSource, 0, std::min(kHeaderReadSize, mSource->size())); + Cursor c{buf, mUrl}; + auto magic = c.bytes(4); + if (std::memcmp(magic.data(), "root", 4) != 0) { + throw FormatError(mUrl + " is not a ROOT file"); + } + auto& h = mHeader; + h.version = c.i32(); + h.begin = c.i32(); + bool big = h.version >= 1000000; + h.end = big ? c.u64() : c.u32(); + h.seekFree = big ? c.u64() : c.u32(); + h.nbytesFree = c.i32(); + h.nfree = c.i32(); + h.nbytesName = c.i32(); + h.units = c.u8(); + h.compress = c.i32(); + h.seekInfo = big ? c.u64() : c.u32(); + h.nbytesInfo = c.i32(); + if (h.begin <= 0 || h.nbytesName <= 0) { + c.fail("invalid file header"); + } +} + +KeyInfo RootFile::parseKey(std::span buf, uint64_t fileOffset) +{ + Cursor c{buf, mUrl, fileOffset}; + KeyInfo k; + k.nbytes = c.u32(); + k.version = c.i16(); + k.objlen = c.u32(); + c.u32(); // fDatime + k.keylen = c.u16(); + k.cycle = c.u16(); + bool big = k.version > 1000; + k.seekKey = big ? c.u64() : c.u32(); + k.seekPdir = big ? c.u64() : c.u32(); + k.className = c.string(); + k.name = c.string(); + k.title = c.string(); + if (k.keylen > k.nbytes || c.pos() > k.keylen) { + c.fail("inconsistent key header for " + k.name); + } + return k; +} + +DirectoryInfo RootFile::readDirectory(uint64_t offset) +{ + if (offset >= mSource->size()) { + throw CorruptData(mUrl, offset, "directory record past the end of the file"); + } + auto buf = readRange(*mSource, offset, std::min(kDirectoryReadSize, mSource->size() - offset)); + Cursor c{buf, mUrl, offset}; + DirectoryInfo d; + d.version = c.i16(); + c.u32(); // fDatimeC + c.u32(); // fDatimeM + d.nbytesKeys = c.i32(); + d.nbytesName = c.i32(); + bool big = d.version > 1000; + d.seekDir = big ? c.u64() : c.u32(); + d.seekParent = big ? c.u64() : c.u32(); + d.seekKeys = big ? c.u64() : c.u32(); + return d; +} + +std::vector RootFile::readKeyList(DirectoryInfo const& dir) +{ + if (dir.seekKeys == 0 || dir.nbytesKeys <= 0) { + return {}; + } + auto buf = readRange(*mSource, dir.seekKeys, dir.nbytesKeys); + auto header = parseKey(buf, dir.seekKeys); + Cursor c{buf, mUrl, dir.seekKeys}; + c.seek(header.keylen); + auto nkeys = c.i32(); + if (nkeys < 0 || (size_t)nkeys > c.remaining() / kMinKeySize) { + c.fail("invalid number of keys " + std::to_string(nkeys)); + } + std::vector keys; + keys.reserve(nkeys); + for (int i = 0; i < nkeys; ++i) { + auto start = c.pos(); + // The key length is only known after parsing, so parse from here to the end + // of the list and advance by the key length. + keys.push_back(parseKey(c.buffer().subspan(start), dir.seekKeys + start)); + c.seek(start + keys.back().keylen); + } + return keys; +} + +const std::vector& RootFile::topKeys() +{ + if (!mTopKeys) { + auto dir = readDirectory((uint64_t)mHeader.begin + mHeader.nbytesName); + mTopKeys = readKeyList(dir); + } + return *mTopKeys; +} + +std::vector highestCycles(std::vector const& keys) +{ + std::vector result; + for (auto& k : keys) { + auto it = std::find_if(result.begin(), result.end(), [&k](KeyInfo const& r) { return r.name == k.name; }); + if (it == result.end()) { + result.push_back(k); + } else if (k.cycle > it->cycle) { + *it = k; + } + } + return result; +} + +std::optional RootFile::findTopKey(std::string const& name) +{ + std::optional result; + for (auto& k : topKeys()) { + if (k.name == name && (!result || k.cycle > result->cycle)) { + result = k; + } + } + return result; +} + +std::vector RootFile::dataFrames() +{ + static const std::regex tfRegex("/?DF_([0-9]+)(|-.*)$"); + std::vector numbers; + std::unordered_set seen; + for (auto& k : topKeys()) { + std::smatch m; + if (std::regex_match(k.name, m, tfRegex)) { + auto n = std::stoull(m[1].str()); + if (seen.insert(n).second) { + numbers.push_back(n); + } + } + } + std::sort(numbers.begin(), numbers.end()); + auto parents = readStringMap("parentFiles"); + if (!parents.empty()) { + // Process DFs grouped by parent file, to avoid swapping between files. + // DataInputDirector uses an unstable sort here; ties are kept in numeric order. + auto parentOf = [&parents](uint64_t n) -> std::string const& { + static const std::string none; + auto it = parents.find("DF_" + std::to_string(n)); + return it == parents.end() ? none : it->second; + }; + std::stable_sort(numbers.begin(), numbers.end(), [&](uint64_t a, uint64_t b) { return parentOf(a) < parentOf(b); }); + } + std::vector result; + result.reserve(numbers.size()); + for (auto n : numbers) { + result.push_back("DF_" + std::to_string(n)); + } + return result; +} + +std::vector RootFile::keysOf(const std::string& df) +{ + if (auto it = mKeysOf.find(df); it != mKeysOf.end()) { + return it->second; + } + auto key = findTopKey(df); + if (!key) { + for (auto& k : topKeys()) { + if (k.name.starts_with(df + "-")) { + throw UnsupportedLayout("flat " + df + "- layout (" + k.className + ") in " + mUrl); + } + } + throw FormatError("no directory " + df + " in " + mUrl); + } + if (key->className != "TDirectoryFile" && key->className != "TDirectory") { + throw UnsupportedLayout(df + " in " + mUrl + " is a " + key->className + ", not a TDirectoryFile"); + } + if (key->compressed()) { + throw UnsupportedLayout("compressed directory record for " + df + " in " + mUrl); + } + auto dir = readDirectory(key->seekKey + key->keylen); + auto keys = highestCycles(readKeyList(dir)); + + // The TTrees of a DF are written next to each other: ranges less than + // kMetadataGap apart are read as one. + struct Wanted { + uint64_t offset, length, seekKey; + }; + std::vector wanted; + for (auto const& k : keys) { + auto stored = (uint64_t)k.nbytes - k.keylen; + auto offset = k.seekKey + k.keylen; + bool listed = mTreesToPrefetch.empty() || std::ranges::find(mTreesToPrefetch, k.name) != mTreesToPrefetch.end(); + // Anything odd is left to readPayload(), which reports it. + if (mSource->canMap() || !listed || k.className != "TTree" || k.nbytes < k.keylen || stored > kMaxMetadataSize || + offset > mSource->size() || stored > mSource->size() - offset) { + continue; + } + wanted.push_back({offset, stored, k.seekKey}); + } + std::ranges::sort(wanted, {}, &Wanted::offset); + std::vector>> spans; + for (auto const& w : wanted) { + if (!spans.empty()) { + auto& [start, bytes] = spans.back(); + auto end = start + bytes.size(); + if (w.offset <= end + kMetadataGap && w.offset + w.length - start <= kMaxMetadataSize) { + bytes.resize(std::max(end, w.offset + w.length) - start); + continue; + } + } + spans.emplace_back(w.offset, std::vector(w.length)); + } + std::vector requests; + for (auto& [start, bytes] : spans) { + requests.push_back({start, bytes.size(), bytes.data()}); + } + mPrefetched.clear(); + if (!requests.empty()) { + mSource->readvInto(requests); + } + size_t span = 0; + for (auto const& w : wanted) { + while (spans[span].first + spans[span].second.size() < w.offset + w.length) { + ++span; + } + auto const* at = spans[span].second.data() + (w.offset - spans[span].first); + mPrefetched[w.seekKey].assign(at, at + w.length); + } + mKeysOf.emplace(df, keys); + return keys; +} + +std::vector RootFile::readStored(const KeyInfo& key) +{ + auto stored = key.nbytes - key.keylen; + if (auto it = mPrefetched.find(key.seekKey); it != mPrefetched.end() && it->second.size() == stored) { + auto bytes = std::move(it->second); + mPrefetched.erase(it); + return bytes; + } + return readRange(*mSource, key.seekKey + key.keylen, stored); +} + +std::vector RootFile::readPayload(const KeyInfo& key) +{ + auto offset = key.seekKey + key.keylen; + auto stored = key.nbytes - key.keylen; + if (key.objlen > kMaxMetadataSize || stored > kMaxMetadataSize) { + throw CorruptData(mUrl, key.seekKey, "object " + key.name + " of " + std::to_string(key.objlen) + " bytes is too large for metadata"); + } + if (!key.compressed()) { + return readStored(key); + } + auto compressed = readStored(key); + std::vector result(key.objlen); + decompressBounded(compressed, key.objlen, result.data(), result.size(), mUrl, offset); + return result; +} + +TreeInfo RootFile::readTree(const KeyInfo& key) +{ + if (key.className != "TTree") { + throw UnsupportedLayout(key.name + " in " + mUrl + " is a " + key.className + ", not a TTree"); + } + auto payload = readPayload(key); + auto tree = readTreePayload(payload, key.keylen, mUrl, key.seekKey + key.keylen); + // When fBasketEntry has no slot past the last normal basket, its entry count + // is only in the basket header (fNevBuf). + for (auto& branch : tree.branches) { + for (auto& basket : branch.baskets) { + if (basket.nEntries >= 0) { + continue; + } + auto buf = readRange(*mSource, basket.seek, std::min(basket.nbytes, kHeaderReadSize * 4)); + auto k = parseKey(buf, basket.seek); + Cursor c{buf, mUrl, basket.seek}; + c.seek(k.keylen - kBasketHeaderTail); + c.u16(); // fVersion + c.i32(); // fBufferSize + c.i32(); // fNevBufSize + basket.nEntries = c.i32(); + } + } + return tree; +} + +std::map RootFile::readStringMap(const std::string& name) +{ + if (auto it = mStringMaps.find(name); it != mStringMaps.end()) { + return it->second; + } + auto key = findTopKey(name); + if (!key) { + return mStringMaps[name]; + } + if (key->className != "TMap") { + throw UnsupportedLayout(name + " in " + mUrl + " is a " + key->className + ", not a TMap"); + } + auto payload = readPayload(*key); + return mStringMaps[name] = readStringMapPayload(payload, key->keylen, mUrl, key->seekKey + key->keylen); +} + +} // namespace o2::aod::native diff --git a/Framework/AODNative/src/Cursor.h b/Framework/AODNative/src/Cursor.h new file mode 100644 index 0000000000000..fd7e097ad1d65 --- /dev/null +++ b/Framework/AODNative/src/Cursor.h @@ -0,0 +1,132 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_CURSOR_H_ +#define O2_FRAMEWORK_AODNATIVE_CURSOR_H_ + +#include "AODNative/Errors.h" +#include "Endian.h" +#include +#include +#include +#include +#include + +namespace o2::aod::native +{ + +/// Bounds-checked big-endian reader over a buffer. Every read past the end +/// raises CorruptData, so malformed input can never cause an out-of-bounds read. +/// @a fileOffset is where the buffer starts in the file, used in error messages. +/// @a origin is added to positions to get ROOT's buffer displacement, which is +/// what object references are keyed on (fKeylen for an object read from a key). +class Cursor +{ + public: + Cursor(std::span buf, std::string const& file, uint64_t fileOffset = 0, int64_t origin = 0) + : mBuf(buf), mFile(file), mFileOffset(fileOffset), mOrigin(origin) + { + } + + size_t pos() const { return mPos; } + int64_t displacement() const { return (int64_t)mPos + mOrigin; } + size_t remaining() const { return mBuf.size() - mPos; } + std::span buffer() const { return mBuf; } + + void seek(size_t p) + { + if (p > mBuf.size()) { + fail("seek past the end of the buffer"); + } + mPos = p; + } + void skip(size_t n) { seek(need(n) + n); } + void back(size_t n) + { + if (n > mPos) { + fail("seek before the start of the buffer"); + } + mPos -= n; + } + + std::span bytes(size_t n) + { + auto p = need(n); + mPos += n; + return mBuf.subspan(p, n); + } + + uint8_t u8() { return bytes(1)[0]; } + uint16_t u16() { return load(); } + uint32_t u32() { return load(); } + uint64_t u64() { return load(); } + int16_t i16() { return (int16_t)u16(); } + int32_t i32() { return (int32_t)u32(); } + int64_t i64() { return (int64_t)u64(); } + double f64() { return std::bit_cast(u64()); } + + /// TString: 1-byte length, or 0xFF followed by a 4-byte length. + std::string string() + { + uint32_t n = u8(); + if (n == 255) { + n = u32(); + } + auto b = bytes(n); + return {(char const*)b.data(), b.size()}; + } + + /// NUL-terminated string, as used for class names after kNewClassTag. + std::string cstring() + { + auto start = mPos; + while (u8() != 0) { + } + return {(char const*)mBuf.data() + start, mPos - start - 1}; + } + + [[noreturn]] void fail(std::string const& what) const + { + throw CorruptData(mFile, mFileOffset + mPos, what); + } + + std::string const& file() const { return mFile; } + + private: + size_t need(size_t n) const + { + if (n > mBuf.size() - mPos) { + fail("read of " + std::to_string(n) + " bytes past the end of a " + std::to_string(mBuf.size()) + " byte buffer"); + } + return mPos; + } + + template + T load() + { + T v; + std::memcpy(&v, bytes(sizeof(T)).data(), sizeof(T)); + if constexpr (std::endian::native == std::endian::little) { + v = bswap(v); + } + return v; + } + + std::span mBuf; + std::string const& mFile; + uint64_t mFileOffset; + int64_t mOrigin; + size_t mPos = 0; +}; + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_CURSOR_H_ diff --git a/Framework/AODNative/src/Decoder.cxx b/Framework/AODNative/src/Decoder.cxx new file mode 100644 index 0000000000000..a98e1267f6efe --- /dev/null +++ b/Framework/AODNative/src/Decoder.cxx @@ -0,0 +1,297 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/Decoder.h" +#include "AODNative/Codecs.h" +#include "AODNative/Errors.h" +#include "Endian.h" +#include "Cursor.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace o2::aod::native +{ + +namespace +{ +// Fixed part of a TBasket key, after which come the class name, name and title. +constexpr size_t kBasketHeaderTail = 19; + +template +void byteswapAll(uint8_t* data, size_t count) +{ + for (size_t i = 0; i < count; ++i) { + T v; + std::memcpy(&v, data + i * sizeof(T), sizeof(T)); + v = bswap(v); + std::memcpy(data + i * sizeof(T), &v, sizeof(T)); + } +} +} // namespace + +void byteswapInPlace(uint8_t* data, size_t count, size_t typeSize) +{ + if constexpr (std::endian::native == std::endian::big) { + return; + } + switch (typeSize) { + case 2: + byteswapAll(data, count); + break; + case 4: + byteswapAll(data, count); + break; + case 8: + byteswapAll(data, count); + break; + } +} + +/// The value bytes of one basket, in order: either plain bytes (uncompressed, +/// embedded or mapped) or a decompressor over the compressed payload. +struct BranchDecoder::BasketValues { + std::span plain; + std::optional compressed; + size_t border = 0; + size_t consumed = 0; + + void read(uint8_t* dst, size_t n) + { + if (compressed) { + compressed->read(dst, n); + } else { + std::memcpy(dst, plain.data() + consumed, n); + } + consumed += n; + } + + /// The first @a n value bytes as the only read: lets zstd decode in place. + void readAll(uint8_t* dst, size_t n) + { + if (compressed) { + compressed->readPrefix(dst, n); + consumed += n; + } else { + read(dst, n); + } + } +}; + +BranchDecoder::BranchDecoder(ByteSource& source, Options const& options) + : mSource(source), mOptions(options), mUrl(source.url()) +{ +} + +void BranchDecoder::checkBaskets(BranchInfo const& branch) const +{ + int64_t next = 0; + for (auto& b : branch.baskets) { + if (b.firstEntry != next || b.nEntries < 0) { + throw CorruptData(mUrl, b.seek, "baskets of branch " + branch.name + " do not cover its entries contiguously"); + } + next += b.nEntries; + } + if (next != branch.entries) { + throw CorruptData(mUrl, 0, "baskets of branch " + branch.name + " hold " + std::to_string(next) + " entries, but the branch has " + std::to_string(branch.entries)); + } +} + +template +void BranchDecoder::forEachBasket(BranchInfo const& branch, bool parallel, F&& fn) +{ + checkBaskets(branch); + auto task = [this, &branch, &fn](size_t i) { + auto const& ref = branch.baskets[i]; + BasketValues values; + std::vector storage; // only when the source cannot be mapped + if (ref.embedded) { + values.plain = ref.embeddedPayload; + values.border = ref.embeddedPayload.size(); + } else { + auto bytes = mSource.tryMap(ref.seek, ref.nbytes); + if (bytes.empty()) { + if (ref.seek > mSource.size() || ref.nbytes > mSource.size() - ref.seek) { + throw CorruptData(mUrl, ref.seek, "basket of branch " + branch.name + " past the end of the file"); + } + storage.resize(ref.nbytes); + mSource.readInto(ref.seek, ref.nbytes, storage.data()); + bytes = storage; + } + Cursor c{bytes, mUrl, ref.seek}; + auto nbytes = c.u32(); + c.i16(); // key fVersion + auto objlen = c.u32(); + c.u32(); // fDatime + auto keylen = c.u16(); + if (nbytes != ref.nbytes || keylen < kBasketHeaderTail || keylen > nbytes) { + c.fail("basket header of branch " + branch.name + " does not match its TBranch"); + } + c.seek(keylen - kBasketHeaderTail); + c.u16(); // fVersion + c.i32(); // fBufferSize + c.i32(); // fNevBufSize + auto nevbuf = c.i32(); + auto last = c.i32(); + if (nevbuf != ref.nEntries || last < keylen || (uint32_t)(last - keylen) > objlen) { + c.fail("basket header of branch " + branch.name + " does not match its TBranch"); + } + values.border = last - keylen; + auto payload = bytes.subspan(keylen); + if (objlen != nbytes - keylen) { + values.compressed.emplace(payload, objlen, mUrl, ref.seek + keylen); + } else { + values.plain = payload; + } + } + fn(i, values); + }; + + auto n = branch.baskets.size(); + int threads = parallel ? std::min(mOptions.threads, n) : 1; + if (threads <= 1) { + for (size_t i = 0; i < n; ++i) { + task(i); + } + return; + } + // Baskets write disjoint slices of the destination, so no locking is needed. + std::atomic next = 0; + std::exception_ptr error; + std::mutex errorMutex; + std::vector workers; + for (int t = 0; t < threads; ++t) { + workers.emplace_back([&] { + for (size_t i; (i = next.fetch_add(1)) < n;) { + try { + task(i); + } catch (...) { + std::lock_guard lock{errorMutex}; + if (!error) { + error = std::current_exception(); + } + next = n; + } + } + }); + } + for (auto& w : workers) { + w.join(); + } + if (error) { + std::rethrow_exception(error); + } +} + +namespace +{ +void checkNeeded(std::string const& url, BranchInfo const& branch, BasketRef const& ref, size_t needed, size_t border) +{ + if (needed > border) { + throw CorruptData(url, ref.seek, "basket of branch " + branch.name + " starting from " + std::to_string(ref.firstEntry) + " holds " + std::to_string(border) + " bytes, but " + std::to_string(needed) + " are needed"); + } +} +} // namespace + +void BranchDecoder::decodeValues(BranchInfo const& branch, uint8_t* dst, size_t nbytes) +{ + auto elementSize = leafElementSize(branch.leafClass); + size_t rowBytes = elementSize * branch.fixedLen; + if (nbytes != branch.entries * rowBytes) { + throw FormatError("destination of " + std::to_string(nbytes) + " bytes for branch " + branch.name + " which needs " + std::to_string(branch.entries * rowBytes)); + } + forEachBasket(branch, true, [&](size_t i, BasketValues& values) { + auto const& ref = branch.baskets[i]; + size_t needed = ref.nEntries * rowBytes; + checkNeeded(mUrl, branch, ref, needed, values.border); + auto* out = dst + ref.firstEntry * rowBytes; + values.readAll(out, needed); + byteswapInPlace(out, needed / elementSize, elementSize); + }); +} + +void BranchDecoder::decodeVLAValues(BranchInfo const& branch, std::span offsets, uint8_t* dst, size_t nbytes) +{ + auto elementSize = leafElementSize(branch.leafClass); + if (offsets.size() != (size_t)branch.entries + 1) { + throw FormatError("branch " + branch.name + " has " + std::to_string(branch.entries) + " entries, but " + std::to_string(offsets.size()) + " offsets were given"); + } + if (nbytes != (size_t)offsets.back() * elementSize) { + throw FormatError("destination of " + std::to_string(nbytes) + " bytes for branch " + branch.name + " which needs " + std::to_string(offsets.back() * elementSize)); + } + forEachBasket(branch, true, [&](size_t i, BasketValues& values) { + auto const& ref = branch.baskets[i]; + auto begin = offsets[ref.firstEntry]; + auto end = offsets[ref.firstEntry + ref.nEntries]; + if (end < begin) { + throw CorruptData(mUrl, ref.seek, "invalid offset range for branch " + branch.name); + } + size_t needed = (size_t)(end - begin) * elementSize; + checkNeeded(mUrl, branch, ref, needed, values.border); + auto* out = dst + (size_t)begin * elementSize; + values.readAll(out, needed); + byteswapInPlace(out, needed / elementSize, elementSize); + }); +} + +void BranchDecoder::decodeBooleans(BranchInfo const& branch, uint8_t* bitmap, size_t nbytes) +{ + size_t total = branch.entries * branch.fixedLen; + if (nbytes * 8 < total) { + throw FormatError("bitmap of " + std::to_string(nbytes) + " bytes for " + std::to_string(total) + " booleans of branch " + branch.name); + } + std::memset(bitmap, 0, nbytes); + std::vector window(std::min(kBooleanWindow, total)); + // The value index is global across baskets, so a basket boundary can fall + // inside a byte of the bitmap: decode serially. + size_t index = 0; + forEachBasket(branch, false, [&](size_t i, BasketValues& values) { + auto const& ref = branch.baskets[i]; + size_t remaining = (size_t)ref.nEntries * branch.fixedLen; + checkNeeded(mUrl, branch, ref, remaining, values.border); + while (remaining > 0) { + auto k = std::min(remaining, window.size()); + values.read(window.data(), k); + for (size_t j = 0; j < k; ++j, ++index) { + bitmap[index / 8] |= (uint8_t)((window[j] != 0) << (index % 8)); + } + remaining -= k; + } + }); +} + +std::vector BranchDecoder::decodeOffsets(BranchInfo const& sizeBranch) +{ + if (sizeBranch.leafClass != 'I' || sizeBranch.fixedLen != 1) { + throw UnsupportedLayout("size branch " + sizeBranch.name + " is not a scalar 32-bit integer"); + } + // Decode the sizes after the leading 0, then prefix-sum in place. + std::vector offsets(sizeBranch.entries + 1); + decodeValues(sizeBranch, (uint8_t*)(offsets.data() + 1), sizeBranch.entries * sizeof(int32_t)); + int64_t total = 0; + offsets[0] = 0; + for (int64_t i = 1; i <= sizeBranch.entries; ++i) { + total += (uint32_t)offsets[i]; + // Arrow lists use 32 bit offsets, a larger total can only come from corrupted sizes. + if (total > INT32_MAX) { + throw CorruptData(mUrl, 0, "invalid sizes for branch " + sizeBranch.name + ": offsets overflow at entry " + std::to_string(i - 1)); + } + offsets[i] = (int32_t)total; + } + return offsets; +} + +} // namespace o2::aod::native diff --git a/Framework/AODNative/src/Endian.h b/Framework/AODNative/src/Endian.h new file mode 100644 index 0000000000000..8629b9ea2955e --- /dev/null +++ b/Framework/AODNative/src/Endian.h @@ -0,0 +1,38 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_ENDIAN_H_ +#define O2_FRAMEWORK_AODNATIVE_ENDIAN_H_ + +#include + +namespace o2::aod::native +{ + +// std::byteswap is C++23. +template +constexpr T bswap(T v) +{ + if constexpr (sizeof(T) == 1) { + return v; + } else if constexpr (sizeof(T) == 2) { + return (T)__builtin_bswap16((uint16_t)v); + } else if constexpr (sizeof(T) == 4) { + return (T)__builtin_bswap32((uint32_t)v); + } else { + static_assert(sizeof(T) == 8); + return (T)__builtin_bswap64((uint64_t)v); + } +} + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_ENDIAN_H_ diff --git a/Framework/AODNative/src/HttpByteSource.cxx b/Framework/AODNative/src/HttpByteSource.cxx new file mode 100644 index 0000000000000..9b4fe14e154a4 --- /dev/null +++ b/Framework/AODNative/src/HttpByteSource.cxx @@ -0,0 +1,229 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "HttpByteSource.h" +#include "AODNative/Errors.h" +#include +#include +#include +#include + +namespace o2::aod::native +{ + +namespace +{ +// Larger ranges are split, so that the pieces go out in parallel. +constexpr size_t kMaxRequest = 16 * 1024 * 1024; + +void globalInit() +{ + static std::once_flag once; + std::call_once(once, [] { curl_global_init(CURL_GLOBAL_DEFAULT); }); +} + +/// One ranged GET, written straight into the caller's memory. +struct Transfer { + uint64_t offset; + size_t length; + uint8_t* dst; + size_t received = 0; + bool overflow = false; + CURL* handle = nullptr; + char error[CURL_ERROR_SIZE] = {}; +}; + +size_t writeInto(char* data, size_t size, size_t n, void* userdata) +{ + auto* t = (Transfer*)userdata; + auto bytes = size * n; + // More than asked for: the server ignored the range. Abort rather than + // downloading the whole file. + if (t->received + bytes > t->length) { + t->overflow = true; + return 0; + } + std::memcpy(t->dst + t->received, data, bytes); + t->received += bytes; + return bytes; +} + +size_t discard(char*, size_t size, size_t n, void*) { return size * n; } +} // namespace + +/// What all the requests on a source share: connections, TLS sessions and DNS. +struct HttpByteSource::Shared { + CURLSH* share = nullptr; + std::mutex locks[CURL_LOCK_DATA_LAST]; + + Shared() + { + share = curl_share_init(); + curl_share_setopt(share, CURLSHOPT_LOCKFUNC, +[](CURL*, curl_lock_data data, curl_lock_access, void* self) { ((Shared*)self)->locks[data].lock(); }); + curl_share_setopt(share, CURLSHOPT_UNLOCKFUNC, +[](CURL*, curl_lock_data data, void* self) { ((Shared*)self)->locks[data].unlock(); }); + curl_share_setopt(share, CURLSHOPT_USERDATA, this); + curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT); + curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION); + curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS); + } + ~Shared() { curl_share_cleanup(share); } + + CURL* handle(std::string const& url, Options const& options) const + { + auto* h = curl_easy_init(); + curl_easy_setopt(h, CURLOPT_URL, url.c_str()); + curl_easy_setopt(h, CURLOPT_SHARE, share); + curl_easy_setopt(h, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(h, CURLOPT_MAXREDIRS, 10L); + curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(h, CURLOPT_USERAGENT, "o2-aod-native"); + curl_easy_setopt(h, CURLOPT_CONNECTTIMEOUT, 30L); + // Give up on a transfer slower than 1 kB/s for a minute. + curl_easy_setopt(h, CURLOPT_LOW_SPEED_LIMIT, 1024L); + curl_easy_setopt(h, CURLOPT_LOW_SPEED_TIME, 60L); + if (auto* dir = std::getenv("X509_CERT_DIR")) { + curl_easy_setopt(h, CURLOPT_CAPATH, dir); + } + long version = CURL_HTTP_VERSION_2TLS; + if (options.httpVersion == Options::HttpVersion::Http1_1) { + version = CURL_HTTP_VERSION_1_1; + } else if (options.httpVersion == Options::HttpVersion::Http2) { + version = CURL_HTTP_VERSION_2_0; + } + curl_easy_setopt(h, CURLOPT_HTTP_VERSION, version); + return h; + } +}; + +HttpByteSource::HttpByteSource(std::string url, Options const& options) + : mUrl(std::move(url)), mName(mUrl.substr(0, mUrl.find('?'))), mOptions(options) +{ + globalInit(); + mShared = std::make_unique(); + auto* h = mShared->handle(mUrl, mOptions); + char error[CURL_ERROR_SIZE] = {}; + curl_easy_setopt(h, CURLOPT_ERRORBUFFER, error); + curl_easy_setopt(h, CURLOPT_NOBODY, 1L); + curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, discard); + auto code = curl_easy_perform(h); + long status = 0; + curl_off_t length = -1; + curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, &status); + curl_easy_getinfo(h, CURLINFO_CONTENT_LENGTH_DOWNLOAD_T, &length); + curl_easy_cleanup(h); + if (code != CURLE_OK) { + throw IoError(mName, error[0] ? error : curl_easy_strerror(code)); + } + if (status != 200) { + throw IoError(mName, "HEAD returned HTTP " + std::to_string(status)); + } + if (length < 0) { + throw IoError(mName, "the server does not tell the size of the file"); + } + mSize = length; +} + +HttpByteSource::~HttpByteSource() = default; + +void HttpByteSource::checkRange(uint64_t off, size_t len) const +{ + if (off > mSize || len > mSize - off) { + throw CorruptData(mName, off, "read of " + std::to_string(len) + " bytes past the end of a " + std::to_string(mSize) + " byte file"); + } +} + +void HttpByteSource::readInto(uint64_t off, size_t len, void* dst) +{ + ReadRequest request{off, len, dst}; + readvInto({&request, 1}); +} + +void HttpByteSource::readvInto(std::span requests) +{ + std::vector transfers; + size_t total = 0; + for (auto const& r : requests) { + checkRange(r.offset, r.length); + total += r.length; + for (size_t done = 0; done < r.length; done += kMaxRequest) { + transfers.push_back({r.offset + done, std::min(kMaxRequest, r.length - done), (uint8_t*)r.dst + done}); + } + } + if (transfers.empty()) { + return; + } + + auto* multi = curl_multi_init(); + curl_multi_setopt(multi, CURLMOPT_PIPELINING, (long)CURLPIPE_MULTIPLEX); + curl_multi_setopt(multi, CURLMOPT_MAX_HOST_CONNECTIONS, (long)std::max(1, mOptions.httpConnections)); + std::vector ranges; + ranges.reserve(transfers.size()); + for (auto& t : transfers) { + t.handle = mShared->handle(mUrl, mOptions); + ranges.push_back(std::to_string(t.offset) + "-" + std::to_string(t.offset + t.length - 1)); + curl_easy_setopt(t.handle, CURLOPT_RANGE, ranges.back().c_str()); + curl_easy_setopt(t.handle, CURLOPT_WRITEFUNCTION, writeInto); + curl_easy_setopt(t.handle, CURLOPT_WRITEDATA, &t); + curl_easy_setopt(t.handle, CURLOPT_ERRORBUFFER, t.error); + curl_easy_setopt(t.handle, CURLOPT_PRIVATE, &t); + // Wait for a multiplexed connection rather than opening one per request. + curl_easy_setopt(t.handle, CURLOPT_PIPEWAIT, 1L); + curl_multi_add_handle(multi, t.handle); + } + + std::string error; + int running = 0; + do { + auto mc = curl_multi_perform(multi, &running); + if (mc != CURLM_OK) { + error = curl_multi_strerror(mc); + break; + } + int queued = 0; + while (auto* msg = curl_multi_info_read(multi, &queued)) { + if (msg->msg != CURLMSG_DONE) { + continue; + } + Transfer* t = nullptr; + curl_easy_getinfo(msg->easy_handle, CURLINFO_PRIVATE, (char**)&t); + long status = 0; + curl_easy_getinfo(msg->easy_handle, CURLINFO_RESPONSE_CODE, &status); + if (!error.empty()) { + continue; + } + if (t->overflow) { + error = "the server ignored the byte range (HTTP " + std::to_string(status) + ")"; + } else if (msg->data.result != CURLE_OK) { + error = t->error[0] ? t->error : curl_easy_strerror(msg->data.result); + } else if (status != 206 && !(status == 200 && t->offset == 0 && t->length == mSize)) { + error = "range request returned HTTP " + std::to_string(status); + } else if (t->received != t->length) { + error = "short read: " + std::to_string(t->received) + " of " + std::to_string(t->length) + " bytes"; + } + } + if (running > 0) { + curl_multi_poll(multi, nullptr, 0, 1000, nullptr); + } + } while (running > 0 && error.empty()); + + for (auto& t : transfers) { + curl_multi_remove_handle(multi, t.handle); + curl_easy_cleanup(t.handle); + } + curl_multi_cleanup(multi); + if (!error.empty()) { + throw IoError(mName, error); + } + mReadCalls.fetch_add(transfers.size(), std::memory_order_relaxed); + mBytesRead.fetch_add(total, std::memory_order_relaxed); +} + +} // namespace o2::aod::native diff --git a/Framework/AODNative/src/HttpByteSource.h b/Framework/AODNative/src/HttpByteSource.h new file mode 100644 index 0000000000000..6f222595fb8e1 --- /dev/null +++ b/Framework/AODNative/src/HttpByteSource.h @@ -0,0 +1,59 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_HTTPBYTESOURCE_H_ +#define O2_FRAMEWORK_AODNATIVE_HTTPBYTESOURCE_H_ + +#include "AODNative/ByteSource.h" +#include +#include +#include + +namespace o2::aod::native +{ + +/// A file read over public HTTP(S) with ranged GETs (no authentication yet). +/// readvInto() sends all its ranges at once as parallel requests, multiplexed +/// on one connection with HTTP/2, or spread over Options::httpConnections +/// connections with HTTP/1.1. Connections, TLS sessions and DNS are shared by +/// all the calls on the source. +class HttpByteSource : public ByteSource +{ + public: + HttpByteSource(std::string url, Options const& options); + ~HttpByteSource() override; + uint64_t size() const override { return mSize; } + void readInto(uint64_t off, size_t len, void* dst) override; + void readvInto(std::span requests) override; + uint64_t bytesRead() const override { return mBytesRead.load(std::memory_order_relaxed); } + /// HTTP requests sent. + uint64_t readCalls() const override { return mReadCalls.load(std::memory_order_relaxed); } + /// Without the query, which may hold a token. + std::string url() const override { return mName; } + bool isRemote() const override { return true; } + + struct Shared; + + private: + void checkRange(uint64_t off, size_t len) const; + + std::string mUrl; + std::string mName; + Options mOptions; + std::unique_ptr mShared; + uint64_t mSize = 0; + std::atomic mBytesRead = 0; + std::atomic mReadCalls = 0; +}; + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_HTTPBYTESOURCE_H_ diff --git a/Framework/AODNative/src/LocalByteSource.cxx b/Framework/AODNative/src/LocalByteSource.cxx new file mode 100644 index 0000000000000..f5ca75aaed094 --- /dev/null +++ b/Framework/AODNative/src/LocalByteSource.cxx @@ -0,0 +1,130 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "LocalByteSource.h" +#include "AODNative/Errors.h" +#include +#include +#include +#include +#include +#include +#include + +namespace o2::aod::native +{ + +namespace +{ +size_t pageSize() +{ + static size_t size = sysconf(_SC_PAGESIZE); + return size; +} +} // namespace + +LocalByteSource::LocalByteSource(std::string path, Options const& options) + : mPath(std::move(path)) +{ + mFd = ::open(mPath.c_str(), O_RDONLY | O_CLOEXEC); + if (mFd < 0) { + throw IoError(mPath, std::strerror(errno)); + } + struct stat st; + if (fstat(mFd, &st) != 0) { + auto err = errno; + ::close(mFd); + throw IoError(mPath, std::strerror(err)); + } + mSize = st.st_size; + if (options.localIO == Options::LocalIO::MMap && sizeof(void*) == 8 && mSize > 0) { + void* p = mmap(nullptr, mSize, PROT_READ, MAP_PRIVATE, mFd, 0); + if (p != MAP_FAILED) { + mMap = (uint8_t const*)p; + } + } +} + +LocalByteSource::~LocalByteSource() +{ + if (mMap) { + munmap((void*)mMap, mSize); + } + ::close(mFd); +} + +void LocalByteSource::checkRange(uint64_t off, size_t len) const +{ + if (off > mSize || len > mSize - off) { + throw CorruptData(mPath, off, "read of " + std::to_string(len) + " bytes past the end of a " + std::to_string(mSize) + " byte file"); + } +} + +void LocalByteSource::readInto(uint64_t off, size_t len, void* dst) +{ + checkRange(off, len); + if (len == 0) { + return; + } + mReadCalls.fetch_add(1, std::memory_order_relaxed); + mBytesRead.fetch_add(len, std::memory_order_relaxed); + if (mMap) { + std::memcpy(dst, mMap + off, len); + return; + } + auto* out = (uint8_t*)dst; + while (len > 0) { + auto n = ::pread(mFd, out, len, off); + if (n < 0 && errno == EINTR) { + continue; + } + if (n <= 0) { + throw IoError(mPath, n == 0 ? "unexpected end of file" : std::strerror(errno)); + } + out += n; + off += n; + len -= n; + } +} + +void LocalByteSource::readvInto(std::span requests) +{ + for (auto& r : requests) { + readInto(r.offset, r.length, r.dst); + } +} + +std::span LocalByteSource::tryMap(uint64_t off, size_t len) +{ + if (!mMap) { + return {}; + } + checkRange(off, len); + // Mapped bytes are accounted as read, so that monitoring is the same for both modes. + mReadCalls.fetch_add(1, std::memory_order_relaxed); + mBytesRead.fetch_add(len, std::memory_order_relaxed); + return {mMap + off, len}; +} + +void LocalByteSource::advise(uint64_t off, size_t len, int advice) +{ + if (!mMap || off >= mSize) { + return; + } + len = std::min(len, mSize - off); + auto start = off & ~(uint64_t)(pageSize() - 1); + madvise((void*)(mMap + start), len + (off - start), advice); +} + +void LocalByteSource::willNeed(uint64_t off, size_t len) { advise(off, len, MADV_WILLNEED); } +void LocalByteSource::dontNeed(uint64_t off, size_t len) { advise(off, len, MADV_DONTNEED); } + +} // namespace o2::aod::native diff --git a/Framework/AODNative/src/LocalByteSource.h b/Framework/AODNative/src/LocalByteSource.h new file mode 100644 index 0000000000000..8a17e34f8a102 --- /dev/null +++ b/Framework/AODNative/src/LocalByteSource.h @@ -0,0 +1,53 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_LOCALBYTESOURCE_H_ +#define O2_FRAMEWORK_AODNATIVE_LOCALBYTESOURCE_H_ + +#include "AODNative/ByteSource.h" +#include + +namespace o2::aod::native +{ + +/// A local file, mmapped read-only on 64-bit hosts, with pread as fallback +/// (mmap failure, or --aod-reader-local-io=pread). +class LocalByteSource : public ByteSource +{ + public: + LocalByteSource(std::string path, Options const& options); + ~LocalByteSource() override; + uint64_t size() const override { return mSize; } + void readInto(uint64_t off, size_t len, void* dst) override; + void readvInto(std::span requests) override; + std::span tryMap(uint64_t off, size_t len) override; + bool canMap() const override { return mMap != nullptr; } + void willNeed(uint64_t off, size_t len) override; + void dontNeed(uint64_t off, size_t len) override; + uint64_t bytesRead() const override { return mBytesRead.load(std::memory_order_relaxed); } + uint64_t readCalls() const override { return mReadCalls.load(std::memory_order_relaxed); } + std::string url() const override { return mPath; } + + private: + void checkRange(uint64_t off, size_t len) const; + void advise(uint64_t off, size_t len, int advice); + + std::string mPath; + int mFd = -1; + uint64_t mSize = 0; + uint8_t const* mMap = nullptr; + std::atomic mBytesRead = 0; + std::atomic mReadCalls = 0; +}; + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_LOCALBYTESOURCE_H_ diff --git a/Framework/AODNative/src/Objects.cxx b/Framework/AODNative/src/Objects.cxx new file mode 100644 index 0000000000000..03048dffbda21 --- /dev/null +++ b/Framework/AODNative/src/Objects.cxx @@ -0,0 +1,556 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/Objects.h" +#include "AODNative/Errors.h" +#include "Cursor.h" +#include +#include +#include + +namespace o2::aod::native +{ + +namespace +{ +constexpr uint32_t kByteCountMask = 0x40000000; +constexpr uint32_t kClassMask = 0x80000000; +constexpr uint32_t kNewClassTag = 0xFFFFFFFF; +constexpr uint16_t kStreamedMemberWise = 0x4000; +constexpr uint32_t kIsReferenced = 1 << 4; +constexpr int64_t kMapOffset = 2; +// Fixed part of a TBasket key, after which come the class name, name and title. +constexpr size_t kBasketHeaderTail = 19; + +struct LeafInfo { + std::string name; + std::string className; + int32_t len = 0; + bool isUnsigned = false; + std::string countLeaf; +}; + +struct EmbeddedBasket { + int32_t nEntries = 0; + std::vector data; +}; + +using Object = std::variant, + std::shared_ptr, + std::shared_ptr, + std::shared_ptr>; + +template +std::shared_ptr as(Object const& o) +{ + auto* p = std::get_if>(&o); + return p ? *p : nullptr; +} + +/// Hardcoded streamers for the handful of classes an AO2D tree is made of. +/// Each layout is checked against the supported versions, and the byte count +/// of every object is used to skip what we do not need. +class ObjectReader +{ + public: + explicit ObjectReader(Cursor& c) : mC(c) {} + + TreeInfo tree(); + std::map stringMap(); + + private: + struct Versioned { + size_t end; + int version; + }; + + Versioned header(char const* className); + void checkVersion(char const* className, int version); + void tobject(); + std::string tnamed(); + void skipObject(char const* className, bool checkVersion = true); + std::vector objArray(); + Object anyObject(); + Object readByClass(std::string const& className, int64_t mapAt); + std::shared_ptr tbranch(int64_t mapAt); + std::shared_ptr tleaf(std::string const& className, int64_t mapAt); + std::shared_ptr tbasket(int64_t mapAt); + std::shared_ptr tobjstring(int64_t mapAt); + + Cursor& mC; + std::map mClasses; + std::map mObjects; +}; + +void ObjectReader::checkVersion(char const* className, int version) +{ + auto& table = supportedClassVersions(); + auto it = table.find(className); + if (it == table.end() || std::find(it->second.begin(), it->second.end(), version) == it->second.end()) { + throw UnsupportedVersion(className, version, mC.file()); + } +} + +ObjectReader::Versioned ObjectReader::header(char const* className) +{ + auto start = mC.pos(); + auto bcnt = mC.u32(); + if (!(bcnt & kByteCountMask)) { + throw UnsupportedLayout(std::string("object of class ") + className + " without byte count"); + } + size_t end = start + 4 + (bcnt & ~kByteCountMask); + if (end > mC.buffer().size()) { + mC.fail(std::string("byte count of ") + className + " past the end of the buffer"); + } + auto version = mC.u16(); + if (version & kStreamedMemberWise) { + throw UnsupportedLayout(std::string("memberwise streaming of ") + className); + } + checkVersion(className, version); + return {end, version}; +} + +void ObjectReader::tobject() +{ + auto version = mC.u16(); + if (version & kStreamedMemberWise) { + // Written with a byte count: those were its high bytes. Skip the low ones. + mC.u16(); + version = mC.u16(); + } + checkVersion("TObject", version); + mC.u32(); // fUniqueID + auto bits = mC.u32(); + if (bits & kIsReferenced) { + mC.u16(); // pidf + } +} + +std::string ObjectReader::tnamed() +{ + auto v = header("TNamed"); + tobject(); + auto name = mC.string(); + mC.string(); // fTitle + mC.seek(v.end); + return name; +} + +void ObjectReader::skipObject(char const* className, bool check) +{ + auto start = mC.pos(); + auto bcnt = mC.u32(); + if (!(bcnt & kByteCountMask)) { + throw UnsupportedLayout(std::string("object of class ") + className + " without byte count"); + } + auto version = mC.u16(); + if (check) { + checkVersion(className, version); + } + mC.seek(start + 4); + mC.skip(bcnt & ~kByteCountMask); +} + +std::vector ObjectReader::objArray() +{ + auto v = header("TObjArray"); + tobject(); + mC.string(); // fName + auto size = mC.i32(); + mC.i32(); // fLowerBound + if (size < 0) { + mC.fail("negative TObjArray size"); + } + std::vector result; + for (int i = 0; i < size; ++i) { + result.push_back(anyObject()); + } + mC.seek(v.end); + return result; +} + +// ROOT's read_object_any scheme. Recorded offsets are buffer displacements +// (relative to the key start) plus kMapOffset. +Object ObjectReader::anyObject() +{ + auto beg = mC.displacement(); + auto begPos = mC.pos(); + auto bcnt = mC.u32(); + if (!(bcnt & kByteCountMask) || bcnt == kNewClassTag) { + // No byte count: the word is the tag itself. Only references to objects + // already read are written like this; new objects without a byte count + // come from very old files. + auto tag = bcnt; + if (tag & kClassMask) { + throw UnsupportedLayout("new object without byte count"); + } + if (tag == 0) { + return {}; + } + if (tag == 1) { + throw UnsupportedLayout("reference to the parent object"); + } + auto it = mObjects.find(tag); + if (it == mObjects.end()) { + mC.fail("dangling object reference " + std::to_string(tag)); + } + return it->second; + } + size_t end = begPos + 4 + (bcnt & ~kByteCountMask); + if (end > mC.buffer().size()) { + mC.fail("object byte count past the end of the buffer"); + } + auto tagAt = mC.displacement(); + auto tag = mC.u32(); + Object result; + if (!(tag & kClassMask)) { + throw UnsupportedLayout("object reference with byte count"); + } else if (tag == kNewClassTag) { + auto className = mC.cstring(); + mClasses[tagAt + kMapOffset] = className; + result = readByClass(className, beg + kMapOffset); + } else { + auto it = mClasses.find(tag & ~kClassMask); + if (it == mClasses.end()) { + mC.fail("dangling class reference " + std::to_string(tag & ~kClassMask)); + } + result = readByClass(it->second, beg + kMapOffset); + } + mC.seek(end); + return result; +} + +Object ObjectReader::readByClass(std::string const& className, int64_t mapAt) +{ + if (className == "TBranch") { + return tbranch(mapAt); + } + if (className.size() == 6 && className.starts_with("TLeaf") && std::string_view("OBSILFD").find(className[5]) != std::string_view::npos) { + return tleaf(className, mapAt); + } + if (className == "TBasket") { + return tbasket(mapAt); + } + if (className == "TObjString") { + return tobjstring(mapAt); + } + throw UnsupportedLayout("objects of class " + className); +} + +std::shared_ptr ObjectReader::tleaf(std::string const& className, int64_t mapAt) +{ + // ROOT maps an object before streaming its members. + auto leaf = std::make_shared(); + mObjects[mapAt] = leaf; + auto vx = header(className.c_str()); + auto vl = header("TLeaf"); + leaf->className = className; + leaf->name = tnamed(); + leaf->len = mC.i32(); + mC.i32(); // fLenType + mC.i32(); // fOffset + mC.u8(); // fIsRange + leaf->isUnsigned = mC.u8() != 0; + auto count = anyObject(); + if (auto countLeaf = as(count)) { + leaf->countLeaf = countLeaf->name; + } else if (!std::holds_alternative(count)) { + throw UnsupportedLayout("fLeafCount of " + leaf->name + " is not a leaf"); + } + mC.seek(vl.end); + // fMinimum and fMaximum, in the type of the leaf. + mC.seek(vx.end); + return leaf; +} + +std::shared_ptr ObjectReader::tbasket(int64_t mapAt) +{ + // TBasket has no versioned header of its own: it starts with its TKey. + auto basket = std::make_shared(); + mObjects[mapAt] = basket; + auto start = mC.pos(); + mC.i32(); // fNbytes + mC.i16(); // key fVersion + mC.i32(); // fObjlen + mC.u32(); // fDatime + auto keylen = mC.u16(); + mC.u16(); // fCycle + if (keylen < kBasketHeaderTail) { + mC.fail("embedded basket with key length " + std::to_string(keylen)); + } + mC.seek(start + keylen - kBasketHeaderTail); + checkVersion("TBasket", mC.u16() % 1000); + mC.i32(); // fBufferSize + mC.i32(); // fNevBufSize + auto nevbuf = mC.i32(); + auto last = mC.i32(); + int flag = mC.u8(); + if (nevbuf < 0 || last < keylen) { + mC.fail("inconsistent embedded basket header"); + } + basket->nEntries = nevbuf; + if (flag == 99) { + // Header only, no data. + return basket; + } + bool displacements = flag > 40; + if (displacements) { + flag -= 40; + } + bool hasBuffer = flag > 10; + if (hasBuffer) { + flag -= 10; + } + if (flag == 1 && nevbuf > 0) { + // Entry offsets (and displacements), each a count followed by fNevBuf ints. + mC.skip(4 + 4 * (size_t)nevbuf); + if (displacements) { + mC.skip(4 + 4 * (size_t)nevbuf); + } + } + if (hasBuffer) { + // The buffer starts with a copy of the key, then the values. + mC.skip(keylen); + auto data = mC.bytes(last - keylen); + basket->data.assign(data.begin(), data.end()); + } + return basket; +} + +std::shared_ptr ObjectReader::tobjstring(int64_t mapAt) +{ + auto s = std::make_shared(); + mObjects[mapAt] = s; + auto v = header("TObjString"); + tobject(); + *s = mC.string(); + mC.seek(v.end); + return s; +} + +std::shared_ptr ObjectReader::tbranch(int64_t mapAt) +{ + auto b = std::make_shared(); + mObjects[mapAt] = b; + auto v = header("TBranch"); + b->name = tnamed(); + skipObject("TAttFill", false); + mC.i32(); // fCompress + mC.i32(); // fBasketSize + mC.i32(); // fEntryOffsetLen + auto writeBasket = mC.i32(); + mC.i64(); // fEntryNumber + skipObject("ROOT::TIOFeatures", false); + mC.i32(); // fOffset + auto maxBaskets = mC.i32(); + mC.i32(); // fSplitLevel + b->entries = mC.i64(); + mC.i64(); // fFirstEntry + b->totBytes = mC.i64(); + b->zipBytes = mC.i64(); + if (maxBaskets < 0 || writeBasket < 0 || b->entries < 0) { + mC.fail("inconsistent TBranch header for " + b->name); + } + // The three basket arrays take 20 bytes per basket: check before allocating them. + if ((size_t)maxBaskets > mC.remaining() / 20) { + mC.fail("fMaxBaskets " + std::to_string(maxBaskets) + " of branch " + b->name + " exceeds the buffer"); + } + + if (!objArray().empty()) { + throw UnsupportedLayout("branch " + b->name + " has sub-branches"); + } + auto leaves = objArray(); + if (leaves.size() != 1 || !as(leaves[0])) { + throw UnsupportedLayout("branch " + b->name + " does not have exactly one leaf"); + } + auto baskets = objArray(); + + auto readArray = [this, maxBaskets](auto reader) { + mC.u8(); // speedbump + std::vector values(maxBaskets); + for (auto& value : values) { + value = reader(); + } + return values; + }; + auto basketBytes = readArray([this] { return (int64_t)mC.i32(); }); + auto basketEntry = readArray([this] { return mC.i64(); }); + auto basketSeek = readArray([this] { return mC.i64(); }); + if (!mC.string().empty()) { + throw UnsupportedLayout("branch " + b->name + " stores its baskets in another file"); + } + mC.seek(v.end); + + auto leaf = as(leaves[0]); + b->leafClass = leaf->className[5]; + b->isUnsigned = leaf->isUnsigned; + b->fixedLen = leaf->len; + b->countBranch = leaf->countLeaf; + + // Normal baskets end at the first empty seek, or at fWriteBasket. + int nNormal = 0; + while (nNormal < maxBaskets && nNormal < writeBasket && basketSeek[nNormal] != 0) { + ++nNormal; + } + for (int i = 0; i < nNormal; ++i) { + if (basketBytes[i] <= 0 || basketSeek[i] < 0) { + mC.fail("invalid basket location in branch " + b->name); + } + // When fBasketEntry has no slot after the last basket, the entry count + // is only in the basket header. The container layer fills it in (-1). + bool hasNext = i + 1 < maxBaskets; + if (basketEntry[i] < 0 || (hasNext && basketEntry[i + 1] < basketEntry[i])) { + mC.fail("invalid basket entries in branch " + b->name); + } + int64_t n = hasNext ? basketEntry[i + 1] - basketEntry[i] : -1; + if (n > INT32_MAX) { + mC.fail("invalid basket entries in branch " + b->name); + } + b->baskets.push_back(BasketRef{(uint64_t)basketSeek[i], (uint32_t)basketBytes[i], basketEntry[i], (int32_t)n, false, {}}); + } + // Embedded baskets exist only if the normal ones do not hold all the entries. + bool knownNormal = nNormal < maxBaskets; + int64_t normalEntries = nNormal == 0 ? 0 : (knownNormal ? basketEntry[nNormal] : -1); + if (normalEntries != b->entries) { + int64_t first = knownNormal ? normalEntries : 0; + for (auto& o : baskets) { + auto e = as(o); + if (!e || (!knownNormal && e->nEntries == 0)) { + continue; + } + b->baskets.push_back(BasketRef{0, 0, first, e->nEntries, true, std::move(e->data)}); + first += e->nEntries; + } + } + // The last normal basket holds what the others do not: computing it saves + // reading its header, a round trip for a remote file. The decoder checks it + // against the header anyway. If it does not add up, the container layer + // reads the header. + if (nNormal > 0 && b->baskets[nNormal - 1].nEntries < 0) { + int64_t rest = b->entries - b->baskets[nNormal - 1].firstEntry; + for (size_t i = nNormal; i < b->baskets.size(); ++i) { + rest -= b->baskets[i].nEntries; + } + if (rest >= 0 && rest <= INT32_MAX) { + b->baskets[nNormal - 1].nEntries = (int32_t)rest; + } + } + return b; +} + +TreeInfo ObjectReader::tree() +{ + TreeInfo t; + auto v = header("TTree"); + t.name = tnamed(); + skipObject("TAttLine", false); + skipObject("TAttFill", false); + skipObject("TAttMarker", false); + t.entries = mC.i64(); + t.totBytes = mC.i64(); + t.zipBytes = mC.i64(); + mC.skip(2 * 8); // fSavedBytes, fFlushedBytes + mC.f64(); // fWeight + mC.skip(4 * 4); // fTimerInterval, fScanField, fUpdate, fDefaultEntryOffsetLen + auto nClusterRange = mC.u32(); + mC.skip(6 * 8); // fMaxEntries ... fEstimate + mC.u8(); // speedbump + mC.skip(8 * (size_t)nClusterRange); + mC.u8(); // speedbump + mC.skip(8 * (size_t)nClusterRange); + skipObject("ROOT::TIOFeatures", false); + for (auto& o : objArray()) { + auto b = as(o); + if (!b) { + throw UnsupportedLayout("tree " + t.name + " has a branch which is not a TBranch"); + } + t.branches.push_back(std::move(*b)); + } + // fLeaves, fAliases, fIndexValues, fIndex, fTreeIndex, fFriends, fUserInfo, fBranchRef + mC.seek(v.end); + return t; +} + +std::map ObjectReader::stringMap() +{ + std::map result; + auto v = header("TMap"); + tobject(); + mC.string(); // fName + auto n = mC.i32(); + for (int i = 0; i < n; ++i) { + auto key = as(anyObject()); + auto value = as(anyObject()); + if (!key || !value) { + throw UnsupportedLayout("TMap entry which is not a TObjString pair"); + } + result[*key] = *value; + } + mC.seek(v.end); + return result; +} +} // namespace + +size_t leafElementSize(char leafClass) +{ + switch (leafClass) { + case 'O': + case 'B': + return 1; + case 'S': + return 2; + case 'I': + case 'F': + return 4; + case 'L': + case 'D': + return 8; + } + throw UnsupportedLayout(std::string("leaf class TLeaf") + leafClass); +} + +std::map> const& supportedClassVersions() +{ + static const std::map> table{ + {"TObject", {1}}, + {"TNamed", {1}}, + {"TObjString", {1}}, + {"TObjArray", {3}}, + {"TMap", {3}}, + {"TBasket", {3}}, + {"TTree", {20}}, + {"TBranch", {13}}, + {"TLeaf", {2}}, + {"TLeafO", {1}}, + {"TLeafB", {1}}, + {"TLeafS", {1}}, + {"TLeafI", {1}}, + {"TLeafL", {1}}, + {"TLeafF", {1}}, + {"TLeafD", {1}}, + }; + return table; +} + +TreeInfo readTreePayload(std::span payload, uint16_t keylen, std::string const& file, uint64_t fileOffset) +{ + Cursor c{payload, file, fileOffset, keylen}; + return ObjectReader{c}.tree(); +} + +std::map readStringMapPayload(std::span payload, uint16_t keylen, std::string const& file, uint64_t fileOffset) +{ + Cursor c{payload, file, fileOffset, keylen}; + return ObjectReader{c}.stringMap(); +} + +} // namespace o2::aod::native diff --git a/Framework/AODNative/src/ReadPlan.cxx b/Framework/AODNative/src/ReadPlan.cxx new file mode 100644 index 0000000000000..27b8c58695b2b --- /dev/null +++ b/Framework/AODNative/src/ReadPlan.cxx @@ -0,0 +1,198 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/ReadPlan.h" +#include +#include + +namespace o2::aod::native +{ + +ReadBufferPool::Buffer ReadBufferPool::get(size_t size) +{ + std::lock_guard lock{mMutex}; + auto best = mFree.end(); + for (auto it = mFree.begin(); it != mFree.end(); ++it) { + if (it->capacity >= size && (best == mFree.end() || it->capacity < best->capacity)) { + best = it; + } + } + if (best != mFree.end()) { + auto buffer = std::move(*best); + mFree.erase(best); + return buffer; + } + // All the free buffers are too small: give them back rather than keep them + // resident next to the new one. Some headroom, so that the next DF, a bit + // larger, still fits. + mFree.clear(); + auto capacity = size + size / 8; + return {std::make_unique_for_overwrite(capacity), capacity}; +} + +void ReadBufferPool::put(Buffer buffer) +{ + std::lock_guard lock{mMutex}; + mFree.push_back(std::move(buffer)); + if (mFree.size() > kMaxFree) { + mFree.erase(std::ranges::min_element(mFree, {}, &Buffer::capacity)); + } +} + +ReadPlan::ReadPlan(std::shared_ptr source, Options const& options, std::shared_ptr pool) + : mSource(std::move(source)), mPool(pool ? std::move(pool) : std::make_shared()), mCoalesceGap(options.coalesceGap) +{ +} + +ReadPlan::~ReadPlan() +{ + for (auto& buffer : mBuffers) { + mPool->put(std::move(buffer)); + } +} + +void ReadPlan::add(uint64_t offset, size_t length) +{ + if (length == 0 || offset > mSource->size() || length > mSource->size() - offset) { + // Left to the decoder, which reports it with the branch it belongs to. + return; + } + std::lock_guard lock{mMutex}; + mPending.emplace_back(offset, length); +} + +void ReadPlan::add(TreeInfo const& tree) +{ + for (auto const& branch : tree.branches) { + for (auto const& basket : branch.baskets) { + if (!basket.embedded) { + add(basket.seek, basket.nbytes); + } + } + } +} + +std::span ReadPlan::lookup(uint64_t offset, size_t length) const +{ + auto it = std::upper_bound(mChunks.begin(), mChunks.end(), offset, [](uint64_t off, Chunk const& c) { return off < c.offset; }); + if (it == mChunks.begin()) { + return {}; + } + --it; + if (offset - it->offset + length > it->size) { + return {}; + } + return {it->data + (offset - it->offset), length}; +} + +void ReadPlan::fetchPending() +{ + std::ranges::sort(mPending); + std::vector fetched; + for (auto [offset, length] : mPending) { + if (!lookup(offset, length).empty()) { + continue; + } + auto end = offset + length; + if (!fetched.empty()) { + auto& last = fetched.back(); + auto lastEnd = last.offset + last.size; + auto next = std::lower_bound(mChunks.begin(), mChunks.end(), lastEnd, [](Chunk const& c, uint64_t off) { return c.offset < off; }); + bool bridgesFetched = next != mChunks.end() && next->offset < offset; + if (offset <= lastEnd + mCoalesceGap && !bridgesFetched) { + if (end > lastEnd) { + last.size = end - last.offset; + } + continue; + } + } + fetched.push_back({offset, nullptr, length}); + } + mPending.clear(); + if (fetched.empty()) { + return; + } + // One buffer for all the chunks of this fetch. Earlier buffers stay where + // they are, so spans handed out before remain valid. + size_t total = 0; + for (auto& chunk : fetched) { + total += chunk.size; + } + auto buffer = mPool->get(total); + std::vector requests; + auto* at = buffer.data.get(); + for (auto& chunk : fetched) { + chunk.data = at; + at += chunk.size; + requests.push_back({chunk.offset, chunk.size, chunk.data}); + } + mBuffers.push_back(std::move(buffer)); + mSource->readvInto(requests); + mChunks.insert(mChunks.end(), fetched.begin(), fetched.end()); + std::ranges::sort(mChunks, {}, &Chunk::offset); +} + +std::span ReadPlan::find(uint64_t offset, size_t length) +{ + std::lock_guard lock{mMutex}; + auto bytes = lookup(offset, length); + if (bytes.empty() && !mPending.empty()) { + fetchPending(); + bytes = lookup(offset, length); + } + return bytes; +} + +void ReadPlan::fetch() +{ + std::lock_guard lock{mMutex}; + if (!mPending.empty()) { + fetchPending(); + } +} + +size_t ReadPlan::bytesHeld() const +{ + std::lock_guard lock{mMutex}; + size_t total = 0; + for (auto const& chunk : mChunks) { + total += chunk.size; + } + return total; +} + +PlannedByteSource::PlannedByteSource(std::shared_ptr source, std::shared_ptr plan) + : mSource(std::move(source)), mPlan(std::move(plan)) +{ +} + +std::span PlannedByteSource::tryMap(uint64_t off, size_t len) +{ + auto bytes = mSource->tryMap(off, len); + return bytes.empty() ? mPlan->find(off, len) : bytes; +} + +void PlannedByteSource::readInto(uint64_t off, size_t len, void* dst) +{ + auto bytes = mPlan->find(off, len); + if (bytes.empty()) { + mSource->readInto(off, len, dst); + return; + } + std::memcpy(dst, bytes.data(), len); +} + +void PlannedByteSource::readvInto(std::span requests) +{ + mSource->readvInto(requests); +} + +} // namespace o2::aod::native diff --git a/Framework/AODNative/src/XRootDByteSource.cxx b/Framework/AODNative/src/XRootDByteSource.cxx new file mode 100644 index 0000000000000..d2025b6960043 --- /dev/null +++ b/Framework/AODNative/src/XRootDByteSource.cxx @@ -0,0 +1,217 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "XRootDByteSource.h" +#include "AODNative/Errors.h" +#include +#include +#include +#include +#include + +namespace o2::aod::native +{ + +namespace +{ +// XrdCl's defaults for kXR_readv: chunks of at most 2097136 bytes, at most 1024 +// of them per request. Larger ranges are read with plain reads, in pieces small +// enough for the uint32_t size of XrdCl::File::Read. +constexpr size_t kMaxVectorChunk = 2097136; +constexpr size_t kMaxVectorChunks = 1024; +constexpr size_t kMaxPlainRead = 64 * 1024 * 1024; + +/// Counts down the replies of the requests sent by one readvInto(), keeping +/// the first error. +class Completion +{ + public: + explicit Completion(size_t pending) : mPending(pending) {} + + void done(std::string error) + { + std::lock_guard lock{mMutex}; + if (!error.empty() && mError.empty()) { + mError = std::move(error); + } + if (--mPending == 0) { + mDone.notify_all(); + } + } + + /// Waits for every reply, including those of requests which were never + /// sent (counted down by the caller), and returns the first error. + std::string wait() + { + std::unique_lock lock{mMutex}; + mDone.wait(lock, [this] { return mPending == 0; }); + return mError; + } + + private: + std::mutex mMutex; + std::condition_variable mDone; + size_t mPending; + std::string mError; +}; + +/// One request: checks that the server returned exactly the bytes asked for. +class Reply : public XrdCl::ResponseHandler +{ + public: + Reply(Completion& completion, size_t expected) : mCompletion(completion), mExpected(expected) {} + + void HandleResponse(XrdCl::XRootDStatus* status, XrdCl::AnyObject* response) override + { + std::string error; + if (!status->IsOK()) { + error = status->ToString(); + } else if (auto got = received(response); got != mExpected) { + error = "short read: " + std::to_string(got) + " of " + std::to_string(mExpected) + " bytes"; + } + delete status; + delete response; + mCompletion.done(std::move(error)); + delete this; + } + + private: + static size_t received(XrdCl::AnyObject* response) + { + if (!response) { + return 0; + } + XrdCl::ChunkInfo* chunk = nullptr; + response->Get(chunk); + if (chunk) { + return chunk->length; + } + XrdCl::VectorReadInfo* vector = nullptr; + response->Get(vector); + return vector ? vector->GetSize() : 0; + } + + Completion& mCompletion; + size_t mExpected; +}; + +struct PlainRead { + uint64_t offset; + uint32_t length; + void* dst; +}; + +struct VectorRead { + XrdCl::ChunkList chunks; + size_t bytes = 0; +}; +} // namespace + +XRootDByteSource::XRootDByteSource(std::string url, Options const& options) + : mName(url.substr(0, url.find('?'))), mFile(std::make_unique()) +{ + auto status = mFile->Open(url, XrdCl::OpenFlags::Read); + if (!status.IsOK()) { + throw IoError(mName, status.ToString()); + } + XrdCl::StatInfo* info = nullptr; + status = mFile->Stat(false, info); + if (!status.IsOK() || !info) { + delete info; + throw IoError(mName, "stat failed: " + status.ToString()); + } + mSize = info->GetSize(); + delete info; +} + +XRootDByteSource::~XRootDByteSource() +{ + if (mFile->IsOpen()) { + [[maybe_unused]] auto status = mFile->Close(); + } +} + +void XRootDByteSource::checkRange(uint64_t off, size_t len) const +{ + if (off > mSize || len > mSize - off) { + throw CorruptData(mName, off, "read of " + std::to_string(len) + " bytes past the end of a " + std::to_string(mSize) + " byte file"); + } +} + +void XRootDByteSource::readInto(uint64_t off, size_t len, void* dst) +{ + ReadRequest request{off, len, dst}; + readvInto({&request, 1}); +} + +void XRootDByteSource::readvInto(std::span requests) +{ + std::vector plain; + std::vector vectors; + size_t total = 0; + for (auto const& r : requests) { + checkRange(r.offset, r.length); + total += r.length; + if (r.length > kMaxVectorChunk) { + for (size_t done = 0; done < r.length; done += kMaxPlainRead) { + auto n = std::min(kMaxPlainRead, r.length - done); + plain.push_back({r.offset + done, (uint32_t)n, (uint8_t*)r.dst + done}); + } + continue; + } + if (r.length == 0) { + continue; + } + if (vectors.empty() || vectors.back().chunks.size() == kMaxVectorChunks) { + vectors.emplace_back(); + } + vectors.back().chunks.emplace_back(r.offset, (uint32_t)r.length, r.dst); + vectors.back().bytes += r.length; + } + // A vector of one chunk is a plain read. + for (auto& v : vectors) { + if (v.chunks.size() == 1) { + auto& c = v.chunks.front(); + plain.push_back({c.offset, c.length, c.buffer}); + v.chunks.clear(); + } + } + std::erase_if(vectors, [](VectorRead const& v) { return v.chunks.empty(); }); + if (plain.empty() && vectors.empty()) { + return; + } + + Completion completion{plain.size() + vectors.size()}; + for (auto const& p : plain) { + auto* reply = new Reply(completion, p.length); + auto status = mFile->Read(p.offset, p.length, p.dst, reply); + if (!status.IsOK()) { + delete reply; + completion.done(status.ToString()); + } + } + for (auto const& v : vectors) { + auto* reply = new Reply(completion, v.bytes); + auto status = mFile->VectorRead(v.chunks, nullptr, reply); + if (!status.IsOK()) { + delete reply; + completion.done(status.ToString()); + } + } + auto error = completion.wait(); + if (!error.empty()) { + throw IoError(mName, error); + } + mReadCalls.fetch_add(plain.size() + vectors.size(), std::memory_order_relaxed); + mBytesRead.fetch_add(total, std::memory_order_relaxed); +} + +} // namespace o2::aod::native diff --git a/Framework/AODNative/src/XRootDByteSource.h b/Framework/AODNative/src/XRootDByteSource.h new file mode 100644 index 0000000000000..d0169fac157ea --- /dev/null +++ b/Framework/AODNative/src/XRootDByteSource.h @@ -0,0 +1,58 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_XROOTDBYTESOURCE_H_ +#define O2_FRAMEWORK_AODNATIVE_XROOTDBYTESOURCE_H_ + +#include "AODNative/ByteSource.h" +#include +#include + +namespace XrdCl +{ +class File; +} + +namespace o2::aod::native +{ + +/// A file read with the XRootD client (root://, roots://, xroot://, xroots://). +/// readvInto() sends all its ranges at once, as vectored reads for the small +/// ones and plain reads for the large ones, and waits for all of them: one +/// round trip for a whole read plan. +class XRootDByteSource : public ByteSource +{ + public: + XRootDByteSource(std::string url, Options const& options); + ~XRootDByteSource() override; + uint64_t size() const override { return mSize; } + void readInto(uint64_t off, size_t len, void* dst) override; + void readvInto(std::span requests) override; + uint64_t bytesRead() const override { return mBytesRead.load(std::memory_order_relaxed); } + /// Requests sent to the server: a vectored read counts once. + uint64_t readCalls() const override { return mReadCalls.load(std::memory_order_relaxed); } + /// Without the query, which may hold an access token. + std::string url() const override { return mName; } + bool isRemote() const override { return true; } + + private: + void checkRange(uint64_t off, size_t len) const; + + std::string mName; + std::unique_ptr mFile; + uint64_t mSize = 0; + std::atomic mBytesRead = 0; + std::atomic mReadCalls = 0; +}; + +} // namespace o2::aod::native + +#endif // O2_FRAMEWORK_AODNATIVE_XROOTDBYTESOURCE_H_ diff --git a/Framework/AODNative/test/RootFraming.h b/Framework/AODNative/test/RootFraming.h new file mode 100644 index 0000000000000..6176bb159f5e0 --- /dev/null +++ b/Framework/AODNative/test/RootFraming.h @@ -0,0 +1,195 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODNATIVE_TEST_ROOTFRAMING_H_ +#define O2_FRAMEWORK_AODNATIVE_TEST_ROOTFRAMING_H_ + +// Helpers to build ROOT-framed compressed payloads and TBasket keys in memory, +// so that codecs and the decoder can be tested without ROOT or a file. + +#include "AODNative/ByteSource.h" +#include "AODNative/Codecs.h" +#include "AODNative/Errors.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace o2::aod::native::test +{ + +enum struct Algo { ZSTD, + ZLIB, + LZMA, + LZ4, + None }; + +inline std::vector compressChunk(Algo algo, std::span in) +{ + std::vector out; + switch (algo) { + case Algo::ZSTD: { + out.resize(ZSTD_compressBound(in.size())); + out.resize(ZSTD_compress(out.data(), out.size(), in.data(), in.size(), 5)); + } break; + case Algo::ZLIB: { + uLongf n = compressBound(in.size()); + out.resize(n); + compress2(out.data(), &n, in.data(), in.size(), 6); + out.resize(n); + } break; + case Algo::LZMA: { + out.resize(lzma_stream_buffer_bound(in.size())); + size_t pos = 0; + lzma_easy_buffer_encode(6, LZMA_CHECK_CRC32, nullptr, in.data(), in.size(), out.data(), &pos, out.size()); + out.resize(pos); + } break; + case Algo::LZ4: { + std::vector block(LZ4_compressBound(in.size())); + block.resize(LZ4_compress_default((char const*)in.data(), (char*)block.data(), in.size(), block.size())); + auto checksum = xxh64(block); + for (int i = 7; i >= 0; --i) { + out.push_back((checksum >> (8 * i)) & 0xff); + } + out.insert(out.end(), block.begin(), block.end()); + } break; + case Algo::None: + throw std::logic_error("no chunk for uncompressed data"); + } + return out; +} + +/// A ROOT compressed payload: chunks of at most @a chunkSize uncompressed bytes, +/// each with its 9-byte header. +inline std::vector rootCompress(Algo algo, std::span in, size_t chunkSize = 0xffffff) +{ + static const char* tags[] = {"ZS", "ZL", "XZ", "L4"}; + std::vector out; + for (size_t pos = 0; pos < in.size(); pos += chunkSize) { + auto piece = in.subspan(pos, std::min(chunkSize, in.size() - pos)); + auto c = compressChunk(algo, piece); + out.push_back(tags[(int)algo][0]); + out.push_back(tags[(int)algo][1]); + out.push_back(algo == Algo::ZLIB ? 8 : 1); + for (auto v : {c.size(), piece.size()}) { + out.push_back(v & 0xff); + out.push_back((v >> 8) & 0xff); + out.push_back((v >> 16) & 0xff); + } + out.insert(out.end(), c.begin(), c.end()); + } + return out; +} + +struct BigEndianWriter { + std::vector bytes; + template + void put(T v) + { + for (int i = sizeof(T) - 1; i >= 0; --i) { + bytes.push_back((uint64_t(v) >> (8 * i)) & 0xff); + } + } + void string(std::string const& s) + { + bytes.push_back(s.size()); + bytes.insert(bytes.end(), s.begin(), s.end()); + } +}; + +/// A TBasket key as written on disk: key header, TBasket fields, then the +/// payload holding @a values followed by a fake entry-offset tail. +inline std::vector makeBasket(Algo algo, std::span values, int32_t nevbuf, size_t tailBytes = 0, size_t chunkSize = 0xffffff) +{ + std::vector raw(values.begin(), values.end()); + // The tail is poisoned: a decoder that reads past the value region sees it. + raw.resize(values.size() + tailBytes, 0xEE); + std::vector payload = algo == Algo::None ? raw : rootCompress(algo, raw, chunkSize); + + BigEndianWriter h; + auto build = [&](uint16_t keylen) { + h.bytes.clear(); + h.put(keylen + payload.size()); // fNbytes + h.put(4); // key version + h.put(raw.size()); // fObjlen + h.put(0); // fDatime + h.put(keylen); + h.put(1); // fCycle + h.put(0); // fSeekKey + h.put(0); // fSeekPdir + h.string("TBasket"); // class, name, title + h.string("fX"); + h.string("O2track"); + h.put(3); // fVersion + h.put(32000); // fBufferSize + h.put(0); // fNevBufSize + h.put(nevbuf); // fNevBuf + h.put(keylen + values.size()); // fLast + h.put(tailBytes ? 1 : 2); // flag + }; + build(0); + auto keylen = (uint16_t)h.bytes.size(); + build(keylen); + h.bytes.insert(h.bytes.end(), payload.begin(), payload.end()); + return h.bytes; +} + +/// A file made of concatenated in-memory buffers. +class MemoryByteSource : public ByteSource +{ + public: + explicit MemoryByteSource(std::vector data, bool mappable = true) : mData(std::move(data)), mMappable(mappable) {} + uint64_t size() const override { return mData.size(); } + void readInto(uint64_t off, size_t len, void* dst) override + { + if (off > mData.size() || len > mData.size() - off) { + throw CorruptData("memory", off, "read past the end"); + } + if (len == 0) { + return; + } + std::memcpy(dst, mData.data() + off, len); + ++mCalls; + mBytes += len; + } + void readvInto(std::span requests) override + { + for (auto& r : requests) { + readInto(r.offset, r.length, r.dst); + } + } + std::span tryMap(uint64_t off, size_t len) override + { + if (!mMappable) { + return {}; + } + if (off > mData.size() || len > mData.size() - off) { + throw CorruptData("memory", off, "map past the end"); + } + return std::span(mData).subspan(off, len); + } + uint64_t bytesRead() const override { return mBytes; } + uint64_t readCalls() const override { return mCalls; } + std::string url() const override { return "memory"; } + + private: + std::vector mData; + bool mMappable; + uint64_t mBytes = 0, mCalls = 0; +}; + +} // namespace o2::aod::native::test + +#endif // O2_FRAMEWORK_AODNATIVE_TEST_ROOTFRAMING_H_ diff --git a/Framework/AODNative/test/fuzz/fuzz_RootFile.cxx b/Framework/AODNative/test/fuzz/fuzz_RootFile.cxx new file mode 100644 index 0000000000000..86b30f1a4c70e --- /dev/null +++ b/Framework/AODNative/test/fuzz/fuzz_RootFile.cxx @@ -0,0 +1,69 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// libFuzzer target: a whole file in memory, read through every layer. Malformed +// input must end in a FormatError; any other exception, crash, hang or +// out-of-bounds access is a bug. Seed it with small corpus files. + +#include "AODNative/ArrowTable.h" +#include "AODNative/Container.h" +#include "AODNative/Errors.h" +#include "../RootFraming.h" + +using namespace o2::aod::native; + +namespace +{ +// Decoding allocates what the file says it holds; keep that within the +// fuzzer's memory limit. The reader itself has no such limit. +constexpr int64_t kMaxDecodedBytes = 64 * 1024 * 1024; + +bool small(TreeInfo const& tree) +{ + int64_t total = 0; + for (auto& b : tree.branches) { + if (b.entries < 0 || b.fixedLen < 0 || b.entries > kMaxDecodedBytes || b.fixedLen > 1024) { + return false; + } + total += b.entries * b.fixedLen * 8; + } + return total < kMaxDecodedBytes; +} +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(uint8_t const* data, size_t size) +{ + auto source = std::make_shared(std::vector(data, data + size)); + try { + RootFile file{source}; + file.topKeys(); + file.readStringMap("metaData"); + for (auto& df : file.dataFrames()) { + try { + for (auto& key : file.keysOf(df)) { + if (key.className != "TTree") { + continue; + } + try { + auto tree = file.readTree(key); + if (small(tree)) { + (void)readTree(*source, tree, Options{}); + } + } catch (FormatError const&) { + } + } + } catch (FormatError const&) { + } + } + } catch (FormatError const&) { + } + return 0; +} diff --git a/Framework/AODNative/test/standalone/CMakeLists.txt b/Framework/AODNative/test/standalone/CMakeLists.txt new file mode 100644 index 0000000000000..44394515b3ada --- /dev/null +++ b/Framework/AODNative/test/standalone/CMakeLists.txt @@ -0,0 +1,75 @@ +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +# Standalone build of Framework/AODNative without the O2 stack, for quick +# iteration: minimal shims for the o2_* CMake functions, Arrow C++ from a +# pyarrow wheel, Catch2 from its amalgamated sources. See ../../DEVELOPMENT.md. +# +# cmake -S Framework/AODNative/test/standalone -B build -G Ninja \ +# -DPYARROW_DIR=/lib/python3.X/site-packages/pyarrow \ +# -DCATCH2_DIR= +cmake_minimum_required(VERSION 3.20) +project(AODNativeStandalone CXX) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) +enable_testing() + +option(SANITIZE "Build with ASan/UBSan" OFF) +if(SANITIZE) + add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer) + add_link_options(-fsanitize=address,undefined) +endif() +add_compile_options(-Wall -Wextra -Wno-unused-parameter) + +get_filename_component(O2_SOURCE_DEFAULT ${CMAKE_CURRENT_LIST_DIR}/../../../.. ABSOLUTE) +set(O2_SOURCE ${O2_SOURCE_DEFAULT} CACHE PATH "O2 source tree") +set(PYARROW_DIR "" CACHE PATH "pyarrow package directory, with include/ and libarrow.so.*") +set(CATCH2_DIR "" CACHE PATH "Directory with catch_amalgamated.hpp and catch_amalgamated.cpp") + +file(GLOB ARROW_LIB ${PYARROW_DIR}/libarrow.so.*) +add_library(Arrow::arrow_shared SHARED IMPORTED) +set_target_properties(Arrow::arrow_shared PROPERTIES + IMPORTED_LOCATION ${ARROW_LIB} + INTERFACE_INCLUDE_DIRECTORIES ${PYARROW_DIR}/include) + +add_library(catch2 STATIC ${CATCH2_DIR}/catch_amalgamated.cpp) +target_include_directories(catch2 PUBLIC ${CATCH2_DIR}) +add_library(O2::Catch2 ALIAS catch2) + +find_package(ZLIB REQUIRED) +find_package(LibLZMA REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(zstd REQUIRED IMPORTED_TARGET libzstd) +pkg_check_modules(lz4 REQUIRED IMPORTED_TARGET liblz4) + +function(o2_add_library name) + cmake_parse_arguments(PARSE_ARGV 1 A "" "TARGETVARNAME" + "SOURCES;PUBLIC_INCLUDE_DIRECTORIES;PUBLIC_LINK_LIBRARIES;PRIVATE_INCLUDE_DIRECTORIES;PRIVATE_LINK_LIBRARIES") + set(target O2lib-${name}) + add_library(${target} SHARED ${A_SOURCES}) + add_library(O2::${name} ALIAS ${target}) + target_include_directories(${target} PUBLIC ${CMAKE_CURRENT_LIST_DIR}/include PRIVATE ${A_PRIVATE_INCLUDE_DIRECTORIES}) + target_link_libraries(${target} PUBLIC ${A_PUBLIC_LINK_LIBRARIES} PRIVATE ${A_PRIVATE_LINK_LIBRARIES}) + if(A_TARGETVARNAME) + set(${A_TARGETVARNAME} ${target} PARENT_SCOPE) + endif() +endfunction() + +function(o2_add_executable name) + cmake_parse_arguments(PARSE_ARGV 1 A "IS_TEST;NO_INSTALL;IS_BENCHMARK" "COMPONENT_NAME;TARGETVARNAME" "SOURCES;PUBLIC_LINK_LIBRARIES") + set(target o2-${A_COMPONENT_NAME}-${name}) + add_executable(${target} ${A_SOURCES}) + target_link_libraries(${target} PRIVATE ${A_PUBLIC_LINK_LIBRARIES}) +endfunction() + +add_subdirectory(${O2_SOURCE}/Framework/AODNative AODNative) diff --git a/Framework/AODNative/test/test_Codecs.cxx b/Framework/AODNative/test/test_Codecs.cxx new file mode 100644 index 0000000000000..86fc79debeb6f --- /dev/null +++ b/Framework/AODNative/test/test_Codecs.cxx @@ -0,0 +1,199 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/Codecs.h" +#include "AODNative/Errors.h" +#include "RootFraming.h" +#include +#include +#include + +using namespace o2::aod::native; +using namespace o2::aod::native::test; + +namespace +{ +std::vector sampleData(size_t n) +{ + // Compressible, but not trivially: a ramp with some noise. + std::vector data(n); + std::mt19937 rng(42); + for (size_t i = 0; i < n; ++i) { + data[i] = (uint8_t)(i / 7 + (rng() % 4)); + } + return data; +} + +auto const allAlgos = {Algo::ZSTD, Algo::ZLIB, Algo::LZMA, Algo::LZ4}; +} // namespace + +TEST_CASE("xxh64 matches the reference implementation") +{ + std::vector big(768); + for (size_t i = 0; i < big.size(); ++i) { + big[i] = i % 256; + } + CHECK(xxh64({}) == 0xef46db3751d8e999ULL); + CHECK(xxh64(std::vector{'a'}) == 0xd24ec4f1a98c6e5bULL); + CHECK(xxh64(std::vector{'a', 'b', 'c'}) == 0x44bc2cf5ad770999ULL); + CHECK(xxh64(big) == 0x8e03c838c596036fULL); +} + +TEST_CASE("each codec round-trips a single chunk") +{ + auto data = sampleData(100000); + for (auto algo : allAlgos) { + auto payload = rootCompress(algo, data); + std::vector out(data.size()); + REQUIRE(decompressBounded(payload, data.size(), out.data(), out.size()) == data.size()); + CHECK(out == data); + } +} + +TEST_CASE("each codec handles several chunks") +{ + auto data = sampleData(50000); + for (auto algo : allAlgos) { + auto payload = rootCompress(algo, data, 12345); + std::vector out(data.size()); + decompressBounded(payload, data.size(), out.data(), out.size()); + CHECK(out == data); + } +} + +TEST_CASE("bounded decompression stops exactly at maxOut") +{ + auto data = sampleData(40000); + for (auto algo : allAlgos) { + for (size_t maxOut : {size_t(1), size_t(9999), size_t(10000), size_t(25001), size_t(39999)}) { + auto payload = rootCompress(algo, data, 10000); + // One guard byte after the bound must stay untouched. + std::vector out(maxOut + 1, 0xAB); + REQUIRE(decompressBounded(payload, data.size(), out.data(), maxOut) == maxOut); + CHECK(std::equal(out.begin(), out.begin() + maxOut, data.begin())); + CHECK(out[maxOut] == 0xAB); + } + } +} + +TEST_CASE("prefix reads of large chunks stop exactly at the bound") +{ + // Several zstd blocks (at most 128 KiB each) per chunk, so that the bound + // falls before, at and after block boundaries, and in the last block. + auto data = sampleData(1 << 20); + constexpr size_t block = 128 * 1024; + for (auto algo : allAlgos) { + auto payload = rootCompress(algo, data, 600000); + for (size_t maxOut : {size_t(1), block - 1, block, block + 1, 2 * block + 17, size_t(599999), size_t(600000), size_t(600001), data.size() - 1}) { + INFO("algorithm " << (int)algo << ", bound " << maxOut); + std::vector out(maxOut + 64, 0xAB); + PayloadDecompressor d{payload, (uint32_t)data.size(), "memory", 0}; + d.readPrefix(out.data(), maxOut); + CHECK(std::equal(out.begin(), out.begin() + maxOut, data.begin())); + CHECK(std::all_of(out.begin() + maxOut, out.end(), [](uint8_t b) { return b == 0xAB; })); + } + } +} + +TEST_CASE("no read can follow a prefix read") +{ + auto data = sampleData(1000); + auto payload = rootCompress(Algo::ZSTD, data); + std::vector out(data.size()); + PayloadDecompressor d{payload, (uint32_t)data.size(), "memory", 0}; + d.readPrefix(out.data(), 10); + CHECK_THROWS_AS(d.read(out.data(), 10), std::logic_error); +} + +TEST_CASE("incremental reads of odd sizes produce the same bytes") +{ + auto data = sampleData(70000); + for (auto algo : allAlgos) { + auto payload = rootCompress(algo, data, 16384); + PayloadDecompressor d{payload, (uint32_t)data.size(), "memory", 0}; + std::vector out(data.size()); + size_t pos = 0; + for (size_t step = 1; pos < data.size(); step = step * 3 + 1) { + auto n = std::min(step, data.size() - pos); + d.read(out.data() + pos, n); + pos += n; + } + CHECK(out == data); + CHECK(d.produced() == data.size()); + } +} + +TEST_CASE("truncated payloads raise CorruptData") +{ + auto data = sampleData(30000); + for (auto algo : allAlgos) { + auto payload = rootCompress(algo, data, 10000); + std::vector out(data.size()); + for (size_t keep : {size_t(0), size_t(5), payload.size() / 2, payload.size() - 1}) { + std::vector truncated(payload.begin(), payload.begin() + keep); + CHECK_THROWS_AS(decompressBounded(truncated, data.size(), out.data(), out.size()), CorruptData); + } + } +} + +TEST_CASE("corrupt payloads raise CorruptData") +{ + auto data = sampleData(30000); + for (auto algo : allAlgos) { + auto payload = rootCompress(algo, data); + std::vector out(data.size()); + // Flip bits in the middle of the compressed stream. + auto corrupt = payload; + for (size_t i = corrupt.size() / 3; i < corrupt.size() / 3 + 16; ++i) { + corrupt[i] ^= 0x5A; + } + // zlib and zstd may decode garbage of the right size without noticing; what + // matters is that nothing crashes and that detected errors are CorruptData. + try { + decompressBounded(corrupt, data.size(), out.data(), out.size()); + } catch (CorruptData const&) { + } + // A wrong uncompressed size in the header is always detected. + auto wrongSize = payload; + wrongSize[6] ^= 0x01; + CHECK_THROWS_AS(decompressBounded(wrongSize, data.size(), out.data(), out.size()), CorruptData); + } +} + +TEST_CASE("LZ4 checksum mismatch is detected") +{ + auto data = sampleData(5000); + auto payload = rootCompress(Algo::LZ4, data); + payload[9] ^= 0xFF; // first checksum byte + std::vector out(data.size()); + CHECK_THROWS_AS(decompressBounded(payload, data.size(), out.data(), out.size()), CorruptData); +} + +TEST_CASE("legacy and unknown algorithms are unsupported") +{ + auto data = sampleData(100); + auto payload = rootCompress(Algo::ZLIB, data); + std::vector out(data.size()); + payload[0] = 'C'; + payload[1] = 'S'; + CHECK_THROWS_AS(decompressBounded(payload, data.size(), out.data(), out.size()), UnsupportedLayout); + payload[0] = 'Q'; + payload[1] = 'Q'; + CHECK_THROWS_AS(decompressBounded(payload, data.size(), out.data(), out.size()), UnsupportedLayout); +} + +TEST_CASE("chunks larger than the object length are rejected") +{ + auto data = sampleData(1000); + auto payload = rootCompress(Algo::ZSTD, data); + std::vector out(data.size()); + CHECK_THROWS_AS(decompressBounded(payload, 500, out.data(), out.size()), CorruptData); +} diff --git a/Framework/AODNative/test/test_Decoder.cxx b/Framework/AODNative/test/test_Decoder.cxx new file mode 100644 index 0000000000000..cfb207ac9e851 --- /dev/null +++ b/Framework/AODNative/test/test_Decoder.cxx @@ -0,0 +1,243 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/Decoder.h" +#include "AODNative/Errors.h" +#include "RootFraming.h" +#include +#include + +using namespace o2::aod::native; +using namespace o2::aod::native::test; + +namespace +{ +// Serializes values big-endian, as ROOT stores them. +template +std::vector bigEndian(std::vector const& values) +{ + BigEndianWriter w; + for (auto v : values) { + w.put(std::bit_cast>>>(v)); + } + return w.bytes; +} + +/// Lays out baskets one after the other in a file, and describes them in a BranchInfo. +struct BranchBuilder { + std::vector file = std::vector(100, 0); // some leading bytes, so seeks are non-zero + BranchInfo branch; + + BranchBuilder(char leafClass, int fixedLen = 1) + { + branch.name = "fX"; + branch.leafClass = leafClass; + branch.isUnsigned = false; + branch.fixedLen = fixedLen; + branch.entries = branch.totBytes = branch.zipBytes = 0; + } + + void add(Algo algo, std::vector const& values, int32_t nEntries, size_t tail = 0, size_t chunkSize = 0xffffff) + { + auto basket = makeBasket(algo, values, nEntries, tail, chunkSize); + branch.baskets.push_back(BasketRef{file.size(), (uint32_t)basket.size(), branch.entries, nEntries, false, {}}); + file.insert(file.end(), basket.begin(), basket.end()); + branch.entries += nEntries; + } + + void addEmbedded(std::vector const& values, int32_t nEntries) + { + branch.baskets.push_back(BasketRef{0, 0, branch.entries, nEntries, true, values}); + branch.entries += nEntries; + } +}; +} // namespace + +TEST_CASE("byteswapInPlace") +{ + std::vector v{1, 2, 3, 4, 5, 6, 7, 8}; + byteswapInPlace(v.data(), 2, 4); + CHECK(v == std::vector{4, 3, 2, 1, 8, 7, 6, 5}); + byteswapInPlace(v.data(), 1, 8); + CHECK(v == std::vector{5, 6, 7, 8, 1, 2, 3, 4}); + byteswapInPlace(v.data(), 4, 2); + CHECK(v == std::vector{6, 5, 8, 7, 2, 1, 4, 3}); +} + +TEST_CASE("float values across codecs, uncompressed and embedded baskets") +{ + BranchBuilder b{'F'}; + std::vector expected; + int n = 0; + for (auto algo : {Algo::ZSTD, Algo::None, Algo::ZLIB, Algo::LZMA, Algo::LZ4}) { + std::vector values; + for (int i = 0; i < 1000; ++i) { + values.push_back(n++ * 0.5f); + } + // Entry-offset tail after the values must never reach the destination. + b.add(algo, bigEndian(values), values.size(), 4004); + expected.insert(expected.end(), values.begin(), values.end()); + } + std::vector tail{-1.f, -2.f, -3.f}; + b.addEmbedded(bigEndian(tail), tail.size()); + expected.insert(expected.end(), tail.begin(), tail.end()); + + for (bool mappable : {true, false}) { + for (int threads : {1, 4}) { + MemoryByteSource source{b.file, mappable}; + Options options; + options.threads = threads; + BranchDecoder decoder{source, options}; + std::vector out(expected.size(), 42.f); + decoder.decodeValues(b.branch, (uint8_t*)out.data(), out.size() * sizeof(float)); + CHECK(out == expected); + } + } +} + +TEST_CASE("decoding stops exactly at the value region for every codec") +{ + // A poisoned entry-offset tail after the values: nothing past fLast - fKeylen + // may reach the destination, which is followed by guard bytes. Sizes span + // several zstd blocks and compression chunks. + for (auto algo : {Algo::ZSTD, Algo::ZLIB, Algo::LZMA, Algo::LZ4, Algo::None}) { + for (size_t chunkSize : {size_t(100000), size_t(0xffffff)}) { + INFO("algorithm " << (int)algo << ", chunk size " << chunkSize); + BranchBuilder b{'D'}; + std::vector values(70001); + for (size_t i = 0; i < values.size(); ++i) { + values[i] = i * 0.25 - 7; + } + b.add(algo, bigEndian(values), values.size(), 8 * values.size() + 4, chunkSize); + MemoryByteSource source{b.file}; + BranchDecoder decoder{source, Options{}}; + std::vector out(values.size() + 16, 1e300); + decoder.decodeValues(b.branch, (uint8_t*)out.data(), values.size() * sizeof(double)); + CHECK(std::equal(values.begin(), values.end(), out.begin())); + CHECK(std::all_of(out.begin() + values.size(), out.end(), [](double v) { return v == 1e300; })); + } + } +} + +TEST_CASE("fixed-size arrays and wrong destination sizes") +{ + BranchBuilder b{'S', 3}; + std::vector values{1, 2, 3, -4, -5, -6}; + b.add(Algo::ZSTD, bigEndian(values), 2); + MemoryByteSource source{b.file}; + BranchDecoder decoder{source, Options{}}; + std::vector out(6); + decoder.decodeValues(b.branch, (uint8_t*)out.data(), 12); + CHECK(out == values); + CHECK_THROWS_AS(decoder.decodeValues(b.branch, (uint8_t*)out.data(), 10), FormatError); +} + +TEST_CASE("booleans are packed across basket and window boundaries") +{ + // Basket sizes chosen so that boundaries fall inside bitmap bytes, and one + // basket is larger than the 64 KiB window. + BranchBuilder b{'O'}; + std::vector expected; + for (int size : {3, 13, (int)BranchDecoder::kBooleanWindow + 5, 1, 7}) { + std::vector bytes; + for (int i = 0; i < size; ++i) { + bool v = ((expected.size() * 7) % 3) == 0; + expected.push_back(v); + // Any non-zero byte is true. + bytes.push_back(v ? (i % 2 ? 1 : 0x80) : 0); + } + b.add(Algo::ZSTD, bytes, size, 8, 30000); + } + MemoryByteSource source{b.file}; + BranchDecoder decoder{source, Options{}}; + std::vector bitmap((expected.size() + 7) / 8, 0xFF); + decoder.decodeBooleans(b.branch, bitmap.data(), bitmap.size()); + for (size_t i = 0; i < expected.size(); ++i) { + INFO("value " << i); + CHECK(((bitmap[i / 8] >> (i % 8)) & 1) == expected[i]); + } + // Padding bits are zero. + for (size_t i = expected.size(); i < bitmap.size() * 8; ++i) { + CHECK(((bitmap[i / 8] >> (i % 8)) & 1) == 0); + } +} + +TEST_CASE("variable-length arrays") +{ + BranchBuilder sizes{'I'}; + sizes.branch.name = "fY_size"; + std::vector s1{2, 0, 3}, s2{1}; + sizes.add(Algo::ZLIB, bigEndian(s1), 3); + sizes.add(Algo::None, bigEndian(s2), 1); + + // Values baskets do not need to align with size baskets. + BranchBuilder values{'D'}; + values.branch.name = "fY"; + values.branch.countBranch = "fY_size"; + std::vector v1{1.5, 2.5}, v2{3.5, 4.5, 5.5, 6.5}; + values.add(Algo::LZ4, bigEndian(v1), 2); + values.add(Algo::ZSTD, bigEndian(v2), 2); + + std::vector file = sizes.file; + // Place the values baskets after the size baskets. + auto shift = file.size() - 100; + file.insert(file.end(), values.file.begin() + 100, values.file.end()); + for (auto& basket : values.branch.baskets) { + basket.seek += shift; + } + + MemoryByteSource source{file}; + BranchDecoder decoder{source, Options{}}; + auto offsets = decoder.decodeOffsets(sizes.branch); + CHECK(offsets == std::vector{0, 2, 2, 5, 6}); + std::vector out(6); + decoder.decodeVLAValues(values.branch, offsets, (uint8_t*)out.data(), out.size() * sizeof(double)); + CHECK(out == std::vector{1.5, 2.5, 3.5, 4.5, 5.5, 6.5}); +} + +TEST_CASE("inconsistent baskets raise CorruptData") +{ + BranchBuilder b{'I'}; + std::vector values{1, 2, 3}; + b.add(Algo::ZSTD, bigEndian(values), 3); + MemoryByteSource source{b.file}; + BranchDecoder decoder{source, Options{}}; + std::vector out(3); + + SECTION("entry count in the header differs from the TBranch") + { + auto branch = b.branch; + branch.baskets[0].nEntries = 2; + branch.entries = 2; + CHECK_THROWS_AS(decoder.decodeValues(branch, (uint8_t*)out.data(), 8), CorruptData); + } + SECTION("baskets do not cover the entries") + { + auto branch = b.branch; + branch.entries = 4; + std::vector big(4); + CHECK_THROWS_AS(decoder.decodeValues(branch, (uint8_t*)big.data(), 16), CorruptData); + } + SECTION("basket size differs from the TBranch") + { + auto branch = b.branch; + branch.baskets[0].nbytes -= 1; + CHECK_THROWS_AS(decoder.decodeValues(branch, (uint8_t*)out.data(), 12), CorruptData); + } + SECTION("basket location past the end of the file") + { + auto branch = b.branch; + branch.baskets[0].seek = b.file.size(); + MemoryByteSource unmapped{b.file, false}; + BranchDecoder d2{unmapped, Options{}}; + CHECK_THROWS_AS(d2.decodeValues(branch, (uint8_t*)out.data(), 12), CorruptData); + } +} diff --git a/Framework/AODNative/test/test_Memory.cxx b/Framework/AODNative/test/test_Memory.cxx new file mode 100644 index 0000000000000..c24dd6fe1658f --- /dev/null +++ b/Framework/AODNative/test/test_Memory.cxx @@ -0,0 +1,259 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// o2-test-framework-aod-native-memory [--threads N] [--pread] ... +// +// Checks that decoding a DF uses bounded private memory. Every heap +// allocation (ours and the codecs') is tracked by interposing malloc. Columns +// are decoded into MAP_SHARED anonymous memory, standing in for the shared +// memory DPL provides, so the output itself is not private. +// +// Per DF, private memory is allowed for: parsed metadata, VLA offsets, and +// per worker a constant decode scratch (boolean window plus codec state). The +// decode phase must stay within threads × kDecodeScratchBound, whatever the +// size of the baskets. In pread mode the compressed input of each basket in +// flight is allowed on top. + +#include "AODNative/ArrowTable.h" +#include "AODNative/Container.h" +#include "AODNative/Errors.h" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace o2::aod::native; + +// ---- malloc interposition --------------------------------------------------- + +extern "C" void* __libc_malloc(size_t); +extern "C" void* __libc_calloc(size_t, size_t); +extern "C" void* __libc_realloc(void*, size_t); +extern "C" void* __libc_memalign(size_t, size_t); +extern "C" void __libc_free(void*); + +namespace +{ +std::atomic gCurrent{0}; +std::atomic gPeak{0}; +std::atomic gLargest{0}; + +void track(void* p) +{ + if (!p) { + return; + } + int64_t n = malloc_usable_size(p); + auto now = gCurrent.fetch_add(n) + n; + for (auto peak = gPeak.load(); now > peak && !gPeak.compare_exchange_weak(peak, now);) { + } + for (auto largest = gLargest.load(); n > largest && !gLargest.compare_exchange_weak(largest, n);) { + } +} + +void untrack(void* p) +{ + if (p) { + gCurrent.fetch_sub(malloc_usable_size(p)); + } +} +} // namespace + +extern "C" { +void* malloc(size_t n) +{ + auto* p = __libc_malloc(n); + track(p); + return p; +} +void* calloc(size_t a, size_t b) +{ + auto* p = __libc_calloc(a, b); + track(p); + return p; +} +void* realloc(void* old, size_t n) +{ + untrack(old); + auto* p = __libc_realloc(old, n); + track(p ? p : (n ? nullptr : p)); + return p; +} +void free(void* p) +{ + untrack(p); + __libc_free(p); +} +void* memalign(size_t a, size_t n) +{ + auto* p = __libc_memalign(a, n); + track(p); + return p; +} +void* aligned_alloc(size_t a, size_t n) { return memalign(a, n); } +int posix_memalign(void** out, size_t a, size_t n) +{ + *out = memalign(a, n); + return *out ? 0 : ENOMEM; +} +} + +// ---- measurement ------------------------------------------------------------ + +namespace +{ +/// Per worker, the decode phase may use the boolean window plus the state of +/// one codec. The largest is liblzma, whose dictionary is fixed by the writer +/// (8 MiB at the presets ROOT uses); zstd and zlib are a few hundred KiB. +constexpr int64_t kDecodeScratchBound = 9 * 1024 * 1024; + +/// No basket-sized buffer: decoding a value column (not booleans) allocates at +/// most a constant per codec, whatever the basket size. For zstd that is its +/// context plus two 128 KiB blocks; for liblzma, its dictionary. Booleans are +/// reported but not bounded this way: their output cannot be the codec's +/// history, so zstd and LZ4 keep up to one window or chunk of it. +int64_t valueAllocationBound(int compress) +{ + return compress / 100 == 2 ? kDecodeScratchBound : 640 * 1024; +} + +struct Phase { + int64_t start; + Phase() + { + start = gCurrent.load(); + gPeak = start; + gLargest = 0; + } + int64_t peakIncrease() const { return gPeak.load() - start; } + int64_t retained() const { return gCurrent.load() - start; } +}; + +int64_t rssAnonKiB() +{ + std::ifstream status("/proc/self/status"); + std::string line; + while (std::getline(status, line)) { + if (line.starts_with("RssAnon:")) { + return std::stoll(line.substr(8)); + } + } + return -1; +} + +std::string mib(int64_t bytes) +{ + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.2f MiB", bytes / 1048576.); + return buf; +} + +struct SharedBuffer { + uint8_t* data = nullptr; + size_t size = 0; + explicit SharedBuffer(size_t n) : size(std::max(n, 1)) + { + void* p = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); + if (p == MAP_FAILED) { + throw std::bad_alloc(); + } + data = (uint8_t*)p; + } + ~SharedBuffer() { munmap(data, size); } +}; +} // namespace + +int main(int argc, char** argv) +{ + Options options; + std::vector files; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--threads") == 0 && i + 1 < argc) { + options.threads = std::stoi(argv[++i]); + } else if (std::strcmp(argv[i], "--pread") == 0) { + options.localIO = Options::LocalIO::PRead; + } else { + files.push_back(argv[i]); + } + } + if (files.empty()) { + std::fprintf(stderr, "usage: %s [--threads N] [--pread] ...\n", argv[0]); + return 2; + } + int failures = 0; + std::printf("%-18s %-6s %11s %10s %10s %12s %13s %13s %15s %10s\n", "file", "DF", "decoded", "metadata", "offsets", + "decode peak", "values alloc", "bools alloc", "largest basket", "RssAnon +"); + for (auto& path : files) { + try { + auto source = std::shared_ptr(ByteSource::open(path, options)); + RootFile file{source}; + for (auto& df : file.dataFrames()) { + auto rssBefore = rssAnonKiB(); + Phase metadata; + std::vector trees; + for (auto& key : file.keysOf(df)) { + if (key.className == "TTree") { + trees.push_back(file.readTree(key)); + } + } + auto metadataBytes = metadata.peakIncrease(); + + Phase offsets; + BranchDecoder decoder{*source, options}; + std::vector> plans; + for (auto& tree : trees) { + int64_t rows; + plans.push_back(planColumns(tree, datasetSchema(physicalSchema(tree)), decoder, arrow::system_memory_pool(), rows)); + } + auto offsetsBytes = offsets.peakIncrease(); + + int64_t decodePeak = 0, valuesAlloc = 0, boolsAlloc = 0, decoded = 0; + uint32_t largestBasket = 0; + for (auto& plan : plans) { + for (auto& op : plan) { + for (auto& b : op.branch->baskets) { + largestBasket = std::max(largestBasket, b.nbytes); + } + SharedBuffer out{op.byteSize}; + Phase decode; + executeColumnOp(decoder, op, out.data); + decodePeak = std::max(decodePeak, decode.peakIncrease()); + auto& largest = op.kind == ColumnOp::Kind::Booleans ? boolsAlloc : valuesAlloc; + largest = std::max(largest, gLargest.load()); + decoded += op.byteSize; + } + } + // In pread mode, the compressed input of each basket in flight is private too. + bool pread = options.localIO == Options::LocalIO::PRead; + int64_t inputAllowance = pread ? (int64_t)options.threads * largestBasket : 0; + auto rssDelta = (rssAnonKiB() - rssBefore) * 1024; + std::string verdict = "ok"; + if (decodePeak > options.threads * kDecodeScratchBound + inputAllowance) { + verdict = "FAIL: decode peak > " + mib(options.threads * kDecodeScratchBound + inputAllowance); + } else if (!pread && valuesAlloc > valueAllocationBound(file.header().compress)) { + verdict = "FAIL: value column allocation > " + mib(valueAllocationBound(file.header().compress)); + } + std::printf("%-18s %-6s %11s %10s %10s %12s %13s %13s %15s %10s %s\n", path.substr(path.rfind('/') + 1).c_str(), df.c_str(), + mib(decoded).c_str(), mib(metadataBytes).c_str(), mib(offsetsBytes).c_str(), mib(decodePeak).c_str(), + mib(valuesAlloc).c_str(), mib(boolsAlloc).c_str(), mib(largestBasket).c_str(), mib(rssDelta).c_str(), verdict.c_str()); + failures += verdict != "ok"; + } + } catch (FormatError const& e) { + std::fprintf(stderr, "%s: %s\n", path.c_str(), e.what()); + ++failures; + } + } + return failures ? 1 : 0; +} diff --git a/Framework/AODNative/test/test_Objects.cxx b/Framework/AODNative/test/test_Objects.cxx new file mode 100644 index 0000000000000..f2ac5bd163e01 --- /dev/null +++ b/Framework/AODNative/test/test_Objects.cxx @@ -0,0 +1,106 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/Errors.h" +#include "AODNative/Objects.h" +#include "Cursor.h" +#include "RootFraming.h" +#include + +using namespace o2::aod::native; +using namespace o2::aod::native::test; + +TEST_CASE("Cursor reads big-endian integers") +{ + std::vector buf{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}; + std::string file = "memory"; + Cursor c{buf, file}; + CHECK(c.u16() == 0x1234); + CHECK(c.u32() == 0x56789abc); + CHECK(c.u8() == 0xde); + CHECK(c.u64() == 0xf001020304050607ULL); + CHECK(c.remaining() == 0); + CHECK_THROWS_AS(c.u8(), CorruptData); +} + +TEST_CASE("Cursor reads both TString encodings") +{ + BigEndianWriter w; + w.string("short"); + w.bytes.push_back(0xFF); + w.put(300); + w.bytes.insert(w.bytes.end(), 300, 'x'); + std::string file = "memory"; + Cursor c{w.bytes, file}; + CHECK(c.string() == "short"); + CHECK(c.string() == std::string(300, 'x')); + CHECK(c.remaining() == 0); +} + +TEST_CASE("Cursor never reads out of bounds") +{ + std::vector buf{0x05, 'a', 'b'}; // TString claiming 5 bytes, only 2 there + std::string file = "memory"; + Cursor c{buf, file}; + CHECK_THROWS_AS(c.string(), CorruptData); + Cursor d{buf, file}; + CHECK_THROWS_AS(d.seek(4), CorruptData); + CHECK_THROWS_AS(d.skip(4), CorruptData); + Cursor e{buf, file}; + CHECK_THROWS_AS(e.cstring(), CorruptData); +} + +TEST_CASE("Cursor reports displacement relative to the key") +{ + std::vector buf(10); + std::string file = "memory"; + Cursor c{buf, file, 1000, 64}; + c.skip(4); + CHECK(c.displacement() == 68); +} + +TEST_CASE("truncated tree payloads raise errors, never crash") +{ + // Arbitrary bytes, then every truncation of them: each must fail cleanly. + std::vector junk(200); + for (size_t i = 0; i < junk.size(); ++i) { + junk[i] = (uint8_t)(i * 37 + 11); + } + // A plausible start: byte count with the flag set, TTree version 20. + junk[0] = 0x40; + junk[4] = 0x00; + junk[5] = 20; + for (size_t n = 0; n <= junk.size(); n += 7) { + std::span part{junk.data(), n}; + CHECK_THROWS_AS(readTreePayload(part, 64, "memory", 0), FormatError); + } +} + +TEST_CASE("unknown class versions raise UnsupportedVersion") +{ + BigEndianWriter w; + w.put(0x40000000 | 100); // byte count + w.put(19); // TTree version 19: not supported + w.bytes.resize(104); + CHECK_THROWS_AS(readTreePayload(w.bytes, 64, "memory", 0), UnsupportedVersion); +} + +TEST_CASE("leaf element sizes") +{ + CHECK(leafElementSize('O') == 1); + CHECK(leafElementSize('B') == 1); + CHECK(leafElementSize('S') == 2); + CHECK(leafElementSize('I') == 4); + CHECK(leafElementSize('F') == 4); + CHECK(leafElementSize('L') == 8); + CHECK(leafElementSize('D') == 8); + CHECK_THROWS_AS(leafElementSize('C'), UnsupportedLayout); +} diff --git a/Framework/AODNative/test/test_ReadPlan.cxx b/Framework/AODNative/test/test_ReadPlan.cxx new file mode 100644 index 0000000000000..2efc1b0d101b8 --- /dev/null +++ b/Framework/AODNative/test/test_ReadPlan.cxx @@ -0,0 +1,187 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODNative/ReadPlan.h" +#include +#include +#include + +using namespace o2::aod::native; + +namespace +{ +// File contents where byte i is i % 251, recording every read. +struct RecordingSource : ByteSource { + explicit RecordingSource(size_t n) : bytes(n) + { + for (size_t i = 0; i < n; ++i) { + bytes[i] = i % 251; + } + } + uint64_t size() const override { return bytes.size(); } + void readInto(uint64_t off, size_t len, void* dst) override + { + reads.emplace_back(off, len); + std::memcpy(dst, bytes.data() + off, len); + } + void readvInto(std::span requests) override + { + for (auto& r : requests) { + readInto(r.offset, r.length, r.dst); + } + } + uint64_t bytesRead() const override { return 0; } + uint64_t readCalls() const override { return reads.size(); } + std::string url() const override { return "memory"; } + + std::vector bytes; + std::vector> reads; +}; + +bool holds(std::span span, uint64_t offset) +{ + for (size_t i = 0; i < span.size(); ++i) { + if (span[i] != (offset + i) % 251) { + return false; + } + } + return true; +} + +Options withGap(size_t gap) +{ + Options options; + options.coalesceGap = gap; + return options; +} +} // namespace + +TEST_CASE("ranges closer than the gap are fetched in one read, on first use") +{ + auto source = std::make_shared(1 << 20); + ReadPlan plan{source, withGap(100)}; + plan.add(5000, 200); + plan.add(1000, 100); + plan.add(1150, 50); // 50 bytes after the first: merged + plan.add(1300, 10); // 100 bytes after the second: merged + REQUIRE(source->reads.empty()); + + auto bytes = plan.find(1150, 50); + REQUIRE(holds(bytes, 1150)); + REQUIRE(source->reads == std::vector>{{1000, 310}, {5000, 200}}); + REQUIRE(plan.bytesHeld() == 510); + + REQUIRE(holds(plan.find(5100, 100), 5100)); + REQUIRE(holds(plan.find(1000, 310), 1000)); + REQUIRE(source->reads.size() == 2); +} + +TEST_CASE("ranges which were never registered are not found") +{ + auto source = std::make_shared(1 << 20); + ReadPlan plan{source, withGap(0)}; + plan.add(1000, 100); + REQUIRE(plan.find(900, 50).empty()); + REQUIRE(plan.find(1050, 100).empty()); // runs past the fetched range + REQUIRE(plan.find(5000, 1).empty()); + REQUIRE(source->reads.size() == 1); +} + +TEST_CASE("ranges added after a fetch are fetched on their first use") +{ + auto source = std::make_shared(1 << 20); + ReadPlan plan{source, withGap(1000)}; + plan.add(1000, 100); + REQUIRE(holds(plan.find(1000, 100), 1000)); + auto first = plan.find(1000, 100); + + // Within the gap of the fetched chunk on both sides, but not merged across it. + plan.add(500, 100); + plan.add(1500, 100); + plan.add(1000, 100); // already in memory: not read again + REQUIRE(holds(plan.find(1500, 100), 1500)); + REQUIRE(holds(plan.find(500, 100), 500)); + REQUIRE(source->reads == std::vector>{{1000, 100}, {500, 100}, {1500, 100}}); + // Spans handed out before stay valid. + REQUIRE(plan.find(1000, 100).data() == first.data()); + REQUIRE(holds(first, 1000)); +} + +TEST_CASE("ranges past the end of the file are left to the reader") +{ + auto source = std::make_shared(1000); + ReadPlan plan{source, withGap(0)}; + plan.add(900, 200); + plan.add(2000, 1); + REQUIRE(plan.find(900, 100).empty()); + REQUIRE(source->reads.empty()); +} + +TEST_CASE("a planned source serves planned ranges from memory and the rest from the source") +{ + auto source = std::make_shared(1 << 20); + auto plan = std::make_shared(source, withGap(0)); + plan->add(1000, 100); + PlannedByteSource planned{source, plan}; + + REQUIRE(holds(planned.tryMap(1010, 20), 1010)); + std::vector out(30); + planned.readInto(1020, 30, out.data()); + REQUIRE(holds(out, 1020)); + REQUIRE(planned.tryMap(3000, 10).empty()); + planned.readInto(3000, 30, out.data()); + REQUIRE(holds(out, 3000)); + REQUIRE(source->reads == std::vector>{{1000, 100}, {3000, 30}}); + REQUIRE(planned.readCalls() == 2); +} + +TEST_CASE("concurrent lookups fetch the pending ranges once") +{ + auto source = std::make_shared(1 << 20); + ReadPlan plan{source, withGap(0)}; + for (int i = 0; i < 64; ++i) { + plan.add(i * 10000, 1000); + } + std::vector threads; + std::atomic good = 0; + for (int t = 0; t < 8; ++t) { + threads.emplace_back([&, t] { + for (int i = t; i < 64; i += 8) { + good += holds(plan.find(i * 10000 + 10, 900), i * 10000 + 10); + } + }); + } + for (auto& t : threads) { + t.join(); + } + REQUIRE(good == 64); + REQUIRE(source->reads.size() == 64); +} + +TEST_CASE("buffers of finished plans are reused by the next one") +{ + auto source = std::make_shared(1 << 20); + auto pool = std::make_shared(); + uint8_t const* first = nullptr; + { + ReadPlan plan{source, withGap(0), pool}; + plan.add(1000, 1000); + first = plan.find(1000, 1000).data(); + } + ReadPlan next{source, withGap(0), pool}; + next.add(50000, 900); // fits in the first buffer + auto bytes = next.find(50000, 900); + REQUIRE(bytes.data() == first); + REQUIRE(holds(bytes, 50000)); + + auto big = pool->get(1 << 16); + REQUIRE(big.capacity >= 1 << 16); +} diff --git a/Framework/AODNative/test/tools/check_corpus.py b/Framework/AODNative/test/tools/check_corpus.py new file mode 100644 index 0000000000000..ab1bbcf8cb4db --- /dev/null +++ b/Framework/AODNative/test/tools/check_corpus.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +"""Checks the native reader tools against the uproot reference on a corpus. + + check_corpus.py [] + +For every file: the JSON of o2-aod-native-inspect must equal the reference; +every tree dumped by o2-aod-native-dump must equal the reference Arrow table, +and the IPC bytes must not depend on --threads or --pread. Then files the +reader must refuse cleanly: an RNTuple, a patched class version, truncations +and a non-ROOT file. Exits non-zero on any failure. +""" + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import pyarrow as pa +import pyarrow.ipc +import uproot + +sys.path.insert(0, str(Path(__file__).parent)) +import reference_inspect # noqa: E402 + +failures = [] + + +def fail(what): + failures.append(what) + print("FAIL", what) + + +def run(cmd): + return subprocess.run([str(c) for c in cmd], capture_output=True, text=True) + + +def first_difference(a, b, path="$"): + if type(a) is not type(b): + return f"{path}: {a!r} != {b!r}" + if isinstance(a, dict): + for k in sorted(set(a) | set(b)): + if k not in a or k not in b: + return f"{path}.{k}: missing on one side" + d = first_difference(a[k], b[k], f"{path}.{k}") + if d: + return d + elif isinstance(a, list): + if len(a) != len(b): + return f"{path}: length {len(a)} != {len(b)}" + for i, (x, y) in enumerate(zip(a, b)): + d = first_difference(x, y, f"{path}[{i}]") + if d: + return d + elif a != b: + return f"{path}: {a!r} != {b!r}" + return None + + +def check_inspect(bindir, path): + r = run([bindir / "o2-aod-native-inspect", path, "--json"]) + if r.returncode != 0: + fail(f"{path.name}: inspect failed: {r.stderr.strip()}") + return None + native = json.loads(r.stdout) + native["header"].pop("nfree") # uproot does not keep it + reference = reference_inspect.inspect(path) + d = first_difference(native, reference) + if d: + fail(f"{path.name}: inspect differs from reference at {d}") + return native + + +def check_dump(bindir, path, native, work): + f = uproot.open(path) + ntrees = 0 + for df in native["dataFrames"]: + for tree in df["trees"]: + name = f"{df['name']}/{tree['name']}" + outputs = {} + for variant, extra in {"default": [], "threads": ["--threads", "4"], "pread": ["--pread"]}.items(): + out = work / f"{path.stem}-{df['name']}-{tree['name']}-{variant}.arrow" + r = run([bindir / "o2-aod-native-dump", path, name, out, *extra]) + if r.returncode != 0: + fail(f"{path.name} {name} ({variant}): dump failed: {r.stderr.strip()}") + break + outputs[variant] = out.read_bytes() + else: + if len(set(outputs.values())) != 1: + fail(f"{path.name} {name}: IPC bytes depend on --threads / --pread") + native_table = pa.ipc.open_stream(outputs["default"]).read_all() + expected = reference_inspect.arrow_table(f[name]) + if not native_table.schema.equals(expected.schema): + fail(f"{path.name} {name}: schema differs\n native: {native_table.schema}\n expected: {expected.schema}") + elif not native_table.equals(expected): + for column in expected.column_names: + if not native_table[column].equals(expected[column]): + fail(f"{path.name} {name}: column {column} differs") + break + ntrees += 1 + return ntrees + + +def expect_refusal(bindir, path, label, must_contain): + r = run([bindir / "o2-aod-native-inspect", path, "--json"]) + if r.returncode != 1: + fail(f"{label}: expected a clean failure (exit 1), got exit {r.returncode}: {r.stderr.strip()[-300:]}") + elif must_contain not in r.stderr: + fail(f"{label}: expected '{must_contain}' in the error, got: {r.stderr.strip()}") + + +def check_refusals(bindir, corpus, work): + if (corpus / "rntuple.root").exists(): + expect_refusal(bindir, corpus / "rntuple.root", "RNTuple file", "unsupported layout") + if not (corpus / "uncompressed.root").exists() or not (corpus / "zstd.root").exists(): + print("skipping the refusal checks: they need uncompressed.root and zstd.root") + return + + # A TBranch claiming version 12: the reader must refuse it, not misparse it. + data = bytearray((corpus / "uncompressed.root").read_bytes()) + pos = data.find(b"TBranch\x00") + assert pos > 0 and data[pos + 12 : pos + 14] == b"\x00\x0d", "TBranch v13 not found where expected" + data[pos + 13] = 12 + patched = work / "patched-version.root" + patched.write_bytes(data) + expect_refusal(bindir, patched, "patched TBranch version", "unsupported version 12 of class TBranch") + + original = (corpus / "zstd.root").read_bytes() + for fraction in (0.1, 0.5, 0.9, 0.999): + truncated = work / f"truncated-{fraction}.root" + truncated.write_bytes(original[: int(len(original) * fraction)]) + expect_refusal(bindir, truncated, f"file truncated to {fraction:.1%}", "") + + garbage = work / "not-root.root" + garbage.write_bytes(b"this is not a ROOT file" * 10) + expect_refusal(bindir, garbage, "non-ROOT file", "is not a ROOT file") + + +def main(): + if len(sys.argv) not in (3, 4): + print(__doc__) + return 2 + bindir, corpus = Path(sys.argv[1]), Path(sys.argv[2]) + work = Path(sys.argv[3]) if len(sys.argv) == 4 else Path(tempfile.mkdtemp()) + work.mkdir(parents=True, exist_ok=True) + for path in sorted(corpus.glob("*.root")): + if path.name == "rntuple.root": + continue + native = check_inspect(bindir, path) + if native is not None: + n = check_dump(bindir, path, native, work) + print(f"ok {path.name}: inspect matches, {n} trees dumped and compared") + check_refusals(bindir, corpus, work) + print(f"\n{len(failures)} failure(s)") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Framework/AODNative/test/tools/make_corpus.py b/Framework/AODNative/test/tools/make_corpus.py new file mode 100644 index 0000000000000..2ddf3b1219187 --- /dev/null +++ b/Framework/AODNative/test/tools/make_corpus.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +"""Writes a corpus of AO2D-like files with uproot, for testing the native reader. + + make_corpus.py [--big] + +Every file has the layout of a DPL AO2D: one directory per timeframe (DF_), +one flat TTree per table, VLA columns as _size + . The same content is +written with each codec and uncompressed. DF numbers are chosen so that numeric +and lexicographic order differ. --big also writes a file larger than 2 GiB. + +uproot cannot write TMap (metaData, parentFiles) nor embedded baskets: those +need files written by ROOT. +""" + +import sys +from pathlib import Path + +import awkward as ak +import numpy as np +import uproot + +CODECS = { + "zstd": uproot.ZSTD(5), + "zlib": uproot.ZLIB(4), + "lzma": uproot.LZMA(6), + "lz4": uproot.LZ4(4), + "uncompressed": None, +} + +# Basket boundaries: each extend() writes one basket per branch. Odd sizes put +# boolean basket boundaries inside bitmap bytes; one basket is larger than the +# decoder's 64 KiB boolean window. +BASKET_SIZES = [3, 13, 70001, 1, 0, 517] + +TRACK_TYPES = { + "fIndexCollisions": np.int32, + "fFlags": np.uint32, + "fX": np.float32, + "fAlpha": np.float64, + "fTPCNClsFindable": np.uint8, + "fTPCNClsShared": np.int8, + "fITSClusterMap": np.uint16, + "fTRDPattern": np.int16, + "fGlobalBC": np.uint64, + "fTime": np.int64, + "fIsPVContributor": np.bool_, + "fCov": np.dtype((np.float32, (5,))), + "fHasDet": np.dtype((np.bool_, (4,))), + "fNoise": np.uint8, # random bytes: their baskets do not compress, so they are stored uncompressed + "fIndexArrayMcParticles": "var * int32", + "fWeights": "var * float64", +} + + +def track_chunk(rng, n, start): + jagged_counts = rng.integers(0, 5, n) + return { + "fIndexCollisions": np.arange(start, start + n, dtype=np.int32) // 7, + "fFlags": rng.integers(0, 2**32, n, dtype=np.uint32), + "fX": rng.normal(size=n).astype(np.float32), + "fAlpha": rng.uniform(-np.pi, np.pi, n), + "fTPCNClsFindable": rng.integers(0, 256, n, dtype=np.uint8), + "fTPCNClsShared": rng.integers(-128, 128, n, dtype=np.int8), + "fITSClusterMap": rng.integers(0, 2**16, n, dtype=np.uint16), + "fTRDPattern": rng.integers(-(2**15), 2**15, n, dtype=np.int16), + "fGlobalBC": rng.integers(0, 2**63, n, dtype=np.uint64) * 2 + 1, + "fTime": rng.integers(-(2**62), 2**62, n, dtype=np.int64), + "fIsPVContributor": rng.integers(0, 2, n).astype(np.bool_), + "fCov": rng.normal(size=(n, 5)).astype(np.float32), + "fHasDet": rng.integers(0, 2, (n, 4)).astype(np.bool_), + "fNoise": rng.integers(0, 256, n, dtype=np.uint8), + "fIndexArrayMcParticles": ak.unflatten(rng.integers(0, 10**6, jagged_counts.sum(), dtype=np.int32), jagged_counts), + "fWeights": ak.unflatten(rng.normal(size=jagged_counts.sum()), jagged_counts), + } + + +def write_tree(f, path, types, chunks): + tree = f.mktree(path, types, counter_name=lambda counted: counted + "_size") + for chunk in chunks: + tree.extend(chunk) + + +def write_file(path, compression, seed): + rng = np.random.default_rng(seed) + with uproot.recreate(path, compression=compression) as f: + for df in [2, 10, 1]: + chunks, start = [], 0 + for n in BASKET_SIZES: + chunks.append(track_chunk(rng, n, start)) + start += n + write_tree(f, f"DF_{df}/O2track_iu", TRACK_TYPES, chunks) + # A small single-basket table, and an empty one. + write_tree(f, f"DF_{df}/O2bc_001", {"fRunNumber": np.int32, "fGlobalBC": np.uint64}, + [{"fRunNumber": np.full(5, 544000 + df, np.int32), "fGlobalBC": np.arange(5, dtype=np.uint64)}]) + write_tree(f, f"DF_{df}/O2empty", {"fA": np.float32}, [{"fA": np.zeros(0, np.float32)}]) + + +def write_big(path): + # About 2.4 GB of barely compressible doubles, in baskets of 440 MB which + # span many 16 MiB compression chunks, then a small DF whose keys and + # baskets sit beyond 2 GiB, so that 64-bit seeks are needed to reach it. + rng = np.random.default_rng(7) + with uproot.recreate(path, compression=uproot.ZSTD(1)) as f: + tree = f.mktree("DF_1/O2filler", {"fValue": np.float64}) + for _ in range(6): + tree.extend({"fValue": rng.normal(size=55_000_000)}) + write_tree(f, "DF_2/O2track_iu", TRACK_TYPES, [track_chunk(rng, 1000, 0), track_chunk(rng, 10, 1000)]) + + +def write_rntuple(path): + # uproot writes an RNTuple when assigning a dict: the reader must refuse it cleanly. + with uproot.recreate(path) as f: + f["DF_1-O2track"] = {"fX": np.arange(10, dtype=np.float32)} + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + return 2 + out = Path(sys.argv[1]) + out.mkdir(parents=True, exist_ok=True) + for i, (name, compression) in enumerate(CODECS.items()): + write_file(out / f"{name}.root", compression, seed=i) + write_rntuple(out / "rntuple.root") + if "--big" in sys.argv[2:]: + write_big(out / "big.root") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Framework/AODNative/test/tools/reference_inspect.py b/Framework/AODNative/test/tools/reference_inspect.py new file mode 100644 index 0000000000000..901fc1568b08b --- /dev/null +++ b/Framework/AODNative/test/tools/reference_inspect.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# Copyright 2019-2026 CERN and copyright holders of ALICE O2. +# See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +# All rights not expressly granted are reserved. +# +# This software is distributed under the terms of the GNU General Public +# License v3 (GPL Version 3), copied verbatim in the file "COPYING". +# +# In applying this license CERN does not waive the privileges and immunities +# granted to it by virtue of its status as an Intergovernmental Organization +# or submit itself to any jurisdiction. + +"""Reference implementations, with uproot, of what the native reader produces. + + reference_inspect.py prints the JSON of o2-aod-native-inspect --json + +The functions are also used by check_corpus.py. uproot stands in for ROOT here +until a ROOT-based reference dumper is available. +""" + +import json +import re +import sys + +import awkward as ak +import numpy as np +import pyarrow as pa +import uproot + +DF_REGEX = re.compile(r"/?DF_([0-9]+)(|-.*)$") + + +def highest_cycles(keys): + result = {} + for k in keys: + if k.fName not in result or k.fCycle > result[k.fName].fCycle: + result[k.fName] = k + return list(result.values()) + + +def data_frames(f): + # DataInputDirector's order, without parentFiles (uproot cannot write TMap). + numbers = [] + for k in f._keys: + m = DF_REGEX.fullmatch(k.fName) + if m and int(m.group(1)) not in numbers: + numbers.append(int(m.group(1))) + return [f"DF_{n}" for n in sorted(numbers)] + + +def leaf_class(leaf): + return re.fullmatch(r"Model_(TLeaf.)_v\d+", type(leaf).__name__).group(1) + + +def branch_json(b): + leaf = b.member("fLeaves")[0] + count = leaf.member("fLeafCount") + offsets = b.entry_offsets + baskets = [] + for i in range(b._num_normal_baskets): + baskets.append([int(b.member("fBasketSeek")[i]), int(b.member("fBasketBytes")[i]), + int(offsets[i]), int(offsets[i + 1] - offsets[i]), False]) + for j, basket in enumerate(b.embedded_baskets): + i = b._num_normal_baskets + j + baskets.append([0, 0, int(offsets[i]), int(basket.num_entries), True]) + return { + "name": b.name, + "leaf": leaf_class(leaf), + "len": int(leaf.member("fLen")), + "isUnsigned": bool(leaf.member("fIsUnsigned")), + "countBranch": count.member("fName") if count is not None else "", + "entries": int(b.member("fEntries")), + "totBytes": int(b.member("fTotBytes")), + "zipBytes": int(b.member("fZipBytes")), + "baskets": baskets, + } + + +def inspect(path): + f = uproot.open(path) + rf = f.file + result = { + "header": { + "version": rf._fVersion, "begin": rf._fBEGIN, "end": rf._fEND, "seekFree": rf._fSeekFree, + "nbytesFree": rf._fNbytesFree, "nbytesName": rf._fNbytesName, "units": rf._fUnits, + "compress": rf._fCompress, "seekInfo": rf._fSeekInfo, "nbytesInfo": rf._fNbytesInfo, + }, + "keys": [{"name": k.fName, "class": k.fClassName, "cycle": k.fCycle, "seek": k.fSeekKey, + "nbytes": k.fNbytes, "objlen": k.fObjlen} for k in f._keys], + "dataFrames": [], + } + for df in data_frames(f): + trees = [] + for k in highest_cycles(f[df]._keys): + if k.fClassName != "TTree": + continue + t = f[f"{df}/{k.fName}"] + trees.append({"name": t.name, "entries": int(t.member("fEntries")), "totBytes": int(t.member("fTotBytes")), + "zipBytes": int(t.member("fZipBytes")), "branches": [branch_json(b) for b in t.branches]}) + result["dataFrames"].append({"name": df, "trees": trees}) + return result + + +ARROW_TYPES = { + ("TLeafO", False): pa.bool_(), ("TLeafB", False): pa.int8(), ("TLeafB", True): pa.uint8(), + ("TLeafS", False): pa.int16(), ("TLeafS", True): pa.uint16(), ("TLeafI", False): pa.int32(), + ("TLeafI", True): pa.uint32(), ("TLeafL", False): pa.int64(), ("TLeafL", True): pa.uint64(), + ("TLeafF", False): pa.float32(), ("TLeafD", False): pa.float64(), +} + + +def arrow_table(tree): + """The dataset table the TTree plugin produces for @a tree.""" + arrays, fields = [], [] + for b in tree.branches: + if b.name.endswith("_size"): + continue + leaf = b.member("fLeaves")[0] + t = ARROW_TYPES[(leaf_class(leaf), bool(leaf.member("fIsUnsigned")))] + n = int(leaf.member("fLen")) + data = b.array(library="ak") + if leaf.member("fLeafCount") is not None: + counts = np.asarray(ak.num(data), dtype=np.int64) + offsets = np.zeros(len(counts) + 1, np.int32) + np.cumsum(counts, out=offsets[1:]) + values = pa.array(np.asarray(ak.flatten(data)), type=t) + arr = pa.ListArray.from_arrays(pa.array(offsets, pa.int32()), values) + t = pa.list_(t) + elif n > 1: + values = pa.array(np.asarray(data).reshape(-1), type=t) + arr = pa.FixedSizeListArray.from_arrays(values, n) + t = pa.list_(t, n) + else: + arr = pa.array(np.asarray(data), type=t) + arrays.append(arr) + fields.append(pa.field(b.name, t)) + return pa.Table.from_arrays(arrays, schema=pa.schema(fields)) + + +def main(): + if len(sys.argv) != 2: + print(__doc__) + return 2 + json.dump(inspect(sys.argv[1]), sys.stdout, indent=2) + print() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Framework/AODNative/tools/dump.cxx b/Framework/AODNative/tools/dump.cxx new file mode 100644 index 0000000000000..f944a00e803ab --- /dev/null +++ b/Framework/AODNative/tools/dump.cxx @@ -0,0 +1,74 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// o2-aod-native-dump [--threads N] [--pread] +// +// Decodes one tree with the ROOT-free reader into heap memory and writes it +// as an Arrow IPC stream, with the dataset schema of the TTree plugin. + +#include "AODNative/ArrowTable.h" +#include "AODNative/Container.h" +#include "AODNative/Errors.h" +#include +#include +#include +#include +#include + +using namespace o2::aod::native; + +int main(int argc, char** argv) +{ + std::vector args; + Options options; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--threads") == 0 && i + 1 < argc) { + options.threads = std::stoi(argv[++i]); + } else if (std::strcmp(argv[i], "--pread") == 0) { + options.localIO = Options::LocalIO::PRead; + } else { + args.push_back(argv[i]); + } + } + if (args.size() != 3 || args[1].find('/') == std::string::npos) { + std::fprintf(stderr, "usage: %s [--threads N] [--pread]\n", argv[0]); + return 2; + } + auto df = args[1].substr(0, args[1].find('/')); + auto treeName = args[1].substr(args[1].find('/') + 1); + try { + RootFile file{ByteSource::open(args[0], options)}; + std::optional tree; + for (auto& key : file.keysOf(df)) { + if (key.name == treeName) { + tree = file.readTree(key); + } + } + if (!tree) { + throw FormatError("no tree " + treeName + " in " + df); + } + auto batch = readTree(file.source(), *tree, options).ValueOrDie(); + auto out = arrow::io::FileOutputStream::Open(args[2]).ValueOrDie(); + auto writer = arrow::ipc::MakeStreamWriter(out, batch->schema()).ValueOrDie(); + auto status = writer->WriteRecordBatch(*batch); + if (status.ok()) { + status = writer->Close(); + } + if (!status.ok()) { + std::fprintf(stderr, "%s: %s\n", argv[0], status.ToString().c_str()); + return 1; + } + } catch (FormatError const& e) { + std::fprintf(stderr, "%s: %s\n", argv[0], e.what()); + return 1; + } + return 0; +} diff --git a/Framework/AODNative/tools/inspect.cxx b/Framework/AODNative/tools/inspect.cxx new file mode 100644 index 0000000000000..e6d0e7119c022 --- /dev/null +++ b/Framework/AODNative/tools/inspect.cxx @@ -0,0 +1,167 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// o2-aod-native-inspect [--json] +// +// Prints the file header, every top-level key and, per DF and tree, the +// branch layout and basket locations, using only the ROOT-free reader. + +#include "AODNative/Container.h" +#include "AODNative/Errors.h" +#include +#include +#include +#include + +using namespace o2::aod::native; + +namespace +{ +std::string quote(std::string const& s) +{ + std::string out = "\""; + for (unsigned char c : s) { + if (c == '"' || c == '\\') { + out += '\\'; + out += (char)c; + } else if (c < 0x20) { + out += fmt::format("\\u{:04x}", c); + } else { + out += (char)c; + } + } + return out + "\""; +} + +char const* boolean(bool b) +{ + return b ? "true" : "false"; +} + +void printJSON(RootFile& file) +{ + auto& h = file.header(); + fmt::print("{{\n"); + fmt::print(R"( "header": {{"version": {}, "begin": {}, "end": {}, "seekFree": {}, "nbytesFree": {}, "nfree": {}, )" + R"("nbytesName": {}, "units": {}, "compress": {}, "seekInfo": {}, "nbytesInfo": {}}},)" + "\n", + h.version, h.begin, h.end, h.seekFree, h.nbytesFree, h.nfree, h.nbytesName, (int)h.units, h.compress, h.seekInfo, h.nbytesInfo); + fmt::print(R"( "keys": [)"); + char const* sep = "\n"; + for (auto& k : file.topKeys()) { + fmt::print(R"({} {{"name": {}, "class": {}, "cycle": {}, "seek": {}, "nbytes": {}, "objlen": {}}})", + sep, quote(k.name), quote(k.className), k.cycle, k.seekKey, k.nbytes, k.objlen); + sep = ",\n"; + } + fmt::print("\n ],\n"); + fmt::print(R"( "dataFrames": [)"); + char const* dfSep = "\n"; + for (auto& df : file.dataFrames()) { + fmt::print(R"({} {{"name": {}, "trees": [)", dfSep, quote(df)); + dfSep = ",\n"; + char const* treeSep = "\n"; + for (auto& key : file.keysOf(df)) { + if (key.className != "TTree") { + continue; + } + auto tree = file.readTree(key); + fmt::print(R"({} {{"name": {}, "entries": {}, "totBytes": {}, "zipBytes": {}, "branches": [)", + treeSep, quote(tree.name), tree.entries, tree.totBytes, tree.zipBytes); + treeSep = ",\n"; + char const* branchSep = "\n"; + for (auto& b : tree.branches) { + fmt::print(R"({} {{"name": {}, "leaf": "TLeaf{}", "len": {}, "isUnsigned": {}, "countBranch": {}, )" + R"("entries": {}, "totBytes": {}, "zipBytes": {}, "baskets": [)", + branchSep, quote(b.name), b.leafClass, b.fixedLen, boolean(b.isUnsigned), quote(b.countBranch), + b.entries, b.totBytes, b.zipBytes); + branchSep = ",\n"; + char const* basketSep = ""; + for (auto& basket : b.baskets) { + fmt::print("{}[{}, {}, {}, {}, {}]", basketSep, basket.seek, basket.nbytes, basket.firstEntry, basket.nEntries, + boolean(basket.embedded)); + basketSep = ", "; + } + fmt::print("]}}"); + } + fmt::print("\n ]}}"); + } + fmt::print("\n ]}}"); + } + fmt::print("\n ]\n}}\n"); +} + +void printText(RootFile& file) +{ + auto& h = file.header(); + fmt::print("ROOT file version {}, begin {}, end {}, compression {}, {} seeks\n", h.version, h.begin, h.end, h.compress, + h.version >= 1000000 ? "64-bit" : "32-bit"); + fmt::print("\n{:<32} {:<16} {:>5} {:>14} {:>10} {:>10}\n", "key", "class", "cycle", "seek", "nbytes", "objlen"); + for (auto& k : file.topKeys()) { + fmt::print("{:<32} {:<16} {:>5} {:>14} {:>10} {:>10}\n", k.name, k.className, k.cycle, k.seekKey, k.nbytes, k.objlen); + } + for (auto& df : file.dataFrames()) { + fmt::print("\n{}\n", df); + for (auto& key : file.keysOf(df)) { + if (key.className != "TTree") { + fmt::print(" {} ({}, skipped)\n", key.name, key.className); + continue; + } + auto tree = file.readTree(key); + fmt::print(" {}: {} entries, {} bytes, {} compressed\n", tree.name, tree.entries, tree.totBytes, tree.zipBytes); + for (auto& b : tree.branches) { + fmt::print(" {:<28} TLeaf{}{} len {}{}{}, {} baskets:", b.name, b.leafClass, b.isUnsigned ? " unsigned" : "", + b.fixedLen, b.countBranch.empty() ? "" : ", count ", b.countBranch, b.baskets.size()); + for (auto& basket : b.baskets) { + if (basket.embedded) { + fmt::print(" [embedded {}+{}]", basket.firstEntry, basket.nEntries); + } else { + fmt::print(" [{}:{} {}+{}]", basket.seek, basket.nbytes, basket.firstEntry, basket.nEntries); + } + } + fmt::print("\n"); + } + } + } +} +} // namespace + +int main(int argc, char** argv) +{ + std::string path; + bool json = false; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--json") == 0) { + json = true; + } else if (path.empty()) { + path = argv[i]; + } else { + path.clear(); + break; + } + } + if (path.empty()) { + fmt::print(stderr, "usage: {} [--json]\n", argv[0]); + return 2; + } + try { + RootFile file{ByteSource::open(path, Options{})}; + if (json) { + printJSON(file); + } else { + printText(file); + } + } catch (FormatError const& e) { + std::fflush(stdout); + fmt::print(stderr, "{}: {}\n", argv[0], e.what()); + return 1; + } + return 0; +} diff --git a/Framework/AnalysisSupport/CMakeLists.txt b/Framework/AnalysisSupport/CMakeLists.txt index 956c4a44c5684..f2cdaffe4fd22 100644 --- a/Framework/AnalysisSupport/CMakeLists.txt +++ b/Framework/AnalysisSupport/CMakeLists.txt @@ -25,11 +25,27 @@ o2_add_library(FrameworkOnDemandTablesSupport o2_add_library(FrameworkAnalysisSupport SOURCES src/Plugin.cxx src/DataInputDirector.cxx + src/AODInputFile.cxx + src/NativeAOD.cxx src/TableTreeHelpers.cxx src/AODJAlienReaderHelpers.cxx src/AODWriterHelpers.cxx PRIVATE_INCLUDE_DIRECTORIES ${CMAKE_CURRENT_LIST_DIR}/src - PUBLIC_LINK_LIBRARIES O2::Framework ${EXTRA_TARGETS} ROOT::TreePlayer) + PUBLIC_LINK_LIBRARIES O2::Framework O2::AODNative ${EXTRA_TARGETS} ROOT::TreePlayer) + +# Reads AO2D trees with the ROOT-free reader. Its sources must not use ROOT: +# the check below fails the configuration on any direct ROOT include. +o2_add_library(FrameworkAnalysisNativeAODSupport + SOURCES src/NativeAODPlugin.cxx + PRIVATE_INCLUDE_DIRECTORIES ${CMAKE_CURRENT_LIST_DIR}/src + PUBLIC_LINK_LIBRARIES O2::FrameworkAnalysisSupport O2::AODNative) + +foreach(source src/NativeAODPlugin.cxx src/NativeAOD.cxx src/NativeAOD.h) + file(STRINGS ${CMAKE_CURRENT_LIST_DIR}/${source} rootIncludes REGEX "^#include [<\"]T[A-Z][A-Za-z]*\\.h") + if(rootIncludes) + message(FATAL_ERROR "${source} must not include ROOT headers: ${rootIncludes}") + endif() +endforeach() o2_add_library(FrameworkAnalysisRNTupleSupport SOURCES src/RNTuplePlugin.cxx @@ -56,6 +72,12 @@ set_property(TARGET o2-test-framework-analysis-support PROPERTY RUNTIME_OUTPUT_D add_test(NAME framework:analysis-support COMMAND o2-test-framework-analysis-support) +# Compares, byte for byte, what the native reader and the TTree plugin +# send for every tree of the AO2Ds given on the command line. +add_executable(o2-test-framework-aod-native-vs-root test/compareNativeToRoot.cxx) +target_link_libraries(o2-test-framework-aod-native-vs-root PRIVATE O2::FrameworkAnalysisSupport) +set_property(TARGET o2-test-framework-aod-native-vs-root PROPERTY RUNTIME_OUTPUT_DIRECTORY ${outdir}) + o2_add_test(TableToTree NAME benchmark_TableToTree SOURCES test/benchmark_TableToTree.cxx COMPONENT_NAME Framework diff --git a/Framework/AnalysisSupport/src/AODInputFile.cxx b/Framework/AnalysisSupport/src/AODInputFile.cxx new file mode 100644 index 0000000000000..e33c2c73f843a --- /dev/null +++ b/Framework/AnalysisSupport/src/AODInputFile.cxx @@ -0,0 +1,169 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "AODInputFile.h" +#include "NativeAOD.h" +#include "AODNative/Errors.h" +#include "Framework/Logger.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if __has_include() +#include +#endif + +namespace o2::framework +{ + +std::shared_ptr RootAODInputFile::open(std::string const& name, RootObjectReadingFactory& factory, TFile* external) +{ + TFile* file = external ? external : TFile::Open(name.c_str()); + if (!file || file->IsZombie()) { + if (file && !external) { + delete file; + } + return nullptr; + } + auto filesystem = std::make_shared(file, 50 * 1024 * 1024, factory, external == nullptr); + return std::shared_ptr(new RootAODInputFile(file, std::move(filesystem))); +} + +RootAODInputFile::RootAODInputFile(TFile* file, std::shared_ptr filesystem) + : mFile(file), mFilesystem(std::move(filesystem)) +{ +} + +std::string RootAODInputFile::name() const { return mFile->GetName(); } +uint64_t RootAODInputFile::size() const { return mFile->GetSize(); } +uint64_t RootAODInputFile::bytesRead() const { return mFile->GetBytesRead(); } +uint64_t RootAODInputFile::readCalls() const { return mFile->GetReadCalls(); } + +std::vector RootAODInputFile::topLevelKeys() +{ + std::vector keys; + for (auto* obj : *mFile->GetListOfKeys()) { + auto* key = (TKey*)obj; + keys.push_back({key->GetName(), key->GetClassName()}); + } + return keys; +} + +std::vector RootAODInputFile::keysIn(std::string const& dir) +{ + std::vector names; + auto* d = (TDirectory*)mFile->GetObjectChecked(dir.c_str(), TClass::GetClass("TDirectory")); + if (!d) { + return names; + } + for (auto* key : *d->GetListOfKeys()) { + names.emplace_back(key->GetName()); + } + return names; +} + +std::map RootAODInputFile::readStringMap(char const* key) +{ + std::map result; + std::unique_ptr map{(TMap*)mFile->Get(key)}; + if (!map) { + return result; + } + map->SetOwnerKeyValue(true, true); + std::unique_ptr it{map->MakeIterator()}; + while (auto* key = it->Next()) { + auto* value = (TObjString*)map->GetValue(key); + result[key->GetName()] = value ? value->String().Data() : ""; + } + return result; +} + +std::map RootAODInputFile::parentFiles() { return readStringMap("parentFiles"); } +std::map RootAODInputFile::metaData() { return readStringMap("metaData"); } + +std::string RootAODInputFile::monitoringDetails() const +{ +#if __has_include() + if (auto* alienFile = dynamic_cast(mFile)) { + return fmt::format(",se={},open_time={:.1f}", alienFile->GetSE(), alienFile->GetElapsed()); + } +#endif + return ""; +} + +std::vector gridReplicas(std::string const& lfn) +{ + TUrl url(lfn.c_str()); + if (url.GetAnchor() && url.GetAnchor()[0] != '\0') { + return {}; + } + if (!gGrid) { + TGrid::Connect("alien://"); + } + if (!gGrid) { + return {}; + } + // As TJAlienFile::Open does for reading. + std::unique_ptr result(gGrid->Command(fmt::format("access read {}", url.GetFile()).c_str())); + if (!result) { + return {}; + } + std::vector replicas; + for (auto* object : *result) { + auto* map = dynamic_cast(object); + if (!map) { + continue; + } + auto value = [map](char const* key) -> std::string { + auto* v = map->GetValue(key); + return v ? v->GetName() : ""; + }; + auto pfn = value("url"); + auto envelope = value("envelope"); + if (pfn.empty() || envelope.empty() || pfn.find('#') != std::string::npos) { + continue; + } + replicas.push_back({pfn + "?&authz=" + envelope, value("se")}); + } + return replicas; +} + +std::shared_ptr openNativeAOD(std::string const& filename, RootObjectReadingFactory& factory, o2::aod::native::Options const& options) +{ + if (!filename.starts_with("alien://")) { + return NativeAODInputFile::open(filename, factory, options); + } + auto start = std::chrono::steady_clock::now(); + auto replicas = gridReplicas(filename); + if (replicas.empty()) { + throw o2::aod::native::IoError(filename, "no replica the native reader can read"); + } + std::string errors; + for (auto const& replica : replicas) { + try { + auto file = NativeAODInputFile::open(replica.url, factory, options, filename); + file->setGridInfo(replica.se, std::chrono::duration(std::chrono::steady_clock::now() - start).count()); + return file; + } catch (o2::aod::native::IoError const& e) { + LOGP(info, "Could not open replica of \"{}\" on {}: {}", filename, replica.se, e.what()); + errors += fmt::format(" {}: {};", replica.se, e.what()); + } + } + throw o2::aod::native::IoError(filename, "no replica could be opened:" + errors); +} + +} // namespace o2::framework diff --git a/Framework/AnalysisSupport/src/AODInputFile.h b/Framework/AnalysisSupport/src/AODInputFile.h new file mode 100644 index 0000000000000..f62bd118a2310 --- /dev/null +++ b/Framework/AnalysisSupport/src/AODInputFile.h @@ -0,0 +1,102 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_AODINPUTFILE_H_ +#define O2_FRAMEWORK_AODINPUTFILE_H_ + +#include "Framework/RootArrowFilesystem.h" +#include "AODNative/ByteSource.h" +#include +#include +#include +#include +#include + +class TFile; + +namespace o2::framework +{ + +/// An AO2D file opened for reading, as DataInputDirector needs it: the list of +/// top-level keys, the file-level maps and an arrow filesystem to read the +/// tables from. This hides which library actually reads the file. +struct AODInputFile { + struct Key { + std::string name; + std::string className; + }; + + virtual ~AODInputFile() = default; + virtual std::string name() const = 0; + virtual uint64_t size() const = 0; + virtual uint64_t bytesRead() const = 0; + virtual uint64_t readCalls() const = 0; + /// Top-level keys, in the order the file lists them, all cycles. + virtual std::vector topLevelKeys() = 0; + /// Names of the keys inside the top-level directory @a dir, empty if there is none. + virtual std::vector keysIn(std::string const& dir) = 0; + /// DF_ to parent file path. Empty if the file has no parentFiles. + virtual std::map parentFiles() = 0; + virtual std::map metaData() = 0; + virtual std::shared_ptr filesystem() = 0; + /// Extra key=value pairs for the file monitoring metrics, starting with a comma. + virtual std::string monitoringDetails() const { return ""; } + /// The DF_ names in the order they will be read, for readers which read ahead. + virtual void setReadOrder(std::vector const& dfs) {} +}; + +/// A replica of an alien:// file: a root:// URL carrying its access envelope, +/// and the storage element it is on. +struct GridReplica { + std::string url; + std::string se; +}; + +/// Asks the AliEn catalogue (through gGrid, connecting if needed) for the +/// replicas to read @a lfn from, in the order it proposes. Empty if there are +/// none, or for a file inside an archive (alien://...#member), which only ROOT reads. +std::vector gridReplicas(std::string const& lfn); + +/// Opens @a filename with the native reader. A grid file is read from the +/// first of its replicas which can be opened, as TJAlienFile does; format +/// problems are not retried on other replicas. Throws o2::aod::native::FormatError. +std::shared_ptr openNativeAOD(std::string const& filename, RootObjectReadingFactory& factory, o2::aod::native::Options const& options); + +/// An AO2D read with ROOT. +class RootAODInputFile : public AODInputFile +{ + public: + /// Opens @a name with TFile::Open, or uses @a external (not owned) when given. + /// Returns nullptr if the file cannot be opened. + static std::shared_ptr open(std::string const& name, RootObjectReadingFactory& factory, TFile* external = nullptr); + + std::string name() const override; + uint64_t size() const override; + uint64_t bytesRead() const override; + uint64_t readCalls() const override; + std::vector topLevelKeys() override; + std::vector keysIn(std::string const& dir) override; + std::map parentFiles() override; + std::map metaData() override; + std::shared_ptr filesystem() override { return mFilesystem; } + std::string monitoringDetails() const override; + + private: + RootAODInputFile(TFile* file, std::shared_ptr filesystem); + std::map readStringMap(char const* key); + + TFile* mFile; + std::shared_ptr mFilesystem; +}; + +} // namespace o2::framework + +#endif // O2_FRAMEWORK_AODINPUTFILE_H_ diff --git a/Framework/AnalysisSupport/src/AODJAlienReaderHelpers.cxx b/Framework/AnalysisSupport/src/AODJAlienReaderHelpers.cxx index 69534efd2c419..86b7779855bb0 100644 --- a/Framework/AnalysisSupport/src/AODJAlienReaderHelpers.cxx +++ b/Framework/AnalysisSupport/src/AODJAlienReaderHelpers.cxx @@ -39,6 +39,7 @@ #include "Framework/StringContext.h" #include "Framework/ConfigContext.h" #include "DataInputDirector.h" +#include "NativeAOD.h" #include "Framework/SourceInfoHeader.h" #include "Framework/ChannelInfo.h" #include "Framework/Logger.h" @@ -198,7 +199,19 @@ AlgorithmSpec AODJAlienReaderHelpers::rootFileReaderCallback(ConfigContext const auto maxRate = options.get("aod-max-io-rate"); // create a DataInputDirector - auto didir = std::make_shared(std::vector{filename}, DataInputDirectorContext{&monitoring, parentAccessLevel, parentFileReplacement, originLevelMapping}); + DataInputDirectorContext context{&monitoring, parentAccessLevel, parentFileReplacement, originLevelMapping}; + context.readerBackend = options.get("aod-reader-backend"); + if (context.readerBackend != "root" && context.readerBackend != "native" && context.readerBackend != "auto") { + LOGP(fatal, "Invalid --aod-reader-backend \"{}\": use root, native or auto.", context.readerBackend); + } + context.nativeOptions.threads = std::max(1, options.get("aod-reader-threads")); + context.nativeOptions.prefetchDepth = std::max(0, options.get("aod-reader-prefetch")); + auto localIO = options.get("aod-reader-local-io"); + if (localIO != "mmap" && localIO != "pread") { + LOGP(fatal, "Invalid --aod-reader-local-io \"{}\": use mmap or pread.", localIO); + } + context.nativeOptions.localIO = localIO == "pread" ? o2::aod::native::Options::LocalIO::PRead : o2::aod::native::Options::LocalIO::MMap; + auto didir = std::make_shared(std::vector{filename}, std::move(context)); if (options.isSet("aod-reader-json")) { auto jsonFile = options.get("aod-reader-json"); if (!didir->readJson(jsonFile)) { @@ -340,13 +353,22 @@ AlgorithmSpec AODJAlienReaderHelpers::rootFileReaderCallback(ConfigContext const // Origin file name for derived output map auto o2 = Output(TFFileNameHeader); auto fileAndFolder = didir->getFileFolder(dh, fcnt, ntf); - auto rootFS = std::dynamic_pointer_cast(fileAndFolder.filesystem()); - auto* f = dynamic_cast(rootFS->GetFile()); - std::string currentFilename(f->GetFile()->GetName()); - if (strcmp(f->GetEndpointUrl()->GetProtocol(), "file") == 0 && f->GetEndpointUrl()->GetFile()[0] != '/') { - // This is not an absolute local path. Make it absolute. - static std::string pwd = gSystem->pwd() + std::string("/"); - currentFilename = pwd + std::string(f->GetName()); + static std::string pwd = gSystem->pwd() + std::string("/"); + std::string currentFilename; + if (auto nativeFS = std::dynamic_pointer_cast(fileAndFolder.filesystem())) { + currentFilename = nativeFS->name(); + // Only a relative local path needs to be made absolute; URLs are kept as they are. + if (!currentFilename.starts_with("/") && currentFilename.find("://") == std::string::npos) { + currentFilename = pwd + currentFilename; + } + } else { + auto rootFS = std::dynamic_pointer_cast(fileAndFolder.filesystem()); + auto* f = dynamic_cast(rootFS->GetFile()); + currentFilename = f->GetFile()->GetName(); + if (strcmp(f->GetEndpointUrl()->GetProtocol(), "file") == 0 && f->GetEndpointUrl()->GetFile()[0] != '/') { + // This is not an absolute local path. Make it absolute. + currentFilename = pwd + std::string(f->GetName()); + } } outputs.make(o2) = currentFilename; } @@ -374,7 +396,7 @@ AlgorithmSpec AODJAlienReaderHelpers::rootFileReaderCallback(ConfigContext const } } catch (AODReadError const& e) { if (!skipInvalidReads) { - throw; + throw AODReadError(e.getKind(), describeException(e)); } switch (e.getKind()) { case AODReadError::Kind::InvalidTimeframe: diff --git a/Framework/AnalysisSupport/src/DataInputDirector.cxx b/Framework/AnalysisSupport/src/DataInputDirector.cxx index 22de54f91c9db..48bc6682408de 100644 --- a/Framework/AnalysisSupport/src/DataInputDirector.cxx +++ b/Framework/AnalysisSupport/src/DataInputDirector.cxx @@ -26,9 +26,10 @@ #include "rapidjson/prettywriter.h" #include "rapidjson/filereadstream.h" +#include "NativeAOD.h" +#include "AODNative/Errors.h" + #include "TGrid.h" -#include "TObjString.h" -#include "TMap.h" #include "TFile.h" #include @@ -36,12 +37,7 @@ #include #include #include - -#if __has_include() -#include - #include -#endif #include O2_DECLARE_DYNAMIC_LOG(reader_memory_dump); @@ -67,6 +63,11 @@ DataInputDescriptor::DataInputDescriptor(bool alienSupport, int level, DataInput "O2Framework:RNTupleObjectReadingCapability", "O2Framework:TTreeObjectReadingCapability", }; + // The native reader comes first, so that it wins for files it opened. For + // files opened with ROOT it provides no handle, and the others are used. + if (mContext.readerBackend != "root") { + capabilitiesSpecs.insert(capabilitiesSpecs.begin(), "O2FrameworkAnalysisNativeAODSupport:NativeAODObjectReadingCapability"); + } std::vector plugins; for (auto spec : capabilitiesSpecs) { @@ -141,39 +142,47 @@ bool DataInputDescriptor::setFile(int counter, int wantedParentLevel, std::strin } // open file - auto rootFS = std::dynamic_pointer_cast(mCurrentFilesystem); - if (rootFS.get()) { - if (rootFS->GetFile()->GetName() == filename) { + if (mCurrentFile) { + if (mCurrentFile->name() == filename) { return true; } closeInputFile(); } - TFile* tfile = nullptr; - bool externalFile = false; + TFile* externalFile = nullptr; for (auto& [name, f] : mContext.openFiles) { if (name == filename) { - tfile = f; - externalFile = true; + externalFile = f; break; } } - if (tfile == nullptr) { - tfile = TFile::Open(filename.c_str()); - } - if (!tfile || tfile->IsZombie()) { - if (tfile && !externalFile) { - delete tfile; + // In-memory files given by tests can only be read with ROOT. + auto backend = externalFile ? std::string("root") : mContext.readerBackend; + if (backend != "root") { + try { + mCurrentFile = openNativeAOD(filename, mFactory, mContext.nativeOptions); + } catch (o2::aod::native::FormatError const& e) { + if (backend == "native") { + throw AODReadError(AODReadError::Kind::FileOpen, fmt::format("Couldn't open file \"{}\" with the native reader: {}", filename, e.what())); + } + LOGP(info, "Reading \"{}\" with ROOT, the native reader cannot: {}", filename, e.what()); + if (mContext.monitoring) { + mContext.monitoring->send(o2::monitoring::Metric{filename, "aod-native-fallback"}.addTag(o2::monitoring::tags::Key::Subsystem, o2::monitoring::tags::Value::DPL)); + } } + } + if (!mCurrentFile) { + mCurrentFile = RootAODInputFile::open(filename, mFactory, externalFile); + } + if (!mCurrentFile) { throw AODReadError(AODReadError::Kind::FileOpen, fmt::format("Couldn't open file \"{}\"!", filename)); } - mCurrentFilesystem = std::make_shared(tfile, 50 * 1024 * 1024, mFactory, !externalFile); - rootFS = std::dynamic_pointer_cast(mCurrentFilesystem); + mCurrentFilesystem = mCurrentFile->filesystem(); printFileOpening(); // get the parent file map if exists - mParentFileMap = (TMap*)rootFS->GetFile()->Get("parentFiles"); // folder name (DF_XXX) --> parent file (absolute path) - if (mParentFileMap && !mContext.parentFileReplacement.empty()) { + mParentFileMap = mCurrentFile->parentFiles(); + if (!mParentFileMap.empty() && !mContext.parentFileReplacement.empty()) { auto pos = mContext.parentFileReplacement.find(';'); if (pos == std::string::npos) { throw std::runtime_error(fmt::format("Invalid syntax in aod-parent-base-path-replacement: \"{}\"", mContext.parentFileReplacement.c_str())); @@ -181,28 +190,26 @@ bool DataInputDescriptor::setFile(int counter, int wantedParentLevel, std::strin auto from = mContext.parentFileReplacement.substr(0, pos); auto to = mContext.parentFileReplacement.substr(pos + 1); - auto it = mParentFileMap->MakeIterator(); - while (auto obj = it->Next()) { - auto objString = (TObjString*)mParentFileMap->GetValue(obj); - objString->String().ReplaceAll(from.c_str(), to.c_str()); + for (auto& [df, parent] : mParentFileMap) { + // Same as TString::ReplaceAll + for (size_t at = 0; !from.empty() && (at = parent.find(from, at)) != std::string::npos; at += to.size()) { + parent.replace(at, from.size(), to); + } } - delete it; } // get the directory names if (mfilenames[counter].numberOfTimeFrames <= 0) { const std::regex TFRegex = std::regex("/?DF_([0-9]+)(|-.*)$"); - TList* keyList = rootFS->GetFile()->GetListOfKeys(); - std::vector finalList; // extract TF numbers and sort accordingly // We use an extra seen set to make sure we preserve the order in which // we instert things in the final list and to make sure we do not have duplicates. // Multiple folder numbers can happen if we use a flat structure /DF_- std::unordered_set seen; - for (auto key : *keyList) { + for (auto& key : mCurrentFile->topLevelKeys()) { std::smatch matchResult; - std::string keyName = ((TObjString*)key)->GetString().Data(); + std::string const& keyName = key.name; bool match = std::regex_match(keyName, matchResult, TFRegex); if (match) { auto folderNumber = std::stoul(matchResult[1].str()); @@ -213,13 +220,16 @@ bool DataInputDescriptor::setFile(int counter, int wantedParentLevel, std::strin } } - if (mParentFileMap != nullptr) { + if (!mParentFileMap.empty()) { // If we have a parent map, we should not process in DF alphabetical order but according to parent file to avoid swapping between files + auto parentOf = [this](long df) -> std::string const& { + static const std::string none; + auto it = mParentFileMap.find("DF_" + std::to_string(df)); + return it == mParentFileMap.end() ? none : it->second; + }; std::ranges::sort(mfilenames[counter].listOfTimeFrameNumbers, - [this](long const& l1, long const& l2) -> bool { - auto p1 = (TObjString*)this->mParentFileMap->GetValue(("DF_" + std::to_string(l1)).c_str()); - auto p2 = (TObjString*)this->mParentFileMap->GetValue(("DF_" + std::to_string(l2)).c_str()); - return p1->GetString().CompareTo(p2->GetString()) < 0; + [&parentOf](long const& l1, long const& l2) -> bool { + return parentOf(l1) < parentOf(l2); }); } else { std::sort(mfilenames[counter].listOfTimeFrameNumbers.begin(), mfilenames[counter].listOfTimeFrameNumbers.end()); @@ -228,6 +238,11 @@ bool DataInputDescriptor::setFile(int counter, int wantedParentLevel, std::strin mfilenames[counter].alreadyRead.resize(mfilenames[counter].alreadyRead.size() + mfilenames[counter].listOfTimeFrameNumbers.size(), false); mfilenames[counter].numberOfTimeFrames = mfilenames[counter].listOfTimeFrameNumbers.size(); } + std::vector readOrder; + for (auto number : mfilenames[counter].listOfTimeFrameNumbers) { + readOrder.push_back(fmt::format("DF_{}", number)); + } + mCurrentFile->setReadOrder(readOrder); mCurrentFileID = counter; mCurrentFileStartedAt = uv_hrtime(); @@ -353,23 +368,22 @@ void DataInputDescriptor::releaseParentFile() std::shared_ptr DataInputDescriptor::getParentFile(int counter, int numTF, std::string treename, int wantedParentLevel, std::string_view wantedOrigin) { - if (!mParentFileMap) { + if (mParentFileMap.empty()) { // This file has no parent map return nullptr; } auto folderName = fmt::format("DF_{}", mfilenames[counter].listOfTimeFrameNumbers[numTF]); - auto parentFileName = (TObjString*)mParentFileMap->GetValue(folderName.c_str()); + auto parentEntry = mParentFileMap.find(folderName); // The current DF is not found in the parent map (this should not happen and is a fatal error) - auto rootFS = std::dynamic_pointer_cast(mCurrentFilesystem); - if (!parentFileName) { - throw AODReadError(AODReadError::Kind::InvalidTimeframe, fmt::format(R"(parent file map exists but does not contain the current DF "{}" in file "{}")", folderName.c_str(), rootFS->GetFile()->GetName())); + if (parentEntry == mParentFileMap.end()) { + throw AODReadError(AODReadError::Kind::InvalidTimeframe, fmt::format(R"(parent file map exists but does not contain the current DF "{}" in file "{}")", folderName.c_str(), mCurrentFile->name())); } + auto const& parentFileName = parentEntry->second; if (mParentFile) { // Is this still the corresponding to the correct file? - auto parentRootFS = std::dynamic_pointer_cast(mParentFile->mCurrentFilesystem); - if (parentFileName->GetString().CompareTo(parentRootFS->GetFile()->GetName()) == 0) { + if (parentFileName == mParentFile->mCurrentFile->name()) { return mParentFile; } else { releaseParentFile(); @@ -378,12 +392,12 @@ std::shared_ptr DataInputDescriptor::getParentFile(int coun if (mLevel == mContext.allowedParentLevel) { throw std::runtime_error(fmt::format(R"(while looking for tree "{}", the parent file was requested but we are already at level {} of maximal allowed level {} for DF "{}" in file "{}")", treename.c_str(), mLevel, mContext.allowedParentLevel, folderName.c_str(), - rootFS->GetFile()->GetName())); + mCurrentFile->name())); } - LOGP(info, "Opening parent file {} for DF {}", parentFileName->GetString().Data(), folderName.c_str()); + LOGP(info, "Opening parent file {} for DF {}", parentFileName, folderName.c_str()); mParentFile = std::make_shared(mAlienSupport, mLevel + 1, mContext); - mParentFile->mdefaultFilenamesPtr.emplace_back(makeFileNameHolder(parentFileName->GetString().Data())); + mParentFile->mdefaultFilenamesPtr.emplace_back(makeFileNameHolder(parentFileName)); mParentFile->fillInputfiles(); try { mParentFile->setFile(0, wantedParentLevel, wantedOrigin); @@ -392,7 +406,7 @@ std::shared_ptr DataInputDescriptor::getParentFile(int coun } } catch (...) { mParentFile.reset(); - std::throw_with_nested(AODReadError(AODReadError::Kind::InvalidTimeframe, fmt::format("Unable to open parent file \"{}\" for {}", parentFileName->GetString().Data(), folderName))); + std::throw_with_nested(AODReadError(AODReadError::Kind::InvalidTimeframe, fmt::format("Unable to open parent file \"{}\" for {}", parentFileName, folderName))); } return mParentFile; } @@ -410,15 +424,8 @@ int DataInputDescriptor::getReadTimeFramesInFile(int counter) void DataInputDescriptor::printFileOpening() { - auto rootFS = std::dynamic_pointer_cast(mCurrentFilesystem); - auto f = dynamic_cast(rootFS->GetFile()); - std::string monitoringInfo(fmt::format("lfn={},size={}", f->GetName(), f->GetSize())); -#if __has_include() - auto alienFile = dynamic_cast(f); - if (alienFile) { - monitoringInfo += fmt::format(",se={},open_time={:.1f}", alienFile->GetSE(), alienFile->GetElapsed()); - } -#endif + std::string monitoringInfo(fmt::format("lfn={},size={}", mCurrentFile->name(), mCurrentFile->size())); + monitoringInfo += mCurrentFile->monitoringDetails(); if (mContext.monitoring) { mContext.monitoring->send(o2::monitoring::Metric{monitoringInfo, "aod-file-open-info"}.addTag(o2::monitoring::tags::Key::Subsystem, o2::monitoring::tags::Value::DPL)); } @@ -431,17 +438,10 @@ void DataInputDescriptor::printFileStatistics() if (wait_time < 0) { wait_time = 0; } - auto rootFS = std::dynamic_pointer_cast(mCurrentFilesystem); - auto f = dynamic_cast(rootFS->GetFile()); - std::string monitoringInfo(fmt::format("lfn={},size={},total_df={},read_bytes={},read_calls={},io_time={:.1f},wait_time={:.1f},level={}", f->GetName(), - f->GetSize(), getTimeFramesInFile(mCurrentFileID), f->GetBytesRead(), f->GetReadCalls(), + std::string monitoringInfo(fmt::format("lfn={},size={},total_df={},read_bytes={},read_calls={},io_time={:.1f},wait_time={:.1f},level={}", mCurrentFile->name(), + mCurrentFile->size(), getTimeFramesInFile(mCurrentFileID), mCurrentFile->bytesRead(), mCurrentFile->readCalls(), ((float)mIOTime / 1e9), ((float)wait_time / 1e9), mLevel)); -#if __has_include() - auto alienFile = dynamic_cast(f); - if (alienFile) { - monitoringInfo += fmt::format(",se={},open_time={:.1f}", alienFile->GetSE(), alienFile->GetElapsed()); - } -#endif + monitoringInfo += mCurrentFile->monitoringDetails(); if (mTimeFrameActive) { // Snapshot I/O statistics now, but publish DF counts after commit or rollback. mPendingFileStatistics.emplace_back(mCurrentFileID, std::move(monitoringInfo)); @@ -461,14 +461,14 @@ void DataInputDescriptor::reportFileStatistics(int counter, std::string monitori void DataInputDescriptor::closeInputFile() { - if (mCurrentFilesystem.get()) { + if (mCurrentFile) { releaseParentFile(); - delete mParentFileMap; - mParentFileMap = nullptr; + mParentFileMap.clear(); printFileStatistics(); mCurrentFilesystem.reset(); + mCurrentFile.reset(); } } @@ -574,12 +574,10 @@ try { return false; } if (parentFile == nullptr) { - auto rootFS = std::dynamic_pointer_cast(mCurrentFilesystem); - throw AODReadError(AODReadError::Kind::InvalidTimeframe, fmt::format(R"(No parent file found for "{}" while looking for level {} in "{}")", treename, wantedLevel, rootFS->GetFile()->GetName())); + throw AODReadError(AODReadError::Kind::InvalidTimeframe, fmt::format(R"(No parent file found for "{}" while looking for level {} in "{}")", treename, wantedLevel, mCurrentFile->name())); } if (parentNumTF == -1) { - auto parentRootFS = std::dynamic_pointer_cast(parentFile->mCurrentFilesystem); - throw AODReadError(AODReadError::Kind::InvalidTimeframe, fmt::format(R"(DF not found in parent file "{}")", parentRootFS->GetFile()->GetName())); + throw AODReadError(AODReadError::Kind::InvalidTimeframe, fmt::format(R"(DF not found in parent file "{}")", parentFile->mCurrentFile->name())); } t.deactivate(); return parentFile->readTree(outputs, dh, 0, parentNumTF, treename, totalSizeCompressed, totalSizeUncompressed); @@ -591,11 +589,11 @@ try { return false; } - auto rootFS = std::dynamic_pointer_cast(folder.filesystem()); + auto rootFS = std::dynamic_pointer_cast(folder.filesystem()); if (!rootFS) { t.deactivate(); - throw std::runtime_error(fmt::format(R"(Not a TFile filesystem!)")); + throw std::runtime_error(fmt::format(R"(Not a ROOT file filesystem!)")); } // FIXME: Ugly. We should detect the format from the treename, good enough for now. std::shared_ptr format; @@ -628,13 +626,11 @@ try { LOGP(debug, "Could not find tree {}. Trying in parent file.", fullpath.path()); auto parentFile = getParentFile(counter, numTF, treename, wantedLevel, wantedOrigin); if (parentFile == nullptr) { - auto rootFS = std::dynamic_pointer_cast(mCurrentFilesystem); - throw std::runtime_error(fmt::format(R"(Couldn't get TTree "{}" from "{}". Please check https://aliceo2group.github.io/analysis-framework/docs/troubleshooting/#tree-not-found for more information.)", fullpath.path(), rootFS->GetFile()->GetName())); + throw std::runtime_error(fmt::format(R"(Couldn't get TTree "{}" from "{}". Please check https://aliceo2group.github.io/analysis-framework/docs/troubleshooting/#tree-not-found for more information.)", fullpath.path(), mCurrentFile->name())); } int parentNumTF = parentFile->findDFNumber(0, folder.path()); if (parentNumTF == -1) { - auto parentRootFS = std::dynamic_pointer_cast(parentFile->mCurrentFilesystem); - throw AODReadError(AODReadError::Kind::InvalidTimeframe, fmt::format(R"(DF {} listed in parent file map but not found in the corresponding file "{}")", folder.path(), parentRootFS->GetFile()->GetName())); + throw AODReadError(AODReadError::Kind::InvalidTimeframe, fmt::format(R"(DF {} listed in parent file map but not found in the corresponding file "{}")", folder.path(), parentFile->mCurrentFile->name())); } // first argument is 0 as the parent file object contains only 1 file return parentFile->readTree(outputs, dh, 0, parentNumTF, treename, totalSizeCompressed, totalSizeUncompressed); @@ -678,8 +674,7 @@ try { return true; } catch (AODReadError const& e) { - auto rootFS = std::dynamic_pointer_cast(mCurrentFilesystem); - auto filename = rootFS ? std::string(rootFS->GetFile()->GetName()) : mfilenames.at(counter).fileName; + auto filename = mCurrentFile ? mCurrentFile->name() : mfilenames.at(counter).fileName; auto const& dfs = mfilenames.at(counter).listOfTimeFrameNumbers; auto df = numTF >= 0 && static_cast(numTF) < dfs.size() ? fmt::format("DF_{}", dfs[numTF]) : fmt::format("timeframe index {}", numTF); std::throw_with_nested(AODReadError(e.getKind(), fmt::format("Reading tree {} in {} from file \"{}\" at parent level {}", treename, df, filename, mLevel))); diff --git a/Framework/AnalysisSupport/src/DataInputDirector.h b/Framework/AnalysisSupport/src/DataInputDirector.h index ef4b7b0269f85..9d8f0fb87bfe3 100644 --- a/Framework/AnalysisSupport/src/DataInputDirector.h +++ b/Framework/AnalysisSupport/src/DataInputDirector.h @@ -16,10 +16,13 @@ #include "Framework/DataDescriptorMatcher.h" #include "Framework/DataAllocator.h" #include "Framework/RootArrowFilesystem.h" +#include "AODInputFile.h" +#include "AODNative/ByteSource.h" #include #include +#include #include #include #include @@ -66,6 +69,10 @@ struct DataInputDirectorContext { // Optional registry of pre-opened TFiles (keyed by name) used to bypass // TFile::Open for testing with in-memory TMemFile instances. std::vector> openFiles = {}; + // Which reader opens the files: "root", "native", or "auto" (native, with a + // per-file fallback to ROOT for what the native reader cannot read). + std::string readerBackend = "root"; + o2::aod::native::Options nativeOptions = {}; int levelForOrigin(std::string_view origin) const { @@ -146,6 +153,7 @@ class DataInputDescriptor std::string* mFilenameRegexPtr = nullptr; std::vector mfilenames; std::vector mdefaultFilenamesPtr; + std::shared_ptr mCurrentFile; std::shared_ptr mCurrentFilesystem; int mCurrentFileID = -1; // File-local DF indices visited during the current output timeframe. @@ -153,7 +161,8 @@ class DataInputDescriptor bool mAlienSupport = false; DataInputDirectorContext& mContext; - TMap* mParentFileMap = nullptr; + // folder name (DF_XXX) --> parent file (absolute path), empty if the file has none + std::map mParentFileMap; std::shared_ptr mParentFile = nullptr; std::vector> mRetainedParents; bool mTimeFrameActive = false; diff --git a/Framework/AnalysisSupport/src/NativeAOD.cxx b/Framework/AnalysisSupport/src/NativeAOD.cxx new file mode 100644 index 0000000000000..11f7e96c2676e --- /dev/null +++ b/Framework/AnalysisSupport/src/NativeAOD.cxx @@ -0,0 +1,317 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#include "NativeAOD.h" +#include "AODNative/Errors.h" +#include "Framework/RuntimeError.h" +#include +#include + +namespace o2::framework +{ + +using namespace o2::aod::native; + +NativeAODFileSystem::NativeAODFileSystem(std::shared_ptr file, Options options, RootObjectReadingFactory& factory) + : mFile(std::move(file)), mOptions(options), mObjectFactory(factory) +{ +} + +namespace +{ +// "DF_/", with or without a leading slash. Empty strings if it is not that. +std::pair splitTreePath(std::string const& path) +{ + size_t start = path.starts_with("/") ? 1 : 0; + auto slash = path.find('/', start); + if (slash == std::string::npos) { + return {}; + } + return {path.substr(start, slash - start), path.substr(slash + 1)}; +} +} // namespace + +NativeTreeHandle* NativeAODFileSystem::getTreeHandle(std::string const& path) +{ + auto [df, treeName] = splitTreePath(path); + if (df.empty()) { + return nullptr; + } + std::lock_guard lock{mMutex}; + return findTree(df, treeName); +} + +std::shared_ptr NativeAODFileSystem::planFor(std::string const& path) +{ + auto [df, treeName] = splitTreePath(path); + if (df.empty() || mFile->source().canMap()) { + return nullptr; + } + std::lock_guard lock{mMutex}; + if (df != mPlanDF) { + auto previous = std::move(mPlanTrees); + mPlanTrees.clear(); + mPlanDF = df; + // Also when the directory is already known, e.g. the first DF, read at open. + adoptLookahead(df); + directory(df); + if (mAdoptedDF == df) { + mPlan = std::move(mAdoptedPlan); + mPlanTrees = std::move(mAdoptedTrees); + } else { + mPlan = std::make_shared(mFile->sourcePtr(), mOptions, mPool); + } + mAdoptedDF.clear(); + auto trees = previous; + if (std::ranges::find(trees, treeName) == trees.end()) { + trees.push_back(treeName); + } + if (!previous.empty()) { + mFile->setTreesToPrefetch(trees); + } + for (auto& name : previous) { + addToPlan(name); + } + // Only once a whole DF has shown which trees the reader asks for. + if (!previous.empty() && mFile->source().isRemote() && mOptions.prefetchDepth > 0) { + startLookahead(df, std::move(trees)); + } + } + addToPlan(treeName); + return mPlan; +} + +void NativeAODFileSystem::adoptLookahead(std::string const& df) +{ + if (!mLookahead.valid() || mLookaheadDF != df) { + return; + } + auto lookahead = mLookahead.get(); + if (!lookahead) { + return; + } + mDirectories.emplace(df, std::move(lookahead->keys)); + mAdoptedDF = df; + mAdoptedPlan = lookahead->plan; + mAdoptedTrees.clear(); + for (auto& [name, tree] : lookahead->trees) { + // The plan already has the baskets of these trees. + mTrees.emplace(df + "/" + name, std::make_unique(NativeTreeHandle{std::move(tree), mFile->sourcePtr(), mOptions})); + mAdoptedTrees.push_back(name); + } +} + +std::vector const& NativeAODFileSystem::directory(std::string const& df) +{ + if (auto it = mDirectories.find(df); it != mDirectories.end()) { + return it->second; + } + adoptLookahead(df); + if (auto it = mDirectories.find(df); it != mDirectories.end()) { + return it->second; + } + return mDirectories.emplace(df, mFile->keysOf(df)).first->second; +} + +std::vector NativeAODFileSystem::keysOf(std::string const& df) +{ + std::lock_guard lock{mMutex}; + return directory(df); +} + +void NativeAODFileSystem::setReadOrder(std::vector dfs) +{ + std::lock_guard lock{mMutex}; + mDataFrames = std::move(dfs); +} + +void NativeAODFileSystem::startLookahead(std::string const& afterDF, std::vector trees) +{ + if (mDataFrames.empty()) { + mDataFrames = mFile->dataFrames(); + } + if (!mLookaheadFile) { + mLookaheadFile = std::make_shared(*mFile); + } + auto it = std::ranges::find(mDataFrames, afterDF); + if (it == mDataFrames.end() || ++it == mDataFrames.end()) { + return; + } + mLookaheadDF = *it; + // Errors are left to the normal path, which reports them when the DF is read. + mLookahead = std::async(std::launch::async, [file = mLookaheadFile, df = *it, trees = std::move(trees), options = mOptions, pool = mPool]() -> std::optional { + try { + Lookahead lookahead{df, {}, {}, std::make_shared(file->sourcePtr(), options, pool)}; + file->setTreesToPrefetch(trees); + lookahead.keys = file->keysOf(df); + for (auto const& name : trees) { + for (auto const& key : lookahead.keys) { + if (key.name == name && key.className == "TTree") { + lookahead.trees.emplace_back(name, file->readTree(key)); + lookahead.plan->add(lookahead.trees.back().second); + } + } + } + lookahead.plan->fetch(); + return lookahead; + } catch (std::exception const&) { + return std::nullopt; + } + }); +} + +void NativeAODFileSystem::addToPlan(std::string const& treeName) +{ + if (std::ranges::find(mPlanTrees, treeName) != mPlanTrees.end()) { + return; + } + NativeTreeHandle* handle = nullptr; + try { + handle = findTree(mPlanDF, treeName); + } catch (FormatError const&) { + // Reported when the tree itself is asked for. + } + if (handle) { + mPlan->add(handle->tree); + mPlanTrees.push_back(treeName); + } +} + +NativeTreeHandle* NativeAODFileSystem::findTree(std::string const& df, std::string const& treeName) +{ + auto path = df + "/" + treeName; + if (auto it = mTrees.find(path); it != mTrees.end()) { + return it->second.get(); + } + if (!mDirectories.contains(df)) { + bool exists = false; + for (auto& k : mFile->topKeys()) { + exists |= k.name == df; + } + if (!exists) { + return nullptr; + } + } + for (auto& key : directory(df)) { + if (key.name == treeName && key.className == "TTree") { + auto handle = std::make_unique(NativeTreeHandle{mFile->readTree(key), mFile->sourcePtr(), mOptions}); + return mTrees.emplace(path, std::move(handle)).first->second.get(); + } + } + return nullptr; +} + +std::shared_ptr NativeAODFileSystem::GetObjectHandler(arrow::dataset::FileSource source) +{ + for (auto& capability : mObjectFactory.capabilities) { + auto objectPath = capability.lfn2objectPath(source.path()); + void* handle = capability.getHandle(shared_from_this(), objectPath); + if (!handle) { + continue; + } + return std::make_shared(handle, capability.factory().format()); + } + throw runtime_error_f("Unable to get handler for object %s", source.path().c_str()); +} + +bool NativeAODFileSystem::CheckSupport(arrow::dataset::FileSource source) +{ + for (auto& capability : mObjectFactory.capabilities) { + if (capability.getHandle(shared_from_this(), capability.lfn2objectPath(source.path()))) { + return true; + } + } + return false; +} + +arrow::Result NativeAODFileSystem::GetFileInfo(const std::string& path) +{ + arrow::fs::FileInfo result; + result.set_path(path); + result.set_type(arrow::fs::FileType::NotFound); + for (auto& k : mFile->topKeys()) { + if (k.name == path) { + result.set_type(k.className == "TTree" ? arrow::fs::FileType::File : arrow::fs::FileType::Directory); + return result; + } + } + if (getTreeHandle(path)) { + result.set_type(arrow::fs::FileType::File); + } + return result; +} + +std::shared_ptr NativeAODInputFile::open(std::string const& url, RootObjectReadingFactory& factory, Options const& options, std::string const& name) +{ + auto result = std::shared_ptr(new NativeAODInputFile); + result->mName = name.empty() ? url : name; + result->mFile = std::make_shared(ByteSource::open(url, options)); + result->mFilesystem = std::make_shared(result->mFile, options, factory); + result->mFilesystem->setName(result->mName); + result->mParentFiles = result->mFile->readStringMap("parentFiles"); + result->mMetaData = result->mFile->readStringMap("metaData"); + // Parse the trees of the first DF now: a layout or version we do not support + // is then found while the file can still be read with ROOT instead. + auto dfs = result->mFile->dataFrames(); + if (!dfs.empty()) { + for (auto& key : result->mFile->keysOf(dfs.front())) { + if (key.className == "TTree") { + result->mFilesystem->getTreeHandle(dfs.front() + "/" + key.name); + } + } + } + return result; +} + +void NativeAODInputFile::setGridInfo(std::string se, double openTime) +{ + mSE = std::move(se); + mOpenTime = openTime; +} + +std::string NativeAODInputFile::monitoringDetails() const +{ + if (mSE.empty()) { + return ""; + } + char openTime[32]; + std::snprintf(openTime, sizeof(openTime), "%.1f", mOpenTime); + return ",se=" + mSE + ",open_time=" + openTime; +} + +uint64_t NativeAODInputFile::size() const { return mFile->source().size(); } +uint64_t NativeAODInputFile::bytesRead() const { return mFile->source().bytesRead(); } +uint64_t NativeAODInputFile::readCalls() const { return mFile->source().readCalls(); } + +std::vector NativeAODInputFile::topLevelKeys() +{ + std::vector keys; + for (auto& k : mFile->topKeys()) { + keys.push_back({k.name, k.className}); + } + return keys; +} + +std::vector NativeAODInputFile::keysIn(std::string const& dir) +{ + std::vector names; + for (auto& k : mFile->topKeys()) { + if (k.name == dir && (k.className == "TDirectoryFile" || k.className == "TDirectory")) { + for (auto& key : mFilesystem->keysOf(dir)) { + names.push_back(key.name); + } + break; + } + } + return names; +} + +} // namespace o2::framework diff --git a/Framework/AnalysisSupport/src/NativeAOD.h b/Framework/AnalysisSupport/src/NativeAOD.h new file mode 100644 index 0000000000000..4e83280cecd58 --- /dev/null +++ b/Framework/AnalysisSupport/src/NativeAOD.h @@ -0,0 +1,150 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +#ifndef O2_FRAMEWORK_NATIVEAOD_H_ +#define O2_FRAMEWORK_NATIVEAOD_H_ + +// Glue between DataInputDirector and the ROOT-free AO2D reader (O2::AODNative). +// No ROOT API is used here: everything goes through RootFile and ByteSource. + +#include "AODInputFile.h" +#include "AODNative/ByteSource.h" +#include "AODNative/Container.h" +#include "AODNative/ReadPlan.h" +#include +#include +#include +#include +#include +#include + +namespace o2::framework +{ + +/// What a native tree reading handle points to: the parsed tree and where to +/// read its baskets from. Owned by the NativeAODFileSystem which created it. +struct NativeTreeHandle { + o2::aod::native::TreeInfo tree; + std::shared_ptr source; + o2::aod::native::Options options; +}; + +/// A filesystem over an AO2D read without ROOT, with the same path convention +/// as TFileFileSystem: "DF_/". +class NativeAODFileSystem : public VirtualRootFileSystemBase +{ + public: + NativeAODFileSystem(std::shared_ptr file, o2::aod::native::Options options, RootObjectReadingFactory& factory); + + std::string type_name() const override { return "native-aod"; } + arrow::Result GetFileInfo(const std::string& path) override; + std::shared_ptr GetObjectHandler(arrow::dataset::FileSource source) override; + bool CheckSupport(arrow::dataset::FileSource source) override; + + /// The tree at @a path, parsed on first use. nullptr if there is no such + /// tree. Throws o2::aod::native::FormatError if it cannot be parsed. + NativeTreeHandle* getTreeHandle(std::string const& path); + /// The read plan of the DF of the tree at @a path, with that tree in it; + /// nullptr when the file is mapped and nothing needs reading. A new DF gets a + /// new plan, with the trees read in the previous DF already in it: a reader + /// asks for the same tables in every DF, so the first read of a DF fetches + /// all of them together. For a remote file, the next DF is prepared in the + /// background meanwhile (keys, TTrees and data of the same trees), so that + /// its round trips overlap with the decoding of this one. + std::shared_ptr planFor(std::string const& path); + o2::aod::native::RootFile& file() { return *mFile; } + /// The name the file was asked for by (an LFN for a grid file), which is + /// not the URL it is read from. + std::string const& name() const { return mName; } + void setName(std::string name) { mName = std::move(name); } + /// The keys of DF @a df, read once (or prepared ahead) for all users. + std::vector keysOf(std::string const& df); + /// The order the DFs are read in, which tells which DF to prepare next. + /// Without it, RootFile::dataFrames() is assumed. + void setReadOrder(std::vector dfs); + + private: + /// A DF prepared ahead: its keys, the TTrees learned so far and a read plan + /// with their baskets already fetched. + struct Lookahead { + std::string df; + std::vector keys; + std::vector> trees; + std::shared_ptr plan; + }; + NativeTreeHandle* findTree(std::string const& df, std::string const& treeName); + /// Keys of @a df, taking them from the lookahead when it prepared this DF. + std::vector const& directory(std::string const& df); + void addToPlan(std::string const& treeName); + /// Takes the keys, trees and plan the lookahead prepared for @a df. + void adoptLookahead(std::string const& df); + void startLookahead(std::string const& afterDF, std::vector trees); + + std::shared_ptr mFile; + std::string mName; + o2::aod::native::Options mOptions; + RootObjectReadingFactory& mObjectFactory; + std::mutex mMutex; + std::map> mDirectories; + std::map> mTrees; + std::shared_ptr mPool = std::make_shared(); + std::shared_ptr mPlan; + std::string mPlanDF; + std::vector mPlanTrees; + // Used only by the lookahead task, so that mFile is never shared across threads. + std::shared_ptr mLookaheadFile; + std::vector mDataFrames; + std::future> mLookahead; + std::string mLookaheadDF; + // The plan and trees adopted for a DF, until planFor() starts that DF. + std::string mAdoptedDF; + std::shared_ptr mAdoptedPlan; + std::vector mAdoptedTrees; +}; + +/// An AO2D read with the ROOT-free reader. +class NativeAODInputFile : public AODInputFile +{ + public: + /// Opens @a url and parses its header, keys, maps and the trees of its first + /// DF, so that layouts and versions we cannot read are found here, when the + /// file can still go to ROOT. Throws o2::aod::native::FormatError. + /// @a name is what the file is called (an LFN for a grid file); @a url by default. + static std::shared_ptr open(std::string const& url, RootObjectReadingFactory& factory, o2::aod::native::Options const& options, std::string const& name = {}); + /// The storage element and the seconds the grid lookup and open took, for monitoring. + void setGridInfo(std::string se, double openTime); + std::string monitoringDetails() const override; + + std::string name() const override { return mName; } + uint64_t size() const override; + uint64_t bytesRead() const override; + uint64_t readCalls() const override; + std::vector topLevelKeys() override; + std::vector keysIn(std::string const& dir) override; + std::map parentFiles() override { return mParentFiles; } + std::map metaData() override { return mMetaData; } + std::shared_ptr filesystem() override { return mFilesystem; } + void setReadOrder(std::vector const& dfs) override { mFilesystem->setReadOrder(dfs); } + + private: + NativeAODInputFile() = default; + std::string mName; + std::string mSE; + double mOpenTime = 0; + std::shared_ptr mFile; + std::shared_ptr mFilesystem; + std::map mParentFiles; + std::map mMetaData; +}; + +} // namespace o2::framework + +#endif // O2_FRAMEWORK_NATIVEAOD_H_ diff --git a/Framework/AnalysisSupport/src/NativeAODPlugin.cxx b/Framework/AnalysisSupport/src/NativeAODPlugin.cxx new file mode 100644 index 0000000000000..bfb054b2ff046 --- /dev/null +++ b/Framework/AnalysisSupport/src/NativeAODPlugin.cxx @@ -0,0 +1,348 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// Reading of AO2D trees with the ROOT-free reader (O2::AODNative). A drop-in +// sibling of the TTree plugin: same schemas, same placeholder sizes and the +// same deferred write protocol, so that the decoded columns land directly in +// the shared memory DPL ships downstream. No ROOT API is used in this file. + +#include "Framework/RootArrowFilesystem.h" +#include "Framework/Plugins.h" +#include "Framework/RuntimeError.h" +#include "Framework/Signpost.h" +#include "AODNative/ArrowTable.h" +#include "AODNative/Errors.h" +#include "DataInputDirector.h" +#include "NativeAOD.h" +#include +#include +#include +#include +#include +#include +#include + +O2_DECLARE_DYNAMIC_LOG(native_aod); + +namespace o2::framework +{ + +using o2::aod::native::BranchDecoder; +using o2::aod::native::ColumnOp; + +// A fragment which holds a natively read tree, and the read operations +// planned for it. +class NativeAODFileFragment : public arrow::dataset::FileFragment +{ + public: + NativeAODFileFragment(arrow::dataset::FileSource source, + std::shared_ptr format, + arrow::compute::Expression partition_expression, + std::shared_ptr physical_schema) + : FileFragment(source, format, std::move(partition_expression), physical_schema) + { + auto rootFS = std::dynamic_pointer_cast(this->source().filesystem()); + if (rootFS.get() == nullptr) { + throw runtime_error_f("Unknown filesystem %s when reading %s.", + source.filesystem()->type_name().c_str(), source.path().c_str()); + } + auto objectHandler = rootFS->GetObjectHandler(source); + if (!objectHandler->format->Equals(*format)) { + throw runtime_error_f("Cannot read source %s with format %s to populate a NativeAODFileFragment.", + source.path().c_str(), objectHandler->format->type_name().c_str()); + } + // The handle is owned by the filesystem, which the source keeps alive. + mHandle = objectHandler->GetObjectAsOwner().release(); + // Unless the file is mapped, the baskets come from the read plan of the DF. + if (auto nativeFS = std::dynamic_pointer_cast(rootFS)) { + if (auto plan = nativeFS->planFor(source.path())) { + mSource = std::make_unique(mHandle->source, std::move(plan)); + } + } + mDecoder = std::make_unique(mSource ? *mSource : *mHandle->source, mHandle->options); + } + + NativeTreeHandle* handle() { return mHandle; } + BranchDecoder& decoder() { return *mDecoder; } + std::vector& ops() { return mOps; } + + private: + NativeTreeHandle* mHandle = nullptr; + std::unique_ptr mSource; + std::unique_ptr mDecoder; + std::vector mOps; +}; + +/// An OutputStream which does the reading of the input buffers directly +/// on writing, if needed. Each deferred operation is encoded in the source +/// buffer by an incremental number which can be used to lookup in the +/// fragment's ops the operation to perform. +class NativeAODDeferredReadOutputStream : public arrow::io::OutputStream +{ + public: + NativeAODDeferredReadOutputStream(std::shared_ptr fragment, + const std::shared_ptr& buffer) + : mFragment(std::move(fragment)), + buffer_(buffer), + is_open_(true), + capacity_(buffer->size()), + position_(0), + mutable_data_(buffer->mutable_data()) + { + } + + // By the time we call the destructor, the contents + // of the buffer are already moved to fairmq + // for being sent. + ~NativeAODDeferredReadOutputStream() override = default; + + arrow::Status Close() override + { + if (is_open_) { + is_open_ = false; + if (position_ < capacity_) { + RETURN_NOT_OK(buffer_->Resize(position_, false)); + } + } + return arrow::Status::OK(); + } + + [[nodiscard]] bool closed() const override { return !is_open_; } + [[nodiscard]] arrow::Result Tell() const override { return position_; } + + arrow::Status Write(const void* data, int64_t nbytes) override + { + if (ARROW_PREDICT_FALSE(!is_open_)) { + return arrow::Status::IOError("OutputStream is closed"); + } + if (ARROW_PREDICT_TRUE(nbytes == 0)) { + return arrow::Status::OK(); + } + if (ARROW_PREDICT_FALSE(position_ + nbytes >= capacity_)) { + RETURN_NOT_OK(Reserve(nbytes)); + } + auto& ops = mFragment->ops(); + // This is a real address which needs to be copied. Do it! + auto ref = (int64_t)data; + if (ref < 0 || ref >= (int64_t)ops.size()) { + memcpy(mutable_data_ + position_, data, nbytes); + position_ += nbytes; + return arrow::Status::OK(); + } + auto& op = ops[ref]; + if ((int64_t)op.byteSize != nbytes) { + return arrow::Status::Invalid("Deferred read of ", nbytes, " bytes for an operation of ", op.byteSize); + } + // Decoding happens here, straight into the message buffer. + o2::aod::native::executeColumnOp(mFragment->decoder(), op, mutable_data_ + position_); + position_ += nbytes; + return arrow::Status::OK(); + } + + using OutputStream::Write; + + private: + arrow::Status Reserve(int64_t nbytes) + { + int64_t new_capacity = position_ + nbytes; + if (new_capacity > capacity_) { + RETURN_NOT_OK(buffer_->Resize(new_capacity)); + capacity_ = new_capacity; + mutable_data_ = buffer_->mutable_data(); + } + return arrow::Status::OK(); + } + + std::shared_ptr mFragment; + std::shared_ptr buffer_; + bool is_open_; + int64_t capacity_; + int64_t position_; + uint8_t* mutable_data_; +}; + +class NativeAODFileWriteOptions : public arrow::dataset::FileWriteOptions +{ + public: + NativeAODFileWriteOptions(std::shared_ptr format) + : FileWriteOptions(format) + { + } +}; + +class NativeAODFileFormat : public arrow::dataset::FileFormat +{ + size_t& mTotCompressedSize; + size_t& mTotUncompressedSize; + + public: + NativeAODFileFormat(size_t& totalCompressedSize, size_t& totalUncompressedSize) + : FileFormat({}), + mTotCompressedSize(totalCompressedSize), + mTotUncompressedSize(totalUncompressedSize) + { + } + + std::string type_name() const override { return "native-ttree"; } + + bool Equals(const FileFormat& other) const override { return other.type_name() == this->type_name(); } + + arrow::Result IsSupported(const arrow::dataset::FileSource& source) const override + { + auto fs = std::dynamic_pointer_cast(source.filesystem()); + if (!fs) { + return false; + } + return fs->CheckSupport(source); + } + + arrow::Result> Inspect(const arrow::dataset::FileSource& source) const override + { + auto fs = std::dynamic_pointer_cast(source.filesystem()); + if (!fs.get()) { + throw runtime_error_f("Unknown filesystem %s\n", source.filesystem()->type_name().c_str()); + } + auto objectHandler = fs->GetObjectHandler(source); + if (!objectHandler->format->Equals(*this)) { + throw runtime_error_f("Unknown filesystem %s\n", source.filesystem()->type_name().c_str()); + } + // The handle is owned by the filesystem. + auto* handle = objectHandler->GetObjectAsOwner().release(); + try { + return o2::aod::native::physicalSchema(handle->tree); + } catch (o2::aod::native::FormatError const& e) { + return arrow::Status::IOError(e.what()); + } + } + + arrow::Result> MakeFragment( + arrow::dataset::FileSource source, arrow::compute::Expression partition_expression, + std::shared_ptr physical_schema) override + { + return std::make_shared(source, std::dynamic_pointer_cast(shared_from_this()), + std::move(partition_expression), physical_schema); + } + + arrow::Result> MakeWriter(std::shared_ptr, std::shared_ptr, + std::shared_ptr, arrow::fs::FileLocator) const override + { + return arrow::Status::NotImplemented("The native AO2D reader cannot write files"); + } + + std::shared_ptr DefaultWriteOptions() override + { + return std::make_shared(shared_from_this()); + } + + arrow::Result ScanBatchesAsync( + const std::shared_ptr& options, + const std::shared_ptr& fragment) const override + { + assert(options->dataset_schema != nullptr); + auto datasetSchema = options->dataset_schema; + auto nativeFragment = std::dynamic_pointer_cast(fragment); + if (nativeFragment.get() == nullptr) { + return {arrow::Status::NotImplemented("Not a native AO2D fragment")}; + } + + auto generator = [pool = options->pool, nativeFragment, datasetSchema, &totalCompressedSize = mTotCompressedSize, + &totalUncompressedSize = mTotUncompressedSize]() -> arrow::Future> { + auto* handle = nativeFragment->handle(); + auto const& tree = handle->tree; + O2_SIGNPOST_ID_FROM_POINTER(tid, native_aod, handle); + O2_SIGNPOST_START(native_aod, tid, "Generator", "Creating batch for tree %{public}s", tree.name.c_str()); + if (datasetSchema->num_fields() > (int)tree.branches.size()) { + throw runtime_error_f("One TTree must have all the fields requested in a table"); + } + // Plan the reads. VLA offsets are decoded now, because they size the + // values; everything else is decoded later, in the deferred Write(). + int64_t rows = 0; + auto& ops = nativeFragment->ops(); + ops = o2::aod::native::planColumns(tree, datasetSchema, nativeFragment->decoder(), pool, rows); + // The pointer to each buffer is an incremental number, indexing ops. + std::vector> placeholders; + for (size_t i = 0; i < ops.size(); ++i) { + placeholders.push_back(std::make_shared((uint8_t*)i, ops[i].byteSize)); + O2_SIGNPOST_EVENT_EMIT(native_aod, tid, "Op", "Created op for branch %{public}s with %lli entries, size of the buffer %zu.", + ops[i].branch->name.c_str(), (long long)ops[i].branch->entries, ops[i].byteSize); + } + auto batch = o2::aod::native::makeBatch(datasetSchema, ops, placeholders, rows); + totalCompressedSize += tree.zipBytes; + totalUncompressedSize += tree.totBytes; + O2_SIGNPOST_END(native_aod, tid, "Generator", "Done creating batch compressed:%zu uncompressed:%zu", totalCompressedSize, totalUncompressedSize); + return batch; + }; + return generator; + } +}; + +struct NativeAODPluginContext { + size_t totalCompressedSize = 0; + size_t totalUncompressedSize = 0; + std::shared_ptr format = nullptr; +}; + +RootArrowFactory* createNativeAODFactory() +{ + auto context = new NativeAODPluginContext; + context->format = std::make_shared(context->totalCompressedSize, context->totalUncompressedSize); + return new RootArrowFactory{ + .options = [context]() { return context->format->DefaultWriteOptions(); }, + .format = [context]() { return context->format; }, + .deferredOutputStreamer = [](std::shared_ptr fragment, const std::shared_ptr& buffer) -> std::shared_ptr { + auto nativeFragment = std::dynamic_pointer_cast(fragment); + return std::make_shared(nativeFragment, buffer); + }}; +} + +struct NativeAODObjectReadingImplementation : public RootArrowFactoryPlugin { + RootArrowFactory* create() override { return createNativeAODFactory(); } +}; + +// The capability lives here rather than in Framework/Core, because recognising +// a native handle needs NativeAODFileSystem. Being first in the capability +// list, it wins for files opened natively; for any other filesystem it returns +// no handle, and DPL falls through to the RNTuple and TTree capabilities. +struct NativeAODObjectReadingCapability : o2::framework::RootObjectReadingCapabilityPlugin { + RootObjectReadingCapability* create() override + { + return new RootObjectReadingCapability{ + .name = "native-ttree", + .lfn2objectPath = [](std::string s) { return s; }, + .getHandle = [](std::shared_ptr fs, std::string const& path) -> void* { + auto nativeFS = std::dynamic_pointer_cast(fs); + if (!nativeFS) { + return nullptr; + } + try { + return nativeFS->getTreeHandle(path); + } catch (o2::aod::native::FormatError const& e) { + // A DF which cannot be parsed: --aod-skip-invalid-reads skips it. + throw AODReadError(AODReadError::Kind::InvalidTimeframe, e.what()); + } + }, + .checkSupport = [](char const* className) -> bool { return std::string_view(className) == "TTree"; }, + .accountBytes = [](void* handle, size_t& compressed, size_t& uncompressed) { + auto* h = (NativeTreeHandle*)handle; + compressed += h->tree.zipBytes; + uncompressed += h->tree.totBytes; }, + .factory = []() -> RootArrowFactory& { + static RootArrowFactory* factory = createNativeAODFactory(); + return *factory; + }}; + } +}; + +DEFINE_DPL_PLUGINS_BEGIN +DEFINE_DPL_PLUGIN_INSTANCE(NativeAODObjectReadingImplementation, RootObjectReadingImplementation); +DEFINE_DPL_PLUGIN_INSTANCE(NativeAODObjectReadingCapability, RootObjectReadingCapability); +DEFINE_DPL_PLUGINS_END +} // namespace o2::framework diff --git a/Framework/AnalysisSupport/src/Plugin.cxx b/Framework/AnalysisSupport/src/Plugin.cxx index 5f61a236cbd58..f1e598a74d67b 100644 --- a/Framework/AnalysisSupport/src/Plugin.cxx +++ b/Framework/AnalysisSupport/src/Plugin.cxx @@ -17,13 +17,14 @@ #include "Framework/Signpost.h" #include "AODJAlienReaderHelpers.h" #include "AODWriterHelpers.h" -#include -#include +#include "AODInputFile.h" +#include #include -#include -#include #include +#include +#include #include +#include O2_DECLARE_DYNAMIC_LOG(analysis_support); @@ -105,35 +106,25 @@ struct RunSummary : o2::framework::ServicePlugin { } }; -std::vector getListOfTables(std::unique_ptr& f) +std::vector getListOfTables(AODInputFile& f) { std::vector r; - TList* keyList = f->GetListOfKeys(); // We should handle two cases, one where the list of tables in a TDirectory, // the other one where the dataframe number is just a prefix std::string first = ""; - for (auto key : *keyList) { - if (!std::string_view(key->GetName()).starts_with("DF_") && !std::string_view(key->GetName()).starts_with("/DF_")) { + for (auto& key : f.topLevelKeys()) { + if (!std::string_view(key.name).starts_with("DF_") && !std::string_view(key.name).starts_with("/DF_")) { continue; } - auto* d = (TDirectory*)f->GetObjectChecked(key->GetName(), TClass::GetClass("TDirectory")); // Objects are in a folder, list it. - if (d) { - TList* branchList = d->GetListOfKeys(); - for (auto b : *branchList) { - r.emplace_back(b->GetName()); - } + if (key.className == "TDirectoryFile" || key.className == "TDirectory") { + r = f.keysIn(key.name); break; } -#if __has_include() - void* v = f->GetObjectChecked(key->GetName(), TClass::GetClass("ROOT::RNTuple")); -#else - void* v = f->GetObjectChecked(key->GetName(), TClass::GetClass("ROOT::Experimental::RNTuple")); -#endif - if (v) { - std::string s = key->GetName(); + if (key.className == "ROOT::RNTuple" || key.className == "ROOT::Experimental::RNTuple") { + std::string const& s = key.name; size_t pos = s.find('-'); // Check if '-' is found // Skip metaData and parentFiles @@ -155,33 +146,36 @@ std::vector getListOfTables(std::unique_ptr& f) return r; } -auto readMetadata(std::unique_ptr& currentFile) -> std::vector +auto readMetadata(AODInputFile& currentFile) -> std::vector { // Get the metadata, if any - auto m = (TMap*)currentFile->Get("metaData"); - if (!m) { - return {}; - } + auto m = currentFile.metaData(); std::vector results; - auto it = m->MakeIterator(); // Serialise metadata into a ; separated string with : separating key and value bool first = true; - while (auto obj = it->Next()) { + for (auto& [name, value] : m) { if (first) { - LOGP(info, "Metadata for file \"{}\":", currentFile->GetName()); + LOGP(info, "Metadata for file \"{}\":", currentFile.name()); first = false; } - auto objString = (TObjString*)m->GetValue(obj); - LOGP(info, "- {}: {}", obj->GetName(), objString->String().Data()); - std::string key = "aod-metadata-" + std::string(obj->GetName()); - char const* value = strdup(objString->String()); - results.push_back(ConfigParamSpec{key, VariantType::String, value, {"Metadata in AOD"}}); + LOGP(info, "- {}: {}", name, value); + std::string key = "aod-metadata-" + name; + char const* valueCopy = strdup(value.c_str()); + results.push_back(ConfigParamSpec{key, VariantType::String, valueCopy, {"Metadata in AOD"}}); } return results; } +/// Opens an AO2D for metadata discovery. The factory is only needed to read +/// tables, which discovery does not do. +std::shared_ptr openForDiscovery(std::string const& filename) +{ + static RootObjectReadingFactory noTables; + return RootAODInputFile::open(filename, noTables); +} + struct DiscoverMetadataInAOD : o2::framework::ConfigDiscoveryPlugin { ConfigDiscovery* create() override { @@ -206,13 +200,13 @@ struct DiscoverMetadataInAOD : o2::framework::ConfigDiscoveryPlugin { TGrid::Connect("alien://"); } LOGP(info, "Loading metadata from file {} in PID {}", filename, getpid()); - std::unique_ptr currentFile{TFile::Open(filename.c_str())}; + auto currentFile = openForDiscovery(filename); if (currentFile.get() == nullptr) { - LOGP(fatal, "Couldn't open file \"{}\"!", filename); + throw std::runtime_error(fmt::format("Couldn't open file \"{}\"!", filename)); } - std::vector results = readMetadata(currentFile); + std::vector results = readMetadata(*currentFile); const bool metaDataEmpty = results.empty(); - auto tables = getListOfTables(currentFile); + auto tables = getListOfTables(*currentFile); if (tables.empty() == false) { results.push_back(ConfigParamSpec{"aod-metadata-tables", VariantType::ArrayString, tables, {"Tables in first AOD"}}); } @@ -230,15 +224,15 @@ struct DiscoverMetadataInAOD : o2::framework::ConfigDiscoveryPlugin { } // Lets try in parent file. - auto parentFiles = (TMap*)currentFile->Get("parentFiles"); - if (!parentFiles) { + auto parentFiles = currentFile->parentFiles(); + if (parentFiles.empty()) { LOGP(info, "No metadata found in file \"{}\"", filename); results.push_back(ConfigParamSpec{"aod-metadata-disable", VariantType::String, "1", {"Metadata not found in AOD"}}); return results; } LOGP(info, "No metadata found in file \"{}\", checking in its parents.", filename); - for (auto* p : *parentFiles) { - std::string parentFilename = ((TPair*)p)->Value()->GetName(); + for (auto& [df, parentFilenameInFile] : parentFiles) { + std::string parentFilename = parentFilenameInFile; // Do the replacement. Notice this will require changing aod-parent-base-path-replacement to be // a workflow option (because the metadata itself is potentially changing the topology). if (registry.isSet("aod-parent-base-path-replacement")) { @@ -259,14 +253,14 @@ struct DiscoverMetadataInAOD : o2::framework::ConfigDiscoveryPlugin { TGrid::Connect("alien://"); } - std::unique_ptr parentFile{TFile::Open(parentFilename.c_str())}; + auto parentFile = openForDiscovery(parentFilename); if (parentFile.get() == nullptr) { LOGP(fatal, "Couldn't open derived file \"{}\"!", parentFilename); } - results = readMetadata(parentFile); + results = readMetadata(*parentFile); // Found metadata already in the main file. if (!results.empty()) { - auto tables = getListOfTables(parentFile); + auto tables = getListOfTables(*parentFile); if (tables.empty() == false) { results.push_back(ConfigParamSpec{"aod-metadata-tables", VariantType::ArrayString, tables, {"Tables in first AOD"}}); } diff --git a/Framework/AnalysisSupport/test/compareNativeToRoot.cxx b/Framework/AnalysisSupport/test/compareNativeToRoot.cxx new file mode 100644 index 0000000000000..bc0fb4879dd02 --- /dev/null +++ b/Framework/AnalysisSupport/test/compareNativeToRoot.cxx @@ -0,0 +1,293 @@ +// Copyright 2019-2026 CERN and copyright holders of ALICE O2. +// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders. +// All rights not expressly granted are reserved. +// +// This software is distributed under the terms of the GNU General Public +// License v3 (GPL Version 3), copied verbatim in the file "COPYING". +// +// In applying this license CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// o2-test-framework-aod-native-vs-root [--threads N] [--pread] [--prefetch N] [--max-dfs N] [--bench root|native] ... +// +// Reads every tree of every DF with the TTree plugin and with the native +// reader, serialises both as DPL does (deferred output stream into an Arrow +// IPC stream) and requires the bytes to be identical. Exits non-zero on any +// difference, naming the first differing column. With --bench, reads with +// that backend only and reports wall time, CPU time and peak RSS instead. +// A file given as = is read from different places by +// the two readers, e.g. over HTTP by one and locally by the other. + +#include "../src/AODInputFile.h" +#include "../src/NativeAOD.h" +#include "AODNative/Errors.h" +#include "Framework/PluginManager.h" +#include "Framework/Plugins.h" +#include "Framework/RootArrowFilesystem.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace o2::framework; + +namespace +{ + +template +T orThrow(arrow::Result result, std::string const& what) +{ + if (!result.ok()) { + throw std::runtime_error(what + ": " + result.status().ToString()); + } + return std::move(result).ValueUnsafe(); +} + +struct Serialised { + std::shared_ptr bytes; + std::string capability; +}; + +// Same steps as DataInputDescriptor::readTree, FragmentToBatch and +// DataAllocator::adopt, with a heap buffer in place of the shm message. +Serialised serialise(RootObjectReadingFactory& factory, std::shared_ptr fs, std::string const& path) +{ + arrow::dataset::FileSource source{path, fs}; + for (auto& capability : factory.capabilities) { + if (!capability.getHandle(fs, capability.lfn2objectPath(path))) { + continue; + } + auto format = capability.factory().format(); + auto physicalSchema = orThrow(format->Inspect(source), "inspect"); + std::vector> fields; + for (auto& field : physicalSchema->fields()) { + if (!field->name().ends_with("_size")) { + fields.push_back(field); + } + } + auto datasetSchema = std::make_shared(fields); + auto fragment = orThrow(format->MakeFragment(source, {}, physicalSchema), "fragment"); + auto options = std::make_shared(); + options->dataset_schema = datasetSchema; + auto batch = orThrow(orThrow(format->ScanBatchesAsync(options, fragment), "scan")().result(), "scan"); + + auto mock = std::make_shared(); + (void)arrow::ipc::MakeStreamWriter(mock.get(), batch->schema()).ValueOrDie()->WriteRecordBatch(*batch); + std::shared_ptr buffer = arrow::AllocateResizableBuffer(0, 64).ValueOrDie(); + if (!buffer->Reserve(mock->Tell().ValueOrDie()).ok()) { + throw std::runtime_error("cannot reserve " + path); + } + auto stream = capability.factory().deferredOutputStreamer(fragment, buffer); + auto writer = arrow::ipc::MakeStreamWriter(stream, batch->schema()).ValueOrDie(); + auto status = writer->WriteRecordBatch(*batch); + if (status.ok()) { + status = writer->Close(); + } + if (status.ok()) { + status = stream->Close(); + } + if (!status.ok()) { + throw std::runtime_error(path + ": " + status.ToString()); + } + return {buffer, capability.name}; + } + throw std::runtime_error("no capability reads " + path); +} + +std::shared_ptr decode(std::shared_ptr const& bytes) +{ + auto reader = arrow::ipc::RecordBatchStreamReader::Open(std::make_shared(bytes)).ValueOrDie(); + std::shared_ptr batch; + (void)reader->ReadNext(&batch); + return batch; +} + +std::string describeDifference(Serialised const& root, Serialised const& native) +{ + auto r = decode(root.bytes); + auto n = decode(native.bytes); + if (!r || !n) { + return "one of the two streams has no batch"; + } + if (!r->schema()->Equals(*n->schema())) { + return "schemas differ:\n root: " + r->schema()->ToString() + "\n native: " + n->schema()->ToString(); + } + if (r->num_rows() != n->num_rows()) { + return "rows differ: " + std::to_string(r->num_rows()) + " vs " + std::to_string(n->num_rows()); + } + for (int i = 0; i < r->num_columns(); ++i) { + if (!r->column(i)->Equals(n->column(i))) { + return "column " + r->schema()->field(i)->name() + " differs"; + } + } + return "same values, different bytes (" + std::to_string(root.bytes->size()) + " vs " + std::to_string(native.bytes->size()) + ")"; +} + +double cpuSeconds(timeval const& tv) { return tv.tv_sec + tv.tv_usec * 1e-6; } + +// The DFs of the file in on-disk order, at most @a maxDFs of them if not negative. +std::vector dataFramesOf(AODInputFile& file, int maxDFs) +{ + static const std::regex dfRegex{"DF_[0-9]+"}; + std::vector dfs; + for (auto& key : file.topLevelKeys()) { + if (std::regex_match(key.name, dfRegex) && (maxDFs < 0 || (int)dfs.size() < maxDFs) && std::ranges::find(dfs, key.name) == dfs.end()) { + dfs.push_back(key.name); + } + } + return dfs; +} + +} // namespace + +int main(int argc, char** argv) +{ + o2::aod::native::Options nativeOptions; + int maxDFs = -1; + std::string bench; + std::vector files; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--threads") == 0 && i + 1 < argc) { + nativeOptions.threads = std::stoi(argv[++i]); + } else if (std::strcmp(argv[i], "--pread") == 0) { + nativeOptions.localIO = o2::aod::native::Options::LocalIO::PRead; + } else if (std::strcmp(argv[i], "--prefetch") == 0 && i + 1 < argc) { + nativeOptions.prefetchDepth = std::stoi(argv[++i]); + } else if (std::strcmp(argv[i], "--max-dfs") == 0 && i + 1 < argc) { + maxDFs = std::stoi(argv[++i]); + } else if (std::strcmp(argv[i], "--bench") == 0 && i + 1 < argc) { + bench = argv[++i]; + } else { + files.push_back(argv[i]); + } + } + if (files.empty() || (!bench.empty() && bench != "root" && bench != "native")) { + std::fprintf(stderr, "usage: %s [--threads N] [--pread] [--prefetch N] [--max-dfs N] [--bench root|native] ...\n", argv[0]); + return 2; + } + + RootObjectReadingFactory factory; + std::vector plugins; + for (auto spec : {"O2FrameworkAnalysisNativeAODSupport:NativeAODObjectReadingCapability", "O2Framework:TTreeObjectReadingCapability"}) { + for (auto& plugin : PluginManager::parsePluginSpecString(spec)) { + plugins.push_back(plugin); + } + } + PluginManager::loadFromPlugin(plugins, factory.capabilities); + for (auto& name : files) { + if (name.starts_with("alien://") && !gGrid) { + TGrid::Connect("alien://"); + } + } + + int failures = 0; + if (!bench.empty()) { + for (auto& name : files) { + auto start = std::chrono::steady_clock::now(); + rusage before; + getrusage(RUSAGE_SELF, &before); + std::shared_ptr file; + if (bench == "root") { + file = RootAODInputFile::open(name, factory); + } else { + file = openNativeAOD(name, factory, nativeOptions); + } + size_t trees = 0, bytes = 0; + auto order = dataFramesOf(*file, maxDFs); + file->setReadOrder(order); + int dfs = order.size(); + for (auto& df : order) { + for (auto& tree : file->keysIn(df)) { + bytes += serialise(factory, file->filesystem(), df + "/" + tree).bytes->size(); + ++trees; + } + } + auto bytesRead = file->bytesRead(); + auto readCalls = file->readCalls(); + file.reset(); + double wall = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + rusage after; + getrusage(RUSAGE_SELF, &after); + double user = cpuSeconds(after.ru_utime) - cpuSeconds(before.ru_utime); + double sys = cpuSeconds(after.ru_stime) - cpuSeconds(before.ru_stime); +#ifdef __APPLE__ + double maxRssMiB = after.ru_maxrss / 1048576.; +#else + double maxRssMiB = after.ru_maxrss / 1024.; +#endif + std::printf("%s %s: %d DFs, %zu trees, %.1f MB out, read %.1f MB in %llu calls; wall %.2f s, user %.2f s, sys %.2f s, %.0f MB/s out, max RSS %.0f MiB\n", + bench.c_str(), name.c_str(), dfs, trees, bytes / 1e6, bytesRead / 1e6, (unsigned long long)readCalls, + wall, user, sys, bytes / 1e6 / wall, maxRssMiB); + } + return 0; + } + for (auto& file : files) { + auto split = file.find('='); + auto name = file.substr(0, split); + auto rootName = split == std::string::npos ? name : file.substr(split + 1); + auto root = RootAODInputFile::open(rootName, factory); + if (!root) { + std::printf("FAIL %s: ROOT cannot open it\n", name.c_str()); + ++failures; + continue; + } + std::shared_ptr native; + try { + native = openNativeAOD(name, factory, nativeOptions); + } catch (o2::aod::native::FormatError const& e) { + std::printf("FAIL %s: native reader cannot open it: %s\n", name.c_str(), e.what()); + ++failures; + continue; + } + if (root->parentFiles() != native->parentFiles() || root->metaData() != native->metaData()) { + std::printf("FAIL %s: parentFiles or metaData differ\n", name.c_str()); + ++failures; + } + size_t trees = 0, rootBytes = 0, nativeBytes = 0; + auto order = dataFramesOf(*root, maxDFs); + native->setReadOrder(order); + int dfs = order.size(); + for (auto& df : order) { + auto nativeKeys = native->keysIn(df); + if (nativeKeys != root->keysIn(df)) { + std::printf("FAIL %s: keys of %s differ\n", name.c_str(), df.c_str()); + ++failures; + continue; + } + for (auto& tree : nativeKeys) { + auto path = df + "/" + tree; + try { + auto r = serialise(factory, root->filesystem(), path); + auto n = serialise(factory, native->filesystem(), path); + ++trees; + rootBytes += r.bytes->size(); + nativeBytes += n.bytes->size(); + if (r.capability != "ttree" || n.capability != "native-ttree") { + std::printf("FAIL %s %s: read by %s and %s\n", name.c_str(), path.c_str(), r.capability.c_str(), n.capability.c_str()); + ++failures; + } else if (!r.bytes->Equals(*n.bytes)) { + std::printf("FAIL %s %s: %s\n", name.c_str(), path.c_str(), describeDifference(r, n).c_str()); + ++failures; + } + } catch (std::exception const& e) { + std::printf("FAIL %s %s: %s\n", name.c_str(), path.c_str(), e.what()); + ++failures; + } + } + } + std::printf("%s: %d DFs, %zu trees, %zu / %zu bytes serialised (root / native), native read %llu bytes in %llu calls\n", + name.c_str(), dfs, trees, rootBytes, nativeBytes, (unsigned long long)native->bytesRead(), (unsigned long long)native->readCalls()); + } + std::printf("%s: %d failure(s)\n", failures ? "FAILED" : "OK", failures); + return failures ? 1 : 0; +} diff --git a/Framework/CMakeLists.txt b/Framework/CMakeLists.txt index 9e1a1d9cee35e..a25d378d24571 100644 --- a/Framework/CMakeLists.txt +++ b/Framework/CMakeLists.txt @@ -17,6 +17,8 @@ add_subdirectory(Core) add_subdirectory(Utils) +add_subdirectory(AODNative) + add_subdirectory(AnalysisSupport) add_subdirectory(CCDBSupport) add_subdirectory(PhysicsSupport) diff --git a/Framework/Core/src/WorkflowHelpers.cxx b/Framework/Core/src/WorkflowHelpers.cxx index 86e1e8c09dee5..c87330f9dbbca 100644 --- a/Framework/Core/src/WorkflowHelpers.cxx +++ b/Framework/Core/src/WorkflowHelpers.cxx @@ -223,6 +223,10 @@ void WorkflowHelpers::injectServiceDevices(WorkflowSpec& workflow, ConfigContext .algorithm = AlgorithmSpec::dummyAlgorithm(), .options = {ConfigParamSpec{"aod-file-private", VariantType::String, ctx.options().get("aod-file"), {"AOD file"}}, ConfigParamSpec{"aod-max-io-rate", VariantType::Float, 0.f, {"Maximum I/O rate in MB/s"}}, + ConfigParamSpec{"aod-reader-backend", VariantType::String, "root", {"AOD reader: root, native (without ROOT), or auto (native, falling back to root per file)"}}, + ConfigParamSpec{"aod-reader-threads", VariantType::Int, 1, {"Decompression threads of the native AOD reader"}}, + ConfigParamSpec{"aod-reader-prefetch", VariantType::Int, 1, {"DFs the native AOD reader prepares ahead for remote files: 0 (off) or 1"}}, + ConfigParamSpec{"aod-reader-local-io", VariantType::String, "pread", {"How the native AOD reader reads local files: pread or mmap"}}, ConfigParamSpec{"aod-reader-json", VariantType::String, {"json configuration file"}}, ConfigParamSpec{"time-limit", VariantType::Int64, 0ll, {"Maximum run time limit in seconds"}}, ConfigParamSpec{"orbit-offset-enumeration", VariantType::Int64, 0ll, {"initial value for the orbit"}}, diff --git a/Framework/Core/src/runDataProcessing.cxx b/Framework/Core/src/runDataProcessing.cxx index 3f746b1d76f48..c4b2cb892f79d 100644 --- a/Framework/Core/src/runDataProcessing.cxx +++ b/Framework/Core/src/runDataProcessing.cxx @@ -2085,6 +2085,10 @@ int runStateMachine(DataProcessorSpecs const& workflow, "--aod-writer-maxfilesize", "--aod-writer-keep", "--aod-max-io-rate", + "--aod-reader-backend", + "--aod-reader-threads", + "--aod-reader-prefetch", + "--aod-reader-local-io", "--aod-parent-access-level", "--aod-parent-base-path-replacement", "--aod-origin-level-mapping", diff --git a/dependencies/O2Dependencies.cmake b/dependencies/O2Dependencies.cmake index b46dabb5690ff..8a0283ccb645f 100644 --- a/dependencies/O2Dependencies.cmake +++ b/dependencies/O2Dependencies.cmake @@ -204,6 +204,14 @@ set_package_properties(RapidJSON PROPERTIES TYPE REQUIRED) find_package(CURL) set_package_properties(CURL PROPERTIES TYPE REQUIRED) +# Codecs for the ROOT-free AOD reader (Framework/AODNative) +find_package(ZLIB) +set_package_properties(ZLIB PROPERTIES TYPE REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(zstd REQUIRED IMPORTED_TARGET libzstd) +pkg_check_modules(lz4 REQUIRED IMPORTED_TARGET liblz4) +pkg_check_modules(lzma REQUIRED IMPORTED_TARGET liblzma) + find_package(TBB) set_package_properties(TBB PROPERTIES TYPE REQUIRED)