Files
Jungfraujoch/reader/HDF5ImageSource.h
T
leonarski_fandClaude Opus 5.5 aa3fa6b9c7 Reader: stream the frame data into the page cache ahead of the image loops
A cold run on a spinning disk waited on the disk twice over. The CBF header
scan read 256 kB from every frame on eight threads that each strode through
their own share of the sweep, so they drifted apart and the scan became a
seek storm (34 s for 2400 frames here); and after it, the pre-scan and the
first-pass indexing touch a few hundred frames and leave the disk idle until
the first image loop reads everything at seek-bound rates.

- ReadAhead (reader/): once the dataset is open, rugnux starts eight threads
  that read the data files - HDF5 data files (legacy, VDS or the integrated
  master) or the per-frame CBF/marCCD/SMV files - in 4 MB pieces taken
  strictly in order, into a throwaway buffer. One stream reads this disk at
  125 MB/s, eight in-order streams at 190 MB/s, 32 at 157 MB/s. It never gets
  more than a quarter of MemAvailable (GlobalMemoryStatusEx on Windows, 4 GiB
  where there is no figure) ahead of what ReadRawImage has handed out, so a
  dataset bigger than the cache does not evict its own start, and it stops
  with the reader. Plain ifstream reads: portable, no POSIX calls.
- Header scans (CBF, marCCD, SMV) hand the files out in order from an atomic
  counter (sweep::ForEachInOrder) instead of striding: 18 s -> 12 s for 2400
  cold CBF headers. The CBF header is first read with a 16 kB probe and again
  with the old 256 kB one only when the separator is not in it, so the parsed
  header is exactly what it was: 12 s -> 6 s.

Output unchanged: p.hkl, p.mtz and p_unmerged.mtz md5-identical to the
rc173 baseline on 6toc (CBF, 2400 frames, 6.0 GB) and 9q41 (HDF5 VDS, 900
frames, 5.1 GB), and on 6z9g (HDF5, 12.8 GB) to the unmodified branch; myob
(p.hkl p.mtz p_P1.mtz p_unmerged.mtz) md5-identical to the reference.

Measured cold (files evicted with POSIX_FADV_DONTNEED before every run),
same code without this commit vs with it, on a shared box (load 20-70, other
agents reading the same disk, so single runs scatter by +-20 s):
  6toc  wall 61.7/62.7 -> 49.3/49.8 s (clean pairs); all data resident
        after 62/51/50 -> 45/41/42 s
  9q41  wall 67.3 -> 57.8 s (clean pair); resident after 58/46/43 -> 48/37/35 s
  6z9g  resident after 81 -> 69 s
The first image loop can look slower with this in CBF runs: the old 256 kB
header probes pulled ~70% of the data in as kernel readahead, so the old
loop started warm - after a 34 s header scan instead of 10 s.
Warm (myob, NVMe, cached): 19.35/20.00 s without, 19.76-20.16 s with; the
read-ahead then only copies 9.3 GB out of the page cache, 0.44 s wall and
3.4 CPU-s measured standalone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 10:32:58 +02:00

145 lines
6.9 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "HDF5ImageLocator.h"
#include "../common/CompressedImage.h"
// Raw-pixel side of the reader. Turns a global image number into a CompressedImage, using
// HDF5ImageLocator to find the file (with its open-file cache). This is the part whose "links
// to files stay" constant: switching which master the per-image metadata is read from never
// touches it. Caller must hold the global hdf5_mutex (HDF5 is not thread-safe).
// Bit depth and signedness of /entry/data/data as it is STORED. Deliberately not the same thing as
// the experiment's image format: the reader hands every image out in a signed 32-bit container
// whatever the file holds (see HDF5MetadataSource), so an output file that links to the original
// images rather than writing its own must describe them with this, not with the experiment.
struct StoredPixelFormat {
int64_t bit_depth = 0;
bool is_signed = false;
};
class HDF5ImageSource {
public:
// Plain positional-read handle on a data file, opened alongside the HDF5 one. Owns the handle.
class RawFile {
public:
explicit RawFile(const std::string &path);
~RawFile();
RawFile(const RawFile &) = delete;
RawFile &operator=(const RawFile &) = delete;
bool IsOpen() const { return handle_ != -1; }
// Read `size` bytes from byte `address`. Positional and stateless, so any number of threads
// may call it on the same handle at once. Throws on a short read.
void ReadAt(void *dst, size_t size, uint64_t address) const;
private:
intptr_t handle_ = -1; // a file descriptor on POSIX, a HANDLE on Windows
};
// Where the bytes of one image are, and what they decode to. Everything needed to read an image
// without calling HDF5 again.
struct DirectChunk {
// Shared, not borrowed: GetRawImage drops the HDF5 lock before reading through this, so a
// concurrent Clear() - which ReadFile() and Close() both do - would otherwise free the file
// and close its descriptor under the reader.
std::shared_ptr<const RawFile> file;
uint64_t address = 0;
uint32_t size = 0;
hsize_t width = 0;
hsize_t height = 0;
CompressedImageMode mode{};
CompressionAlgorithm algorithm = CompressionAlgorithm::NO_COMPRESSION;
};
void Configure(HDF5ImageLocator::Layout layout);
void Clear();
[[nodiscard]] StoredPixelFormat GetStoredPixelFormat() const;
// Where image `global` physically lives. Also used by the metadata source to find the data
// file that holds a legacy/VDS image's per-image metadata.
HDF5ImageLocator::Location Resolve(int64_t global) const;
// The files holding the pixels, in image order.
std::vector<std::string> DataFiles() const { return locator_.DataFiles(); }
// Read the pixels at a resolved location into a CompressedImage backed by `buffer`. Templated on
// the allocator so a caller can hand over a buffer that does not zero what it is about to
// overwrite (RawByteBuffer).
template<class Alloc>
CompressedImage ReadImageAt(std::vector<uint8_t, Alloc> &buffer, const HDF5ImageLocator::Location &loc) const {
const auto &ds = GetDataset(loc);
std::vector<hsize_t> start = {static_cast<hsize_t>(loc.local_index), 0, 0};
std::vector<hsize_t> size = {1, ds.height, ds.width};
if (ds.multichannel) {
start.insert(start.begin() + 1, loc.channel);
size.insert(size.begin() + 1, 1);
}
if (ds.direct_chunk)
ds.dataset->ReadDirectChunk(buffer, start);
else
ds.dataset->ReadVectorToU8(buffer, start, size);
return {buffer.data(), buffer.size(), ds.width, ds.height, ds.mode, ds.algorithm};
}
// Ask HDF5 where image `loc` is in the file rather than asking it for the image. This is a
// lookup in the chunk index and nothing else - no read - so the mutex is held for a fraction of
// what an actual read costs, and the read itself then happens on any number of threads at once
// through ReadDirect(). Caller must hold hdf5_mutex.
//
// Empty when this file cannot be served that way: one chunk per image is what makes an image a
// single contiguous run of bytes, and a chunk that has never been written has no address at all.
// The caller falls back to ReadImageAt() then.
std::optional<DirectChunk> PrepareDirectRead(const HDF5ImageLocator::Location &loc) const;
// Read what PrepareDirectRead() found. Touches no HDF5 and no shared state, so it needs no
// mutex; this is the whole point of the two-step split.
static CompressedImage ReadDirect(RawByteBuffer &buffer, const DirectChunk &chunk);
std::vector<HDF5DataSourceMessage> GetSourceMapping(uint64_t first_image,
std::optional<uint64_t> image_count,
uint64_t total_images,
uint64_t stride = 1) const;
private:
HDF5ImageLocator locator_;
// /entry/data/data and everything asked of it here - its rank and dimensions, its element type,
// its chunking, its compression - are properties of the file, identical for every image in it.
// They used to be looked up again for each image: four HDF5 object opens per frame, inside the
// global hdf5_mutex that every worker thread queues on. Resolve them once per file instead.
//
// The entry keeps the file alive, so the pointer it is keyed by cannot be recycled underneath it
// and the dataset handle cannot outlive the file it belongs to.
struct OpenDataset {
std::shared_ptr<HDF5ReadOnlyFile> file;
std::unique_ptr<HDF5DataSet> dataset;
std::shared_ptr<RawFile> raw;
// HDF5 addresses count from the end of the user block, so they are file offsets only once
// its size is added. Zero for everything this project writes, but not for every file.
uint64_t user_block = 0;
hsize_t width = 0;
hsize_t height = 0;
CompressedImageMode mode{};
CompressionAlgorithm algorithm = CompressionAlgorithm::NO_COMPRESSION;
bool direct_chunk = false;
bool multichannel = false; // 4D: [image, channel, y, x]
};
// Keyed by file AND dataset path: a master whose VDS sources are datasets in itself serves
// several of them out of one file, and keying by file alone would hand back the wrong one.
mutable std::map<std::pair<const HDF5ReadOnlyFile *, std::string>, OpenDataset> dataset_cache_;
const OpenDataset &GetDataset(const HDF5ImageLocator::Location &loc) const;
};