From 3c5a213a8e334fdcb1babb25a1ba91fbace92dc1 Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Mon, 9 Feb 2026 16:48:45 -0500 Subject: [PATCH 01/11] add tools for benchmarking --- CMakeLists.txt | 16 + bench/CMakeLists.txt | 3 + bench/bench_trx_stream.cpp | 1880 ++++++++++++++++++++++++++++++ bench/plot_bench.py | 214 ++++ docs/_static/benchmarks/.gitkeep | 1 + docs/benchmarks.rst | 118 ++ docs/index.rst | 1 + docs/usage.rst | 168 +++ include/trx/trx.h | 151 ++- include/trx/trx.tpp | 634 +++++++++- src/trx.cpp | 284 ++++- tests/test_trx_mmap.cpp | 2 +- tests/test_trx_trxfile.cpp | 39 + 13 files changed, 3437 insertions(+), 74 deletions(-) create mode 100644 bench/CMakeLists.txt create mode 100644 bench/bench_trx_stream.cpp create mode 100644 bench/plot_bench.py create mode 100644 docs/_static/benchmarks/.gitkeep create mode 100644 docs/benchmarks.rst diff --git a/CMakeLists.txt b/CMakeLists.txt index 2dc74c4..bf07eac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,7 @@ endif() option(TRX_USE_CONAN "Should Conan package manager be used?" OFF) option(TRX_BUILD_TESTS "Build trx tests" OFF) option(TRX_BUILD_EXAMPLES "Build trx example commandline programs" ON) +option(TRX_BUILD_BENCHMARKS "Build trx benchmarks" OFF) option(TRX_ENABLE_CLANG_TIDY "Run clang-tidy during builds" OFF) option(TRX_ENABLE_INSTALL "Install trx-cpp targets" ${TRX_IS_TOP_LEVEL}) option(TRX_BUILD_DOCS "Build API documentation with Doxygen/Sphinx" OFF) @@ -148,6 +149,21 @@ if(TRX_BUILD_TESTS) endif() endif() +if(TRX_BUILD_BENCHMARKS) + find_package(benchmark CONFIG QUIET) + if(NOT benchmark_FOUND) + set(BENCHMARK_ENABLE_TESTING OFF CACHE BOOL "Disable benchmark tests" FORCE) + set(BENCHMARK_ENABLE_GTEST_TESTS OFF CACHE BOOL "Disable benchmark gtest" FORCE) + FetchContent_Declare( + benchmark + GIT_REPOSITORY https://github.com/google/benchmark.git + GIT_TAG v1.8.3 + ) + FetchContent_MakeAvailable(benchmark) + endif() + add_subdirectory(bench) +endif() + if(TRX_ENABLE_NIFTI) find_package(ZLIB REQUIRED) add_library(trx-nifti diff --git a/bench/CMakeLists.txt b/bench/CMakeLists.txt new file mode 100644 index 0000000..b493d34 --- /dev/null +++ b/bench/CMakeLists.txt @@ -0,0 +1,3 @@ +add_executable(bench_trx_stream bench_trx_stream.cpp) +target_link_libraries(bench_trx_stream PRIVATE trx benchmark::benchmark) +target_compile_features(bench_trx_stream PRIVATE cxx_std_17) diff --git a/bench/bench_trx_stream.cpp b/bench/bench_trx_stream.cpp new file mode 100644 index 0000000..e664481 --- /dev/null +++ b/bench/bench_trx_stream.cpp @@ -0,0 +1,1880 @@ +// Benchmark TRX streaming workloads for realistic datasets. +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__unix__) || defined(__APPLE__) +#include +#include +#include +#endif + +#include + +namespace { +using Eigen::half; + +constexpr float kMinLengthMm = 20.0f; +constexpr float kMaxLengthMm = 500.0f; +constexpr float kStepMm = 2.0f; +constexpr float kCurvatureSigma = 0.08f; +constexpr float kSlabThicknessMm = 5.0f; +constexpr size_t kSlabCount = 20; + +constexpr std::array kStreamlineCounts = {100000, 500000, 1000000, 5000000, 10000000}; + +struct Fov { + float min_x; + float max_x; + float min_y; + float max_y; + float min_z; + float max_z; +}; + +constexpr Fov kFov{-70.0f, 70.0f, -108.0f, 79.0f, -60.0f, 75.0f}; +constexpr float kRandomMinMm = 10.0f; +constexpr float kRandomMaxMm = 400.0f; + +enum class GroupScenario : int { None = 0, Bundles = 1, Connectome = 2 }; +enum class LengthProfile : int { Mixed = 0, Short = 1, Medium = 2, Long = 3 }; + +constexpr size_t kBundleCount = 80; +constexpr size_t kConnectomeRegions = 100; + +std::string make_temp_path(const std::string &prefix) { + static std::atomic counter{0}; + const auto id = counter.fetch_add(1, std::memory_order_relaxed); + const auto dir = std::filesystem::temp_directory_path(); + return (dir / (prefix + "_" + std::to_string(id) + ".trx")).string(); +} + +std::string make_temp_dir_name(const std::string &prefix) { + static std::atomic counter{0}; + const auto id = counter.fetch_add(1, std::memory_order_relaxed); + const auto dir = std::filesystem::temp_directory_path(); + return (dir / (prefix + "_" + std::to_string(id))).string(); +} + +std::string make_work_dir_name(const std::string &prefix) { + static std::atomic counter{0}; + const auto id = counter.fetch_add(1, std::memory_order_relaxed); +#if defined(__unix__) || defined(__APPLE__) + const auto pid = static_cast(getpid()); +#else + const auto pid = static_cast(0); +#endif + const auto dir = std::filesystem::current_path(); + return (dir / (prefix + "_" + std::to_string(pid) + "_" + std::to_string(id))).string(); +} + +std::string make_status_path(const std::string &prefix) { + static std::atomic counter{0}; + const auto id = counter.fetch_add(1, std::memory_order_relaxed); + const auto dir = std::filesystem::temp_directory_path(); + return (dir / (prefix + "_" + std::to_string(id) + ".txt")).string(); +} + +std::string make_temp_dir_path(const std::string &prefix) { + return trx::make_temp_dir(prefix); +} + +void register_cleanup(const std::string &path); +std::vector list_files(const std::string &dir); + +std::string find_file_by_prefix(const std::string &dir, const std::string &prefix) { + std::error_code ec; + for (const auto &entry : trx::fs::directory_iterator(dir, ec)) { + if (ec) { + break; + } + if (!entry.is_regular_file()) { + continue; + } + const auto filename = entry.path().filename().string(); + if (filename.rfind(prefix, 0) == 0) { + return entry.path().string(); + } + } + return ""; +} + +std::vector list_files(const std::string &dir) { + std::vector files; + std::error_code ec; + if (!trx::fs::exists(dir, ec)) { + return files; + } + for (const auto &entry : trx::fs::directory_iterator(dir, ec)) { + if (ec) { + break; + } + if (!entry.is_regular_file()) { + continue; + } + files.push_back(entry.path().filename().string()); + } + std::sort(files.begin(), files.end()); + return files; +} + +size_t file_size_bytes(const std::string &path) { + std::error_code ec; + if (!trx::fs::exists(path, ec)) { + return 0; + } + if (trx::fs::is_directory(path, ec)) { + size_t total = 0; + for (trx::fs::recursive_directory_iterator it(path, ec), end; it != end; it.increment(ec)) { + if (ec) { + break; + } + if (!it->is_regular_file(ec)) { + continue; + } + total += static_cast(trx::fs::file_size(it->path(), ec)); + if (ec) { + break; + } + } + return total; + } + return static_cast(trx::fs::file_size(path, ec)); +} + +void wait_for_shard_ok(const std::vector &shard_paths, + const std::vector &status_paths, + size_t timeout_ms) { + const auto start = std::chrono::steady_clock::now(); + while (true) { + bool all_ok = true; + for (size_t i = 0; i < shard_paths.size(); ++i) { + const auto ok_path = trx::fs::path(shard_paths[i]) / "SHARD_OK"; + std::error_code ec; + if (!trx::fs::exists(ok_path, ec)) { + all_ok = false; + break; + } + } + if (all_ok) { + return; + } + const auto now = std::chrono::steady_clock::now(); + const auto elapsed_ms = + std::chrono::duration_cast(now - start).count(); + if (elapsed_ms > static_cast(timeout_ms)) { + std::string detail = "Timed out waiting for SHARD_OK"; + for (size_t i = 0; i < status_paths.size(); ++i) { + std::ifstream in(status_paths[i]); + std::string line; + if (in.is_open()) { + std::getline(in, line); + } + if (!line.empty()) { + detail += " shard_" + std::to_string(i) + "=" + line; + } + } + throw std::runtime_error(detail); + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } +} + +void copy_file_append(const std::string &src, const std::string &dst, std::size_t buffer_bytes = 8 * 1024 * 1024) { + std::ifstream in(src, std::ios::binary); + if (!in.is_open()) { + throw std::runtime_error("Failed to open file for read: " + src); + } + std::ofstream out(dst, std::ios::binary | std::ios::out | std::ios::app); + if (!out.is_open()) { + throw std::runtime_error("Failed to open file for append: " + dst); + } + std::vector buffer(buffer_bytes); + while (in) { + in.read(buffer.data(), static_cast(buffer.size())); + const std::streamsize count = in.gcount(); + if (count > 0) { + out.write(buffer.data(), count); + } + } +} + +std::pair read_header_counts(const std::string &dir) { + const auto header_path = trx::fs::path(dir) / "header.json"; + std::ifstream in; + for (int attempt = 0; attempt < 5; ++attempt) { + in.open(header_path); + if (in.is_open()) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + if (!in.is_open()) { + std::error_code ec; + const bool exists = trx::fs::exists(dir, ec); + const auto files = list_files(dir); + const int open_err = errno; + std::string detail = "Failed to open header.json at: " + header_path.string(); + detail += " exists=" + std::string(exists ? "true" : "false"); + detail += " errno=" + std::to_string(open_err) + " msg=" + std::string(std::strerror(open_err)); + if (!files.empty()) { + detail += " files=["; + for (size_t i = 0; i < files.size(); ++i) { + if (i > 0) { + detail += ","; + } + detail += files[i]; + } + detail += "]"; + } + throw std::runtime_error(detail); + } + std::string contents((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + std::string err; + const auto header = json::parse(contents, err); + if (!err.empty()) { + throw std::runtime_error("Failed to parse header.json: " + err); + } + const auto nb_streamlines = static_cast(header["NB_STREAMLINES"].int_value()); + const auto nb_vertices = static_cast(header["NB_VERTICES"].int_value()); + return {nb_streamlines, nb_vertices}; +} + +json read_header_json(const std::string &dir) { + const auto header_path = trx::fs::path(dir) / "header.json"; + std::ifstream in; + for (int attempt = 0; attempt < 5; ++attempt) { + in.open(header_path); + if (in.is_open()) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + if (!in.is_open()) { + std::error_code ec; + const bool exists = trx::fs::exists(dir, ec); + const auto files = list_files(dir); + const int open_err = errno; + std::string detail = "Failed to open header.json at: " + header_path.string(); + detail += " exists=" + std::string(exists ? "true" : "false"); + detail += " errno=" + std::to_string(open_err) + " msg=" + std::string(std::strerror(open_err)); + if (!files.empty()) { + detail += " files=["; + for (size_t i = 0; i < files.size(); ++i) { + if (i > 0) { + detail += ","; + } + detail += files[i]; + } + detail += "]"; + } + throw std::runtime_error(detail); + } + std::string contents((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + std::string err; + const auto header = json::parse(contents, err); + if (!err.empty()) { + throw std::runtime_error("Failed to parse header.json: " + err); + } + return header; +} + +double get_max_rss_kb() { +#if defined(__unix__) || defined(__APPLE__) + rusage usage{}; + if (getrusage(RUSAGE_SELF, &usage) != 0) { + return 0.0; + } +#if defined(__APPLE__) + return static_cast(usage.ru_maxrss) / 1024.0; +#else + return static_cast(usage.ru_maxrss); +#endif +#else + return 0.0; +#endif +} + +size_t parse_env_size(const char *name, size_t default_value) { + const char *raw = std::getenv(name); + if (!raw || raw[0] == '\0') { + return default_value; + } + char *end = nullptr; + const unsigned long long value = std::strtoull(raw, &end, 10); + if (end == raw) { + return default_value; + } + return static_cast(value); +} + +bool parse_env_bool(const char *name, bool default_value) { + const char *raw = std::getenv(name); + if (!raw || raw[0] == '\0') { + return default_value; + } + return std::string(raw) != "0"; +} + +int parse_env_int(const char *name, int default_value) { + const char *raw = std::getenv(name); + if (!raw || raw[0] == '\0') { + return default_value; + } + char *end = nullptr; + const long value = std::strtol(raw, &end, 10); + if (end == raw) { + return default_value; + } + return static_cast(value); +} + +size_t group_count_for(GroupScenario scenario) { + switch (scenario) { + case GroupScenario::Bundles: + return kBundleCount; + case GroupScenario::Connectome: + return (kConnectomeRegions * (kConnectomeRegions - 1)) / 2; + case GroupScenario::None: + default: + return 0; + } +} + +std::size_t buffer_bytes_for_streamlines(std::size_t streamlines) { + if (streamlines >= 5000000) { + return 2ULL * 1024ULL * 1024ULL * 1024ULL; + } + if (streamlines >= 1000000) { + return 256ULL * 1024ULL * 1024ULL; + } + return 16ULL * 1024ULL * 1024ULL; +} + +std::vector streamlines_for_benchmarks() { + const size_t only = parse_env_size("TRX_BENCH_ONLY_STREAMLINES", 0); + if (only > 0) { + return {only}; + } + const size_t max_val = parse_env_size("TRX_BENCH_MAX_STREAMLINES", 10000000); + std::vector counts = {10000000, 5000000, 1000000, 500000, 100000}; + counts.erase(std::remove_if(counts.begin(), counts.end(), [&](size_t v) { return v > max_val; }), counts.end()); + if (counts.empty()) { + counts.push_back(max_val); + } + return counts; +} + +void log_bench_start(const std::string &name, const std::string &details) { + if (!parse_env_bool("TRX_BENCH_LOG", false)) { + return; + } + std::cerr << "[trx-bench] start " << name << " " << details << std::endl; +} + +void log_bench_end(const std::string &name, const std::string &details) { + if (!parse_env_bool("TRX_BENCH_LOG", false)) { + return; + } + std::cerr << "[trx-bench] end " << name << " " << details << std::endl; +} + +void log_bench_config(const std::string &name, size_t threads, size_t batch_size) { + if (!parse_env_bool("TRX_BENCH_LOG", false)) { + return; + } + std::cerr << "[trx-bench] config " << name << " threads=" << threads << " batch=" << batch_size << std::endl; +} + +const std::vector &group_names_for(GroupScenario scenario) { + static const std::vector empty; + static const std::vector bundle_names = []() { + std::vector names; + names.reserve(kBundleCount); + for (size_t i = 1; i <= kBundleCount; ++i) { + names.push_back("Bundle" + std::to_string(i)); + } + return names; + }(); + static const std::vector connectome_names = []() { + std::vector names; + names.reserve((kConnectomeRegions * (kConnectomeRegions - 1)) / 2); + for (size_t i = 1; i <= kConnectomeRegions; ++i) { + for (size_t j = i + 1; j <= kConnectomeRegions; ++j) { + names.push_back("conn_" + std::to_string(i) + "_" + std::to_string(j)); + } + } + return names; + }(); + + switch (scenario) { + case GroupScenario::Bundles: + return bundle_names; + case GroupScenario::Connectome: + return connectome_names; + case GroupScenario::None: + default: + return empty; + } +} + +float sample_length_mm(std::mt19937 &rng, LengthProfile profile) { + auto sample_uniform = [&](float min_val, float max_val) { + std::uniform_real_distribution dist(min_val, max_val); + return dist(rng); + }; + switch (profile) { + case LengthProfile::Short: + return sample_uniform(20.0f, 120.0f); + case LengthProfile::Medium: + return sample_uniform(80.0f, 260.0f); + case LengthProfile::Long: + return sample_uniform(200.0f, 500.0f); + case LengthProfile::Mixed: + default: + return sample_uniform(kMinLengthMm, kMaxLengthMm); + } +} + +size_t estimate_points_per_streamline(LengthProfile profile) { + float mean_length = 0.0f; + switch (profile) { + case LengthProfile::Short: + mean_length = 70.0f; + break; + case LengthProfile::Medium: + mean_length = 170.0f; + break; + case LengthProfile::Long: + mean_length = 350.0f; + break; + case LengthProfile::Mixed: + default: + mean_length = 260.0f; + break; + } + return static_cast(std::ceil(mean_length / kStepMm)) + 1; +} + +std::array random_unit_vector(std::mt19937 &rng) { + std::normal_distribution dist(0.0f, 1.0f); + std::array v{dist(rng), dist(rng), dist(rng)}; + const float norm = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + if (norm < 1e-6f) { + return {1.0f, 0.0f, 0.0f}; + } + v[0] /= norm; + v[1] /= norm; + v[2] /= norm; + return v; +} + +std::vector> generate_streamline_points(std::mt19937 &rng, LengthProfile profile) { + const float length_mm = sample_length_mm(rng, profile); + const size_t point_count = std::max(2, static_cast(std::ceil(length_mm / kStepMm)) + 1); + std::vector> points; + points.reserve(point_count); + + std::uniform_real_distribution dist_x(kRandomMinMm, kRandomMaxMm); + std::uniform_real_distribution dist_y(kRandomMinMm, kRandomMaxMm); + std::uniform_real_distribution dist_z(kRandomMinMm, kRandomMaxMm); + + for (size_t i = 0; i < point_count; ++i) { + points.push_back({dist_x(rng), dist_y(rng), dist_z(rng)}); + } + + return points; +} + +std::vector> generate_streamline_points_seeded(uint32_t seed, LengthProfile profile) { + std::mt19937 rng(seed); + return generate_streamline_points(rng, profile); +} + +size_t bench_threads() { + const size_t requested = parse_env_size("TRX_BENCH_THREADS", 0); + if (requested > 0) { + return requested; + } + const unsigned int hc = std::thread::hardware_concurrency(); + return hc == 0 ? 1U : static_cast(hc); +} + +size_t bench_batch_size() { + return parse_env_size("TRX_BENCH_BATCH", 1000); +} + +template +void generate_streamlines_parallel(size_t streamlines, + LengthProfile profile, + size_t threads, + size_t batch_size, + uint32_t base_seed, + BatchConsumer consumer) { + const size_t total_batches = (streamlines + batch_size - 1) / batch_size; + std::atomic next_batch{0}; + std::mutex mutex; + std::condition_variable cv; + std::map>>> completed; + std::condition_variable cv_producer; + size_t inflight_batches = 0; + const size_t max_inflight = std::max(1, parse_env_size("TRX_BENCH_QUEUE_MAX", 8)); + + auto worker = [&]() { + for (;;) { + size_t batch_idx; + { + // Wait for queue space BEFORE grabbing batch index to avoid missed notifications + std::unique_lock lock(mutex); + cv_producer.wait(lock, [&]() { return inflight_batches < max_inflight || next_batch.load() >= total_batches; }); + batch_idx = next_batch.fetch_add(1); + if (batch_idx >= total_batches) { + return; + } + ++inflight_batches; + } + const size_t start = batch_idx * batch_size; + const size_t count = std::min(batch_size, streamlines - start); + std::vector>> batch; + batch.reserve(count); + for (size_t i = 0; i < count; ++i) { + const uint32_t seed = base_seed + static_cast(start + i); + batch.push_back(generate_streamline_points_seeded(seed, profile)); + } + { + std::lock_guard lock(mutex); + completed.emplace(batch_idx, std::move(batch)); + } + cv.notify_one(); + } + }; + + std::vector workers; + workers.reserve(threads); + for (size_t t = 0; t < threads; ++t) { + workers.emplace_back(worker); + } + + for (size_t expected = 0; expected < total_batches; ++expected) { + std::unique_lock lock(mutex); + cv.wait(lock, [&]() { return completed.find(expected) != completed.end(); }); + auto batch = std::move(completed[expected]); + completed.erase(expected); + if (inflight_batches > 0) { + --inflight_batches; + } + lock.unlock(); + cv_producer.notify_all(); // Wake all waiting workers, not just one, to avoid deadlock + + const size_t start = expected * batch_size; + consumer(start, batch); + } + + for (auto &worker_thread : workers) { + worker_thread.join(); + } +} + +struct TrxWriteStats { + double write_ms = 0.0; + double file_size_bytes = 0.0; +}; + +struct RssSample { + double elapsed_ms = 0.0; + double rss_kb = 0.0; + std::string phase; +}; + +struct FileSizeScenario { + size_t streamlines = 0; + LengthProfile profile = LengthProfile::Mixed; + bool add_dps = false; + bool add_dpv = false; + zip_uint32_t compression = ZIP_CM_STORE; +}; + +std::mutex g_rss_samples_mutex; + +void append_rss_samples(const FileSizeScenario &scenario, const std::vector &samples) { + if (samples.empty()) { + return; + } + const char *path = std::getenv("TRX_RSS_SAMPLES_PATH"); + if (!path || path[0] == '\0') { + return; + } + std::lock_guard lock(g_rss_samples_mutex); + std::ofstream out(path, std::ios::app); + if (!out.is_open()) { + return; + } + + out << "{" + << "\"streamlines\":" << scenario.streamlines << "," + << "\"length_profile\":" << static_cast(scenario.profile) << "," + << "\"dps\":" << (scenario.add_dps ? 1 : 0) << "," + << "\"dpv\":" << (scenario.add_dpv ? 1 : 0) << "," + << "\"compression\":" << (scenario.compression == ZIP_CM_DEFLATE ? 1 : 0) << "," + << "\"samples\":["; + for (size_t i = 0; i < samples.size(); ++i) { + if (i > 0) { + out << ","; + } + out << "{" + << "\"elapsed_ms\":" << samples[i].elapsed_ms << "," + << "\"rss_kb\":" << samples[i].rss_kb << "," + << "\"phase\":\"" << samples[i].phase << "\"" + << "}"; + } + out << "]}\n"; +} + +std::mutex g_cleanup_mutex; +std::vector g_cleanup_paths; +pid_t g_cleanup_owner_pid = 0; +bool g_cleanup_only_on_success = true; +bool g_run_success = false; + +void cleanup_temp_paths() { + if (g_cleanup_only_on_success && !g_run_success) { + return; + } + if (g_cleanup_owner_pid != 0 && getpid() != g_cleanup_owner_pid) { + return; + } + std::error_code ec; + for (const auto &p : g_cleanup_paths) { + std::filesystem::remove_all(p, ec); + } +} + +void register_cleanup(const std::string &path) { + static bool registered = false; + { + std::lock_guard lock(g_cleanup_mutex); + if (g_cleanup_owner_pid == 0) { + g_cleanup_owner_pid = getpid(); + } + g_cleanup_paths.push_back(path); + } + if (!registered) { + registered = true; + std::atexit(cleanup_temp_paths); + } +} + +TrxWriteStats run_trx_file_size(size_t streamlines, + LengthProfile profile, + bool add_dps, + bool add_dpv, + zip_uint32_t compression) { + trx::TrxStream stream("float16"); + stream.set_metadata_mode(trx::TrxStream::MetadataMode::OnDisk); + stream.set_metadata_buffer_max_bytes(64ULL * 1024ULL * 1024ULL); + stream.set_positions_buffer_max_bytes(buffer_bytes_for_streamlines(streamlines)); + + const size_t threads = bench_threads(); + const size_t batch_size = std::max(1, bench_batch_size()); + const uint32_t base_seed = static_cast(1337 + streamlines + static_cast(profile) * 13); + const size_t progress_every = parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 0); + log_bench_config("file_size_generate", threads, batch_size); + + const bool collect_rss = std::getenv("TRX_RSS_SAMPLES_PATH") != nullptr; + const size_t sample_every = parse_env_size("TRX_RSS_SAMPLE_EVERY", 50000); + const int sample_interval_ms = parse_env_int("TRX_RSS_SAMPLE_MS", 500); + std::vector samples; + std::mutex samples_mutex; + const auto bench_start = std::chrono::steady_clock::now(); + auto record_sample = [&](const std::string &phase) { + if (!collect_rss) { + return; + } + const auto now = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = now - bench_start; + std::lock_guard lock(samples_mutex); + samples.push_back({elapsed.count(), get_max_rss_kb(), phase}); + }; + + std::vector dps; + std::vector dpv; + if (add_dps) { + dps.reserve(streamlines); + } + if (add_dpv) { + const size_t estimated_vertices = streamlines * estimate_points_per_streamline(profile); + dpv.reserve(estimated_vertices); + } + + generate_streamlines_parallel( + streamlines, + profile, + threads, + batch_size, + base_seed, + [&](size_t start, const std::vector>> &batch) { + if (parse_env_bool("TRX_BENCH_LOG", false)) { + std::cerr << "[trx-bench] batch file_size start=" << start << " count=" << batch.size() << std::endl; + } + for (size_t i = 0; i < batch.size(); ++i) { + const auto &points = batch[i]; + stream.push_streamline(points); + if (add_dps) { + dps.push_back(1.0f); + } + if (add_dpv) { + dpv.insert(dpv.end(), points.size(), 0.5f); + } + const size_t global_idx = start + i + 1; + if (progress_every > 0 && (global_idx % progress_every == 0)) { + std::cerr << "[trx-bench] progress file_size streamlines=" << global_idx << " / " << streamlines + << std::endl; + } + if (collect_rss && sample_every > 0 && (global_idx % sample_every == 0)) { + record_sample("generate"); + } + } + }); + + if (add_dps) { + stream.push_dps_from_vector("dps_scalar", "float32", dps); + } + if (add_dpv) { + stream.push_dpv_from_vector("dpv_scalar", "float32", dpv); + } + + const std::string out_path = make_temp_path("trx_size"); + record_sample("before_finalize"); + + std::atomic sampling{false}; + std::thread sampler; + if (collect_rss) { + sampling.store(true, std::memory_order_relaxed); + sampler = std::thread([&]() { + while (sampling.load(std::memory_order_relaxed)) { + record_sample("finalize"); + std::this_thread::sleep_for(std::chrono::milliseconds(sample_interval_ms)); + } + }); + } + + const auto start = std::chrono::steady_clock::now(); + stream.finalize(out_path, compression); + const auto end = std::chrono::steady_clock::now(); + + if (collect_rss) { + sampling.store(false, std::memory_order_relaxed); + if (sampler.joinable()) { + sampler.join(); + } + } + record_sample("after_finalize"); + + TrxWriteStats stats; + stats.write_ms = std::chrono::duration(end - start).count(); + std::error_code size_ec; + const auto size = std::filesystem::file_size(out_path, size_ec); + stats.file_size_bytes = size_ec ? 0.0 : static_cast(size); + std::error_code ec; + std::filesystem::remove(out_path, ec); + + if (collect_rss) { + FileSizeScenario scenario; + scenario.streamlines = streamlines; + scenario.profile = profile; + scenario.add_dps = add_dps; + scenario.add_dpv = add_dpv; + scenario.compression = compression; + append_rss_samples(scenario, samples); + } + return stats; +} + +struct TrxOnDisk { + std::string path; + size_t streamlines = 0; + size_t vertices = 0; + double shard_merge_ms = 0.0; + size_t shard_processes = 1; +}; + +TrxOnDisk build_trx_file_on_disk_single(size_t streamlines, + GroupScenario scenario, + bool add_dps, + bool add_dpv, + LengthProfile profile, + zip_uint32_t compression, + const std::string &out_path_override = "", + bool finalize_to_directory = false) { + trx::TrxStream stream("float16"); + stream.set_metadata_mode(trx::TrxStream::MetadataMode::OnDisk); + stream.set_metadata_buffer_max_bytes(64ULL * 1024ULL * 1024ULL); + stream.set_positions_buffer_max_bytes(buffer_bytes_for_streamlines(streamlines)); + const size_t progress_every = parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 0); + + const auto group_count = group_count_for(scenario); + const auto &group_names = group_names_for(scenario); + std::vector> groups(group_count); + + const size_t threads = bench_threads(); + const size_t batch_size = std::max(1, bench_batch_size()); + const uint32_t base_seed = static_cast(1337 + streamlines + static_cast(scenario) * 31); + log_bench_config("build_trx_generate", threads, batch_size); + + std::vector dps; + std::vector dpv; + if (add_dps) { + dps.reserve(streamlines); + } + if (add_dpv) { + const size_t estimated_vertices = streamlines * estimate_points_per_streamline(profile); + dpv.reserve(estimated_vertices); + } + + size_t total_vertices = 0; + generate_streamlines_parallel( + streamlines, + profile, + threads, + batch_size, + base_seed, + [&](size_t start, const std::vector>> &batch) { + if (parse_env_bool("TRX_BENCH_LOG", false)) { + std::cerr << "[trx-bench] batch build_trx start=" << start << " count=" << batch.size() << std::endl; + } + for (size_t i = 0; i < batch.size(); ++i) { + const auto &points = batch[i]; + total_vertices += points.size(); + stream.push_streamline(points); + if (add_dps) { + dps.push_back(1.0f); + } + if (add_dpv) { + dpv.insert(dpv.end(), points.size(), 0.5f); + } + const size_t global_idx = start + i; + if (group_count > 0) { + groups[global_idx % group_count].push_back(static_cast(global_idx)); + } + if (progress_every > 0 && ((global_idx + 1) % progress_every == 0)) { + if (parse_env_bool("TRX_BENCH_CHILD_LOG", false) || parse_env_bool("TRX_BENCH_LOG", false)) { + const char *shard_env = std::getenv("TRX_BENCH_SHARD_INDEX"); + const std::string shard_prefix = shard_env ? std::string(" shard=") + shard_env : ""; + std::cerr << "[trx-bench] progress build_trx" << shard_prefix << " streamlines=" << (global_idx + 1) + << " / " << streamlines << std::endl; + } + } + } + }); + + if (add_dps) { + stream.push_dps_from_vector("dps_scalar", "float32", dps); + } + if (add_dpv) { + stream.push_dpv_from_vector("dpv_scalar", "float32", dpv); + } + if (group_count > 0) { + for (size_t g = 0; g < group_count; ++g) { + stream.push_group_from_indices(group_names[g], groups[g]); + } + } + + const std::string out_path = out_path_override.empty() ? make_temp_path("trx_input") : out_path_override; + if (finalize_to_directory) { + // Use persistent variant to avoid removing pre-created shard directories + stream.finalize_directory_persistent(out_path); + } else { + stream.finalize(out_path, compression); + } + if (out_path_override.empty() && !finalize_to_directory) { + register_cleanup(out_path); + } + return {out_path, streamlines, total_vertices, 0.0, 1}; +} + +void build_trx_shard(const std::string &out_path, + size_t streamlines, + GroupScenario scenario, + bool add_dps, + bool add_dpv, + LengthProfile profile, + zip_uint32_t compression) { + (void)build_trx_file_on_disk_single(streamlines, + scenario, + add_dps, + add_dpv, + profile, + compression, + out_path, + true); + + // Defensive validation: ensure all required files were written by finalize_directory_persistent + std::error_code ec; + const auto header_path = trx::fs::path(out_path) / "header.json"; + if (!trx::fs::exists(header_path, ec)) { + throw std::runtime_error("Shard missing header.json after finalize_directory_persistent: " + header_path.string()); + } + const auto positions_path = find_file_by_prefix(out_path, "positions."); + if (positions_path.empty()) { + throw std::runtime_error("Shard missing positions after finalize_directory_persistent: " + out_path); + } + const auto offsets_path = find_file_by_prefix(out_path, "offsets."); + if (offsets_path.empty()) { + throw std::runtime_error("Shard missing offsets after finalize_directory_persistent: " + out_path); + } + const auto ok_path = trx::fs::path(out_path) / "SHARD_OK"; + std::ofstream ok(ok_path, std::ios::out | std::ios::trunc); + if (ok.is_open()) { + ok << "ok\n"; + ok.flush(); + ok.close(); + } + + // Force filesystem sync to ensure all shard data is visible to parent process +#if defined(__unix__) || defined(__APPLE__) + sync(); + // Brief sleep to ensure filesystem metadata updates are visible across processes + std::this_thread::sleep_for(std::chrono::milliseconds(50)); +#endif +} + +TrxOnDisk build_trx_file_on_disk(size_t streamlines, + GroupScenario scenario, + bool add_dps, + bool add_dpv, + LengthProfile profile, + zip_uint32_t compression) { + size_t processes = parse_env_size("TRX_BENCH_PROCESSES", 1); + const size_t mp_min_streamlines = parse_env_size("TRX_BENCH_MP_MIN_STREAMLINES", 1000000); + if (streamlines < mp_min_streamlines) { + processes = 1; + } + if (processes <= 1) { + return build_trx_file_on_disk_single(streamlines, scenario, add_dps, add_dpv, profile, compression); + } +#if defined(__unix__) || defined(__APPLE__) + g_cleanup_owner_pid = getpid(); + const std::string shard_root = make_work_dir_name("trx_shards"); + { + std::error_code ec; + trx::fs::create_directories(shard_root, ec); + if (ec) { + throw std::runtime_error("Failed to create shard root: " + shard_root); + } + } + { + const std::string marker = shard_root + trx::SEPARATOR + "SHARD_ROOT_CREATED"; + std::ofstream out(marker, std::ios::out | std::ios::trunc); + if (out.is_open()) { + out << "ok\n"; + out.flush(); + out.close(); + } + } + if (parse_env_bool("TRX_BENCH_LOG", false)) { + std::cerr << "[trx-bench] shard_root " << shard_root << std::endl; + } + std::vector counts(processes, streamlines / processes); + const size_t remainder = streamlines % processes; + for (size_t i = 0; i < remainder; ++i) { + counts[i] += 1; + } + + std::vector shard_paths(processes); + std::vector status_paths(processes); + for (size_t i = 0; i < processes; ++i) { + shard_paths[i] = shard_root + trx::SEPARATOR + "shard_" + std::to_string(i); + status_paths[i] = shard_root + trx::SEPARATOR + "shard_" + std::to_string(i) + ".status"; + + // Pre-create shard directories to validate filesystem writability before forking. + // finalize_directory_persistent() will use these existing directories without + // removing them, avoiding race conditions in the multiprocess workflow. + std::error_code ec; + trx::fs::create_directories(shard_paths[i], ec); + if (ec) { + throw std::runtime_error("Failed to create shard dir: " + shard_paths[i] + " " + ec.message()); + } + std::ofstream status(status_paths[i], std::ios::out | std::ios::trunc); + if (status.is_open()) { + status << "pending\n"; + } + } + if (parse_env_bool("TRX_BENCH_LOG", false)) { + for (size_t i = 0; i < processes; ++i) { + std::cerr << "[trx-bench] shard_path[" << i << "] " << shard_paths[i] << std::endl; + } + } + + std::vector pids; + pids.reserve(processes); + for (size_t i = 0; i < processes; ++i) { + const pid_t pid = fork(); + if (pid == 0) { + try { + setenv("TRX_BENCH_THREADS", "1", 1); + setenv("TRX_BENCH_BATCH", "1000", 1); + setenv("TRX_BENCH_LOG", "0", 1); + setenv("TRX_BENCH_SHARD_INDEX", std::to_string(i).c_str(), 1); + if (parse_env_bool("TRX_BENCH_LOG", false)) { + std::cerr << "[trx-bench] shard_child_start path=" << shard_paths[i] << std::endl; + } + { + std::ofstream status(status_paths[i], std::ios::out | std::ios::trunc); + if (status.is_open()) { + status << "started pid=" << getpid() << "\n"; + status.flush(); + } + } + build_trx_shard(shard_paths[i], counts[i], scenario, add_dps, add_dpv, profile, compression); + { + std::ofstream status(status_paths[i], std::ios::out | std::ios::trunc); + if (status.is_open()) { + status << "ok\n"; + status.flush(); + } + } + _exit(0); + } catch (const std::exception &ex) { + std::ofstream out(status_paths[i], std::ios::out | std::ios::trunc); + if (out.is_open()) { + out << ex.what() << "\n"; + out.flush(); + out.close(); + } + _exit(1); + } catch (...) { + std::ofstream out(status_paths[i], std::ios::out | std::ios::trunc); + if (out.is_open()) { + out << "Unknown error\n"; + out.flush(); + out.close(); + } + _exit(1); + } + } + if (pid < 0) { + throw std::runtime_error("Failed to fork shard process"); + } + pids.push_back(pid); + } + + for (size_t i = 0; i < pids.size(); ++i) { + const auto pid = pids[i]; + int status = 0; + waitpid(pid, &status, 0); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + std::string detail; + std::ifstream in(status_paths[i]); + if (in.is_open()) { + std::getline(in, detail); + } + if (detail.empty()) { + detail = "No status file content"; + } + throw std::runtime_error("Shard process failed: " + detail); + } + } + + const size_t shard_wait_ms = parse_env_size("TRX_BENCH_SHARD_WAIT_MS", 10000); + wait_for_shard_ok(shard_paths, status_paths, shard_wait_ms); + + size_t total_vertices = 0; + size_t total_streamlines = 0; + std::vector shard_vertices(processes, 0); + std::vector shard_streamlines(processes, 0); + for (size_t i = 0; i < processes; ++i) { + const auto ok_path = trx::fs::path(shard_paths[i]) / "SHARD_OK"; + std::error_code ok_ec; + if (!trx::fs::exists(ok_path, ok_ec)) { + std::string detail; + std::ifstream in(status_paths[i]); + if (in.is_open()) { + std::getline(in, detail); + } + if (detail.empty()) { + detail = "SHARD_OK missing for " + shard_paths[i]; + } + throw std::runtime_error("Shard process failed: " + detail); + } + std::error_code ec; + if (!trx::fs::exists(shard_paths[i], ec) || !trx::fs::is_directory(shard_paths[i], ec)) { + const auto root_files = list_files(shard_root); + std::string detail = "Shard output directory missing: " + shard_paths[i]; + if (!root_files.empty()) { + detail += " root_files=["; + for (size_t j = 0; j < root_files.size(); ++j) { + if (j > 0) { + detail += ","; + } + detail += root_files[j]; + } + detail += "]"; + } + throw std::runtime_error(detail); + } + const auto header_path = trx::fs::path(shard_paths[i]) / "header.json"; + if (!trx::fs::exists(header_path, ec)) { + const auto files = list_files(shard_paths[i]); + std::string detail = "Shard missing header.json: " + header_path.string(); + if (!files.empty()) { + detail += " files=["; + for (size_t j = 0; j < files.size(); ++j) { + if (j > 0) { + detail += ","; + } + detail += files[j]; + } + detail += "]"; + } + const auto root_files = list_files(shard_root); + if (!root_files.empty()) { + detail += " root_files=["; + for (size_t j = 0; j < root_files.size(); ++j) { + if (j > 0) { + detail += ","; + } + detail += root_files[j]; + } + detail += "]"; + } + throw std::runtime_error(detail); + } + const auto counts = read_header_counts(shard_paths[i]); + shard_streamlines[i] = counts.first; + shard_vertices[i] = counts.second; + total_streamlines += counts.first; + total_vertices += counts.second; + } + + const auto merge_start = std::chrono::steady_clock::now(); + const auto group_count = group_count_for(scenario); + const auto &group_names = group_names_for(scenario); + + const std::string merge_dir = make_temp_dir_path("trx_merge"); + const auto shard_positions0 = find_file_by_prefix(shard_paths[0], "positions."); + const auto shard_offsets0 = find_file_by_prefix(shard_paths[0], "offsets."); + if (shard_positions0.empty()) { + throw std::runtime_error("Missing positions file in first shard: " + shard_paths[0]); + } + if (shard_offsets0.empty()) { + throw std::runtime_error("Missing offsets file in first shard: " + shard_paths[0]); + } + const auto positions_filename = trx::fs::path(shard_positions0).filename().string(); + const auto offsets_filename = trx::fs::path(shard_offsets0).filename().string(); + const auto positions_path = trx::fs::path(merge_dir) / positions_filename; + const auto offsets_path = trx::fs::path(merge_dir) / offsets_filename; + + { + std::ofstream out_pos(positions_path, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out_pos.is_open()) { + throw std::runtime_error("Failed to open output positions file: " + positions_path.string()); + } + } + { + std::ofstream out_off(offsets_path, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out_off.is_open()) { + throw std::runtime_error("Failed to open output offsets file: " + offsets_path.string()); + } + } + + std::vector dps_files; + std::vector dpv_files; + if (add_dps) { + dps_files = list_files((trx::fs::path(shard_paths[0]) / "dps").string()); + if (dps_files.empty()) { + throw std::runtime_error("No DPS files found in shard: " + shard_paths[0]); + } + } + if (add_dpv) { + dpv_files = list_files((trx::fs::path(shard_paths[0]) / "dpv").string()); + if (dpv_files.empty()) { + throw std::runtime_error("No DPV files found in shard: " + shard_paths[0]); + } + } + std::vector group_files; + if (group_count > 0) { + group_files = list_files((trx::fs::path(shard_paths[0]) / "groups").string()); + if (group_files.empty()) { + throw std::runtime_error("No group files found in shard: " + shard_paths[0]); + } + } + + if (add_dps) { + trx::fs::create_directories(trx::fs::path(merge_dir) / "dps"); + for (const auto &name : dps_files) { + const auto dst = trx::fs::path(merge_dir) / "dps" / name; + std::ofstream out(dst, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to create DPS file: " + dst.string()); + } + } + } + if (add_dpv) { + trx::fs::create_directories(trx::fs::path(merge_dir) / "dpv"); + for (const auto &name : dpv_files) { + const auto dst = trx::fs::path(merge_dir) / "dpv" / name; + std::ofstream out(dst, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to create DPV file: " + dst.string()); + } + } + } + if (group_count > 0) { + trx::fs::create_directories(trx::fs::path(merge_dir) / "groups"); + for (const auto &name : group_files) { + const auto dst = trx::fs::path(merge_dir) / "groups" / name; + std::ofstream out(dst, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to create group file: " + dst.string()); + } + } + } + + size_t vertex_offset = 0; + size_t streamline_offset = 0; + for (size_t i = 0; i < processes; ++i) { + const auto shard_dir = shard_paths[i]; + const auto shard_positions = find_file_by_prefix(shard_dir, "positions."); + const auto shard_offsets = find_file_by_prefix(shard_dir, "offsets."); + if (shard_positions.empty()) { + throw std::runtime_error("Missing positions file in shard: " + shard_dir); + } + if (shard_offsets.empty()) { + throw std::runtime_error("Missing offsets file in shard: " + shard_dir); + } + + copy_file_append(shard_positions, positions_path.string()); + + { + const bool offsets_u32 = offsets_filename.find("uint32") != std::string::npos; + std::ifstream in(shard_offsets, std::ios::binary); + if (!in.is_open()) { + throw std::runtime_error("Failed to open shard offsets: " + shard_offsets); + } + std::ofstream out(offsets_path, std::ios::binary | std::ios::out | std::ios::app); + if (!out.is_open()) { + throw std::runtime_error("Failed to open output offsets file: " + offsets_path.string()); + } + constexpr size_t kBatch = 1 << 14; + const bool skip_first_value = (i != 0); + bool skipped_first = false; + if (offsets_u32) { + std::vector buffer(kBatch); + while (in) { + in.read(reinterpret_cast(buffer.data()), + static_cast(buffer.size() * sizeof(uint32_t))); + const std::streamsize count = in.gcount(); + if (count <= 0) { + break; + } + const size_t elems = static_cast(count) / sizeof(uint32_t); + size_t start = 0; + if (skip_first_value && !skipped_first) { + start = 1; + skipped_first = true; + } + for (size_t j = start; j < elems; ++j) { + const uint64_t value = static_cast(buffer[j]) + static_cast(vertex_offset); + if (value > std::numeric_limits::max()) { + throw std::runtime_error("Offsets overflow uint32 during merge."); + } + buffer[j] = static_cast(value); + } + if (elems > start) { + out.write(reinterpret_cast(buffer.data() + start), + static_cast((elems - start) * sizeof(uint32_t))); + } + } + } else { + std::vector buffer(kBatch); + while (in) { + in.read(reinterpret_cast(buffer.data()), + static_cast(buffer.size() * sizeof(uint64_t))); + const std::streamsize count = in.gcount(); + if (count <= 0) { + break; + } + const size_t elems = static_cast(count) / sizeof(uint64_t); + size_t start = 0; + if (skip_first_value && !skipped_first) { + start = 1; + skipped_first = true; + } + for (size_t j = start; j < elems; ++j) { + buffer[j] += static_cast(vertex_offset); + } + if (elems > start) { + out.write(reinterpret_cast(buffer.data() + start), + static_cast((elems - start) * sizeof(uint64_t))); + } + } + } + } + + if (add_dps) { + const auto shard_dps = trx::fs::path(shard_dir) / "dps"; + for (const auto &name : dps_files) { + const auto src = shard_dps / name; + const auto dst = trx::fs::path(merge_dir) / "dps" / name; + if (!trx::fs::exists(src)) { + throw std::runtime_error("Missing DPS file in shard: " + src.string()); + } + copy_file_append(src.string(), dst.string()); + } + } + + if (add_dpv) { + const auto shard_dpv = trx::fs::path(shard_dir) / "dpv"; + for (const auto &name : dpv_files) { + const auto src = shard_dpv / name; + const auto dst = trx::fs::path(merge_dir) / "dpv" / name; + if (!trx::fs::exists(src)) { + throw std::runtime_error("Missing DPV file in shard: " + src.string()); + } + copy_file_append(src.string(), dst.string()); + } + } + + if (group_count > 0) { + const auto shard_groups = trx::fs::path(shard_dir) / "groups"; + for (const auto &name : group_files) { + const auto src = shard_groups / name; + const auto dst = trx::fs::path(merge_dir) / "groups" / name; + if (!trx::fs::exists(src)) { + throw std::runtime_error("Missing group file in shard: " + src.string()); + } + std::ifstream in(src, std::ios::binary); + if (!in.is_open()) { + throw std::runtime_error("Failed to open shard group: " + src.string()); + } + std::ofstream out(dst, std::ios::binary | std::ios::out | std::ios::app); + if (!out.is_open()) { + throw std::runtime_error("Failed to open output group file: " + dst.string()); + } + constexpr size_t kBatch = 1 << 14; + std::vector buffer(kBatch); + while (in) { + in.read(reinterpret_cast(buffer.data()), + static_cast(buffer.size() * sizeof(uint32_t))); + const std::streamsize count = in.gcount(); + if (count <= 0) { + break; + } + const size_t elems = static_cast(count) / sizeof(uint32_t); + for (size_t j = 0; j < elems; ++j) { + buffer[j] += static_cast(streamline_offset); + } + out.write(reinterpret_cast(buffer.data()), + static_cast(elems * sizeof(uint32_t))); + } + } + } + + vertex_offset += shard_vertices[i]; + streamline_offset += shard_streamlines[i]; + } + + // Read header before cleanup to avoid accessing deleted files + const json header_json = read_header_json(shard_paths[0]); + json::object header_obj = header_json.object_items(); + header_obj["NB_VERTICES"] = json(static_cast(total_vertices)); + header_obj["NB_STREAMLINES"] = json(static_cast(total_streamlines)); + const json header = header_obj; + { + const auto header_path = trx::fs::path(merge_dir) / "header.json"; + std::ofstream out(header_path, std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to write header.json: " + header_path.string()); + } + out << header.dump(); + } + + const std::string zip_path = make_temp_path("trx_input"); + int errorp; + zip_t *zf = zip_open(zip_path.c_str(), ZIP_CREATE + ZIP_TRUNCATE, &errorp); + if (zf == nullptr) { + throw std::runtime_error("Could not open archive " + zip_path + ": " + strerror(errorp)); + } + const std::string header_payload = header.dump() + "\n"; + zip_source_t *header_source = + zip_source_buffer(zf, header_payload.data(), header_payload.size(), 0 /* do not free */); + if (header_source == nullptr) { + zip_close(zf); + throw std::runtime_error("Failed to create zip source for header.json: " + std::string(zip_strerror(zf))); + } + const zip_int64_t header_idx = zip_file_add(zf, "header.json", header_source, ZIP_FL_ENC_UTF_8 | ZIP_FL_OVERWRITE); + if (header_idx < 0) { + zip_source_free(header_source); + zip_close(zf); + throw std::runtime_error("Failed to add header.json to archive: " + std::string(zip_strerror(zf))); + } + const zip_int32_t compression_mode = static_cast(compression); + if (zip_set_file_compression(zf, header_idx, compression_mode, 0) < 0) { + zip_close(zf); + throw std::runtime_error("Failed to set compression for header.json: " + std::string(zip_strerror(zf))); + } + const std::unordered_set skip = {"header.json"}; + trx::zip_from_folder(zf, merge_dir, merge_dir, compression, &skip); + if (zip_close(zf) != 0) { + throw std::runtime_error("Unable to close archive " + zip_path + ": " + zip_strerror(zf)); + } + trx::fs::remove_all(merge_dir); + const std::string out_path = zip_path; + + register_cleanup(out_path); + const auto merge_end = std::chrono::steady_clock::now(); + const std::chrono::duration merge_elapsed = merge_end - merge_start; + + // Final cleanup of shard directories after merge is complete + if (!parse_env_bool("TRX_BENCH_KEEP_SHARDS", false)) { + std::error_code ec; + trx::fs::remove_all(shard_root, ec); + } + return {out_path, streamlines, total_vertices, merge_elapsed.count(), processes}; +#else + (void)processes; + return build_trx_file_on_disk_single(streamlines, scenario, add_dps, add_dpv, profile, compression); +#endif +} + +struct QueryDataset { + std::unique_ptr> trx; + std::vector> aabbs; + std::vector> slab_mins; + std::vector> slab_maxs; +}; + +void build_slabs(std::vector> &mins, std::vector> &maxs) { + mins.clear(); + maxs.clear(); + mins.reserve(kSlabCount); + maxs.reserve(kSlabCount); + const float z_range = kFov.max_z - kFov.min_z; + for (size_t i = 0; i < kSlabCount; ++i) { + const float t = (kSlabCount == 1) ? 0.5f : static_cast(i) / static_cast(kSlabCount - 1); + const float center_z = kFov.min_z + t * z_range; + const float min_z = std::max(kFov.min_z, center_z - kSlabThicknessMm * 0.5f); + const float max_z = std::min(kFov.max_z, center_z + kSlabThicknessMm * 0.5f); + mins.push_back({kFov.min_x, kFov.min_y, min_z}); + maxs.push_back({kFov.max_x, kFov.max_y, max_z}); + } +} + +struct ScenarioParams { + size_t streamlines = 0; + GroupScenario scenario = GroupScenario::None; + bool add_dps = false; + bool add_dpv = false; + LengthProfile profile = LengthProfile::Mixed; +}; + +struct KeyHash { + using Key = std::tuple; + size_t operator()(const Key &key) const { + size_t h = 0; + auto hash_combine = [&](size_t v) { + h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); + }; + hash_combine(std::hash{}(std::get<0>(key))); + hash_combine(std::hash{}(std::get<1>(key))); + hash_combine(std::hash{}(std::get<2>(key))); + hash_combine(std::hash{}(std::get<3>(key))); + return h; + } +}; + +void maybe_write_query_timings(const ScenarioParams &scenario, const std::vector &timings_ms) { + static std::mutex mutex; + static std::unordered_set seen; + const KeyHash::Key key{scenario.streamlines, + static_cast(scenario.scenario), + scenario.add_dps ? 1 : 0, + scenario.add_dpv ? 1 : 0}; + + std::lock_guard lock(mutex); + if (!seen.insert(key).second) { + return; + } + + const char *env_path = std::getenv("TRX_QUERY_TIMINGS_PATH"); + const std::filesystem::path out_path = env_path ? env_path : "bench/query_timings.jsonl"; + std::error_code ec; + if (!out_path.parent_path().empty()) { + std::filesystem::create_directories(out_path.parent_path(), ec); + } + std::ofstream out(out_path, std::ios::app); + if (!out.is_open()) { + return; + } + + out << "{" + << "\"streamlines\":" << scenario.streamlines << "," + << "\"group_case\":" << static_cast(scenario.scenario) << "," + << "\"group_count\":" << group_count_for(scenario.scenario) << "," + << "\"dps\":" << (scenario.add_dps ? 1 : 0) << "," + << "\"dpv\":" << (scenario.add_dpv ? 1 : 0) << "," + << "\"slab_thickness_mm\":" << kSlabThicknessMm << "," + << "\"timings_ms\":["; + for (size_t i = 0; i < timings_ms.size(); ++i) { + if (i > 0) { + out << ","; + } + out << timings_ms[i]; + } + out << "]}\n"; +} +} // namespace + +static void BM_TrxFileSize_Float16(benchmark::State &state) { + const size_t streamlines = static_cast(state.range(0)); + const auto profile = static_cast(state.range(1)); + const bool add_dps = state.range(2) != 0; + const bool add_dpv = state.range(3) != 0; + const bool use_zip = state.range(4) != 0; + const auto compression = use_zip ? ZIP_CM_DEFLATE : ZIP_CM_STORE; + const size_t skip_zip_at = parse_env_size("TRX_BENCH_SKIP_ZIP_AT", 5000000); + if (use_zip && streamlines >= skip_zip_at) { + state.SkipWithMessage("zip compression skipped for large streamlines"); + return; + } + log_bench_start("BM_TrxFileSize_Float16", + "streamlines=" + std::to_string(streamlines) + " profile=" + std::to_string(state.range(1)) + + " dps=" + std::to_string(static_cast(add_dps)) + + " dpv=" + std::to_string(static_cast(add_dpv)) + + " compression=" + std::to_string(static_cast(use_zip))); + + double total_write_ms = 0.0; + double total_file_bytes = 0.0; + double total_merge_ms = 0.0; + double total_build_ms = 0.0; + double total_merge_processes = 0.0; + for (auto _ : state) { + const auto start = std::chrono::steady_clock::now(); + const auto on_disk = + build_trx_file_on_disk(streamlines, GroupScenario::None, add_dps, add_dpv, profile, compression); + const auto end = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = end - start; + total_build_ms += elapsed.count(); + total_merge_ms += on_disk.shard_merge_ms; + total_merge_processes += static_cast(on_disk.shard_processes); + total_write_ms += elapsed.count(); + total_file_bytes += static_cast(file_size_bytes(on_disk.path)); + } + + state.counters["streamlines"] = static_cast(streamlines); + state.counters["length_profile"] = static_cast(state.range(1)); + state.counters["dps"] = add_dps ? 1.0 : 0.0; + state.counters["dpv"] = add_dpv ? 1.0 : 0.0; + state.counters["compression"] = use_zip ? 1.0 : 0.0; + state.counters["positions_dtype"] = 16.0; + state.counters["write_ms"] = total_write_ms / static_cast(state.iterations()); + state.counters["build_ms"] = total_build_ms / static_cast(state.iterations()); + if (total_merge_ms > 0.0) { + state.counters["shard_merge_ms"] = total_merge_ms / static_cast(state.iterations()); + state.counters["shard_processes"] = total_merge_processes / static_cast(state.iterations()); + } + state.counters["file_bytes"] = total_file_bytes / static_cast(state.iterations()); + state.counters["max_rss_kb"] = get_max_rss_kb(); + + log_bench_end("BM_TrxFileSize_Float16", + "streamlines=" + std::to_string(streamlines) + " profile=" + std::to_string(state.range(1))); +} + +static void BM_TrxStream_TranslateWrite(benchmark::State &state) { + const size_t streamlines = static_cast(state.range(0)); + const auto scenario = static_cast(state.range(1)); + const bool add_dps = state.range(2) != 0; + const bool add_dpv = state.range(3) != 0; + const size_t progress_every = parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 0); + log_bench_config("translate_write", bench_threads(), std::max(1, bench_batch_size())); + log_bench_start("BM_TrxStream_TranslateWrite", + "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1)) + + " dps=" + std::to_string(static_cast(add_dps)) + + " dpv=" + std::to_string(static_cast(add_dpv))); + + using Key = KeyHash::Key; + static std::unordered_map cache; + + const Key key{streamlines, static_cast(scenario), add_dps ? 1 : 0, add_dpv ? 1 : 0}; + if (cache.find(key) == cache.end()) { + state.PauseTiming(); + cache.emplace(key, + build_trx_file_on_disk(streamlines, scenario, add_dps, add_dpv, LengthProfile::Mixed, ZIP_CM_STORE)); + state.ResumeTiming(); + } + + const auto &dataset = cache.at(key); + if (dataset.shard_processes > 1 && dataset.shard_merge_ms > 0.0) { + state.counters["shard_merge_ms"] = dataset.shard_merge_ms; + state.counters["shard_processes"] = static_cast(dataset.shard_processes); + } + for (auto _ : state) { + const auto start = std::chrono::steady_clock::now(); + auto trx = trx::load_any(dataset.path); + const size_t chunk_bytes = parse_env_size("TRX_BENCH_CHUNK_BYTES", 1024ULL * 1024ULL * 1024ULL); + const std::string out_dir = make_work_dir_name("trx_translate_chunk"); + const auto out_info = trx::prepare_positions_output(trx, out_dir); + + std::ofstream out_positions(out_info.positions_path, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out_positions.is_open()) { + throw std::runtime_error("Failed to open output positions file: " + out_info.positions_path); + } + + trx.for_each_positions_chunk(chunk_bytes, + [&](trx::TrxScalarType dtype, const void *data, size_t offset, size_t count) { + (void)offset; + if (progress_every > 0 && ((offset + count) % progress_every == 0)) { + std::cerr << "[trx-bench] progress translate points=" << (offset + count) + << " / " << out_info.points << std::endl; + } + const size_t total_vals = count * 3; + if (dtype == trx::TrxScalarType::Float16) { + const auto *src = reinterpret_cast(data); + std::vector tmp(total_vals); + for (size_t i = 0; i < total_vals; ++i) { + tmp[i] = static_cast(static_cast(src[i]) + 1.0f); + } + out_positions.write(reinterpret_cast(tmp.data()), + static_cast(tmp.size() * sizeof(Eigen::half))); + } else if (dtype == trx::TrxScalarType::Float32) { + const auto *src = reinterpret_cast(data); + std::vector tmp(total_vals); + for (size_t i = 0; i < total_vals; ++i) { + tmp[i] = src[i] + 1.0f; + } + out_positions.write(reinterpret_cast(tmp.data()), + static_cast(tmp.size() * sizeof(float))); + } else { + const auto *src = reinterpret_cast(data); + std::vector tmp(total_vals); + for (size_t i = 0; i < total_vals; ++i) { + tmp[i] = src[i] + 1.0; + } + out_positions.write(reinterpret_cast(tmp.data()), + static_cast(tmp.size() * sizeof(double))); + } + }); + out_positions.flush(); + out_positions.close(); + + const std::string out_path = make_temp_path("trx_translate"); + int errorp; + zip_t *zf = zip_open(out_path.c_str(), ZIP_CREATE + ZIP_TRUNCATE, &errorp); + if (zf == nullptr) { + trx::rm_dir(out_dir); + throw std::runtime_error("Could not open archive " + out_path + ": " + strerror(errorp)); + } + trx::zip_from_folder(zf, out_dir, out_dir, ZIP_CM_STORE, nullptr); + if (zip_close(zf) != 0) { + trx::rm_dir(out_dir); + throw std::runtime_error("Unable to close archive " + out_path + ": " + zip_strerror(zf)); + } + trx::rm_dir(out_dir); + const auto end = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = end - start; + state.SetIterationTime(elapsed.count()); + + std::error_code ec; + std::filesystem::remove(out_path, ec); + benchmark::DoNotOptimize(trx); + } + + state.counters["streamlines"] = static_cast(streamlines); + state.counters["group_case"] = static_cast(state.range(1)); + state.counters["group_count"] = static_cast(group_count_for(scenario)); + state.counters["dps"] = add_dps ? 1.0 : 0.0; + state.counters["dpv"] = add_dpv ? 1.0 : 0.0; + state.counters["length_profile"] = static_cast(static_cast(LengthProfile::Mixed)); + state.counters["positions_dtype"] = 16.0; + state.counters["max_rss_kb"] = get_max_rss_kb(); + + log_bench_end("BM_TrxStream_TranslateWrite", + "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1))); +} + +static void BM_TrxQueryAabb_Slabs(benchmark::State &state) { + const size_t streamlines = static_cast(state.range(0)); + const auto scenario = static_cast(state.range(1)); + const bool add_dps = state.range(2) != 0; + const bool add_dpv = state.range(3) != 0; + log_bench_start("BM_TrxQueryAabb_Slabs", + "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1)) + + " dps=" + std::to_string(static_cast(add_dps)) + + " dpv=" + std::to_string(static_cast(add_dpv))); + + using Key = KeyHash::Key; + static std::unordered_map cache; + + const Key key{streamlines, static_cast(scenario), add_dps ? 1 : 0, add_dpv ? 1 : 0}; + if (cache.find(key) == cache.end()) { + state.PauseTiming(); + QueryDataset dataset; + auto on_disk = build_trx_file_on_disk(streamlines, scenario, add_dps, add_dpv, LengthProfile::Mixed, ZIP_CM_STORE); + dataset.trx = trx::load(on_disk.path); + dataset.aabbs = dataset.trx->build_streamline_aabbs(); + build_slabs(dataset.slab_mins, dataset.slab_maxs); + cache.emplace(key, std::move(dataset)); + state.ResumeTiming(); + } + + auto &dataset = cache.at(key); + for (auto _ : state) { + std::vector slab_times_ms; + slab_times_ms.reserve(kSlabCount); + + const auto start = std::chrono::steady_clock::now(); + size_t total = 0; + for (size_t i = 0; i < kSlabCount; ++i) { + const auto &min_corner = dataset.slab_mins[i]; + const auto &max_corner = dataset.slab_maxs[i]; + const auto q_start = std::chrono::steady_clock::now(); + auto subset = dataset.trx->query_aabb(min_corner, max_corner, &dataset.aabbs); + const auto q_end = std::chrono::steady_clock::now(); + const std::chrono::duration q_elapsed = q_end - q_start; + slab_times_ms.push_back(q_elapsed.count()); + total += subset->num_streamlines(); + subset->close(); + } + const auto end = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = end - start; + state.SetIterationTime(elapsed.count()); + benchmark::DoNotOptimize(total); + + auto sorted = slab_times_ms; + std::sort(sorted.begin(), sorted.end()); + const auto p50 = sorted[sorted.size() / 2]; + const auto p95_idx = static_cast(std::ceil(0.95 * sorted.size())) - 1; + const auto p95 = sorted[std::min(p95_idx, sorted.size() - 1)]; + state.counters["query_p50_ms"] = p50; + state.counters["query_p95_ms"] = p95; + + ScenarioParams params; + params.streamlines = streamlines; + params.scenario = scenario; + params.add_dps = add_dps; + params.add_dpv = add_dpv; + params.profile = LengthProfile::Mixed; + maybe_write_query_timings(params, slab_times_ms); + } + + state.counters["streamlines"] = static_cast(streamlines); + state.counters["group_case"] = static_cast(state.range(1)); + state.counters["group_count"] = static_cast(group_count_for(scenario)); + state.counters["dps"] = add_dps ? 1.0 : 0.0; + state.counters["dpv"] = add_dpv ? 1.0 : 0.0; + state.counters["query_count"] = static_cast(kSlabCount); + state.counters["slab_thickness_mm"] = kSlabThicknessMm; + state.counters["positions_dtype"] = 16.0; + state.counters["max_rss_kb"] = get_max_rss_kb(); + + log_bench_end("BM_TrxQueryAabb_Slabs", + "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1))); +} + +static void ApplySizeArgs(benchmark::internal::Benchmark *bench) { + const std::array profiles = {static_cast(LengthProfile::Short), + static_cast(LengthProfile::Medium), + static_cast(LengthProfile::Long)}; + const std::array flags = {0, 1}; + const auto counts_desc = streamlines_for_benchmarks(); + for (const auto count : counts_desc) { + for (const auto profile : profiles) { + for (const auto dps : flags) { + for (const auto dpv : flags) { + for (const auto compression : flags) { + bench->Args({static_cast(count), profile, dps, dpv, compression}); + } + } + } + } + } +} + +static void ApplyStreamArgs(benchmark::internal::Benchmark *bench) { + const std::array groups = {static_cast(GroupScenario::None), + static_cast(GroupScenario::Bundles), + static_cast(GroupScenario::Connectome)}; + const std::array flags = {0, 1}; + const auto counts_desc = streamlines_for_benchmarks(); + for (const auto count : counts_desc) { + for (const auto group_case : groups) { + for (const auto dps : flags) { + for (const auto dpv : flags) { + bench->Args({static_cast(count), group_case, dps, dpv}); + } + } + } + } +} + +static void ApplyQueryArgs(benchmark::internal::Benchmark *bench) { + const std::array groups = {static_cast(GroupScenario::None), + static_cast(GroupScenario::Bundles), + static_cast(GroupScenario::Connectome)}; + const std::array flags = {0, 1}; + const auto counts_desc = streamlines_for_benchmarks(); + for (const auto count : counts_desc) { + for (const auto group_case : groups) { + for (const auto dps : flags) { + for (const auto dpv : flags) { + bench->Args({static_cast(count), group_case, dps, dpv}); + } + } + } + } + bench->Iterations(1); +} + +BENCHMARK(BM_TrxFileSize_Float16) + ->Apply(ApplySizeArgs) + ->Unit(benchmark::kMillisecond); + +BENCHMARK(BM_TrxStream_TranslateWrite) + ->Apply(ApplyStreamArgs) + ->UseManualTime() + ->Unit(benchmark::kMillisecond); + +BENCHMARK(BM_TrxQueryAabb_Slabs) + ->Apply(ApplyQueryArgs) + ->UseManualTime() + ->Unit(benchmark::kMillisecond); + +int main(int argc, char **argv) { + ::benchmark::Initialize(&argc, argv); + if (::benchmark::ReportUnrecognizedArguments(argc, argv)) { + return 1; + } + try { + ::benchmark::RunSpecifiedBenchmarks(); + g_run_success = true; + } catch (const std::exception &ex) { + std::cerr << "Benchmark failed: " << ex.what() << std::endl; + return 1; + } catch (...) { + std::cerr << "Benchmark failed with unknown exception." << std::endl; + return 1; + } + return 0; +} diff --git a/bench/plot_bench.py b/bench/plot_bench.py new file mode 100644 index 0000000..6b0aa3d --- /dev/null +++ b/bench/plot_bench.py @@ -0,0 +1,214 @@ +import argparse +import json +from pathlib import Path + +import matplotlib.pyplot as plt +import pandas as pd + +LENGTH_LABELS = { + 0: "mixed", + 1: "short (20-120mm)", + 2: "medium (80-260mm)", + 3: "long (200-500mm)", +} +GROUP_LABELS = { + 0: "no groups", + 1: "bundle groups (80)", + 2: "connectome groups (4950)", +} +COMPRESSION_LABELS = {0: "store (no zip)", 1: "zip deflate"} + + +def _parse_base_name(name: str) -> str: + return name.split("/")[0] + + +def _time_to_ms(bench: dict) -> float: + value = bench.get("real_time", 0.0) + unit = bench.get("time_unit", "ns") + if unit == "ns": + return value / 1e6 + if unit == "us": + return value / 1e3 + if unit == "ms": + return value + if unit == "s": + return value * 1e3 + return value / 1e6 + + +def load_benchmarks(path: Path) -> pd.DataFrame: + with path.open() as f: + data = json.load(f) + + rows = [] + for bench in data.get("benchmarks", []): + name = bench.get("name", "") + if not name.startswith("BM_"): + continue + rows.append( + { + "name": name, + "base": _parse_base_name(name), + "real_time_ms": _time_to_ms(bench), + "streamlines": bench.get("streamlines"), + "length_profile": bench.get("length_profile"), + "compression": bench.get("compression"), + "group_case": bench.get("group_case"), + "group_count": bench.get("group_count"), + "dps": bench.get("dps"), + "dpv": bench.get("dpv"), + "write_ms": bench.get("write_ms"), + "file_bytes": bench.get("file_bytes"), + "max_rss_kb": bench.get("max_rss_kb"), + "query_p50_ms": bench.get("query_p50_ms"), + "query_p95_ms": bench.get("query_p95_ms"), + } + ) + + return pd.DataFrame(rows) + + +def plot_file_sizes(df: pd.DataFrame, output_dir: Path) -> None: + sub = df[df["base"] == "BM_TrxFileSize_Float16"].copy() + if sub.empty: + return + sub["file_mb"] = sub["file_bytes"] / 1e6 + sub["length_label"] = sub["length_profile"].map(LENGTH_LABELS) + sub["dp_label"] = "dpv=" + sub["dpv"].astype(int).astype(str) + ", dps=" + sub["dps"].astype(int).astype(str) + + fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True) + for compression, ax in zip([0, 1], axes): + scomp = sub[sub["compression"] == compression] + for (length_label, dp_label), series in scomp.groupby(["length_label", "dp_label"]): + series = series.sort_values("streamlines") + ax.plot( + series["streamlines"], + series["file_mb"], + marker="o", + label=f"{length_label}, {dp_label}", + ) + ax.set_title(COMPRESSION_LABELS.get(compression, str(compression))) + ax.set_xlabel("streamlines") + ax.grid(True) + ax.legend(loc="best", fontsize="x-small") + + axes[0].set_ylabel("file size (MB)") + fig.suptitle("TRX file size vs streamlines (float16 positions)") + output_dir.mkdir(parents=True, exist_ok=True) + fig.savefig(output_dir / "trx_size_vs_streamlines.png", dpi=160, bbox_inches="tight") + plt.close(fig) + + +def _plot_translate_series(df: pd.DataFrame, output_dir: Path, metric: str, ylabel: str, filename: str) -> None: + sub = df[df["base"] == "BM_TrxStream_TranslateWrite"].copy() + if sub.empty: + return + sub["group_label"] = sub["group_case"].map(GROUP_LABELS) + sub["dp_label"] = "dpv=" + sub["dpv"].astype(int).astype(str) + ", dps=" + sub["dps"].astype(int).astype(str) + + fig, axes = plt.subplots(1, 3, figsize=(14, 4), sharey=True) + for ax, (group_label, gsub) in zip(axes, sub.groupby("group_label")): + for dp_label, series in gsub.groupby("dp_label"): + series = series.sort_values("streamlines") + ax.plot(series["streamlines"], series[metric], marker="o", label=dp_label) + ax.set_title(group_label) + ax.set_xlabel("streamlines") + ax.grid(True) + ax.legend(loc="best", fontsize="x-small") + axes[0].set_ylabel(ylabel) + fig.suptitle("Translate + stream write throughput") + output_dir.mkdir(parents=True, exist_ok=True) + fig.savefig(output_dir / filename, dpi=160, bbox_inches="tight") + plt.close(fig) + + +def plot_translate_write(df: pd.DataFrame, output_dir: Path) -> None: + sub = df[df["base"] == "BM_TrxStream_TranslateWrite"].copy() + if sub.empty: + return + sub["rss_mb"] = sub["max_rss_kb"] / 1024.0 + _plot_translate_series( + sub, + output_dir, + metric="real_time_ms", + ylabel="time (ms)", + filename="trx_translate_write_time.png", + ) + _plot_translate_series( + sub, + output_dir, + metric="rss_mb", + ylabel="max RSS (MB)", + filename="trx_translate_write_rss.png", + ) + + +def load_query_timings(path: Path) -> list[dict]: + if not path.exists(): + return [] + rows = [] + with path.open() as f: + for line in f: + line = line.strip() + if not line: + continue + rows.append(json.loads(line)) + return rows + + +def plot_query_timings(path: Path, output_dir: Path, group_case: int, dpv: int, dps: int) -> None: + rows = load_query_timings(path) + if not rows: + return + rows = [ + r + for r in rows + if r.get("group_case") == group_case and r.get("dpv") == dpv and r.get("dps") == dps + ] + if not rows: + return + rows.sort(key=lambda r: r["streamlines"]) + data = [r["timings_ms"] for r in rows] + labels = [str(r["streamlines"]) for r in rows] + + fig, ax = plt.subplots(figsize=(8, 4)) + ax.boxplot(data, labels=labels, showfliers=False) + ax.set_title( + f"Slab query timings ({GROUP_LABELS.get(group_case, group_case)}, dpv={dpv}, dps={dps})" + ) + ax.set_xlabel("streamlines") + ax.set_ylabel("per-slab query time (ms)") + ax.grid(True, axis="y") + output_dir.mkdir(parents=True, exist_ok=True) + fig.savefig(output_dir / "trx_query_slab_timings.png", dpi=160, bbox_inches="tight") + plt.close(fig) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Plot trx-cpp benchmark results.") + parser.add_argument("bench_json", type=Path, help="Path to benchmark JSON output.") + parser.add_argument("--query-json", type=Path, help="Path to slab timing JSONL file.") + parser.add_argument( + "--out-dir", + type=Path, + default=Path("docs/_static/benchmarks"), + help="Directory to save PNGs.", + ) + parser.add_argument("--group-case", type=int, default=0, help="Group case filter for query plot.") + parser.add_argument("--dpv", type=int, default=0, help="DPV filter for query plot.") + parser.add_argument("--dps", type=int, default=0, help="DPS filter for query plot.") + args = parser.parse_args() + + df = load_benchmarks(args.bench_json) + if df.empty: + raise SystemExit("No benchmarks found in JSON file.") + + plot_file_sizes(df, args.out_dir) + plot_translate_write(df, args.out_dir) + if args.query_json: + plot_query_timings(args.query_json, args.out_dir, args.group_case, args.dpv, args.dps) + + +if __name__ == "__main__": + main() diff --git a/docs/_static/benchmarks/.gitkeep b/docs/_static/benchmarks/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/_static/benchmarks/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst new file mode 100644 index 0000000..3c5f468 --- /dev/null +++ b/docs/benchmarks.rst @@ -0,0 +1,118 @@ +Benchmarks +========== + +This page documents the benchmarking suite and how to interpret the results. +The benchmarks are designed for realistic tractography workloads (HPC scale), +not for CI. They focus on file size, throughput, and interactive spatial queries. + +Data model +---------- + +All benchmarks synthesize smooth, slightly curved streamlines in a realistic +field of view: + +- **Lengths:** random between 20 and 500 mm (profiles skew short/medium/long) +- **Field of view:** x = [-70, 70], y = [-108, 79], z = [-60, 75] (mm, RAS+) +- **Streamline counts:** 100k, 500k, 1M, 5M, 10M +- **Groups:** none, 80 bundle groups, or 4950 connectome groups (100 regions) +- **DPV/DPS:** either present (1 value) or absent + +Positions are stored as float16 to highlight storage efficiency. + +TRX size vs streamline count +---------------------------- + +This benchmark writes TRX files with float16 positions and measures the final +on-disk size for different streamline counts. It compares short/medium/long +length profiles, DPV/DPS presence, and zip compression (store vs deflate). + +.. figure:: _static/benchmarks/trx_size_vs_streamlines.png + :alt: TRX file size vs streamlines + :align: center + + File size (MB) as a function of streamline count. + +Translate + stream write throughput +----------------------------------- + +This benchmark loads a TRX file, iterates through every streamline, translates +each point by +1 mm in x/y/z, and streams the result into a new TRX file. It +reports total wall time and max RSS so researchers can understand throughput +and memory pressure on both clusters and laptops. + +.. figure:: _static/benchmarks/trx_translate_write_time.png + :alt: Translate + stream write time + :align: center + + End-to-end time for translating and rewriting streamlines. + +.. figure:: _static/benchmarks/trx_translate_write_rss.png + :alt: Translate + stream write RSS + :align: center + + Max RSS during translate + stream write. + +Spatial slab query latency +-------------------------- + +This benchmark precomputes per-streamline AABBs and then issues 100 spatial +queries using 5 mm slabs that sweep through the tractogram volume. Each slab +query mimics a GUI slice update and records its timing so distributions can be +visualized. + +.. figure:: _static/benchmarks/trx_query_slab_timings.png + :alt: Slab query timings + :align: center + + Distribution of per-slab query latency. + +Running the benchmarks +---------------------- + +Build and run the benchmarks, then plot results with matplotlib: + +.. code-block:: bash + + cmake -S . -B build -DTRX_BUILD_BENCHMARKS=ON + cmake --build build --target bench_trx_stream + + # Run benchmarks (this can be long for large datasets). + ./build/bench/bench_trx_stream \ + --benchmark_out=bench/results.json \ + --benchmark_out_format=json + + # Capture per-slab timings for query distributions. + TRX_QUERY_TIMINGS_PATH=bench/query_timings.jsonl \ + ./build/bench/bench_trx_stream \ + --benchmark_filter=BM_TrxQueryAabb_Slabs \ + --benchmark_out=bench/results.json \ + --benchmark_out_format=json + + # Optional: record RSS samples for file-size runs. + TRX_RSS_SAMPLES_PATH=bench/rss_samples.jsonl \ + TRX_RSS_SAMPLE_EVERY=50000 \ + TRX_RSS_SAMPLE_MS=500 \ + ./build/bench/bench_trx_stream \ + --benchmark_filter=BM_TrxFileSize_Float16 \ + --benchmark_out=bench/results.json \ + --benchmark_out_format=json + + # Generate plots into docs/_static/benchmarks. + python bench/plot_bench.py bench/results.json \ + --query-json bench/query_timings.jsonl \ + --out-dir docs/_static/benchmarks + +The query plot defaults to the "no groups, no DPV/DPS" case. Use +``--group-case``, ``--dpv``, and ``--dps`` in ``plot_bench.py`` to select other +scenarios. + +If zip compression is too slow or unstable for the largest datasets, set +``TRX_BENCH_SKIP_ZIP_AT`` (default 5000000) to skip compression for large +streamline counts. + +When running with multiprocessing, the benchmark uses +``finalize_directory_persistent()`` to write shard outputs without removing +pre-created directories, avoiding race conditions in the parallel workflow. You +can keep shard outputs for debugging by setting ``TRX_BENCH_KEEP_SHARDS=1``. The +merge step waits for each shard to finish (via ``SHARD_OK`` files); adjust the +timeout with ``TRX_BENCH_SHARD_WAIT_MS`` if needed. diff --git a/docs/index.rst b/docs/index.rst index 58f0d3f..229a769 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,6 +16,7 @@ tractography file format. building usage downstream_usage + benchmarks linting .. toctree:: diff --git a/docs/usage.rst b/docs/usage.rst index 16860ee..04b2619 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -51,6 +51,174 @@ Write a TRX file trx.save("tracks_copy.trx", ZIP_CM_STORE); trx.close(); +Thread-safe streaming pattern +----------------------------- + +``TrxStream`` is **not** thread-safe for concurrent writes. A common pattern for +multi-core streamline generation is to use worker threads for generation and a +single writer thread (or the main thread) to append to ``TrxStream``. + +.. code-block:: cpp + + #include + #include + #include + #include + #include + + struct Batch { + std::vector>> streamlines; + }; + + std::mutex mutex; + std::condition_variable cv; + std::queue queue; + bool done = false; + + // Worker threads: generate streamlines and push batches. + auto producer = [&]() { + Batch batch; + batch.streamlines.reserve(1000); + for (int i = 0; i < 1000; ++i) { + std::vector> points = {/* ... generate ... */}; + batch.streamlines.push_back(std::move(points)); + } + { + std::lock_guard lock(mutex); + queue.push(std::move(batch)); + } + cv.notify_one(); + }; + + // Writer thread (single): pop batches and push into TrxStream. + trx::TrxStream stream("float16"); + auto consumer = [&]() { + for (;;) { + std::unique_lock lock(mutex); + cv.wait(lock, [&]() { return done || !queue.empty(); }); + if (queue.empty() && done) { + return; + } + Batch batch = std::move(queue.front()); + queue.pop(); + lock.unlock(); + + for (const auto &points : batch.streamlines) { + stream.push_streamline(points); + } + } + }; + + std::thread writer(consumer); + std::thread t1(producer); + std::thread t2(producer); + t1.join(); + t2.join(); + { + std::lock_guard lock(mutex); + done = true; + } + cv.notify_all(); + writer.join(); + + stream.finalize("tracks.trx", ZIP_CM_STORE); + +Process-based sharding and merge +-------------------------------- + +For large tractograms it is common to generate streamlines in separate +processes, write shard outputs, and merge them later. ``TrxStream`` provides two +finalization methods for directory output: + +- ``finalize_directory()`` — Single-process variant that removes any existing + directory before writing. Use when you control the entire lifecycle. + +- ``finalize_directory_persistent()`` — Multiprocess-safe variant that does NOT + remove existing directories. Use when coordinating parallel writes where a + parent process may pre-create output directories. + +Recommended multiprocess pattern: + +1. **Parent** pre-creates shard directories to validate filesystem writability. +2. Each **child process** writes a directory shard using + ``finalize_directory_persistent()``. +3. After finalization completes, child writes a sentinel file (e.g., ``SHARD_OK``) + to signal completion. +4. **Parent** waits for all ``SHARD_OK`` markers before merging shards. + +This pattern avoids race conditions where the parent checks for directory +existence while children are still writing. + +.. code-block:: cpp + + // Parent process: pre-create shard directories + for (size_t i = 0; i < num_shards; ++i) { + const std::string shard_path = "shards/shard_" + std::to_string(i); + std::filesystem::create_directories(shard_path); + } + + // Fork child processes... + +.. code-block:: cpp + + // Child process: write to pre-created directory + trx::TrxStream stream("float16"); + // ... push streamlines, dpv, dps, groups ... + stream.finalize_directory_persistent("/path/to/shards/shard_0"); + + // Signal completion to parent + std::ofstream ok("/path/to/shards/shard_0/SHARD_OK"); + ok << "ok\n"; + ok.close(); + +.. code-block:: cpp + + // Parent process (after waiting for all SHARD_OK markers) + // Merge by concatenating positions/DPV/DPS, adjusting offsets/groups. + // See bench/bench_trx_stream.cpp for a reference merge implementation. + +.. note:: + Use ``finalize_directory()`` for single-process writes where you want to + ensure a clean output state. Use ``finalize_directory_persistent()`` for + multiprocess workflows to avoid removing directories that may be checked + for existence by other processes. + +MRtrix-style write kernel (single-writer) +----------------------------------------- + +MRtrix uses a multi-threaded producer stage and a single-writer kernel to +serialize streamlines to disk. The same pattern works for TRX by letting the +writer own the ``TrxStream`` and accepting batches from the thread queue. + +.. code-block:: cpp + + #include + #include + #include + + struct TrxWriteKernel { + explicit TrxWriteKernel(const std::string &path) + : stream("float16"), out_path(path) {} + + void operator()(const std::vector>> &batch) { + for (const auto &points : batch) { + stream.push_streamline(points); + } + } + + void finalize() { + stream.finalize(out_path, ZIP_CM_STORE); + } + + private: + trx::TrxStream stream; + std::string out_path; + }; + +This kernel can be used as the final stage of a producer pipeline. The key rule +is: **only the writer thread touches ``TrxStream``**, while worker threads only +generate streamlines. + Optional NIfTI header support ----------------------------- diff --git a/include/trx/trx.h b/include/trx/trx.h index 03ab146..0bc1504 100644 --- a/include/trx/trx.h +++ b/include/trx/trx.h @@ -9,18 +9,24 @@ #include #include #include +#include +#include #include #include #include +#include #include #include #include #include #include #include +#include #include +#include #include #include +#include #include #include @@ -110,55 +116,68 @@ inline zip_t *open_zip_for_read(const std::string &path, int &errorp) { } template struct DTypeName { - static constexpr std::string_view value() { return "float16"; } + static constexpr bool supported = false; + static constexpr std::string_view value() { return ""; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "float16"; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "float32"; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "float64"; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "int8"; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "int16"; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "int32"; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "int64"; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "uint8"; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "uint16"; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "uint32"; } }; template <> struct DTypeName { + static constexpr bool supported = true; static constexpr std::string_view value() { return "uint64"; } }; template inline std::string dtype_from_scalar() { - typedef typename std::remove_cv::type>::type CleanT; + using CleanT = std::remove_cv_t>; + static_assert(DTypeName::supported, "Unsupported dtype for TRX scalar."); return std::string(DTypeName::value()); } @@ -236,6 +255,7 @@ template class TrxFile { std::string root = ""); template friend class TrxReader; + template friend std::unique_ptr> load(const std::string &path); /** * @brief Create a deepcopy of the TrxFile @@ -550,6 +570,8 @@ struct TypedArray { } }; +enum class TrxScalarType; + class AnyTrxFile { public: AnyTrxFile() = default; @@ -576,6 +598,14 @@ class AnyTrxFile { void close(); void save(const std::string &filename, zip_uint32_t compression_standard = ZIP_CM_STORE); + using PositionsChunkCallback = + std::function; + using PositionsChunkMutableCallback = + std::function; + + void for_each_positions_chunk(size_t chunk_bytes, const PositionsChunkCallback &fn) const; + void for_each_positions_chunk_mutable(size_t chunk_bytes, const PositionsChunkMutableCallback &fn); + static AnyTrxFile load(const std::string &path); static AnyTrxFile load_from_zip(const std::string &path); static AnyTrxFile load_from_directory(const std::string &path); @@ -628,6 +658,42 @@ class TrxStream { */ void push_streamline(const std::vector> &points); + /** + * @brief Set max in-memory position buffer size (bytes). + * + * When set to a non-zero value, positions are buffered in memory and flushed + * to the temp file once the buffer reaches this size. Useful for reducing + * small I/O writes on slow disks. + */ + void set_positions_buffer_max_bytes(std::size_t max_bytes); + + enum class MetadataMode { InMemory, OnDisk }; + + /** + * @brief Control how DPS/DPV/groups are stored during streaming. + * + * InMemory keeps metadata in RAM until finalize (default). + * OnDisk writes metadata to temp files and copies them at finalize. + */ + void set_metadata_mode(MetadataMode mode); + + /** + * @brief Set max in-memory buffer size for metadata writes (bytes). + * + * Applies when MetadataMode::OnDisk. Larger buffers reduce write calls. + */ + void set_metadata_buffer_max_bytes(std::size_t max_bytes); + + /** + * @brief Set the VOXEL_TO_RASMM affine matrix in the header. + */ + void set_voxel_to_rasmm(const Eigen::Matrix4f &affine); + + /** + * @brief Set DIMENSIONS in the header. + */ + void set_dimensions(const std::array &dims); + /** * @brief Add per-streamline values (DPS) from an in-memory vector. */ @@ -648,6 +714,54 @@ class TrxStream { */ template void finalize(const std::string &filename, zip_uint32_t compression_standard = ZIP_CM_STORE); + /** + * @brief Finalize and write a TRX directory (no zip). + * + * This method removes any existing directory at the output path before + * writing. Use this for single-process writes or when you control the + * entire output location lifecycle. + * + * @param directory Path where the uncompressed TRX directory will be created. + * + * @throws std::runtime_error if already finalized or if I/O fails. + * + * @see finalize_directory_persistent for multiprocess-safe variant. + */ + void finalize_directory(const std::string &directory); + + /** + * @brief Finalize and write a TRX directory without removing existing files. + * + * This variant is designed for multiprocess workflows where the output + * directory is pre-created by a parent process. Unlike finalize_directory(), + * this method does NOT remove the output directory if it exists, making it + * safe for coordinated parallel writes where multiple processes may check + * for the directory's existence. + * + * @param directory Path where the uncompressed TRX directory will be created. + * If the directory exists, its contents will be overwritten + * but the directory itself will not be removed and recreated. + * + * @throws std::runtime_error if already finalized or if I/O fails. + * + * @note Typical usage pattern: + * @code + * // Parent process creates shard directories + * fs::create_directories("shards/shard_0"); + * + * // Child process writes without removing directory + * trx::TrxStream stream("float16"); + * // ... push streamlines ... + * stream.finalize_directory_persistent("shards/shard_0"); + * std::ofstream("shards/shard_0/SHARD_OK") << "ok\n"; + * + * // Parent waits for SHARD_OK before reading results + * @endcode + * + * @see finalize_directory for single-process variant that ensures clean slate. + */ + void finalize_directory_persistent(const std::string &directory); + size_t num_streamlines() const { return lengths_.size(); } size_t num_vertices() const { return total_vertices_; } @@ -659,13 +773,24 @@ class TrxStream { std::vector values; }; + struct MetadataFile { + std::string relative_path; + std::string absolute_path; + }; + void ensure_positions_stream(); + void flush_positions_buffer(); void cleanup_tmp(); + void ensure_metadata_dir(const std::string &subdir); + void finalize_directory_impl(const std::string &directory, bool remove_existing); std::string positions_dtype_; std::string tmp_dir_; std::string positions_path_; std::ofstream positions_out_; + std::vector positions_buffer_float_; + std::vector positions_buffer_half_; + std::size_t positions_buffer_max_entries_ = 0; std::vector lengths_; size_t total_vertices_ = 0; bool finalized_ = false; @@ -673,6 +798,9 @@ class TrxStream { std::map> groups_; std::map dps_; std::map dpv_; + MetadataMode metadata_mode_ = MetadataMode::InMemory; + std::vector metadata_files_; + std::size_t metadata_buffer_max_bytes_ = 8 * 1024 * 1024; }; /** @@ -740,6 +868,22 @@ inline std::string scalar_type_name(TrxScalarType dtype) { } } +struct PositionsOutputInfo { + std::string directory; + std::string positions_path; + std::string dtype; + size_t points = 0; +}; + +/** + * @brief Prepare an output directory with copied metadata and offsets. + * + * Creates a new TRX directory (no zip) that contains header, offsets, and + * metadata (groups, dps, dpv, dpg), and returns where the positions file + * should be written. + */ +PositionsOutputInfo prepare_positions_output(const AnyTrxFile &input, const std::string &output_directory); + /** * @brief Detect the positions scalar type for a TRX path. * @@ -877,7 +1021,8 @@ void ediff1d(Eigen::Matrix &lengths, void zip_from_folder(zip_t *zf, const std::string &root, const std::string &directory, - zip_uint32_t compression_standard = ZIP_CM_STORE); + zip_uint32_t compression_standard = ZIP_CM_STORE, + const std::unordered_set *skip = nullptr); std::string get_base(const std::string &delimiter, const std::string &str); std::string get_ext(const std::string &str); diff --git a/include/trx/trx.tpp b/include/trx/trx.tpp index f0c8079..07da736 100644 --- a/include/trx/trx.tpp +++ b/include/trx/trx.tpp @@ -169,7 +169,7 @@ std::unique_ptr> _initialize_empty_trx(int nb_streamlines, int nb_ve offsets_dtype = dtype_from_scalar(); lengths_dtype = dtype_from_scalar(); } else { - positions_dtype = dtype_from_scalar(); + positions_dtype = dtype_from_scalar
(); offsets_dtype = dtype_from_scalar(); lengths_dtype = dtype_from_scalar(); } @@ -181,8 +181,7 @@ std::unique_ptr> _initialize_empty_trx(int nb_streamlines, int nb_ve trx->streamlines = std::make_unique>(); trx->streamlines->mmap_pos = trx::_create_memmap(positions_filename, shape, "w+", positions_dtype); - // TODO: find a better way to get the dtype than using all these switch cases. Also refactor - // into function as per specifications, positions can only be floats + // TODO: find a better way to get the dtype than using all these switch cases. if (positions_dtype.compare("float16") == 0) { new (&(trx->streamlines->_data)) Map>( reinterpret_cast(trx->streamlines->mmap_pos.data()), std::get<0>(shape), std::get<1>(shape)); @@ -356,9 +355,12 @@ TrxFile
::_create_trx_from_pointer(json header, long long size = std::get<1>(x->second); if (base.compare("positions") == 0 && (folder.compare("") == 0 || folder.compare(".") == 0)) { - if (size != static_cast(trx->header["NB_VERTICES"].int_value()) * 3 || dim != 3) { - - throw std::invalid_argument("Wrong data size/dimensionality"); + const auto nb_vertices = static_cast(trx->header["NB_VERTICES"].int_value()); + const auto expected = nb_vertices * 3; + if (size != expected || dim != 3) { + throw std::invalid_argument("Wrong data size/dimensionality: size=" + std::to_string(size) + + " expected=" + std::to_string(expected) + " dim=" + std::to_string(dim) + + " filename=" + elem_filename); } std::tuple shape = std::make_tuple(static_cast(trx->header["NB_VERTICES"].int_value()), 3); @@ -380,11 +382,12 @@ TrxFile
::_create_trx_from_pointer(json header, } else if (base.compare("offsets") == 0 && (folder.compare("") == 0 || folder.compare(".") == 0)) { - if (size != static_cast(trx->header["NB_STREAMLINES"].int_value()) + 1 || dim != 1) { - throw std::invalid_argument( - "Wrong offsets size/dimensionality: size=" + std::to_string(size) + - " nb_streamlines=" + std::to_string(static_cast(trx->header["NB_STREAMLINES"].int_value())) + - " dim=" + std::to_string(dim) + " filename=" + elem_filename); + const auto nb_streamlines = static_cast(trx->header["NB_STREAMLINES"].int_value()); + const auto expected = nb_streamlines + 1; + if (size != expected || dim != 1) { + throw std::invalid_argument("Wrong offsets size/dimensionality: size=" + std::to_string(size) + + " expected=" + std::to_string(expected) + " dim=" + std::to_string(dim) + + " filename=" + elem_filename); } const int nb_str = static_cast(trx->header["NB_STREAMLINES"].int_value()); @@ -965,9 +968,42 @@ template std::unique_ptr> TrxFile
::load_from_direc std::string header_name = directory + SEPARATOR + "header.json"; // TODO: add check to verify that it's open - std::ifstream header_file(header_name); + std::ifstream header_file; + for (int attempt = 0; attempt < 5; ++attempt) { + header_file.open(header_name); + if (header_file.is_open()) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } if (!header_file.is_open()) { - throw std::runtime_error("Failed to open header.json at: " + header_name); + std::error_code ec; + const bool exists = trx::fs::exists(directory, ec); + const int open_err = errno; + std::string detail = "Failed to open header.json at: " + header_name; + detail += " exists=" + std::string(exists ? "true" : "false"); + detail += " errno=" + std::to_string(open_err) + " msg=" + std::string(std::strerror(open_err)); + if (exists) { + std::vector files; + for (const auto &entry : trx::fs::directory_iterator(directory, ec)) { + if (ec) { + break; + } + files.push_back(entry.path().filename().string()); + } + if (!files.empty()) { + std::sort(files.begin(), files.end()); + detail += " files=["; + for (size_t i = 0; i < files.size(); ++i) { + if (i > 0) { + detail += ","; + } + detail += files[i]; + } + detail += "]"; + } + } + throw std::runtime_error(detail); } std::string jstream((std::istreambuf_iterator(header_file)), std::istreambuf_iterator()); header_file.close(); @@ -995,6 +1031,10 @@ template std::unique_ptr> TrxFile
::load(const std: return TrxFile
::load_from_zip(path); } +template std::unique_ptr> load(const std::string &path) { + return TrxFile
::load(path); +} + template TrxReader
::TrxReader(const std::string &path) { trx_ = TrxFile
::load(path); } template TrxReader
::TrxReader(TrxReader &&other) noexcept : trx_(std::move(other.trx_)) {} @@ -1067,13 +1107,58 @@ template void TrxFile
::save(const std::string &filename, zip_u } std::string tmp_dir_name = copy_trx->_uncompressed_folder_handle; + if (!tmp_dir_name.empty()) { + const std::string header_path = tmp_dir_name + SEPARATOR + "header.json"; + std::ofstream out_json(header_path, std::ios::out | std::ios::trunc); + if (!out_json.is_open()) { + throw std::runtime_error("Failed to write header.json to: " + header_path); + } + out_json << copy_trx->header.dump() << std::endl; + out_json.close(); + } + if (ext.size() > 0 && (ext == "zip" || ext == "trx")) { + auto sync_unmap_seq = [](auto &seq) { + if (!seq) { + return; + } + std::error_code ec; + seq->mmap_pos.sync(ec); + seq->mmap_pos.unmap(); + seq->mmap_off.sync(ec); + seq->mmap_off.unmap(); + }; + auto sync_unmap_mat = [](auto &mat) { + if (!mat) { + return; + } + std::error_code ec; + mat->mmap.sync(ec); + mat->mmap.unmap(); + }; + + sync_unmap_seq(copy_trx->streamlines); + for (auto &kv : copy_trx->groups) { + sync_unmap_mat(kv.second); + } + for (auto &kv : copy_trx->data_per_streamline) { + sync_unmap_mat(kv.second); + } + for (auto &kv : copy_trx->data_per_vertex) { + sync_unmap_seq(kv.second); + } + for (auto &group_kv : copy_trx->data_per_group) { + for (auto &kv : group_kv.second) { + sync_unmap_mat(kv.second); + } + } + int errorp; zip_t *zf; if ((zf = zip_open(filename.c_str(), ZIP_CREATE + ZIP_TRUNCATE, &errorp)) == nullptr) { throw std::runtime_error("Could not open archive " + filename + ": " + strerror(errorp)); } else { - zip_from_folder(zf, tmp_dir_name, tmp_dir_name, compression_standard); + zip_from_folder(zf, tmp_dir_name, tmp_dir_name, compression_standard, nullptr); if (zip_close(zf) != 0) { throw std::runtime_error("Unable to close archive " + filename + ": " + zip_strerror(zf)); } @@ -1388,8 +1473,8 @@ inline TrxStream::TrxStream(std::string positions_dtype) : positions_dtype_(std: std::transform(positions_dtype_.begin(), positions_dtype_.end(), positions_dtype_.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (positions_dtype_ != "float32") { - throw std::invalid_argument("TrxStream only supports float32 positions for now"); + if (positions_dtype_ != "float32" && positions_dtype_ != "float16") { + throw std::invalid_argument("TrxStream only supports float16/float32 positions for now"); } tmp_dir_ = make_temp_dir("trx_proto"); positions_path_ = tmp_dir_ + SEPARATOR + "positions.tmp"; @@ -1398,6 +1483,20 @@ inline TrxStream::TrxStream(std::string positions_dtype) : positions_dtype_(std: inline TrxStream::~TrxStream() { cleanup_tmp(); } +inline void TrxStream::set_metadata_mode(MetadataMode mode) { + if (finalized_) { + throw std::runtime_error("Cannot adjust metadata mode after finalize"); + } + metadata_mode_ = mode; +} + +inline void TrxStream::set_metadata_buffer_max_bytes(std::size_t max_bytes) { + if (finalized_) { + throw std::runtime_error("Cannot adjust metadata buffer after finalize"); + } + metadata_buffer_max_bytes_ = max_bytes; +} + inline void TrxStream::ensure_positions_stream() { if (!positions_out_.is_open()) { positions_out_.open(positions_path_, std::ios::binary | std::ios::out | std::ios::trunc); @@ -1407,7 +1506,50 @@ inline void TrxStream::ensure_positions_stream() { } } +inline void TrxStream::ensure_metadata_dir(const std::string &subdir) { + if (tmp_dir_.empty()) { + throw std::runtime_error("TrxStream temp directory not initialized"); + } + const std::string dir = tmp_dir_ + SEPARATOR + subdir + SEPARATOR; + std::error_code ec; + trx::fs::create_directories(dir, ec); + if (ec) { + throw std::runtime_error("Could not create directory " + dir); + } +} + +inline void TrxStream::flush_positions_buffer() { + if (positions_dtype_ == "float16") { + if (positions_buffer_half_.empty()) { + return; + } + ensure_positions_stream(); + const size_t byte_count = positions_buffer_half_.size() * sizeof(half); + positions_out_.write(reinterpret_cast(positions_buffer_half_.data()), + static_cast(byte_count)); + if (!positions_out_) { + throw std::runtime_error("Failed to write TrxStream positions buffer"); + } + positions_buffer_half_.clear(); + return; + } + + if (positions_buffer_float_.empty()) { + return; + } + ensure_positions_stream(); + const size_t byte_count = positions_buffer_float_.size() * sizeof(float); + positions_out_.write(reinterpret_cast(positions_buffer_float_.data()), + static_cast(byte_count)); + if (!positions_out_) { + throw std::runtime_error("Failed to write TrxStream positions buffer"); + } + positions_buffer_float_.clear(); +} + inline void TrxStream::cleanup_tmp() { + positions_buffer_float_.clear(); + positions_buffer_half_.clear(); if (positions_out_.is_open()) { positions_out_.close(); } @@ -1425,11 +1567,42 @@ inline void TrxStream::push_streamline(const float *xyz, size_t point_count) { lengths_.push_back(0); return; } - ensure_positions_stream(); - const size_t byte_count = point_count * 3 * sizeof(float); - positions_out_.write(reinterpret_cast(xyz), static_cast(byte_count)); - if (!positions_out_) { - throw std::runtime_error("Failed to write TrxStream positions"); + if (positions_buffer_max_entries_ == 0) { + ensure_positions_stream(); + if (positions_dtype_ == "float16") { + std::vector tmp; + tmp.reserve(point_count * 3); + for (size_t i = 0; i < point_count * 3; ++i) { + tmp.push_back(static_cast(xyz[i])); + } + const size_t byte_count = tmp.size() * sizeof(half); + positions_out_.write(reinterpret_cast(tmp.data()), static_cast(byte_count)); + if (!positions_out_) { + throw std::runtime_error("Failed to write TrxStream positions"); + } + } else { + const size_t byte_count = point_count * 3 * sizeof(float); + positions_out_.write(reinterpret_cast(xyz), static_cast(byte_count)); + if (!positions_out_) { + throw std::runtime_error("Failed to write TrxStream positions"); + } + } + } else { + const size_t floats_count = point_count * 3; + if (positions_dtype_ == "float16") { + positions_buffer_half_.reserve(positions_buffer_half_.size() + floats_count); + for (size_t i = 0; i < floats_count; ++i) { + positions_buffer_half_.push_back(static_cast(xyz[i])); + } + if (positions_buffer_half_.size() >= positions_buffer_max_entries_) { + flush_positions_buffer(); + } + } else { + positions_buffer_float_.insert(positions_buffer_float_.end(), xyz, xyz + floats_count); + if (positions_buffer_float_.size() >= positions_buffer_max_entries_) { + flush_positions_buffer(); + } + } } total_vertices_ += point_count; lengths_.push_back(static_cast(point_count)); @@ -1443,7 +1616,32 @@ inline void TrxStream::push_streamline(const std::vector &xyz_flat) { } inline void TrxStream::push_streamline(const std::vector> &points) { - push_streamline(reinterpret_cast(points.data()), points.size()); + if (points.empty()) { + push_streamline(static_cast(nullptr), 0); + return; + } + std::vector xyz_flat; + xyz_flat.reserve(points.size() * 3); + for (const auto &point : points) { + xyz_flat.push_back(point[0]); + xyz_flat.push_back(point[1]); + xyz_flat.push_back(point[2]); + } + push_streamline(xyz_flat); +} + +inline void TrxStream::set_voxel_to_rasmm(const Eigen::Matrix4f &affine) { + std::vector> matrix(4, std::vector(4, 0.0f)); + for (int i = 0; i < 4; ++i) { + for (int j = 0; j < 4; ++j) { + matrix[static_cast(i)][static_cast(j)] = affine(i, j); + } + } + header = _json_set(header, "VOXEL_TO_RASMM", matrix); +} + +inline void TrxStream::set_dimensions(const std::array &dims) { + header = _json_set(header, "DIMENSIONS", std::vector{dims[0], dims[1], dims[2]}); } template @@ -1462,13 +1660,67 @@ TrxStream::push_dps_from_vector(const std::string &name, const std::string &dtyp if (dtype_norm != "float16" && dtype_norm != "float32" && dtype_norm != "float64") { throw std::invalid_argument("Unsupported DPS dtype: " + dtype); } - FieldValues field; - field.dtype = dtype_norm; - field.values.reserve(values.size()); - for (const auto &v : values) { - field.values.push_back(static_cast(v)); + if (metadata_mode_ == MetadataMode::OnDisk) { + ensure_metadata_dir("dps"); + const std::string filename = tmp_dir_ + SEPARATOR + "dps" + SEPARATOR + name + "." + dtype_norm; + std::ofstream out(filename, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to open DPS file: " + filename); + } + if (dtype_norm == "float16") { + const size_t chunk_elems = std::max(1, metadata_buffer_max_bytes_ / sizeof(half)); + std::vector tmp; + tmp.reserve(chunk_elems); + size_t offset = 0; + while (offset < values.size()) { + const size_t count = std::min(chunk_elems, values.size() - offset); + tmp.clear(); + for (size_t i = 0; i < count; ++i) { + tmp.push_back(static_cast(values[offset + i])); + } + out.write(reinterpret_cast(tmp.data()), static_cast(count * sizeof(half))); + offset += count; + } + } else if (dtype_norm == "float32") { + const size_t chunk_elems = std::max(1, metadata_buffer_max_bytes_ / sizeof(float)); + std::vector tmp; + tmp.reserve(chunk_elems); + size_t offset = 0; + while (offset < values.size()) { + const size_t count = std::min(chunk_elems, values.size() - offset); + tmp.clear(); + for (size_t i = 0; i < count; ++i) { + tmp.push_back(static_cast(values[offset + i])); + } + out.write(reinterpret_cast(tmp.data()), static_cast(count * sizeof(float))); + offset += count; + } + } else { + const size_t chunk_elems = std::max(1, metadata_buffer_max_bytes_ / sizeof(double)); + std::vector tmp; + tmp.reserve(chunk_elems); + size_t offset = 0; + while (offset < values.size()) { + const size_t count = std::min(chunk_elems, values.size() - offset); + tmp.clear(); + for (size_t i = 0; i < count; ++i) { + tmp.push_back(static_cast(values[offset + i])); + } + out.write(reinterpret_cast(tmp.data()), static_cast(count * sizeof(double))); + offset += count; + } + } + out.close(); + metadata_files_.push_back({std::string("dps") + SEPARATOR + name + "." + dtype_norm, filename}); + } else { + FieldValues field; + field.dtype = dtype_norm; + field.values.reserve(values.size()); + for (const auto &v : values) { + field.values.push_back(static_cast(v)); + } + dps_[name] = std::move(field); } - dps_[name] = std::move(field); } template @@ -1487,20 +1739,113 @@ TrxStream::push_dpv_from_vector(const std::string &name, const std::string &dtyp if (dtype_norm != "float16" && dtype_norm != "float32" && dtype_norm != "float64") { throw std::invalid_argument("Unsupported DPV dtype: " + dtype); } - FieldValues field; - field.dtype = dtype_norm; - field.values.reserve(values.size()); - for (const auto &v : values) { - field.values.push_back(static_cast(v)); + if (metadata_mode_ == MetadataMode::OnDisk) { + ensure_metadata_dir("dpv"); + const std::string filename = tmp_dir_ + SEPARATOR + "dpv" + SEPARATOR + name + "." + dtype_norm; + std::ofstream out(filename, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to open DPV file: " + filename); + } + if (dtype_norm == "float16") { + const size_t chunk_elems = std::max(1, metadata_buffer_max_bytes_ / sizeof(half)); + std::vector tmp; + tmp.reserve(chunk_elems); + size_t offset = 0; + while (offset < values.size()) { + const size_t count = std::min(chunk_elems, values.size() - offset); + tmp.clear(); + for (size_t i = 0; i < count; ++i) { + tmp.push_back(static_cast(values[offset + i])); + } + out.write(reinterpret_cast(tmp.data()), static_cast(count * sizeof(half))); + offset += count; + } + } else if (dtype_norm == "float32") { + const size_t chunk_elems = std::max(1, metadata_buffer_max_bytes_ / sizeof(float)); + std::vector tmp; + tmp.reserve(chunk_elems); + size_t offset = 0; + while (offset < values.size()) { + const size_t count = std::min(chunk_elems, values.size() - offset); + tmp.clear(); + for (size_t i = 0; i < count; ++i) { + tmp.push_back(static_cast(values[offset + i])); + } + out.write(reinterpret_cast(tmp.data()), static_cast(count * sizeof(float))); + offset += count; + } + } else { + const size_t chunk_elems = std::max(1, metadata_buffer_max_bytes_ / sizeof(double)); + std::vector tmp; + tmp.reserve(chunk_elems); + size_t offset = 0; + while (offset < values.size()) { + const size_t count = std::min(chunk_elems, values.size() - offset); + tmp.clear(); + for (size_t i = 0; i < count; ++i) { + tmp.push_back(static_cast(values[offset + i])); + } + out.write(reinterpret_cast(tmp.data()), static_cast(count * sizeof(double))); + offset += count; + } + } + out.close(); + metadata_files_.push_back({std::string("dpv") + SEPARATOR + name + "." + dtype_norm, filename}); + } else { + FieldValues field; + field.dtype = dtype_norm; + field.values.reserve(values.size()); + for (const auto &v : values) { + field.values.push_back(static_cast(v)); + } + dpv_[name] = std::move(field); + } +} + +inline void TrxStream::set_positions_buffer_max_bytes(std::size_t max_bytes) { + if (finalized_) { + throw std::runtime_error("Cannot adjust buffer after finalize"); + } + if (max_bytes == 0) { + positions_buffer_max_entries_ = 0; + positions_buffer_float_.clear(); + positions_buffer_half_.clear(); + return; + } + const std::size_t element_size = positions_dtype_ == "float16" ? sizeof(half) : sizeof(float); + const std::size_t entries = max_bytes / element_size; + const std::size_t aligned = (entries / 3) * 3; + positions_buffer_max_entries_ = aligned; + if (positions_buffer_max_entries_ == 0) { + positions_buffer_float_.clear(); + positions_buffer_half_.clear(); } - dpv_[name] = std::move(field); } inline void TrxStream::push_group_from_indices(const std::string &name, const std::vector &indices) { if (name.empty()) { throw std::invalid_argument("Group name cannot be empty"); } - groups_[name] = indices; + if (metadata_mode_ == MetadataMode::OnDisk) { + ensure_metadata_dir("groups"); + const std::string filename = tmp_dir_ + SEPARATOR + "groups" + SEPARATOR + name + ".uint32"; + std::ofstream out(filename, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to open group file: " + filename); + } + const size_t chunk_elems = std::max(1, metadata_buffer_max_bytes_ / sizeof(uint32_t)); + size_t offset = 0; + while (offset < indices.size()) { + const size_t count = std::min(chunk_elems, indices.size() - offset); + out.write(reinterpret_cast(indices.data() + offset), + static_cast(count * sizeof(uint32_t))); + offset += count; + } + out.close(); + metadata_files_.push_back({std::string("groups") + SEPARATOR + name + ".uint32", filename}); + } else { + groups_[name] = indices; + } } template void TrxStream::finalize(const std::string &filename, zip_uint32_t compression_standard) { @@ -1509,6 +1854,7 @@ template void TrxStream::finalize(const std::string &filename, zip } finalized_ = true; + flush_positions_buffer(); if (positions_out_.is_open()) { positions_out_.flush(); positions_out_.close(); @@ -1539,14 +1885,25 @@ template void TrxStream::finalize(const std::string &filename, zip throw std::runtime_error("Failed to open TrxStream temp positions file for read: " + positions_path_); } for (size_t i = 0; i < nb_vertices; ++i) { - float xyz[3]; - in.read(reinterpret_cast(xyz), sizeof(xyz)); - if (!in) { - throw std::runtime_error("Failed to read TrxStream positions"); + if (positions_dtype_ == "float16") { + half xyz[3]; + in.read(reinterpret_cast(xyz), sizeof(xyz)); + if (!in) { + throw std::runtime_error("Failed to read TrxStream positions"); + } + positions(static_cast(i), 0) = static_cast
(xyz[0]); + positions(static_cast(i), 1) = static_cast
(xyz[1]); + positions(static_cast(i), 2) = static_cast
(xyz[2]); + } else { + float xyz[3]; + in.read(reinterpret_cast(xyz), sizeof(xyz)); + if (!in) { + throw std::runtime_error("Failed to read TrxStream positions"); + } + positions(static_cast(i), 0) = static_cast
(xyz[0]); + positions(static_cast(i), 1) = static_cast
(xyz[1]); + positions(static_cast(i), 2) = static_cast
(xyz[2]); } - positions(static_cast(i), 0) = static_cast
(xyz[0]); - positions(static_cast(i), 1) = static_cast
(xyz[1]); - positions(static_cast(i), 2) = static_cast
(xyz[2]); } for (const auto &kv : dps_) { @@ -1559,12 +1916,205 @@ template void TrxStream::finalize(const std::string &filename, zip trx.add_group_from_indices(kv.first, kv.second); } + if (metadata_mode_ == MetadataMode::OnDisk) { + for (const auto &meta : metadata_files_) { + const std::string dest = trx._uncompressed_folder_handle + SEPARATOR + meta.relative_path; + const trx::fs::path dest_path(dest); + if (dest_path.has_parent_path()) { + std::error_code parent_ec; + trx::fs::create_directories(dest_path.parent_path(), parent_ec); + } + std::error_code copy_ec; + trx::fs::copy_file(meta.absolute_path, dest, trx::fs::copy_options::overwrite_existing, copy_ec); + if (copy_ec) { + throw std::runtime_error("Failed to copy metadata file: " + meta.absolute_path + " -> " + dest); + } + } + } + trx.save(filename, compression_standard); trx.close(); cleanup_tmp(); } +inline void TrxStream::finalize_directory_impl(const std::string &directory, bool remove_existing) { + if (finalized_) { + throw std::runtime_error("TrxStream already finalized"); + } + finalized_ = true; + + flush_positions_buffer(); + if (positions_out_.is_open()) { + positions_out_.flush(); + positions_out_.close(); + } + + const size_t nb_streamlines = lengths_.size(); + const size_t nb_vertices = total_vertices_; + + std::error_code ec; + if (remove_existing && trx::fs::exists(directory, ec)) { + trx::fs::remove_all(directory, ec); + ec.clear(); + } + + // Create directory if it doesn't exist + if (!trx::fs::exists(directory, ec)) { + trx::fs::create_directories(directory, ec); + if (ec) { + throw std::runtime_error("Failed to create output directory: " + directory); + } + } + ec.clear(); + + json header_out = header; + header_out = _json_set(header_out, "NB_VERTICES", static_cast(nb_vertices)); + header_out = _json_set(header_out, "NB_STREAMLINES", static_cast(nb_streamlines)); + const std::string header_path = directory + SEPARATOR + "header.json"; + std::ofstream out_header(header_path, std::ios::out | std::ios::trunc); + if (!out_header.is_open()) { + throw std::runtime_error("Failed to write header.json to: " + header_path); + } + out_header << header_out.dump() << std::endl; + out_header.close(); + + const std::string positions_name = "positions.3." + positions_dtype_; + const std::string positions_dst = directory + SEPARATOR + positions_name; + trx::fs::rename(positions_path_, positions_dst, ec); + if (ec) { + ec.clear(); + trx::fs::copy_file(positions_path_, positions_dst, trx::fs::copy_options::overwrite_existing, ec); + if (ec) { + throw std::runtime_error("Failed to copy positions file to: " + positions_dst); + } + } + + const std::string offsets_dst = directory + SEPARATOR + "offsets.uint64"; + std::ofstream offsets_out(offsets_dst, std::ios::binary | std::ios::out | std::ios::trunc); + if (!offsets_out.is_open()) { + throw std::runtime_error("Failed to open offsets file for write: " + offsets_dst); + } + uint64_t offset = 0; + offsets_out.write(reinterpret_cast(&offset), sizeof(offset)); + for (const auto length : lengths_) { + offset += static_cast(length); + offsets_out.write(reinterpret_cast(&offset), sizeof(offset)); + } + offsets_out.flush(); + offsets_out.close(); + + auto write_field_values = [&](const std::string &path, const FieldValues &values) { + std::ofstream out(path, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to open metadata file: " + path); + } + const size_t count = values.values.size(); + if (values.dtype == "float16") { + const size_t chunk = std::max(1, metadata_buffer_max_bytes_ / sizeof(half)); + std::vector tmp; + tmp.reserve(chunk); + size_t idx = 0; + while (idx < count) { + const size_t n = std::min(chunk, count - idx); + tmp.clear(); + for (size_t i = 0; i < n; ++i) { + tmp.push_back(static_cast(values.values[idx + i])); + } + out.write(reinterpret_cast(tmp.data()), static_cast(n * sizeof(half))); + idx += n; + } + } else if (values.dtype == "float32") { + const size_t chunk = std::max(1, metadata_buffer_max_bytes_ / sizeof(float)); + std::vector tmp; + tmp.reserve(chunk); + size_t idx = 0; + while (idx < count) { + const size_t n = std::min(chunk, count - idx); + tmp.clear(); + for (size_t i = 0; i < n; ++i) { + tmp.push_back(static_cast(values.values[idx + i])); + } + out.write(reinterpret_cast(tmp.data()), static_cast(n * sizeof(float))); + idx += n; + } + } else if (values.dtype == "float64") { + const size_t chunk = std::max(1, metadata_buffer_max_bytes_ / sizeof(double)); + std::vector tmp; + tmp.reserve(chunk); + size_t idx = 0; + while (idx < count) { + const size_t n = std::min(chunk, count - idx); + tmp.clear(); + for (size_t i = 0; i < n; ++i) { + tmp.push_back(values.values[idx + i]); + } + out.write(reinterpret_cast(tmp.data()), static_cast(n * sizeof(double))); + idx += n; + } + } else { + throw std::runtime_error("Unsupported metadata dtype: " + values.dtype); + } + out.close(); + }; + + if (metadata_mode_ == MetadataMode::OnDisk) { + for (const auto &meta : metadata_files_) { + const std::string dest = directory + SEPARATOR + meta.relative_path; + const trx::fs::path dest_path(dest); + if (dest_path.has_parent_path()) { + std::error_code parent_ec; + trx::fs::create_directories(dest_path.parent_path(), parent_ec); + } + std::error_code copy_ec; + trx::fs::copy_file(meta.absolute_path, dest, trx::fs::copy_options::overwrite_existing, copy_ec); + if (copy_ec) { + throw std::runtime_error("Failed to copy metadata file: " + meta.absolute_path + " -> " + dest); + } + } + } else { + if (!dps_.empty()) { + trx::fs::create_directories(directory + SEPARATOR + "dps", ec); + for (const auto &kv : dps_) { + const std::string path = directory + SEPARATOR + "dps" + SEPARATOR + kv.first + "." + kv.second.dtype; + write_field_values(path, kv.second); + } + } + if (!dpv_.empty()) { + trx::fs::create_directories(directory + SEPARATOR + "dpv", ec); + for (const auto &kv : dpv_) { + const std::string path = directory + SEPARATOR + "dpv" + SEPARATOR + kv.first + "." + kv.second.dtype; + write_field_values(path, kv.second); + } + } + if (!groups_.empty()) { + trx::fs::create_directories(directory + SEPARATOR + "groups", ec); + for (const auto &kv : groups_) { + const std::string path = directory + SEPARATOR + "groups" + SEPARATOR + kv.first + ".uint32"; + std::ofstream out(path, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to open group file: " + path); + } + if (!kv.second.empty()) { + out.write(reinterpret_cast(kv.second.data()), + static_cast(kv.second.size() * sizeof(uint32_t))); + } + out.close(); + } + } + } + + cleanup_tmp(); +} + +inline void TrxStream::finalize_directory(const std::string &directory) { + finalize_directory_impl(directory, true); +} + +inline void TrxStream::finalize_directory_persistent(const std::string &directory) { + finalize_directory_impl(directory, false); +} + template void TrxFile
::add_dpv_from_tsf(const std::string &name, const std::string &dtype, const std::string &path) { if (name.empty()) { diff --git a/src/trx.cpp b/src/trx.cpp index 5762b34..d3f81fe 100644 --- a/src/trx.cpp +++ b/src/trx.cpp @@ -16,12 +16,18 @@ #include #include #include +#include #include #include #include #include #include #include +#if defined(_WIN32) || defined(_WIN64) +#include +#else +#include +#endif #include #include @@ -268,9 +274,42 @@ AnyTrxFile AnyTrxFile::load_from_directory(const std::string &path) { } std::string header_name = directory + SEPARATOR + "header.json"; - std::ifstream header_file(header_name); + std::ifstream header_file; + for (int attempt = 0; attempt < 5; ++attempt) { + header_file.open(header_name); + if (header_file.is_open()) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } if (!header_file.is_open()) { - throw std::runtime_error("Failed to open header.json at: " + header_name); + std::error_code ec; + const bool exists = trx::fs::exists(directory, ec); + const int open_err = errno; + std::string detail = "Failed to open header.json at: " + header_name; + detail += " exists=" + std::string(exists ? "true" : "false"); + detail += " errno=" + std::to_string(open_err) + " msg=" + std::string(std::strerror(open_err)); + if (exists) { + std::vector files; + for (const auto &entry : trx::fs::directory_iterator(directory, ec)) { + if (ec) { + break; + } + files.push_back(entry.path().filename().string()); + } + if (!files.empty()) { + std::sort(files.begin(), files.end()); + detail += " files=["; + for (size_t i = 0; i < files.size(); ++i) { + if (i > 0) { + detail += ","; + } + detail += files[i]; + } + detail += "]"; + } + } + throw std::runtime_error(detail); } std::string jstream((std::istreambuf_iterator(header_file)), std::istreambuf_iterator()); header_file.close(); @@ -461,35 +500,41 @@ void AnyTrxFile::save(const std::string &filename, zip_uint32_t compression_stan throw std::runtime_error("TRX file has no backing directory to save from"); } - std::string tmp_dir = make_temp_dir("trx_runtime"); - copy_dir(source_dir, tmp_dir); - - { - const trx::fs::path header_path = trx::fs::path(tmp_dir) / "header.json"; - std::ofstream out_json(header_path); - if (!out_json.is_open()) { - throw std::runtime_error("Failed to write header.json to: " + header_path.string()); - } - out_json << header.dump() << std::endl; - } - if (ext.size() > 0 && (ext == "zip" || ext == "trx")) { int errorp; zip_t *zf; if ((zf = zip_open(filename.c_str(), ZIP_CREATE + ZIP_TRUNCATE, &errorp)) == nullptr) { - rm_dir(tmp_dir); throw std::runtime_error("Could not open archive " + filename + ": " + strerror(errorp)); } - zip_from_folder(zf, tmp_dir, tmp_dir, compression_standard); + + const std::string header_payload = header.dump() + "\n"; + zip_source_t *header_source = + zip_source_buffer(zf, header_payload.data(), header_payload.size(), 0 /* do not free */); + if (header_source == nullptr) { + zip_close(zf); + throw std::runtime_error("Failed to create zip source for header.json: " + std::string(zip_strerror(zf))); + } + const zip_int64_t header_idx = zip_file_add(zf, "header.json", header_source, ZIP_FL_ENC_UTF_8 | ZIP_FL_OVERWRITE); + if (header_idx < 0) { + zip_source_free(header_source); + zip_close(zf); + throw std::runtime_error("Failed to add header.json to archive: " + std::string(zip_strerror(zf))); + } + const zip_int32_t compression = static_cast(compression_standard); + if (zip_set_file_compression(zf, header_idx, compression, 0) < 0) { + zip_close(zf); + throw std::runtime_error("Failed to set compression for header.json: " + std::string(zip_strerror(zf))); + } + + const std::unordered_set skip = {"header.json"}; + zip_from_folder(zf, source_dir, source_dir, compression_standard, &skip); if (zip_close(zf) != 0) { - rm_dir(tmp_dir); throw std::runtime_error("Unable to close archive " + filename + ": " + zip_strerror(zf)); } } else { std::error_code ec; if (trx::fs::exists(filename, ec) && trx::fs::is_directory(filename, ec)) { if (rm_dir(filename) != 0) { - rm_dir(tmp_dir); throw std::runtime_error("Could not remove existing directory " + filename); } } @@ -498,24 +543,29 @@ void AnyTrxFile::save(const std::string &filename, zip_uint32_t compression_stan std::error_code parent_ec; trx::fs::create_directories(dest_path.parent_path(), parent_ec); if (parent_ec) { - rm_dir(tmp_dir); throw std::runtime_error("Could not create output parent directory: " + dest_path.parent_path().string()); } } + std::string tmp_dir = make_temp_dir("trx_runtime"); + copy_dir(source_dir, tmp_dir); + const trx::fs::path tmp_header_path = trx::fs::path(tmp_dir) / "header.json"; + std::ofstream out_json(tmp_header_path); + if (!out_json.is_open()) { + rm_dir(tmp_dir); + throw std::runtime_error("Failed to write header.json to: " + tmp_header_path.string()); + } + out_json << header.dump() << std::endl; copy_dir(tmp_dir, filename); + rm_dir(tmp_dir); ec.clear(); if (!trx::fs::exists(filename, ec) || !trx::fs::is_directory(filename, ec)) { - rm_dir(tmp_dir); throw std::runtime_error("Failed to create output directory: " + filename); } - const trx::fs::path header_path = dest_path / "header.json"; - if (!trx::fs::exists(header_path)) { - rm_dir(tmp_dir); - throw std::runtime_error("Missing header.json in output directory: " + header_path.string()); + const trx::fs::path final_header_path = dest_path / "header.json"; + if (!trx::fs::exists(final_header_path)) { + throw std::runtime_error("Missing header.json in output directory: " + final_header_path.string()); } } - - rm_dir(tmp_dir); } void populate_fps(const string &name, std::map> &files_pointer_size) { @@ -806,8 +856,15 @@ std::string make_temp_dir(const std::string &prefix) { static std::mt19937_64 rng(std::random_device{}()); std::uniform_int_distribution dist; + const uint64_t pid = +#if defined(_WIN32) || defined(_WIN64) + static_cast(_getpid()); +#else + static_cast(getpid()); +#endif for (int attempt = 0; attempt < 100; ++attempt) { - const trx::fs::path candidate = base_path / (prefix + "_" + std::to_string(dist(rng))); + const trx::fs::path candidate = + base_path / (prefix + "_" + std::to_string(pid) + "_" + std::to_string(dist(rng))); ec.clear(); if (trx::fs::create_directory(candidate, ec)) { return candidate.string(); @@ -906,7 +963,8 @@ std::string extract_zip_to_directory(zip_t *zfolder) { void zip_from_folder(zip_t *zf, const std::string &root, const std::string &directory, - zip_uint32_t compression_standard) { + zip_uint32_t compression_standard, + const std::unordered_set *skip) { std::error_code ec; for (trx::fs::recursive_directory_iterator it(directory, ec), end; it != end; it.increment(ec)) { if (ec) { @@ -928,6 +986,10 @@ void zip_from_folder(zip_t *zf, if (source == nullptr) { throw std::runtime_error(std::string("Error adding file ") + zip_fname + ": " + zip_strerror(zf)); } + if (skip && skip->find(zip_fname) != skip->end()) { + zip_source_free(source); + continue; + } const zip_int64_t file_idx = zip_file_add(zf, zip_fname.c_str(), source, ZIP_FL_ENC_UTF_8); if (file_idx < 0) { zip_source_free(source); @@ -949,4 +1011,170 @@ std::string rm_root(const std::string &root, const std::string &path) { } return stripped; } + +namespace { +TrxScalarType scalar_type_from_dtype(const std::string &dtype) { + if (dtype == "float16") { + return TrxScalarType::Float16; + } + if (dtype == "float32") { + return TrxScalarType::Float32; + } + if (dtype == "float64") { + return TrxScalarType::Float64; + } + return TrxScalarType::Float32; +} + +std::string typed_array_filename(const std::string &base, const TypedArray &arr) { + if (arr.cols <= 1) { + return base + "." + arr.dtype; + } + return base + "." + std::to_string(arr.cols) + "." + arr.dtype; +} + +void write_typed_array_file(const std::string &path, const TypedArray &arr) { + const auto bytes = arr.to_bytes(); + std::ofstream out(path, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to open output file: " + path); + } + if (bytes.data && bytes.size > 0) { + out.write(reinterpret_cast(bytes.data), static_cast(bytes.size)); + } + out.flush(); + out.close(); +} +} // namespace + +void AnyTrxFile::for_each_positions_chunk(size_t chunk_bytes, const PositionsChunkCallback &fn) const { + if (positions.empty()) { + throw std::runtime_error("TRX positions are empty."); + } + if (positions.cols != 3) { + throw std::runtime_error("Positions must have 3 columns."); + } + if (!fn) { + return; + } + const size_t elem_size = static_cast(detail::_sizeof_dtype(positions.dtype)); + const size_t bytes_per_point = elem_size * 3; + const size_t total_points = static_cast(positions.rows); + size_t points_per_chunk = 0; + if (chunk_bytes == 0) { + points_per_chunk = total_points; + } else { + points_per_chunk = std::max(1, chunk_bytes / bytes_per_point); + } + const auto bytes = positions.to_bytes(); + const auto *base = bytes.data; + const auto dtype = scalar_type_from_dtype(positions.dtype); + for (size_t offset = 0; offset < total_points; offset += points_per_chunk) { + const size_t count = std::min(points_per_chunk, total_points - offset); + const void *ptr = base + offset * bytes_per_point; + fn(dtype, ptr, offset, count); + } +} + +void AnyTrxFile::for_each_positions_chunk_mutable(size_t chunk_bytes, const PositionsChunkMutableCallback &fn) { + if (positions.empty()) { + throw std::runtime_error("TRX positions are empty."); + } + if (positions.cols != 3) { + throw std::runtime_error("Positions must have 3 columns."); + } + if (!fn) { + return; + } + const size_t elem_size = static_cast(detail::_sizeof_dtype(positions.dtype)); + const size_t bytes_per_point = elem_size * 3; + const size_t total_points = static_cast(positions.rows); + size_t points_per_chunk = 0; + if (chunk_bytes == 0) { + points_per_chunk = total_points; + } else { + points_per_chunk = std::max(1, chunk_bytes / bytes_per_point); + } + const auto bytes = positions.to_bytes_mutable(); + auto *base = bytes.data; + const auto dtype = scalar_type_from_dtype(positions.dtype); + for (size_t offset = 0; offset < total_points; offset += points_per_chunk) { + const size_t count = std::min(points_per_chunk, total_points - offset); + void *ptr = base + offset * bytes_per_point; + fn(dtype, ptr, offset, count); + } +} + +PositionsOutputInfo prepare_positions_output(const AnyTrxFile &input, const std::string &output_directory) { + if (input.positions.empty() || input.offsets.empty()) { + throw std::runtime_error("Input TRX missing positions/offsets."); + } + if (input.positions.cols != 3) { + throw std::runtime_error("Positions must have 3 columns."); + } + + std::error_code ec; + if (trx::fs::exists(output_directory, ec)) { + trx::fs::remove_all(output_directory, ec); + } + ec.clear(); + trx::fs::create_directories(output_directory, ec); + if (ec) { + throw std::runtime_error("Failed to create output directory: " + output_directory); + } + + const std::string header_path = output_directory + SEPARATOR + "header.json"; + { + std::ofstream out(header_path, std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to write header.json to: " + header_path); + } + out << input.header.dump() << std::endl; + } + + write_typed_array_file(output_directory + SEPARATOR + typed_array_filename("offsets", input.offsets), input.offsets); + + if (!input.groups.empty()) { + const std::string groups_dir = output_directory + SEPARATOR + "groups"; + trx::fs::create_directories(groups_dir, ec); + for (const auto &kv : input.groups) { + write_typed_array_file(groups_dir + SEPARATOR + typed_array_filename(kv.first, kv.second), kv.second); + } + } + + if (!input.data_per_streamline.empty()) { + const std::string dps_dir = output_directory + SEPARATOR + "dps"; + trx::fs::create_directories(dps_dir, ec); + for (const auto &kv : input.data_per_streamline) { + write_typed_array_file(dps_dir + SEPARATOR + typed_array_filename(kv.first, kv.second), kv.second); + } + } + + if (!input.data_per_vertex.empty()) { + const std::string dpv_dir = output_directory + SEPARATOR + "dpv"; + trx::fs::create_directories(dpv_dir, ec); + for (const auto &kv : input.data_per_vertex) { + write_typed_array_file(dpv_dir + SEPARATOR + typed_array_filename(kv.first, kv.second), kv.second); + } + } + + if (!input.data_per_group.empty()) { + const std::string dpg_dir = output_directory + SEPARATOR + "dpg"; + trx::fs::create_directories(dpg_dir, ec); + for (const auto &group_kv : input.data_per_group) { + const std::string group_dir = dpg_dir + SEPARATOR + group_kv.first; + trx::fs::create_directories(group_dir, ec); + for (const auto &kv : group_kv.second) { + write_typed_array_file(group_dir + SEPARATOR + typed_array_filename(kv.first, kv.second), kv.second); + } + } + } + + PositionsOutputInfo info; + info.directory = output_directory; + info.dtype = input.positions.dtype; + info.points = static_cast(input.positions.rows); + info.positions_path = output_directory + SEPARATOR + typed_array_filename("positions", input.positions); + return info; +} }; // namespace trx \ No newline at end of file diff --git a/tests/test_trx_mmap.cpp b/tests/test_trx_mmap.cpp index d4fb5ef..7eb443b 100644 --- a/tests/test_trx_mmap.cpp +++ b/tests/test_trx_mmap.cpp @@ -181,7 +181,7 @@ TestTrxFixture create_fixture() { if (zf == nullptr) { throw std::runtime_error("Failed to create trx zip file"); } - trx::zip_from_folder(zf, trx_dir.string(), trx_dir.string(), ZIP_CM_STORE); + trx::zip_from_folder(zf, trx_dir.string(), trx_dir.string(), ZIP_CM_STORE, nullptr); if (zip_close(zf) != 0) { throw std::runtime_error("Failed to close trx zip file"); } diff --git a/tests/test_trx_trxfile.cpp b/tests/test_trx_trxfile.cpp index 9bb96d9..43edd7b 100644 --- a/tests/test_trx_trxfile.cpp +++ b/tests/test_trx_trxfile.cpp @@ -292,6 +292,45 @@ TEST(TrxFileTpp, TrxStreamFinalize) { fs::remove_all(tmp_dir, ec); } +TEST(TrxFileTpp, QueryAabbCounts) { + constexpr int kStreamlineCount = 1000; + constexpr int kInsideCount = 250; + constexpr int kPointsPerStreamline = 5; + + const int nb_vertices = kStreamlineCount * kPointsPerStreamline; + trx::TrxFile trx(nb_vertices, kStreamlineCount); + + trx.streamlines->_offsets(0, 0) = 0; + for (int i = 0; i < kStreamlineCount; ++i) { + trx.streamlines->_lengths(i) = kPointsPerStreamline; + trx.streamlines->_offsets(i + 1, 0) = (i + 1) * kPointsPerStreamline; + } + + int cursor = 0; + for (int i = 0; i < kStreamlineCount; ++i) { + const bool inside = i < kInsideCount; + for (int p = 0; p < kPointsPerStreamline; ++p, ++cursor) { + if (inside) { + trx.streamlines->_data(cursor, 0) = -0.8f + 0.05f * static_cast(p); + trx.streamlines->_data(cursor, 1) = 0.3f + 0.1f * static_cast(p); + trx.streamlines->_data(cursor, 2) = 0.1f + 0.05f * static_cast(p); + } else { + trx.streamlines->_data(cursor, 0) = 0.0f; + trx.streamlines->_data(cursor, 1) = 0.0f; + trx.streamlines->_data(cursor, 2) = -1000.0f - static_cast(i); + } + } + } + + const std::array min_corner{ -0.9f, 0.2f, 0.05f }; + const std::array max_corner{ -0.1f, 1.1f, 0.55f }; + + auto subset = trx.query_aabb(min_corner, max_corner); + EXPECT_EQ(subset->num_streamlines(), static_cast(kInsideCount)); + EXPECT_EQ(subset->num_vertices(), static_cast(kInsideCount * kPointsPerStreamline)); + subset->close(); +} + // resize() with default arguments is a no-op when sizes already match. TEST(TrxFileTpp, ResizeNoChange) { const fs::path data_dir = create_float_trx_dir(); From 9c094dd96638f92166f5d52080c7465c4319b912 Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Tue, 10 Feb 2026 11:01:21 -0500 Subject: [PATCH 02/11] update rst --- bench/bench_trx_stream.cpp | 29 ++++++++++--- docs/benchmarks.rst | 87 +++++++++++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 14 deletions(-) diff --git a/bench/bench_trx_stream.cpp b/bench/bench_trx_stream.cpp index e664481..c507121 100644 --- a/bench/bench_trx_stream.cpp +++ b/bench/bench_trx_stream.cpp @@ -363,14 +363,23 @@ size_t group_count_for(GroupScenario scenario) { } } +// Compute position buffer size based on streamline count. +// For slow storage (spinning disks, network filesystems), set TRX_BENCH_BUFFER_MULTIPLIER +// to 2-8 to reduce I/O frequency at the cost of higher memory usage. +// Example: multiplier=4 scales 256 MB → 1 GB for 1M streamlines. std::size_t buffer_bytes_for_streamlines(std::size_t streamlines) { + std::size_t base_bytes; if (streamlines >= 5000000) { - return 2ULL * 1024ULL * 1024ULL * 1024ULL; - } - if (streamlines >= 1000000) { - return 256ULL * 1024ULL * 1024ULL; + base_bytes = 2ULL * 1024ULL * 1024ULL * 1024ULL; // 2 GB + } else if (streamlines >= 1000000) { + base_bytes = 256ULL * 1024ULL * 1024ULL; // 256 MB + } else { + base_bytes = 16ULL * 1024ULL * 1024ULL; // 16 MB } - return 16ULL * 1024ULL * 1024ULL; + + // Allow scaling buffer sizes for slower storage (HDD, NFS) to amortize I/O latency + const size_t multiplier = std::max(1, parse_env_size("TRX_BENCH_BUFFER_MULTIPLIER", 1)); + return base_bytes * multiplier; } std::vector streamlines_for_benchmarks() { @@ -693,7 +702,10 @@ TrxWriteStats run_trx_file_size(size_t streamlines, zip_uint32_t compression) { trx::TrxStream stream("float16"); stream.set_metadata_mode(trx::TrxStream::MetadataMode::OnDisk); - stream.set_metadata_buffer_max_bytes(64ULL * 1024ULL * 1024ULL); + + // Scale metadata buffer with TRX_BENCH_BUFFER_MULTIPLIER for slow storage + const size_t buffer_multiplier = std::max(1, parse_env_size("TRX_BENCH_BUFFER_MULTIPLIER", 1)); + stream.set_metadata_buffer_max_bytes(64ULL * 1024ULL * 1024ULL * buffer_multiplier); stream.set_positions_buffer_max_bytes(buffer_bytes_for_streamlines(streamlines)); const size_t threads = bench_threads(); @@ -830,7 +842,10 @@ TrxOnDisk build_trx_file_on_disk_single(size_t streamlines, bool finalize_to_directory = false) { trx::TrxStream stream("float16"); stream.set_metadata_mode(trx::TrxStream::MetadataMode::OnDisk); - stream.set_metadata_buffer_max_bytes(64ULL * 1024ULL * 1024ULL); + + // Scale buffers with TRX_BENCH_BUFFER_MULTIPLIER for slow storage + const size_t buffer_multiplier = std::max(1, parse_env_size("TRX_BENCH_BUFFER_MULTIPLIER", 1)); + stream.set_metadata_buffer_max_bytes(64ULL * 1024ULL * 1024ULL * buffer_multiplier); stream.set_positions_buffer_max_bytes(buffer_bytes_for_streamlines(streamlines)); const size_t progress_every = parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 0); diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index 3c5f468..e10ddfa 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -66,6 +66,30 @@ visualized. Distribution of per-slab query latency. +Performance characteristics +--------------------------- + +Benchmark results vary significantly based on storage performance: + +**SSD (solid-state drives):** +- **CPU-bound**: Disk writes complete faster than streamline generation +- High CPU utilization (~100%) +- Results reflect pure computational throughput + +**HDD (spinning disks):** +- **I/O-bound**: Disk writes are the bottleneck +- Low CPU utilization (~5-10%) +- Results reflect realistic workstation performance with storage latency + +Both scenarios are valuable. SSD results show the library's maximum throughput, +while HDD results show real-world performance on cost-effective storage. On +Linux, monitor I/O wait time with ``iostat -x 1`` to identify the bottleneck. + +For spinning disks or network filesystems, you may want to increase buffer sizes +to amortize I/O latency. Set ``TRX_BENCH_BUFFER_MULTIPLIER`` to use larger +buffers (e.g., ``TRX_BENCH_BUFFER_MULTIPLIER=4`` uses 4× the default buffer +sizes). + Running the benchmarks ---------------------- @@ -80,6 +104,12 @@ Build and run the benchmarks, then plot results with matplotlib: ./build/bench/bench_trx_stream \ --benchmark_out=bench/results.json \ --benchmark_out_format=json + + # For slower storage (HDD, NFS), use larger buffers: + TRX_BENCH_BUFFER_MULTIPLIER=4 \ + ./build/bench/bench_trx_stream \ + --benchmark_out=bench/results_hdd.json \ + --benchmark_out_format=json # Capture per-slab timings for query distributions. TRX_QUERY_TIMINGS_PATH=bench/query_timings.jsonl \ @@ -106,13 +136,56 @@ The query plot defaults to the "no groups, no DPV/DPS" case. Use ``--group-case``, ``--dpv``, and ``--dps`` in ``plot_bench.py`` to select other scenarios. -If zip compression is too slow or unstable for the largest datasets, set -``TRX_BENCH_SKIP_ZIP_AT`` (default 5000000) to skip compression for large -streamline counts. +Environment variables +--------------------- + +The benchmark suite supports several environment variables for customization: + +**Multiprocessing:** + +- ``TRX_BENCH_PROCESSES`` (default: 1): Number of processes for parallel shard + generation. Recommended: number of physical cores. +- ``TRX_BENCH_MP_MIN_STREAMLINES`` (default: 1000000): Minimum streamline count + to enable multiprocessing. Below this threshold, single-process mode is used. +- ``TRX_BENCH_KEEP_SHARDS`` (default: 0): Set to 1 to preserve shard directories + after merging for debugging. +- ``TRX_BENCH_SHARD_WAIT_MS`` (default: 10000): Timeout in milliseconds for + waiting for shard completion markers. + +**Buffering (for slow storage):** + +- ``TRX_BENCH_BUFFER_MULTIPLIER`` (default: 1): Scales position and metadata + buffer sizes. Use larger values (2-8) for spinning disks or network + filesystems to reduce I/O latency. Example: multiplier=4 uses 64 MB → 256 MB + for small datasets, 256 MB → 1 GB for 1M streamlines, 2 GB → 8 GB for 5M+ + streamlines. + +**Performance tuning:** + +- ``TRX_BENCH_THREADS`` (default: hardware_concurrency): Worker threads for + streamline generation within each process. +- ``TRX_BENCH_BATCH`` (default: 1000): Streamlines per batch in the producer- + consumer queue. +- ``TRX_BENCH_QUEUE_MAX`` (default: 8): Maximum batches in flight between + producers and consumer. + +**Dataset control:** + +- ``TRX_BENCH_ONLY_STREAMLINES`` (default: 0): If nonzero, benchmark only this + streamline count instead of the full range. +- ``TRX_BENCH_MAX_STREAMLINES`` (default: 10000000): Maximum streamline count + to benchmark. Use smaller values for faster iteration. +- ``TRX_BENCH_SKIP_ZIP_AT`` (default: 5000000): Skip zip compression for + streamline counts at or above this threshold. + +**Logging and diagnostics:** + +- ``TRX_BENCH_LOG`` (default: 0): Enable benchmark progress logging to stderr. +- ``TRX_BENCH_CHILD_LOG`` (default: 0): Enable logging from child processes in + multiprocess mode. +- ``TRX_BENCH_LOG_PROGRESS_EVERY`` (default: 0): Log progress every N + streamlines. When running with multiprocessing, the benchmark uses ``finalize_directory_persistent()`` to write shard outputs without removing -pre-created directories, avoiding race conditions in the parallel workflow. You -can keep shard outputs for debugging by setting ``TRX_BENCH_KEEP_SHARDS=1``. The -merge step waits for each shard to finish (via ``SHARD_OK`` files); adjust the -timeout with ``TRX_BENCH_SHARD_WAIT_MS`` if needed. +pre-created directories, avoiding race conditions in the parallel workflow. From 5ce0fb18934a5791c8230038b3f208a4343f47ae Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Tue, 10 Feb 2026 20:50:33 -0500 Subject: [PATCH 03/11] add --verbose --- CMakeLists.txt | 6 +- bench/bench_trx_stream.cpp | 62 +++++- bench/plot_bench.R | 412 +++++++++++++++++++++++++++++++++++++ bench/plot_bench.py | 1 - 4 files changed, 471 insertions(+), 10 deletions(-) create mode 100755 bench/plot_bench.R diff --git a/CMakeLists.txt b/CMakeLists.txt index bf07eac..cf0f267 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,7 +69,11 @@ elseif(TARGET zip::zip) else() message(FATAL_ERROR "No suitable libzip target (expected libzip::libzip or zip::zip)") endif() -find_package(Eigen3 CONFIG QUIET) +# Prefer Eigen3_ROOT so -DEigen3_ROOT=/path/to/eigen-3.4 is used over system Eigen +if(Eigen3_ROOT) + list(PREPEND CMAKE_PREFIX_PATH "${Eigen3_ROOT}") +endif() +find_package(Eigen3 3.4 CONFIG QUIET) if (NOT Eigen3_FOUND) find_package(Eigen3 REQUIRED) # try module mode endif() diff --git a/bench/bench_trx_stream.cpp b/bench/bench_trx_stream.cpp index c507121..8029aed 100644 --- a/bench/bench_trx_stream.cpp +++ b/bench/bench_trx_stream.cpp @@ -1828,9 +1828,8 @@ static void ApplySizeArgs(benchmark::internal::Benchmark *bench) { } static void ApplyStreamArgs(benchmark::internal::Benchmark *bench) { - const std::array groups = {static_cast(GroupScenario::None), - static_cast(GroupScenario::Bundles), - static_cast(GroupScenario::Connectome)}; + const std::array groups = {static_cast(GroupScenario::None), + static_cast(GroupScenario::Bundles)}; const std::array flags = {0, 1}; const auto counts_desc = streamlines_for_benchmarks(); for (const auto count : counts_desc) { @@ -1845,9 +1844,8 @@ static void ApplyStreamArgs(benchmark::internal::Benchmark *bench) { } static void ApplyQueryArgs(benchmark::internal::Benchmark *bench) { - const std::array groups = {static_cast(GroupScenario::None), - static_cast(GroupScenario::Bundles), - static_cast(GroupScenario::Connectome)}; + const std::array groups = {static_cast(GroupScenario::None), + static_cast(GroupScenario::Bundles)}; const std::array flags = {0, 1}; const auto counts_desc = streamlines_for_benchmarks(); for (const auto count : counts_desc) { @@ -1877,8 +1875,56 @@ BENCHMARK(BM_TrxQueryAabb_Slabs) ->Unit(benchmark::kMillisecond); int main(int argc, char **argv) { - ::benchmark::Initialize(&argc, argv); - if (::benchmark::ReportUnrecognizedArguments(argc, argv)) { + // Parse custom flags before benchmark::Initialize + bool verbose = false; + bool show_help = false; + + // First pass: detect custom flags + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "--verbose" || arg == "-v") { + verbose = true; + } else if (arg == "--help-custom") { + show_help = true; + } + } + + if (show_help) { + std::cout << "\nCustom benchmark options:\n" + << " --verbose, -v Enable verbose progress logging (prints every 50k streamlines)\n" + << " Equivalent to: TRX_BENCH_LOG=1 TRX_BENCH_CHILD_LOG=1 \n" + << " TRX_BENCH_LOG_PROGRESS_EVERY=50000\n" + << " --help-custom Show this help message\n" + << "\nFor standard benchmark options, use --help\n" + << std::endl; + return 0; + } + + // Enable verbose logging if requested + if (verbose) { + setenv("TRX_BENCH_LOG", "1", 0); // Don't override if already set + setenv("TRX_BENCH_CHILD_LOG", "1", 0); + if (std::getenv("TRX_BENCH_LOG_PROGRESS_EVERY") == nullptr) { + setenv("TRX_BENCH_LOG_PROGRESS_EVERY", "50000", 1); + } + std::cerr << "[trx-bench] Verbose mode enabled (progress every " + << parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 50000) + << " streamlines)\n" << std::endl; + } + + // Second pass: remove custom flags from argv before passing to benchmark::Initialize + std::vector filtered_argv; + filtered_argv.push_back(argv[0]); // Keep program name + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg != "--verbose" && arg != "-v" && arg != "--help-custom") { + filtered_argv.push_back(argv[i]); + } + } + int filtered_argc = static_cast(filtered_argv.size()); + + ::benchmark::Initialize(&filtered_argc, filtered_argv.data()); + if (::benchmark::ReportUnrecognizedArguments(filtered_argc, filtered_argv.data())) { return 1; } try { diff --git a/bench/plot_bench.R b/bench/plot_bench.R new file mode 100755 index 0000000..d0218db --- /dev/null +++ b/bench/plot_bench.R @@ -0,0 +1,412 @@ +#!/usr/bin/env Rscript +# +# plot_bench.R - Plot trx-cpp benchmark results with ggplot2 +# +# Usage: +# Rscript bench/plot_bench.R [--bench-dir DIR] [--out-dir DIR] [--help] +# +# This script automatically detects benchmark result files in the bench/ +# directory and generates plots for: +# - File sizes (BM_TrxFileSize_Float16) +# - Translate/write throughput (BM_TrxStream_TranslateWrite) +# - Query performance (BM_TrxQueryAabb_Slabs) +# +# Expected input files (searched in bench-dir): +# - results*.json: Main benchmark results (Google Benchmark JSON format) +# - query_timings.jsonl: Per-query timing distributions (JSONL format) +# - rss_samples.jsonl: Memory samples over time (JSONL format, optional) +# + +suppressPackageStartupMessages({ + library(jsonlite) + library(ggplot2) + library(dplyr) + library(tidyr) + library(scales) +}) + +# Constants +LENGTH_LABELS <- c( + "0" = "mixed", + "1" = "short (20-120mm)", + "2" = "medium (80-260mm)", + "3" = "long (200-500mm)" +) + +GROUP_LABELS <- c( + "0" = "no groups", + "1" = "bundle groups (80)" +) + +COMPRESSION_LABELS <- c( + "0" = "store (no zip)", + "1" = "zip deflate" +) + +#' Parse command line arguments +parse_args <- function() { + args <- commandArgs(trailingOnly = TRUE) + + bench_dir <- "bench" + out_dir <- "docs/_static/benchmarks" + + i <- 1 + while (i <= length(args)) { + if (args[i] == "--bench-dir") { + bench_dir <- args[i + 1] + i <- i + 2 + } else if (args[i] == "--out-dir") { + out_dir <- args[i + 1] + i <- i + 2 + } else if (args[i] == "--help" || args[i] == "-h") { + cat("Usage: Rscript plot_bench.R [--bench-dir DIR] [--out-dir DIR]\n") + cat("\n") + cat("Options:\n") + cat(" --bench-dir DIR Directory containing benchmark JSON files (default: bench)\n") + cat(" --out-dir DIR Output directory for plots (default: docs/_static/benchmarks)\n") + cat(" --help, -h Show this help message\n") + quit(status = 0) + } else { + i <- i + 1 + } + } + + list(bench_dir = bench_dir, out_dir = out_dir) +} + +#' Convert benchmark time to milliseconds +time_to_ms <- function(bench) { + value <- bench$real_time + unit <- bench$time_unit + + multiplier <- switch(unit, + "ns" = 1e-6, + "us" = 1e-3, + "ms" = 1, + "s" = 1e3, + 1e-6 # default to nanoseconds + ) + + value * multiplier +} + +#' Extract base benchmark name +parse_base_name <- function(name) { + sub("/.*", "", name) +} + +#' Load all benchmark result JSON files from a directory +load_benchmarks <- function(bench_dir) { + json_files <- list.files(bench_dir, pattern = "^results.*\\.json$", full.names = TRUE) + + if (length(json_files) == 0) { + stop("No results*.json files found in ", bench_dir) + } + + cat("Found", length(json_files), "benchmark result file(s):\n") + for (f in json_files) { + cat(" -", basename(f), "\n") + } + + all_rows <- list() + + for (json_file in json_files) { + data <- tryCatch({ + fromJSON(json_file, simplifyDataFrame = FALSE) + }, error = function(e) { + warning("Failed to parse ", json_file, ": ", e$message) + return(NULL) + }) + + if (is.null(data)) { + next + } + + benchmarks <- data$benchmarks + + if (is.null(benchmarks) || length(benchmarks) == 0) { + warning("No benchmarks found in ", json_file) + next + } + + for (bench in benchmarks) { + name <- bench$name %||% "" + if (!grepl("^BM_", name)) next + + row <- list( + name = name, + base = parse_base_name(name), + real_time_ms = time_to_ms(bench), + streamlines = bench$streamlines %||% NA, + length_profile = bench$length_profile %||% NA, + compression = bench$compression %||% NA, + group_case = bench$group_case %||% NA, + group_count = bench$group_count %||% NA, + dps = bench$dps %||% NA, + dpv = bench$dpv %||% NA, + write_ms = bench$write_ms %||% NA, + build_ms = bench$build_ms %||% NA, + file_bytes = bench$file_bytes %||% NA, + max_rss_kb = bench$max_rss_kb %||% NA, + query_p50_ms = bench$query_p50_ms %||% NA, + query_p95_ms = bench$query_p95_ms %||% NA, + shard_merge_ms = bench$shard_merge_ms %||% NA, + shard_processes = bench$shard_processes %||% NA, + source_file = basename(json_file) + ) + + all_rows[[length(all_rows) + 1]] <- row + } + } + + if (length(all_rows) == 0) { + stop("No valid benchmarks found in any JSON file") + } + + df <- bind_rows(all_rows) + + cat("\nLoaded", nrow(df), "benchmark results\n") + cat("Benchmark types found:\n") + for (base in unique(df$base)) { + count <- sum(df$base == base) + cat(" -", base, ":", count, "results\n") + } + + df +} + +#' Plot file sizes +plot_file_sizes <- function(df, out_dir) { + sub_df <- df %>% + filter(base == "BM_TrxFileSize_Float16") %>% + filter(!is.na(file_bytes), !is.na(streamlines)) + + if (nrow(sub_df) == 0) { + cat("No BM_TrxFileSize_Float16 results found, skipping file size plot\n") + return(invisible(NULL)) + } + + sub_df <- sub_df %>% + mutate( + file_mb = file_bytes / 1e6, + length_label = recode(as.character(length_profile), !!!LENGTH_LABELS), + compression_label = recode(as.character(compression), !!!COMPRESSION_LABELS), + dp_label = sprintf("dpv=%d, dps=%d", as.integer(dpv), as.integer(dps)) + ) + + p <- ggplot(sub_df, aes(x = streamlines, y = file_mb, + color = length_label, linetype = dp_label)) + + geom_line(linewidth = 0.8) + + geom_point(size = 2) + + facet_wrap(~compression_label, ncol = 2) + + scale_x_continuous(labels = label_number(scale = 1e-6, suffix = "M")) + + scale_y_continuous(labels = label_number()) + + labs( + title = "TRX file size vs streamlines (float16 positions)", + x = "Streamlines", + y = "File size (MB)", + color = "Length profile", + linetype = "Data per point" + ) + + theme_bw() + + theme( + legend.position = "bottom", + legend.box = "vertical", + strip.background = element_rect(fill = "grey90") + ) + + out_path <- file.path(out_dir, "trx_size_vs_streamlines.png") + ggsave(out_path, p, width = 12, height = 7, dpi = 160) + cat("Saved:", out_path, "\n") +} + +#' Plot translate/write performance +plot_translate_write <- function(df, out_dir) { + sub_df <- df %>% + filter(base == "BM_TrxStream_TranslateWrite") %>% + filter(!is.na(real_time_ms), !is.na(streamlines)) + + if (nrow(sub_df) == 0) { + cat("No BM_TrxStream_TranslateWrite results found, skipping translate plots\n") + return(invisible(NULL)) + } + + sub_df <- sub_df %>% + mutate( + group_label = recode(as.character(group_case), !!!GROUP_LABELS), + dp_label = sprintf("dpv=%d, dps=%d", as.integer(dpv), as.integer(dps)), + rss_mb = max_rss_kb / 1024 + ) + + # Time plot + p_time <- ggplot(sub_df, aes(x = streamlines, y = real_time_ms, + color = dp_label)) + + geom_line(linewidth = 0.8) + + geom_point(size = 2) + + facet_wrap(~group_label, ncol = 2) + + scale_x_continuous(labels = label_number(scale = 1e-6, suffix = "M")) + + scale_y_continuous(labels = label_number()) + + labs( + title = "Translate + stream write throughput", + x = "Streamlines", + y = "Time (ms)", + color = "Data per point" + ) + + theme_bw() + + theme( + legend.position = "bottom", + strip.background = element_rect(fill = "grey90") + ) + + out_path <- file.path(out_dir, "trx_translate_write_time.png") + ggsave(out_path, p_time, width = 12, height = 5, dpi = 160) + cat("Saved:", out_path, "\n") + + # RSS plot + p_rss <- ggplot(sub_df, aes(x = streamlines, y = rss_mb, + color = dp_label)) + + geom_line(linewidth = 0.8) + + geom_point(size = 2) + + facet_wrap(~group_label, ncol = 2) + + scale_x_continuous(labels = label_number(scale = 1e-6, suffix = "M")) + + scale_y_continuous(labels = label_number()) + + labs( + title = "Translate + stream write memory usage", + x = "Streamlines", + y = "Max RSS (MB)", + color = "Data per point" + ) + + theme_bw() + + theme( + legend.position = "bottom", + strip.background = element_rect(fill = "grey90") + ) + + out_path <- file.path(out_dir, "trx_translate_write_rss.png") + ggsave(out_path, p_rss, width = 12, height = 5, dpi = 160) + cat("Saved:", out_path, "\n") +} + +#' Load query timings from JSONL file +load_query_timings <- function(jsonl_path) { + if (!file.exists(jsonl_path)) { + return(NULL) + } + + lines <- readLines(jsonl_path, warn = FALSE) + lines <- lines[nzchar(lines)] + + if (length(lines) == 0) { + return(NULL) + } + + rows <- lapply(lines, function(line) { + tryCatch({ + obj <- fromJSON(line, simplifyDataFrame = FALSE) + list( + streamlines = obj$streamlines %||% NA, + group_case = obj$group_case %||% NA, + group_count = obj$group_count %||% NA, + dps = obj$dps %||% NA, + dpv = obj$dpv %||% NA, + slab_thickness_mm = obj$slab_thickness_mm %||% NA, + timings_ms = I(list(unlist(obj$timings_ms))) + ) + }, error = function(e) NULL) + }) + + rows <- rows[!sapply(rows, is.null)] + + if (length(rows) == 0) { + return(NULL) + } + + bind_rows(rows) +} + +#' Plot query timing distributions +plot_query_timings <- function(bench_dir, out_dir, group_case = 0, dpv = 0, dps = 0) { + jsonl_path <- file.path(bench_dir, "query_timings.jsonl") + + df <- load_query_timings(jsonl_path) + + if (is.null(df) || nrow(df) == 0) { + cat("No query_timings.jsonl found or empty, skipping query timing plot\n") + return(invisible(NULL)) + } + + # Filter by specified conditions + df_filtered <- df %>% + filter( + group_case == !!group_case, + dpv == !!dpv, + dps == !!dps + ) + + if (nrow(df_filtered) == 0) { + cat("No query timings matching filters (group_case=", group_case, + ", dpv=", dpv, ", dps=", dps, "), skipping plot\n", sep = "") + return(invisible(NULL)) + } + + # Expand timings into long format + timing_data <- df_filtered %>% + mutate(streamlines_label = format(streamlines, big.mark = ",")) %>% + select(streamlines, streamlines_label, timings_ms) %>% + unnest(timings_ms) %>% + group_by(streamlines, streamlines_label) %>% + mutate(query_id = row_number()) %>% + ungroup() + + # Create boxplot + group_label <- GROUP_LABELS[as.character(group_case)] + + p <- ggplot(timing_data, aes(x = streamlines_label, y = timings_ms)) + + geom_boxplot(fill = "steelblue", alpha = 0.7, outlier.size = 0.5) + + labs( + title = sprintf("Slab query timings (%s, dpv=%d, dps=%d)", + group_label, dpv, dps), + x = "Streamlines", + y = "Per-slab query time (ms)" + ) + + theme_bw() + + theme( + axis.text.x = element_text(angle = 45, hjust = 1) + ) + + out_path <- file.path(out_dir, "trx_query_slab_timings.png") + ggsave(out_path, p, width = 10, height = 6, dpi = 160) + cat("Saved:", out_path, "\n") +} + +#' Main function +main <- function() { + args <- parse_args() + + # Create output directory + dir.create(args$out_dir, recursive = TRUE, showWarnings = FALSE) + + cat("\n=== TRX-CPP Benchmark Plotting ===\n\n") + cat("Benchmark directory:", args$bench_dir, "\n") + cat("Output directory:", args$out_dir, "\n\n") + + # Load benchmark results + df <- load_benchmarks(args$bench_dir) + + cat("\n--- Generating plots ---\n\n") + + # Generate plots + plot_file_sizes(df, args$out_dir) + plot_translate_write(df, args$out_dir) + plot_query_timings(args$bench_dir, args$out_dir, group_case = 0, dpv = 0, dps = 0) + + cat("\nDone! Plots saved to:", args$out_dir, "\n") +} + +# Define null-coalescing operator +`%||%` <- function(x, y) if (is.null(x)) y else x + +# Run main if executed as script +if (!interactive()) { + main() +} diff --git a/bench/plot_bench.py b/bench/plot_bench.py index 6b0aa3d..b346035 100644 --- a/bench/plot_bench.py +++ b/bench/plot_bench.py @@ -14,7 +14,6 @@ GROUP_LABELS = { 0: "no groups", 1: "bundle groups (80)", - 2: "connectome groups (4950)", } COMPRESSION_LABELS = {0: "store (no zip)", 1: "zip deflate"} From fa9f03684e8db04de2affde3c52ff4f4301f5782 Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Sat, 21 Feb 2026 11:05:41 -0500 Subject: [PATCH 04/11] clean up benchmarks --- .github/workflows/ci.yml | 2 + .github/workflows/trx-cpp-tests.yml | 6 + .gitignore | 7 +- bench/CMakeLists.txt | 6 +- bench/bench_trx_realdata.cpp | 1150 +++++++++++++++++ bench/bench_trx_stream.cpp | 425 +----- bench/plot_bench.R | 67 +- bench/plot_bench.py | 213 --- bench/run_benchmarks.sh | 237 ++++ codecov.yml | 1 + .../benchmarks/trx_query_slab_timings.png | Bin 0 -> 42841 bytes .../benchmarks/trx_size_vs_streamlines.png | Bin 0 -> 70328 bytes .../benchmarks/trx_translate_write_rss.png | Bin 0 -> 64086 bytes .../benchmarks/trx_translate_write_time.png | Bin 0 -> 67147 bytes docs/index.rst | 2 +- include/trx/trx.h | 52 +- include/trx/trx.tpp | 231 +++- src/trx.cpp | 449 ++++++- tests/test_trx_anytrxfile.cpp | 363 ++++++ tests/test_trx_trxfile.cpp | 40 + 20 files changed, 2607 insertions(+), 644 deletions(-) create mode 100644 bench/bench_trx_realdata.cpp delete mode 100644 bench/plot_bench.py create mode 100755 bench/run_benchmarks.sh create mode 100644 docs/_static/benchmarks/trx_query_slab_timings.png create mode 100644 docs/_static/benchmarks/trx_size_vs_streamlines.png create mode 100644 docs/_static/benchmarks/trx_translate_write_rss.png create mode 100644 docs/_static/benchmarks/trx_translate_write_time.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b461d5..b812f0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,8 @@ jobs: --output-file coverage.info lcov --remove coverage.info "/opt/homebrew/*" "/Applications/Xcode_*.app/*" \ "/Users/runner/work/trx-cpp/trx-cpp/third_party/*" \ + "/Users/runner/work/trx-cpp/trx-cpp/examples/*" \ + "/Users/runner/work/trx-cpp/trx-cpp/bench/*" \ --ignore-errors mismatch,inconsistent,unsupported,format \ --output-file coverage.info lcov --summary coverage.info --ignore-errors mismatch,inconsistent,unsupported,format diff --git a/.github/workflows/trx-cpp-tests.yml b/.github/workflows/trx-cpp-tests.yml index 3ad1b81..687ba51 100644 --- a/.github/workflows/trx-cpp-tests.yml +++ b/.github/workflows/trx-cpp-tests.yml @@ -68,6 +68,12 @@ jobs: --rc geninfo_unexecuted_blocks=1 \ --ignore-errors mismatch \ --output-file coverage.info + lcov --remove coverage.info \ + "*/third_party/*" \ + "*/examples/*" \ + "*/bench/*" \ + --ignore-errors mismatch \ + --output-file coverage.info lcov --summary coverage.info - name: Upload to Codecov diff --git a/.gitignore b/.gitignore index 43bcab0..66a6152 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ **/*.cmake **/Makefile **/build +**build-release **/bin **/load_trx **/tests/memmap @@ -15,6 +16,8 @@ test_package/CMakeUserPresets.json syntax.log docs/_build/ docs/api/ - +.DS_Store test_package/build -test_package/CMakeUserPresets.json \ No newline at end of file +test_package/CMakeUserPresets.json +test-data/* +bench/results* \ No newline at end of file diff --git a/bench/CMakeLists.txt b/bench/CMakeLists.txt index b493d34..e02d21d 100644 --- a/bench/CMakeLists.txt +++ b/bench/CMakeLists.txt @@ -1,3 +1,3 @@ -add_executable(bench_trx_stream bench_trx_stream.cpp) -target_link_libraries(bench_trx_stream PRIVATE trx benchmark::benchmark) -target_compile_features(bench_trx_stream PRIVATE cxx_std_17) +add_executable(bench_trx_realdata bench_trx_realdata.cpp) +target_link_libraries(bench_trx_realdata PRIVATE trx benchmark::benchmark) +target_compile_features(bench_trx_realdata PRIVATE cxx_std_17) diff --git a/bench/bench_trx_realdata.cpp b/bench/bench_trx_realdata.cpp new file mode 100644 index 0000000..035916d --- /dev/null +++ b/bench/bench_trx_realdata.cpp @@ -0,0 +1,1150 @@ +// Benchmark TRX streaming workloads for realistic datasets. +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__unix__) || defined(__APPLE__) +#include +#include +#include +#endif + +namespace { +using Eigen::half; + +std::string g_reference_trx_path; + +constexpr float kMinLengthMm = 20.0f; +constexpr float kMaxLengthMm = 500.0f; +constexpr float kStepMm = 2.0f; +constexpr float kSlabThicknessMm = 5.0f; +constexpr size_t kSlabCount = 20; + +constexpr std::array kStreamlineCounts = {100000, 500000, 1000000, 5000000, 10000000}; + +struct Fov { + float min_x; + float max_x; + float min_y; + float max_y; + float min_z; + float max_z; +}; + +constexpr Fov kFov{-70.0f, 70.0f, -108.0f, 79.0f, -60.0f, 75.0f}; + +enum class GroupScenario : int { None = 0, Bundles = 1, Connectome = 2 }; + +constexpr size_t kBundleCount = 80; +constexpr std::array kConnectomeAtlasSizes = {80, 400, 1000}; +constexpr size_t kConnectomeTotalGroups = 1480; // sum of atlas sizes + +std::string make_temp_path(const std::string &prefix) { + static std::atomic counter{0}; + const auto id = counter.fetch_add(1, std::memory_order_relaxed); + const auto dir = std::filesystem::temp_directory_path(); + return (dir / (prefix + "_" + std::to_string(id) + ".trx")).string(); +} + +std::string make_work_dir_name(const std::string &prefix) { + static std::atomic counter{0}; + const auto id = counter.fetch_add(1, std::memory_order_relaxed); +#if defined(__unix__) || defined(__APPLE__) + const auto pid = static_cast(getpid()); +#else + const auto pid = static_cast(0); +#endif + const auto dir = std::filesystem::current_path(); + return (dir / (prefix + "_" + std::to_string(pid) + "_" + std::to_string(id))).string(); +} + +void register_cleanup(const std::string &path); + +size_t file_size_bytes(const std::string &path) { + std::error_code ec; + if (!trx::fs::exists(path, ec)) { + return 0; + } + if (trx::fs::is_directory(path, ec)) { + size_t total = 0; + for (trx::fs::recursive_directory_iterator it(path, ec), end; it != end; it.increment(ec)) { + if (ec) { + break; + } + if (!it->is_regular_file(ec)) { + continue; + } + total += static_cast(trx::fs::file_size(it->path(), ec)); + if (ec) { + break; + } + } + return total; + } + return static_cast(trx::fs::file_size(path, ec)); +} + +double get_max_rss_kb() { +#if defined(__unix__) || defined(__APPLE__) + rusage usage{}; + if (getrusage(RUSAGE_SELF, &usage) != 0) { + return 0.0; + } +#if defined(__APPLE__) + return static_cast(usage.ru_maxrss) / 1024.0; +#else + return static_cast(usage.ru_maxrss); +#endif +#else + return 0.0; +#endif +} + +size_t parse_env_size(const char *name, size_t default_value) { + const char *raw = std::getenv(name); + if (!raw || raw[0] == '\0') { + return default_value; + } + char *end = nullptr; + const unsigned long long value = std::strtoull(raw, &end, 10); + if (end == raw) { + return default_value; + } + return static_cast(value); +} + +bool parse_env_bool(const char *name, bool default_value) { + const char *raw = std::getenv(name); + if (!raw || raw[0] == '\0') { + return default_value; + } + return std::string(raw) != "0"; +} + +int parse_env_int(const char *name, int default_value) { + const char *raw = std::getenv(name); + if (!raw || raw[0] == '\0') { + return default_value; + } + char *end = nullptr; + const long value = std::strtol(raw, &end, 10); + if (end == raw) { + return default_value; + } + return static_cast(value); +} + +bool is_core_profile() { + const char *raw = std::getenv("TRX_BENCH_PROFILE"); + return raw && std::string(raw) == "core"; +} + +bool include_bundles_in_core_profile() { + return parse_env_bool("TRX_BENCH_CORE_INCLUDE_BUNDLES", false); +} + +size_t core_dpv_max_streamlines() { + return parse_env_size("TRX_BENCH_CORE_DPV_MAX_STREAMLINES", 1000000); +} + +size_t core_zip_max_streamlines() { + return parse_env_size("TRX_BENCH_CORE_ZIP_MAX_STREAMLINES", 1000000); +} + +std::vector group_cases_for_benchmarks() { + std::vector groups = {static_cast(GroupScenario::None)}; + if (!is_core_profile() || include_bundles_in_core_profile()) { + groups.push_back(static_cast(GroupScenario::Bundles)); + } + if (parse_env_bool("TRX_BENCH_INCLUDE_CONNECTOME", !is_core_profile())) { + groups.push_back(static_cast(GroupScenario::Connectome)); + } + return groups; +} + +size_t group_count_for(GroupScenario scenario) { + switch (scenario) { + case GroupScenario::Bundles: + return kBundleCount; + case GroupScenario::Connectome: + return kConnectomeTotalGroups; + case GroupScenario::None: + default: + return 0; + } +} + +// Compute position buffer size based on streamline count. +// For slow storage (spinning disks, network filesystems), set TRX_BENCH_BUFFER_MULTIPLIER +// to 2-8 to reduce I/O frequency at the cost of higher memory usage. +// Example: multiplier=4 scales 256 MB → 1 GB for 1M streamlines. +std::size_t buffer_bytes_for_streamlines(std::size_t streamlines) { + std::size_t base_bytes; + if (streamlines >= 5000000) { + base_bytes = 2ULL * 1024ULL * 1024ULL * 1024ULL; // 2 GB + } else if (streamlines >= 1000000) { + base_bytes = 256ULL * 1024ULL * 1024ULL; // 256 MB + } else { + base_bytes = 16ULL * 1024ULL * 1024ULL; // 16 MB + } + + // Allow scaling buffer sizes for slower storage (HDD, NFS) to amortize I/O latency + const size_t multiplier = std::max(1, parse_env_size("TRX_BENCH_BUFFER_MULTIPLIER", 1)); + return base_bytes * multiplier; +} + +std::vector streamlines_for_benchmarks() { + const size_t only = parse_env_size("TRX_BENCH_ONLY_STREAMLINES", 0); + if (only > 0) { + return {only}; + } + const size_t max_val = parse_env_size("TRX_BENCH_MAX_STREAMLINES", 10000000); + std::vector counts = {10000000, 5000000, 1000000, 500000, 100000}; + counts.erase(std::remove_if(counts.begin(), counts.end(), [&](size_t v) { return v > max_val; }), counts.end()); + if (counts.empty()) { + counts.push_back(max_val); + } + return counts; +} + +void log_bench_start(const std::string &name, const std::string &details) { + if (!parse_env_bool("TRX_BENCH_LOG", false)) { + return; + } + std::cerr << "[trx-bench] start " << name << " " << details << std::endl; +} + +void log_bench_end(const std::string &name, const std::string &details) { + if (!parse_env_bool("TRX_BENCH_LOG", false)) { + return; + } + std::cerr << "[trx-bench] end " << name << " " << details << std::endl; +} + +const std::vector &group_names_for(GroupScenario scenario) { + static const std::vector empty; + static const std::vector bundle_names = []() { + std::vector names; + names.reserve(kBundleCount); + for (size_t i = 1; i <= kBundleCount; ++i) { + names.push_back("Bundle" + std::to_string(i)); + } + return names; + }(); + static const std::vector connectome_names = []() { + std::vector names; + names.reserve(kConnectomeTotalGroups); + for (size_t a = 0; a < kConnectomeAtlasSizes.size(); ++a) { + for (size_t r = 1; r <= kConnectomeAtlasSizes[a]; ++r) { + names.push_back("atlas" + std::to_string(a + 1) + "_region" + std::to_string(r)); + } + } + return names; + }(); + switch (scenario) { + case GroupScenario::Bundles: + return bundle_names; + case GroupScenario::Connectome: + return connectome_names; + case GroupScenario::None: + default: + return empty; + } +} + +std::vector build_prefix_ids(size_t num_streamlines) { + if (num_streamlines > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Too many streamlines for uint32 index space."); + } + std::vector ids; + ids.reserve(num_streamlines); + for (size_t i = 0; i < num_streamlines; ++i) { + ids.push_back(static_cast(i)); + } + return ids; +} + +void assign_groups_to_trx(trx::TrxFile &trx, GroupScenario scenario, size_t streamlines) { + const auto group_count = group_count_for(scenario); + const auto &group_names = group_names_for(scenario); + if (group_count == 0) { + return; + } + + if (scenario == GroupScenario::Connectome) { + std::vector> group_indices(kConnectomeTotalGroups); + std::mt19937 rng(42); + std::uniform_real_distribution coin(0.0f, 1.0f); + + for (size_t i = 0; i < streamlines; ++i) { + size_t group_offset = 0; + for (size_t a = 0; a < kConnectomeAtlasSizes.size(); ++a) { + const size_t n_regions = kConnectomeAtlasSizes[a]; + const size_t picks = (coin(rng) < 0.1f) ? 3 : 2; + std::unordered_set chosen; + std::uniform_int_distribution region_dist(0, n_regions - 1); + while (chosen.size() < picks) { + chosen.insert(region_dist(rng)); + } + for (size_t r : chosen) { + group_indices[group_offset + r].push_back(static_cast(i)); + } + group_offset += n_regions; + } + } + for (size_t g = 0; g < kConnectomeTotalGroups; ++g) { + trx.add_group_from_indices(group_names[g], group_indices[g]); + } + } else { + for (size_t g = 0; g < group_count; ++g) { + std::vector group_indices; + group_indices.reserve(streamlines / group_count + 1); + for (size_t i = g; i < streamlines; i += group_count) { + group_indices.push_back(static_cast(i)); + } + trx.add_group_from_indices(group_names[g], group_indices); + } + } +} + +std::unique_ptr> build_prefix_subset_trx(size_t streamlines, + GroupScenario scenario, + bool add_dps, + bool add_dpv) { + if (g_reference_trx_path.empty()) { + throw std::runtime_error("Reference TRX path not set."); + } + auto ref_trx = trx::load(g_reference_trx_path); + const size_t ref_count = ref_trx->num_streamlines(); + if (streamlines > ref_count) { + throw std::runtime_error("Requested " + std::to_string(streamlines) + + " streamlines but reference only has " + std::to_string(ref_count)); + } + + const auto ids = build_prefix_ids(streamlines); + auto out = ref_trx->subset_streamlines(ids, false); + + // Benchmark scenario owns grouping; drop any inherited groups first. + out->groups.clear(); + if (!out->_uncompressed_folder_handle.empty()) { + std::error_code ec; + std::filesystem::remove_all(std::filesystem::path(out->_uncompressed_folder_handle) / "groups", ec); + } + + if (!add_dps) { + out->data_per_streamline.clear(); + if (!out->_uncompressed_folder_handle.empty()) { + std::error_code ec; + std::filesystem::remove_all(std::filesystem::path(out->_uncompressed_folder_handle) / "dps", ec); + } + } else if (out->data_per_streamline.empty()) { + std::vector ones(streamlines, 1.0f); + out->add_dps_from_vector("sift_weights", "float32", ones); + } + + if (add_dpv) { + std::vector dpv(out->num_vertices(), 0.5f); + out->add_dpv_from_vector("dpv_random", "float32", dpv); + } else { + out->data_per_vertex.clear(); + if (!out->_uncompressed_folder_handle.empty()) { + std::error_code ec; + std::filesystem::remove_all(std::filesystem::path(out->_uncompressed_folder_handle) / "dpv", ec); + } + } + + assign_groups_to_trx(*out, scenario, streamlines); + + return out; +} + +struct TrxWriteStats { + double write_ms = 0.0; + double file_size_bytes = 0.0; +}; + +struct RssSample { + double elapsed_ms = 0.0; + double rss_kb = 0.0; + std::string phase; +}; + +struct FileSizeScenario { + size_t streamlines = 0; + bool add_dps = false; + bool add_dpv = false; + zip_uint32_t compression = ZIP_CM_STORE; +}; + +std::mutex g_rss_samples_mutex; + +void append_rss_samples(const FileSizeScenario &scenario, const std::vector &samples) { + if (samples.empty()) { + return; + } + const char *path = std::getenv("TRX_RSS_SAMPLES_PATH"); + if (!path || path[0] == '\0') { + return; + } + std::lock_guard lock(g_rss_samples_mutex); + std::ofstream out(path, std::ios::app); + if (!out.is_open()) { + return; + } + + out << "{" + << "\"streamlines\":" << scenario.streamlines << "," + << "\"dps\":" << (scenario.add_dps ? 1 : 0) << "," + << "\"dpv\":" << (scenario.add_dpv ? 1 : 0) << "," + << "\"compression\":" << (scenario.compression == ZIP_CM_DEFLATE ? 1 : 0) << "," + << "\"samples\":["; + for (size_t i = 0; i < samples.size(); ++i) { + if (i > 0) { + out << ","; + } + out << "{" + << "\"elapsed_ms\":" << samples[i].elapsed_ms << "," + << "\"rss_kb\":" << samples[i].rss_kb << "," + << "\"phase\":\"" << samples[i].phase << "\"" + << "}"; + } + out << "]}\n"; +} + +std::mutex g_cleanup_mutex; +std::vector g_cleanup_paths; +pid_t g_cleanup_owner_pid = 0; +bool g_cleanup_only_on_success = true; +bool g_run_success = false; + +void cleanup_temp_paths() { + if (g_cleanup_only_on_success && !g_run_success) { + return; + } + if (g_cleanup_owner_pid != 0 && getpid() != g_cleanup_owner_pid) { + return; + } + std::error_code ec; + for (const auto &p : g_cleanup_paths) { + std::filesystem::remove_all(p, ec); + } +} + +void register_cleanup(const std::string &path) { + static bool registered = false; + { + std::lock_guard lock(g_cleanup_mutex); + if (g_cleanup_owner_pid == 0) { + g_cleanup_owner_pid = getpid(); + } + g_cleanup_paths.push_back(path); + } + if (!registered) { + registered = true; + std::atexit(cleanup_temp_paths); + } +} + +TrxWriteStats run_trx_file_size(size_t streamlines, + bool add_dps, + bool add_dpv, + zip_uint32_t compression) { + const size_t progress_every = parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 0); + + const bool collect_rss = std::getenv("TRX_RSS_SAMPLES_PATH") != nullptr; + const size_t sample_every = parse_env_size("TRX_RSS_SAMPLE_EVERY", 50000); + const int sample_interval_ms = parse_env_int("TRX_RSS_SAMPLE_MS", 500); + std::vector samples; + std::mutex samples_mutex; + const auto bench_start = std::chrono::steady_clock::now(); + auto record_sample = [&](const std::string &phase) { + if (!collect_rss) { + return; + } + const auto now = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = now - bench_start; + std::lock_guard lock(samples_mutex); + samples.push_back({elapsed.count(), get_max_rss_kb(), phase}); + }; + + auto trx_subset = build_prefix_subset_trx(streamlines, GroupScenario::None, add_dps, add_dpv); + if (progress_every > 0) { + std::cerr << "[trx-bench] progress file_size streamlines=" << streamlines << " / " << streamlines << std::endl; + } + if (collect_rss && sample_every > 0) { + record_sample("generate"); + } + + const std::string out_path = make_temp_path("trx_size"); + record_sample("before_finalize"); + + std::atomic sampling{false}; + std::thread sampler; + if (collect_rss) { + sampling.store(true, std::memory_order_relaxed); + sampler = std::thread([&]() { + while (sampling.load(std::memory_order_relaxed)) { + record_sample("finalize"); + std::this_thread::sleep_for(std::chrono::milliseconds(sample_interval_ms)); + } + }); + } + + trx::TrxSaveOptions save_opts; + save_opts.compression_standard = compression; + const auto start = std::chrono::steady_clock::now(); + trx_subset->save(out_path, save_opts); + const auto end = std::chrono::steady_clock::now(); + trx_subset->close(); + + if (collect_rss) { + sampling.store(false, std::memory_order_relaxed); + if (sampler.joinable()) { + sampler.join(); + } + } + record_sample("after_finalize"); + + TrxWriteStats stats; + stats.write_ms = std::chrono::duration(end - start).count(); + std::error_code size_ec; + const auto size = std::filesystem::file_size(out_path, size_ec); + stats.file_size_bytes = size_ec ? 0.0 : static_cast(size); + std::error_code ec; + std::filesystem::remove(out_path, ec); + + if (collect_rss) { + FileSizeScenario scenario; + scenario.streamlines = streamlines; + scenario.add_dps = add_dps; + scenario.add_dpv = add_dpv; + scenario.compression = compression; + append_rss_samples(scenario, samples); + } + return stats; +} + +struct TrxOnDisk { + std::string path; + size_t streamlines = 0; + size_t vertices = 0; + double shard_merge_ms = 0.0; + size_t shard_processes = 1; +}; + +TrxOnDisk build_trx_file_on_disk_single(size_t streamlines, + GroupScenario scenario, + bool add_dps, + bool add_dpv, + zip_uint32_t compression, + const std::string &out_path_override = "") { + const size_t progress_every = parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 0); + + // Fast path: For 10M (full reference) WITHOUT DPV, just copy and add groups + // DPV requires 1.3B vertices × 8 bytes (vector + internal copy) = 10 GB extra memory! + if (g_reference_trx_path.empty()) { + throw std::runtime_error("Reference TRX path not set."); + } + auto ref_trx_check = trx::load(g_reference_trx_path); + const size_t ref_count = ref_trx_check->num_streamlines(); + const bool is_full_reference = (streamlines == ref_count); + ref_trx_check.reset(); // Release mmap + + // Fast path for full reference WITHOUT DPV: work with reference directly + // DPV + 10M requires 40-50 GB due to: mmap(7.6) + dpv_vec(4.8) + dpv_mmap(4.8) + save() overhead(20+) + if (is_full_reference && !add_dpv) { + log_bench_start("build_trx_copy_fast", "streamlines=" + std::to_string(streamlines)); + + // Filesystem copy reference (disk I/O only) + const std::string temp_copy = make_temp_path("trx_ref_copy"); + std::error_code copy_ec; + std::filesystem::copy_file(g_reference_trx_path, temp_copy, + std::filesystem::copy_options::overwrite_existing, copy_ec); + if (copy_ec) { + throw std::runtime_error("Failed to copy reference: " + copy_ec.message()); + } + + // Load and modify (just groups, no DPV) + auto trx = trx::load(temp_copy); + + // Add groups + assign_groups_to_trx(*trx, scenario, streamlines); + + // Note: DPV for 10M handled by sampling path (skipped by default due to 40-50 GB memory) + + // Save to output + const std::string out_path = out_path_override.empty() ? make_temp_path("trx_input") : out_path_override; + trx::TrxSaveOptions save_opts; + save_opts.compression_standard = compression; + trx->save(out_path, save_opts); + const size_t total_vertices = trx->num_vertices(); + trx->close(); + + std::filesystem::remove(temp_copy, copy_ec); + + if (out_path_override.empty()) { + register_cleanup(out_path); + } + + log_bench_end("build_trx_copy_fast", "streamlines=" + std::to_string(streamlines)); + return {out_path, streamlines, total_vertices, 0.0, 1}; + } + + // Prefix-subset path: copy first N streamlines into a TrxFile and save. + auto trx_subset = build_prefix_subset_trx(streamlines, scenario, add_dps, add_dpv); + const size_t total_vertices = trx_subset->num_vertices(); + const std::string out_path = out_path_override.empty() ? make_temp_path("trx_input") : out_path_override; + trx::TrxSaveOptions save_opts; + save_opts.compression_standard = compression; + trx_subset->save(out_path, save_opts); + trx_subset->close(); + if (progress_every > 0 && (parse_env_bool("TRX_BENCH_CHILD_LOG", false) || parse_env_bool("TRX_BENCH_LOG", false))) { + std::cerr << "[trx-bench] progress build_trx streamlines=" << streamlines << " / " << streamlines << std::endl; + } + if (out_path_override.empty()) { + register_cleanup(out_path); + } + return {out_path, streamlines, total_vertices, 0.0, 1}; +} + +TrxOnDisk build_trx_file_on_disk(size_t streamlines, + GroupScenario scenario, + bool add_dps, + bool add_dpv, + zip_uint32_t compression) { + return build_trx_file_on_disk_single(streamlines, scenario, add_dps, add_dpv, compression); +} + +struct QueryDataset { + std::unique_ptr> trx; + std::vector> slab_mins; + std::vector> slab_maxs; +}; + +void build_slabs(std::vector> &mins, std::vector> &maxs) { + mins.clear(); + maxs.clear(); + mins.reserve(kSlabCount); + maxs.reserve(kSlabCount); + const float z_range = kFov.max_z - kFov.min_z; + for (size_t i = 0; i < kSlabCount; ++i) { + const float t = (kSlabCount == 1) ? 0.5f : static_cast(i) / static_cast(kSlabCount - 1); + const float center_z = kFov.min_z + t * z_range; + const float min_z = std::max(kFov.min_z, center_z - kSlabThicknessMm * 0.5f); + const float max_z = std::min(kFov.max_z, center_z + kSlabThicknessMm * 0.5f); + mins.push_back({kFov.min_x, kFov.min_y, min_z}); + maxs.push_back({kFov.max_x, kFov.max_y, max_z}); + } +} + +struct ScenarioParams { + size_t streamlines = 0; + GroupScenario scenario = GroupScenario::None; + bool add_dps = false; + bool add_dpv = false; +}; + +struct KeyHash { + using Key = std::tuple; + size_t operator()(const Key &key) const { + size_t h = 0; + auto hash_combine = [&](size_t v) { + h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); + }; + hash_combine(std::hash{}(std::get<0>(key))); + hash_combine(std::hash{}(std::get<1>(key))); + hash_combine(std::hash{}(std::get<2>(key))); + hash_combine(std::hash{}(std::get<3>(key))); + return h; + } +}; + +void maybe_write_query_timings(const ScenarioParams &scenario, const std::vector &timings_ms) { + static std::mutex mutex; + static std::unordered_set seen; + const KeyHash::Key key{scenario.streamlines, + static_cast(scenario.scenario), + scenario.add_dps ? 1 : 0, + scenario.add_dpv ? 1 : 0}; + + std::lock_guard lock(mutex); + if (!seen.insert(key).second) { + return; + } + + const char *env_path = std::getenv("TRX_QUERY_TIMINGS_PATH"); + const std::filesystem::path out_path = env_path ? env_path : "bench/query_timings.jsonl"; + std::error_code ec; + if (!out_path.parent_path().empty()) { + std::filesystem::create_directories(out_path.parent_path(), ec); + } + std::ofstream out(out_path, std::ios::app); + if (!out.is_open()) { + return; + } + + out << "{" + << "\"streamlines\":" << scenario.streamlines << "," + << "\"group_case\":" << static_cast(scenario.scenario) << "," + << "\"group_count\":" << group_count_for(scenario.scenario) << "," + << "\"dps\":" << (scenario.add_dps ? 1 : 0) << "," + << "\"dpv\":" << (scenario.add_dpv ? 1 : 0) << "," + << "\"slab_thickness_mm\":" << kSlabThicknessMm << "," + << "\"timings_ms\":["; + for (size_t i = 0; i < timings_ms.size(); ++i) { + if (i > 0) { + out << ","; + } + out << timings_ms[i]; + } + out << "]}\n"; +} +} // namespace + +static void BM_TrxFileSize_Float16(benchmark::State &state) { + const size_t streamlines = static_cast(state.range(0)); + const auto scenario = static_cast(state.range(1)); + const bool add_dps = state.range(2) != 0; + const bool add_dpv = state.range(3) != 0; + const bool use_zip = state.range(4) != 0; + const auto compression = use_zip ? ZIP_CM_DEFLATE : ZIP_CM_STORE; + const size_t skip_zip_at = parse_env_size("TRX_BENCH_SKIP_ZIP_AT", 5000000); + const size_t skip_dpv_at = parse_env_size("TRX_BENCH_SKIP_DPV_AT", 10000000); + const size_t skip_connectome_at = parse_env_size("TRX_BENCH_SKIP_CONNECTOME_AT", 5000000); + if (use_zip && streamlines >= skip_zip_at) { + state.SkipWithMessage("zip compression skipped for large streamlines"); + return; + } + if (add_dpv && streamlines >= skip_dpv_at) { + state.SkipWithMessage("dpv skipped: requires 40-50 GB memory (set TRX_BENCH_SKIP_DPV_AT=0 to force)"); + return; + } + if (scenario == GroupScenario::Connectome && streamlines >= skip_connectome_at) { + state.SkipWithMessage("connectome groups skipped for large streamlines (set TRX_BENCH_SKIP_CONNECTOME_AT=0 to force)"); + return; + } + log_bench_start("BM_TrxFileSize_Float16", + "streamlines=" + std::to_string(streamlines) + + " group_case=" + std::to_string(state.range(1)) + + " dps=" + std::to_string(static_cast(add_dps)) + + " dpv=" + std::to_string(static_cast(add_dpv)) + + " compression=" + std::to_string(static_cast(use_zip))); + + double total_write_ms = 0.0; + double total_file_bytes = 0.0; + double total_merge_ms = 0.0; + double total_build_ms = 0.0; + double total_merge_processes = 0.0; + for (auto _ : state) { + const auto start = std::chrono::steady_clock::now(); + const auto on_disk = + build_trx_file_on_disk(streamlines, scenario, add_dps, add_dpv, compression); + const auto end = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = end - start; + total_build_ms += elapsed.count(); + total_merge_ms += on_disk.shard_merge_ms; + total_merge_processes += static_cast(on_disk.shard_processes); + total_write_ms += elapsed.count(); + total_file_bytes += static_cast(file_size_bytes(on_disk.path)); + } + + state.counters["streamlines"] = static_cast(streamlines); + state.counters["group_case"] = static_cast(state.range(1)); + state.counters["group_count"] = static_cast(group_count_for(scenario)); + state.counters["dps"] = add_dps ? 1.0 : 0.0; + state.counters["dpv"] = add_dpv ? 1.0 : 0.0; + state.counters["compression"] = use_zip ? 1.0 : 0.0; + state.counters["positions_dtype"] = 16.0; + state.counters["write_ms"] = total_write_ms / static_cast(state.iterations()); + state.counters["build_ms"] = total_build_ms / static_cast(state.iterations()); + if (total_merge_ms > 0.0) { + state.counters["shard_merge_ms"] = total_merge_ms / static_cast(state.iterations()); + state.counters["shard_processes"] = total_merge_processes / static_cast(state.iterations()); + } + state.counters["file_bytes"] = total_file_bytes / static_cast(state.iterations()); + state.counters["max_rss_kb"] = get_max_rss_kb(); + + log_bench_end("BM_TrxFileSize_Float16", + "streamlines=" + std::to_string(streamlines)); +} + +static void BM_TrxStream_TranslateWrite(benchmark::State &state) { + const size_t streamlines = static_cast(state.range(0)); + const auto scenario = static_cast(state.range(1)); + const bool add_dps = state.range(2) != 0; + const bool add_dpv = state.range(3) != 0; + const size_t progress_every = parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 0); + log_bench_start("BM_TrxStream_TranslateWrite", + "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1)) + + " dps=" + std::to_string(static_cast(add_dps)) + + " dpv=" + std::to_string(static_cast(add_dpv))); + + using Key = KeyHash::Key; + static std::unordered_map cache; + + const Key key{streamlines, static_cast(scenario), add_dps ? 1 : 0, add_dpv ? 1 : 0}; + if (cache.find(key) == cache.end()) { + state.PauseTiming(); + cache.emplace(key, + build_trx_file_on_disk(streamlines, scenario, add_dps, add_dpv, ZIP_CM_STORE)); + state.ResumeTiming(); + } + + const auto &dataset = cache.at(key); + if (dataset.shard_processes > 1 && dataset.shard_merge_ms > 0.0) { + state.counters["shard_merge_ms"] = dataset.shard_merge_ms; + state.counters["shard_processes"] = static_cast(dataset.shard_processes); + } + for (auto _ : state) { + const auto start = std::chrono::steady_clock::now(); + auto trx = trx::load_any(dataset.path); + const size_t chunk_bytes = parse_env_size("TRX_BENCH_CHUNK_BYTES", 1024ULL * 1024ULL * 1024ULL); + const std::string out_dir = make_work_dir_name("trx_translate_chunk"); + trx::PrepareOutputOptions prep_opts; + prep_opts.overwrite_existing = true; + const auto out_info = trx::prepare_positions_output(trx, out_dir, prep_opts); + + std::ofstream out_positions(out_info.positions_path, std::ios::binary | std::ios::out | std::ios::trunc); + if (!out_positions.is_open()) { + throw std::runtime_error("Failed to open output positions file: " + out_info.positions_path); + } + + trx.for_each_positions_chunk(chunk_bytes, + [&](trx::TrxScalarType dtype, const void *data, size_t offset, size_t count) { + (void)offset; + if (progress_every > 0 && ((offset + count) % progress_every == 0)) { + std::cerr << "[trx-bench] progress translate points=" << (offset + count) + << " / " << out_info.points << std::endl; + } + const size_t total_vals = count * 3; + if (dtype == trx::TrxScalarType::Float16) { + const auto *src = reinterpret_cast(data); + std::vector tmp(total_vals); + for (size_t i = 0; i < total_vals; ++i) { + tmp[i] = static_cast(static_cast(src[i]) + 1.0f); + } + out_positions.write(reinterpret_cast(tmp.data()), + static_cast(tmp.size() * sizeof(Eigen::half))); + } else if (dtype == trx::TrxScalarType::Float32) { + const auto *src = reinterpret_cast(data); + std::vector tmp(total_vals); + for (size_t i = 0; i < total_vals; ++i) { + tmp[i] = src[i] + 1.0f; + } + out_positions.write(reinterpret_cast(tmp.data()), + static_cast(tmp.size() * sizeof(float))); + } else { + const auto *src = reinterpret_cast(data); + std::vector tmp(total_vals); + for (size_t i = 0; i < total_vals; ++i) { + tmp[i] = src[i] + 1.0; + } + out_positions.write(reinterpret_cast(tmp.data()), + static_cast(tmp.size() * sizeof(double))); + } + }); + out_positions.flush(); + out_positions.close(); + + const std::string out_path = make_temp_path("trx_translate"); + trx::TrxSaveOptions save_opts; + save_opts.mode = trx::TrxSaveMode::Archive; + save_opts.compression_standard = ZIP_CM_STORE; + save_opts.overwrite_existing = true; + trx.save(out_path, save_opts); + trx::rm_dir(out_dir); + const auto end = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = end - start; + state.SetIterationTime(elapsed.count()); + + std::error_code ec; + std::filesystem::remove(out_path, ec); + benchmark::DoNotOptimize(trx); + } + + state.counters["streamlines"] = static_cast(streamlines); + state.counters["group_case"] = static_cast(state.range(1)); + state.counters["group_count"] = static_cast(group_count_for(scenario)); + state.counters["dps"] = add_dps ? 1.0 : 0.0; + state.counters["dpv"] = add_dpv ? 1.0 : 0.0; + state.counters["positions_dtype"] = 16.0; + state.counters["max_rss_kb"] = get_max_rss_kb(); + + log_bench_end("BM_TrxStream_TranslateWrite", + "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1))); +} + +static void BM_TrxQueryAabb_Slabs(benchmark::State &state) { + const size_t streamlines = static_cast(state.range(0)); + const auto scenario = static_cast(state.range(1)); + const bool add_dps = state.range(2) != 0; + const bool add_dpv = state.range(3) != 0; + log_bench_start("BM_TrxQueryAabb_Slabs", + "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1)) + + " dps=" + std::to_string(static_cast(add_dps)) + + " dpv=" + std::to_string(static_cast(add_dpv))); + + using Key = KeyHash::Key; + static std::unordered_map cache; + + const Key key{streamlines, static_cast(scenario), add_dps ? 1 : 0, add_dpv ? 1 : 0}; + if (cache.find(key) == cache.end()) { + state.PauseTiming(); + QueryDataset dataset; + auto on_disk = build_trx_file_on_disk(streamlines, scenario, add_dps, add_dpv, ZIP_CM_STORE); + dataset.trx = trx::load(on_disk.path); + dataset.trx->get_or_build_streamline_aabbs(); + build_slabs(dataset.slab_mins, dataset.slab_maxs); + cache.emplace(key, std::move(dataset)); + state.ResumeTiming(); + } + + auto &dataset = cache.at(key); + for (auto _ : state) { + std::vector slab_times_ms; + slab_times_ms.reserve(kSlabCount); + + const auto start = std::chrono::steady_clock::now(); + size_t total = 0; + for (size_t i = 0; i < kSlabCount; ++i) { + const auto &min_corner = dataset.slab_mins[i]; + const auto &max_corner = dataset.slab_maxs[i]; + const auto q_start = std::chrono::steady_clock::now(); + auto subset = dataset.trx->query_aabb(min_corner, max_corner); + const auto q_end = std::chrono::steady_clock::now(); + const std::chrono::duration q_elapsed = q_end - q_start; + slab_times_ms.push_back(q_elapsed.count()); + total += subset->num_streamlines(); + subset->close(); + } + const auto end = std::chrono::steady_clock::now(); + const std::chrono::duration elapsed = end - start; + state.SetIterationTime(elapsed.count()); + benchmark::DoNotOptimize(total); + + auto sorted = slab_times_ms; + std::sort(sorted.begin(), sorted.end()); + const auto p50 = sorted[sorted.size() / 2]; + const auto p95_idx = static_cast(std::ceil(0.95 * sorted.size())) - 1; + const auto p95 = sorted[std::min(p95_idx, sorted.size() - 1)]; + state.counters["query_p50_ms"] = p50; + state.counters["query_p95_ms"] = p95; + + ScenarioParams params; + params.streamlines = streamlines; + params.scenario = scenario; + params.add_dps = add_dps; + params.add_dpv = add_dpv; + maybe_write_query_timings(params, slab_times_ms); + } + + state.counters["streamlines"] = static_cast(streamlines); + state.counters["group_case"] = static_cast(state.range(1)); + state.counters["group_count"] = static_cast(group_count_for(scenario)); + state.counters["dps"] = add_dps ? 1.0 : 0.0; + state.counters["dpv"] = add_dpv ? 1.0 : 0.0; + state.counters["query_count"] = static_cast(kSlabCount); + state.counters["slab_thickness_mm"] = kSlabThicknessMm; + state.counters["positions_dtype"] = 16.0; + state.counters["max_rss_kb"] = get_max_rss_kb(); + + log_bench_end("BM_TrxQueryAabb_Slabs", + "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1))); +} + +static void ApplySizeArgs(benchmark::internal::Benchmark *bench) { + const std::array flags = {0, 1}; + const bool core_profile = is_core_profile(); + const size_t dpv_limit = core_dpv_max_streamlines(); + const size_t zip_limit = core_zip_max_streamlines(); + const auto counts_desc = streamlines_for_benchmarks(); + const auto groups = group_cases_for_benchmarks(); + for (const auto count : counts_desc) { + const std::vector dpv_flags = (!core_profile || count <= dpv_limit) + ? std::vector{0, 1} + : std::vector{0}; + const std::vector compression_flags = (!core_profile || count <= zip_limit) + ? std::vector{0, 1} + : std::vector{0}; + for (const auto group_case : groups) { + for (const auto dps : flags) { + for (const auto dpv : dpv_flags) { + for (const auto compression : compression_flags) { + bench->Args({static_cast(count), group_case, dps, dpv, compression}); + } + } + } + } + } +} + +static void ApplyStreamArgs(benchmark::internal::Benchmark *bench) { + const std::array flags = {0, 1}; + const bool core_profile = is_core_profile(); + const size_t dpv_limit = core_dpv_max_streamlines(); + const auto groups = group_cases_for_benchmarks(); + const auto counts_desc = streamlines_for_benchmarks(); + for (const auto count : counts_desc) { + const std::vector dpv_flags = (!core_profile || count <= dpv_limit) + ? std::vector{0, 1} + : std::vector{0}; + for (const auto group_case : groups) { + for (const auto dps : flags) { + for (const auto dpv : dpv_flags) { + bench->Args({static_cast(count), group_case, dps, dpv}); + } + } + } + } +} + +static void ApplyQueryArgs(benchmark::internal::Benchmark *bench) { + const std::array flags = {0, 1}; + const bool core_profile = is_core_profile(); + const size_t dpv_limit = core_dpv_max_streamlines(); + const auto groups = group_cases_for_benchmarks(); + const auto counts_desc = streamlines_for_benchmarks(); + for (const auto count : counts_desc) { + const std::vector dpv_flags = (!core_profile || count <= dpv_limit) + ? std::vector{0, 1} + : std::vector{0}; + for (const auto group_case : groups) { + for (const auto dps : flags) { + for (const auto dpv : dpv_flags) { + bench->Args({static_cast(count), group_case, dps, dpv}); + } + } + } + } + bench->Iterations(1); +} + +BENCHMARK(BM_TrxFileSize_Float16) + ->Apply(ApplySizeArgs) + ->Unit(benchmark::kMillisecond); + +BENCHMARK(BM_TrxStream_TranslateWrite) + ->Apply(ApplyStreamArgs) + ->UseManualTime() + ->Unit(benchmark::kMillisecond); + +BENCHMARK(BM_TrxQueryAabb_Slabs) + ->Apply(ApplyQueryArgs) + ->UseManualTime() + ->Unit(benchmark::kMillisecond); + +int main(int argc, char **argv) { + // Parse custom flags before benchmark::Initialize + bool verbose = false; + bool show_help = false; + std::string reference_trx; + + // First pass: detect custom flags + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "--verbose" || arg == "-v") { + verbose = true; + } else if (arg == "--help-custom") { + show_help = true; + } else if (arg == "--reference-trx" && i + 1 < argc) { + reference_trx = argv[i + 1]; + ++i; // Skip next arg since it's the value + } + } + + if (show_help) { + std::cout << "\nCustom benchmark options:\n" + << " --reference-trx PATH Path to reference TRX file for sampling (REQUIRED)\n" + << " --verbose, -v Enable verbose progress logging (prints every 50k streamlines)\n" + << " Equivalent to: TRX_BENCH_LOG=1 TRX_BENCH_CHILD_LOG=1 \n" + << " TRX_BENCH_LOG_PROGRESS_EVERY=50000\n" + << " --help-custom Show this help message\n" + << "\nFor standard benchmark options, use --help\n" + << std::endl; + return 0; + } + + // Validate reference TRX path + if (reference_trx.empty()) { + std::cerr << "Error: --reference-trx flag is required\n" + << "Usage: " << argv[0] << " --reference-trx [benchmark_options]\n" + << "Use --help-custom for more information\n" << std::endl; + return 1; + } + + // Check if reference file exists + std::error_code ec; + if (!std::filesystem::exists(reference_trx, ec)) { + std::cerr << "Error: Reference TRX file not found: " << reference_trx << std::endl; + return 1; + } + + // Set global reference path + g_reference_trx_path = reference_trx; + std::cerr << "[trx-bench] Using reference TRX: " << g_reference_trx_path << std::endl; + + // Enable verbose logging if requested + if (verbose) { + setenv("TRX_BENCH_LOG", "1", 0); // Don't override if already set + setenv("TRX_BENCH_CHILD_LOG", "1", 0); + if (std::getenv("TRX_BENCH_LOG_PROGRESS_EVERY") == nullptr) { + setenv("TRX_BENCH_LOG_PROGRESS_EVERY", "50000", 1); + } + std::cerr << "[trx-bench] Verbose mode enabled (progress every " + << parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 50000) + << " streamlines)\n" << std::endl; + } + + // Second pass: remove custom flags from argv before passing to benchmark::Initialize + std::vector filtered_argv; + filtered_argv.push_back(argv[0]); // Keep program name + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "--verbose" || arg == "-v" || arg == "--help-custom") { + continue; + } else if (arg == "--reference-trx") { + ++i; // Skip the next arg (the path value) + continue; + } + filtered_argv.push_back(argv[i]); + } + int filtered_argc = static_cast(filtered_argv.size()); + + ::benchmark::Initialize(&filtered_argc, filtered_argv.data()); + if (::benchmark::ReportUnrecognizedArguments(filtered_argc, filtered_argv.data())) { + return 1; + } + try { + ::benchmark::RunSpecifiedBenchmarks(); + g_run_success = true; + } catch (const std::exception &ex) { + std::cerr << "Benchmark failed: " << ex.what() << std::endl; + return 1; + } catch (...) { + std::cerr << "Benchmark failed with unknown exception." << std::endl; + return 1; + } + return 0; +} diff --git a/bench/bench_trx_stream.cpp b/bench/bench_trx_stream.cpp index 8029aed..871303e 100644 --- a/bench/bench_trx_stream.cpp +++ b/bench/bench_trx_stream.cpp @@ -31,8 +31,6 @@ #include #endif -#include - namespace { using Eigen::half; @@ -202,25 +200,6 @@ void wait_for_shard_ok(const std::vector &shard_paths, } } -void copy_file_append(const std::string &src, const std::string &dst, std::size_t buffer_bytes = 8 * 1024 * 1024) { - std::ifstream in(src, std::ios::binary); - if (!in.is_open()) { - throw std::runtime_error("Failed to open file for read: " + src); - } - std::ofstream out(dst, std::ios::binary | std::ios::out | std::ios::app); - if (!out.is_open()) { - throw std::runtime_error("Failed to open file for append: " + dst); - } - std::vector buffer(buffer_bytes); - while (in) { - in.read(buffer.data(), static_cast(buffer.size())); - const std::streamsize count = in.gcount(); - if (count > 0) { - out.write(buffer.data(), count); - } - } -} - std::pair read_header_counts(const std::string &dir) { const auto header_path = trx::fs::path(dir) / "header.json"; std::ifstream in; @@ -262,45 +241,6 @@ std::pair read_header_counts(const std::string &dir) { return {nb_streamlines, nb_vertices}; } -json read_header_json(const std::string &dir) { - const auto header_path = trx::fs::path(dir) / "header.json"; - std::ifstream in; - for (int attempt = 0; attempt < 5; ++attempt) { - in.open(header_path); - if (in.is_open()) { - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - if (!in.is_open()) { - std::error_code ec; - const bool exists = trx::fs::exists(dir, ec); - const auto files = list_files(dir); - const int open_err = errno; - std::string detail = "Failed to open header.json at: " + header_path.string(); - detail += " exists=" + std::string(exists ? "true" : "false"); - detail += " errno=" + std::to_string(open_err) + " msg=" + std::string(std::strerror(open_err)); - if (!files.empty()) { - detail += " files=["; - for (size_t i = 0; i < files.size(); ++i) { - if (i > 0) { - detail += ","; - } - detail += files[i]; - } - detail += "]"; - } - throw std::runtime_error(detail); - } - std::string contents((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - std::string err; - const auto header = json::parse(contents, err); - if (!err.empty()) { - throw std::runtime_error("Failed to parse header.json: " + err); - } - return header; -} - double get_max_rss_kb() { #if defined(__unix__) || defined(__APPLE__) rusage usage{}; @@ -351,6 +291,31 @@ int parse_env_int(const char *name, int default_value) { return static_cast(value); } +bool is_core_profile() { + const char *raw = std::getenv("TRX_BENCH_PROFILE"); + return raw && std::string(raw) == "core"; +} + +bool include_bundles_in_core_profile() { + return parse_env_bool("TRX_BENCH_CORE_INCLUDE_BUNDLES", false); +} + +size_t core_dpv_max_streamlines() { + return parse_env_size("TRX_BENCH_CORE_DPV_MAX_STREAMLINES", 1000000); +} + +size_t core_zip_max_streamlines() { + return parse_env_size("TRX_BENCH_CORE_ZIP_MAX_STREAMLINES", 1000000); +} + +std::vector group_cases_for_benchmarks() { + std::vector groups = {static_cast(GroupScenario::None)}; + if (!is_core_profile() || include_bundles_in_core_profile()) { + groups.push_back(static_cast(GroupScenario::Bundles)); + } + return groups; +} + size_t group_count_for(GroupScenario scenario) { switch (scenario) { case GroupScenario::Bundles: @@ -1184,284 +1149,20 @@ TrxOnDisk build_trx_file_on_disk(size_t streamlines, } const auto merge_start = std::chrono::steady_clock::now(); - const auto group_count = group_count_for(scenario); - const auto &group_names = group_names_for(scenario); - - const std::string merge_dir = make_temp_dir_path("trx_merge"); - const auto shard_positions0 = find_file_by_prefix(shard_paths[0], "positions."); - const auto shard_offsets0 = find_file_by_prefix(shard_paths[0], "offsets."); - if (shard_positions0.empty()) { - throw std::runtime_error("Missing positions file in first shard: " + shard_paths[0]); - } - if (shard_offsets0.empty()) { - throw std::runtime_error("Missing offsets file in first shard: " + shard_paths[0]); - } - const auto positions_filename = trx::fs::path(shard_positions0).filename().string(); - const auto offsets_filename = trx::fs::path(shard_offsets0).filename().string(); - const auto positions_path = trx::fs::path(merge_dir) / positions_filename; - const auto offsets_path = trx::fs::path(merge_dir) / offsets_filename; - - { - std::ofstream out_pos(positions_path, std::ios::binary | std::ios::out | std::ios::trunc); - if (!out_pos.is_open()) { - throw std::runtime_error("Failed to open output positions file: " + positions_path.string()); - } - } - { - std::ofstream out_off(offsets_path, std::ios::binary | std::ios::out | std::ios::trunc); - if (!out_off.is_open()) { - throw std::runtime_error("Failed to open output offsets file: " + offsets_path.string()); - } - } - - std::vector dps_files; - std::vector dpv_files; - if (add_dps) { - dps_files = list_files((trx::fs::path(shard_paths[0]) / "dps").string()); - if (dps_files.empty()) { - throw std::runtime_error("No DPS files found in shard: " + shard_paths[0]); - } - } - if (add_dpv) { - dpv_files = list_files((trx::fs::path(shard_paths[0]) / "dpv").string()); - if (dpv_files.empty()) { - throw std::runtime_error("No DPV files found in shard: " + shard_paths[0]); - } - } - std::vector group_files; - if (group_count > 0) { - group_files = list_files((trx::fs::path(shard_paths[0]) / "groups").string()); - if (group_files.empty()) { - throw std::runtime_error("No group files found in shard: " + shard_paths[0]); - } - } - - if (add_dps) { - trx::fs::create_directories(trx::fs::path(merge_dir) / "dps"); - for (const auto &name : dps_files) { - const auto dst = trx::fs::path(merge_dir) / "dps" / name; - std::ofstream out(dst, std::ios::binary | std::ios::out | std::ios::trunc); - if (!out.is_open()) { - throw std::runtime_error("Failed to create DPS file: " + dst.string()); - } - } - } - if (add_dpv) { - trx::fs::create_directories(trx::fs::path(merge_dir) / "dpv"); - for (const auto &name : dpv_files) { - const auto dst = trx::fs::path(merge_dir) / "dpv" / name; - std::ofstream out(dst, std::ios::binary | std::ios::out | std::ios::trunc); - if (!out.is_open()) { - throw std::runtime_error("Failed to create DPV file: " + dst.string()); - } - } - } - if (group_count > 0) { - trx::fs::create_directories(trx::fs::path(merge_dir) / "groups"); - for (const auto &name : group_files) { - const auto dst = trx::fs::path(merge_dir) / "groups" / name; - std::ofstream out(dst, std::ios::binary | std::ios::out | std::ios::trunc); - if (!out.is_open()) { - throw std::runtime_error("Failed to create group file: " + dst.string()); - } - } - } + const std::string out_path = make_temp_path("trx_input"); - size_t vertex_offset = 0; - size_t streamline_offset = 0; - for (size_t i = 0; i < processes; ++i) { - const auto shard_dir = shard_paths[i]; - const auto shard_positions = find_file_by_prefix(shard_dir, "positions."); - const auto shard_offsets = find_file_by_prefix(shard_dir, "offsets."); - if (shard_positions.empty()) { - throw std::runtime_error("Missing positions file in shard: " + shard_dir); - } - if (shard_offsets.empty()) { - throw std::runtime_error("Missing offsets file in shard: " + shard_dir); - } - - copy_file_append(shard_positions, positions_path.string()); - - { - const bool offsets_u32 = offsets_filename.find("uint32") != std::string::npos; - std::ifstream in(shard_offsets, std::ios::binary); - if (!in.is_open()) { - throw std::runtime_error("Failed to open shard offsets: " + shard_offsets); - } - std::ofstream out(offsets_path, std::ios::binary | std::ios::out | std::ios::app); - if (!out.is_open()) { - throw std::runtime_error("Failed to open output offsets file: " + offsets_path.string()); - } - constexpr size_t kBatch = 1 << 14; - const bool skip_first_value = (i != 0); - bool skipped_first = false; - if (offsets_u32) { - std::vector buffer(kBatch); - while (in) { - in.read(reinterpret_cast(buffer.data()), - static_cast(buffer.size() * sizeof(uint32_t))); - const std::streamsize count = in.gcount(); - if (count <= 0) { - break; - } - const size_t elems = static_cast(count) / sizeof(uint32_t); - size_t start = 0; - if (skip_first_value && !skipped_first) { - start = 1; - skipped_first = true; - } - for (size_t j = start; j < elems; ++j) { - const uint64_t value = static_cast(buffer[j]) + static_cast(vertex_offset); - if (value > std::numeric_limits::max()) { - throw std::runtime_error("Offsets overflow uint32 during merge."); - } - buffer[j] = static_cast(value); - } - if (elems > start) { - out.write(reinterpret_cast(buffer.data() + start), - static_cast((elems - start) * sizeof(uint32_t))); - } - } - } else { - std::vector buffer(kBatch); - while (in) { - in.read(reinterpret_cast(buffer.data()), - static_cast(buffer.size() * sizeof(uint64_t))); - const std::streamsize count = in.gcount(); - if (count <= 0) { - break; - } - const size_t elems = static_cast(count) / sizeof(uint64_t); - size_t start = 0; - if (skip_first_value && !skipped_first) { - start = 1; - skipped_first = true; - } - for (size_t j = start; j < elems; ++j) { - buffer[j] += static_cast(vertex_offset); - } - if (elems > start) { - out.write(reinterpret_cast(buffer.data() + start), - static_cast((elems - start) * sizeof(uint64_t))); - } - } - } - } - - if (add_dps) { - const auto shard_dps = trx::fs::path(shard_dir) / "dps"; - for (const auto &name : dps_files) { - const auto src = shard_dps / name; - const auto dst = trx::fs::path(merge_dir) / "dps" / name; - if (!trx::fs::exists(src)) { - throw std::runtime_error("Missing DPS file in shard: " + src.string()); - } - copy_file_append(src.string(), dst.string()); - } - } - - if (add_dpv) { - const auto shard_dpv = trx::fs::path(shard_dir) / "dpv"; - for (const auto &name : dpv_files) { - const auto src = shard_dpv / name; - const auto dst = trx::fs::path(merge_dir) / "dpv" / name; - if (!trx::fs::exists(src)) { - throw std::runtime_error("Missing DPV file in shard: " + src.string()); - } - copy_file_append(src.string(), dst.string()); - } - } - - if (group_count > 0) { - const auto shard_groups = trx::fs::path(shard_dir) / "groups"; - for (const auto &name : group_files) { - const auto src = shard_groups / name; - const auto dst = trx::fs::path(merge_dir) / "groups" / name; - if (!trx::fs::exists(src)) { - throw std::runtime_error("Missing group file in shard: " + src.string()); - } - std::ifstream in(src, std::ios::binary); - if (!in.is_open()) { - throw std::runtime_error("Failed to open shard group: " + src.string()); - } - std::ofstream out(dst, std::ios::binary | std::ios::out | std::ios::app); - if (!out.is_open()) { - throw std::runtime_error("Failed to open output group file: " + dst.string()); - } - constexpr size_t kBatch = 1 << 14; - std::vector buffer(kBatch); - while (in) { - in.read(reinterpret_cast(buffer.data()), - static_cast(buffer.size() * sizeof(uint32_t))); - const std::streamsize count = in.gcount(); - if (count <= 0) { - break; - } - const size_t elems = static_cast(count) / sizeof(uint32_t); - for (size_t j = 0; j < elems; ++j) { - buffer[j] += static_cast(streamline_offset); - } - out.write(reinterpret_cast(buffer.data()), - static_cast(elems * sizeof(uint32_t))); - } - } - } - - vertex_offset += shard_vertices[i]; - streamline_offset += shard_streamlines[i]; - } - - // Read header before cleanup to avoid accessing deleted files - const json header_json = read_header_json(shard_paths[0]); - json::object header_obj = header_json.object_items(); - header_obj["NB_VERTICES"] = json(static_cast(total_vertices)); - header_obj["NB_STREAMLINES"] = json(static_cast(total_streamlines)); - const json header = header_obj; - { - const auto header_path = trx::fs::path(merge_dir) / "header.json"; - std::ofstream out(header_path, std::ios::out | std::ios::trunc); - if (!out.is_open()) { - throw std::runtime_error("Failed to write header.json: " + header_path.string()); - } - out << header.dump(); - } - - const std::string zip_path = make_temp_path("trx_input"); - int errorp; - zip_t *zf = zip_open(zip_path.c_str(), ZIP_CREATE + ZIP_TRUNCATE, &errorp); - if (zf == nullptr) { - throw std::runtime_error("Could not open archive " + zip_path + ": " + strerror(errorp)); - } - const std::string header_payload = header.dump() + "\n"; - zip_source_t *header_source = - zip_source_buffer(zf, header_payload.data(), header_payload.size(), 0 /* do not free */); - if (header_source == nullptr) { - zip_close(zf); - throw std::runtime_error("Failed to create zip source for header.json: " + std::string(zip_strerror(zf))); - } - const zip_int64_t header_idx = zip_file_add(zf, "header.json", header_source, ZIP_FL_ENC_UTF_8 | ZIP_FL_OVERWRITE); - if (header_idx < 0) { - zip_source_free(header_source); - zip_close(zf); - throw std::runtime_error("Failed to add header.json to archive: " + std::string(zip_strerror(zf))); - } - const zip_int32_t compression_mode = static_cast(compression); - if (zip_set_file_compression(zf, header_idx, compression_mode, 0) < 0) { - zip_close(zf); - throw std::runtime_error("Failed to set compression for header.json: " + std::string(zip_strerror(zf))); - } - const std::unordered_set skip = {"header.json"}; - trx::zip_from_folder(zf, merge_dir, merge_dir, compression, &skip); - if (zip_close(zf) != 0) { - throw std::runtime_error("Unable to close archive " + zip_path + ": " + zip_strerror(zf)); - } - trx::fs::remove_all(merge_dir); - const std::string out_path = zip_path; + trx::MergeTrxShardsOptions merge_opts; + merge_opts.shard_directories = shard_paths; + merge_opts.output_path = out_path; + merge_opts.compression_standard = compression; + merge_opts.output_directory = false; + merge_opts.overwrite_existing = true; + trx::merge_trx_shards(merge_opts); register_cleanup(out_path); const auto merge_end = std::chrono::steady_clock::now(); const std::chrono::duration merge_elapsed = merge_end - merge_start; - + // Final cleanup of shard directories after merge is complete if (!parse_env_bool("TRX_BENCH_KEEP_SHARDS", false)) { std::error_code ec; @@ -1476,7 +1177,6 @@ TrxOnDisk build_trx_file_on_disk(size_t streamlines, struct QueryDataset { std::unique_ptr> trx; - std::vector> aabbs; std::vector> slab_mins; std::vector> slab_maxs; }; @@ -1650,7 +1350,9 @@ static void BM_TrxStream_TranslateWrite(benchmark::State &state) { auto trx = trx::load_any(dataset.path); const size_t chunk_bytes = parse_env_size("TRX_BENCH_CHUNK_BYTES", 1024ULL * 1024ULL * 1024ULL); const std::string out_dir = make_work_dir_name("trx_translate_chunk"); - const auto out_info = trx::prepare_positions_output(trx, out_dir); + trx::PrepareOutputOptions prep_opts; + prep_opts.overwrite_existing = true; + const auto out_info = trx::prepare_positions_output(trx, out_dir, prep_opts); std::ofstream out_positions(out_info.positions_path, std::ios::binary | std::ios::out | std::ios::trunc); if (!out_positions.is_open()) { @@ -1695,17 +1397,11 @@ static void BM_TrxStream_TranslateWrite(benchmark::State &state) { out_positions.close(); const std::string out_path = make_temp_path("trx_translate"); - int errorp; - zip_t *zf = zip_open(out_path.c_str(), ZIP_CREATE + ZIP_TRUNCATE, &errorp); - if (zf == nullptr) { - trx::rm_dir(out_dir); - throw std::runtime_error("Could not open archive " + out_path + ": " + strerror(errorp)); - } - trx::zip_from_folder(zf, out_dir, out_dir, ZIP_CM_STORE, nullptr); - if (zip_close(zf) != 0) { - trx::rm_dir(out_dir); - throw std::runtime_error("Unable to close archive " + out_path + ": " + zip_strerror(zf)); - } + trx::TrxSaveOptions save_opts; + save_opts.mode = trx::TrxSaveMode::Archive; + save_opts.compression_standard = ZIP_CM_STORE; + save_opts.overwrite_existing = true; + trx.save(out_path, save_opts); trx::rm_dir(out_dir); const auto end = std::chrono::steady_clock::now(); const std::chrono::duration elapsed = end - start; @@ -1748,7 +1444,7 @@ static void BM_TrxQueryAabb_Slabs(benchmark::State &state) { QueryDataset dataset; auto on_disk = build_trx_file_on_disk(streamlines, scenario, add_dps, add_dpv, LengthProfile::Mixed, ZIP_CM_STORE); dataset.trx = trx::load(on_disk.path); - dataset.aabbs = dataset.trx->build_streamline_aabbs(); + dataset.trx->get_or_build_streamline_aabbs(); build_slabs(dataset.slab_mins, dataset.slab_maxs); cache.emplace(key, std::move(dataset)); state.ResumeTiming(); @@ -1765,7 +1461,7 @@ static void BM_TrxQueryAabb_Slabs(benchmark::State &state) { const auto &min_corner = dataset.slab_mins[i]; const auto &max_corner = dataset.slab_maxs[i]; const auto q_start = std::chrono::steady_clock::now(); - auto subset = dataset.trx->query_aabb(min_corner, max_corner, &dataset.aabbs); + auto subset = dataset.trx->query_aabb(min_corner, max_corner); const auto q_end = std::chrono::steady_clock::now(); const std::chrono::duration q_elapsed = q_end - q_start; slab_times_ms.push_back(q_elapsed.count()); @@ -1813,12 +1509,21 @@ static void ApplySizeArgs(benchmark::internal::Benchmark *bench) { static_cast(LengthProfile::Medium), static_cast(LengthProfile::Long)}; const std::array flags = {0, 1}; + const bool core_profile = is_core_profile(); + const size_t dpv_limit = core_dpv_max_streamlines(); + const size_t zip_limit = core_zip_max_streamlines(); const auto counts_desc = streamlines_for_benchmarks(); for (const auto count : counts_desc) { + const std::vector dpv_flags = (!core_profile || count <= dpv_limit) + ? std::vector{0, 1} + : std::vector{0}; + const std::vector compression_flags = (!core_profile || count <= zip_limit) + ? std::vector{0, 1} + : std::vector{0}; for (const auto profile : profiles) { for (const auto dps : flags) { - for (const auto dpv : flags) { - for (const auto compression : flags) { + for (const auto dpv : dpv_flags) { + for (const auto compression : compression_flags) { bench->Args({static_cast(count), profile, dps, dpv, compression}); } } @@ -1828,14 +1533,18 @@ static void ApplySizeArgs(benchmark::internal::Benchmark *bench) { } static void ApplyStreamArgs(benchmark::internal::Benchmark *bench) { - const std::array groups = {static_cast(GroupScenario::None), - static_cast(GroupScenario::Bundles)}; const std::array flags = {0, 1}; + const bool core_profile = is_core_profile(); + const size_t dpv_limit = core_dpv_max_streamlines(); + const auto groups = group_cases_for_benchmarks(); const auto counts_desc = streamlines_for_benchmarks(); for (const auto count : counts_desc) { + const std::vector dpv_flags = (!core_profile || count <= dpv_limit) + ? std::vector{0, 1} + : std::vector{0}; for (const auto group_case : groups) { for (const auto dps : flags) { - for (const auto dpv : flags) { + for (const auto dpv : dpv_flags) { bench->Args({static_cast(count), group_case, dps, dpv}); } } @@ -1844,14 +1553,18 @@ static void ApplyStreamArgs(benchmark::internal::Benchmark *bench) { } static void ApplyQueryArgs(benchmark::internal::Benchmark *bench) { - const std::array groups = {static_cast(GroupScenario::None), - static_cast(GroupScenario::Bundles)}; const std::array flags = {0, 1}; + const bool core_profile = is_core_profile(); + const size_t dpv_limit = core_dpv_max_streamlines(); + const auto groups = group_cases_for_benchmarks(); const auto counts_desc = streamlines_for_benchmarks(); for (const auto count : counts_desc) { + const std::vector dpv_flags = (!core_profile || count <= dpv_limit) + ? std::vector{0, 1} + : std::vector{0}; for (const auto group_case : groups) { for (const auto dps : flags) { - for (const auto dpv : flags) { + for (const auto dpv : dpv_flags) { bench->Args({static_cast(count), group_case, dps, dpv}); } } diff --git a/bench/plot_bench.R b/bench/plot_bench.R index d0218db..09ade27 100755 --- a/bench/plot_bench.R +++ b/bench/plot_bench.R @@ -13,7 +13,8 @@ # # Expected input files (searched in bench-dir): # - results*.json: Main benchmark results (Google Benchmark JSON format) -# - query_timings.jsonl: Per-query timing distributions (JSONL format) +# - query_timings.jsonl: Canonical per-query timing distributions (JSONL format) +# - query_timings_*.jsonl: Legacy/suite-specific timing files (also supported) # - rss_samples.jsonl: Memory samples over time (JSONL format, optional) # @@ -26,16 +27,10 @@ suppressPackageStartupMessages({ }) # Constants -LENGTH_LABELS <- c( - "0" = "mixed", - "1" = "short (20-120mm)", - "2" = "medium (80-260mm)", - "3" = "long (200-500mm)" -) - GROUP_LABELS <- c( "0" = "no groups", - "1" = "bundle groups (80)" + "1" = "bundle groups (80)", + "2" = "connectome groups (1480)" ) COMPRESSION_LABELS <- c( @@ -189,24 +184,28 @@ plot_file_sizes <- function(df, out_dir) { sub_df <- sub_df %>% mutate( file_mb = file_bytes / 1e6, - length_label = recode(as.character(length_profile), !!!LENGTH_LABELS), compression_label = recode(as.character(compression), !!!COMPRESSION_LABELS), + group_label = recode( + as.character(ifelse(is.na(group_case), "0", as.character(group_case))), + !!!GROUP_LABELS), dp_label = sprintf("dpv=%d, dps=%d", as.integer(dpv), as.integer(dps)) ) - - p <- ggplot(sub_df, aes(x = streamlines, y = file_mb, - color = length_label, linetype = dp_label)) + + + n_group_levels <- length(unique(sub_df$group_label)) + plot_height <- if (n_group_levels > 1) 5 + 3 * n_group_levels else 7 + + p <- ggplot(sub_df, aes(x = streamlines, y = file_mb, + color = dp_label)) + geom_line(linewidth = 0.8) + geom_point(size = 2) + - facet_wrap(~compression_label, ncol = 2) + + facet_grid(group_label ~ compression_label) + scale_x_continuous(labels = label_number(scale = 1e-6, suffix = "M")) + scale_y_continuous(labels = label_number()) + labs( title = "TRX file size vs streamlines (float16 positions)", x = "Streamlines", y = "File size (MB)", - color = "Length profile", - linetype = "Data per point" + color = "Data per streamline/vertex" ) + theme_bw() + theme( @@ -214,9 +213,9 @@ plot_file_sizes <- function(df, out_dir) { legend.box = "vertical", strip.background = element_rect(fill = "grey90") ) - + out_path <- file.path(out_dir, "trx_size_vs_streamlines.png") - ggsave(out_path, p, width = 12, height = 7, dpi = 160) + ggsave(out_path, p, width = 12, height = plot_height, dpi = 160) cat("Saved:", out_path, "\n") } @@ -324,14 +323,38 @@ load_query_timings <- function(jsonl_path) { bind_rows(rows) } +#' Load query timings from canonical and legacy JSONL files +load_all_query_timings <- function(bench_dir) { + canonical <- file.path(bench_dir, "query_timings.jsonl") + legacy <- list.files(bench_dir, pattern = "^query_timings.*\\.jsonl$", full.names = TRUE) + jsonl_paths <- unique(c(canonical, legacy)) + jsonl_paths <- jsonl_paths[file.exists(jsonl_paths)] + + if (length(jsonl_paths) == 0) { + return(NULL) + } + + dfs <- lapply(jsonl_paths, function(path) { + df <- load_query_timings(path) + if (is.null(df) || nrow(df) == 0) { + return(NULL) + } + df$source_file <- basename(path) + df + }) + dfs <- dfs[!sapply(dfs, is.null)] + if (length(dfs) == 0) { + return(NULL) + } + bind_rows(dfs) +} + #' Plot query timing distributions plot_query_timings <- function(bench_dir, out_dir, group_case = 0, dpv = 0, dps = 0) { - jsonl_path <- file.path(bench_dir, "query_timings.jsonl") - - df <- load_query_timings(jsonl_path) + df <- load_all_query_timings(bench_dir) if (is.null(df) || nrow(df) == 0) { - cat("No query_timings.jsonl found or empty, skipping query timing plot\n") + cat("No query_timings*.jsonl found or empty, skipping query timing plot\n") return(invisible(NULL)) } diff --git a/bench/plot_bench.py b/bench/plot_bench.py deleted file mode 100644 index b346035..0000000 --- a/bench/plot_bench.py +++ /dev/null @@ -1,213 +0,0 @@ -import argparse -import json -from pathlib import Path - -import matplotlib.pyplot as plt -import pandas as pd - -LENGTH_LABELS = { - 0: "mixed", - 1: "short (20-120mm)", - 2: "medium (80-260mm)", - 3: "long (200-500mm)", -} -GROUP_LABELS = { - 0: "no groups", - 1: "bundle groups (80)", -} -COMPRESSION_LABELS = {0: "store (no zip)", 1: "zip deflate"} - - -def _parse_base_name(name: str) -> str: - return name.split("/")[0] - - -def _time_to_ms(bench: dict) -> float: - value = bench.get("real_time", 0.0) - unit = bench.get("time_unit", "ns") - if unit == "ns": - return value / 1e6 - if unit == "us": - return value / 1e3 - if unit == "ms": - return value - if unit == "s": - return value * 1e3 - return value / 1e6 - - -def load_benchmarks(path: Path) -> pd.DataFrame: - with path.open() as f: - data = json.load(f) - - rows = [] - for bench in data.get("benchmarks", []): - name = bench.get("name", "") - if not name.startswith("BM_"): - continue - rows.append( - { - "name": name, - "base": _parse_base_name(name), - "real_time_ms": _time_to_ms(bench), - "streamlines": bench.get("streamlines"), - "length_profile": bench.get("length_profile"), - "compression": bench.get("compression"), - "group_case": bench.get("group_case"), - "group_count": bench.get("group_count"), - "dps": bench.get("dps"), - "dpv": bench.get("dpv"), - "write_ms": bench.get("write_ms"), - "file_bytes": bench.get("file_bytes"), - "max_rss_kb": bench.get("max_rss_kb"), - "query_p50_ms": bench.get("query_p50_ms"), - "query_p95_ms": bench.get("query_p95_ms"), - } - ) - - return pd.DataFrame(rows) - - -def plot_file_sizes(df: pd.DataFrame, output_dir: Path) -> None: - sub = df[df["base"] == "BM_TrxFileSize_Float16"].copy() - if sub.empty: - return - sub["file_mb"] = sub["file_bytes"] / 1e6 - sub["length_label"] = sub["length_profile"].map(LENGTH_LABELS) - sub["dp_label"] = "dpv=" + sub["dpv"].astype(int).astype(str) + ", dps=" + sub["dps"].astype(int).astype(str) - - fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True) - for compression, ax in zip([0, 1], axes): - scomp = sub[sub["compression"] == compression] - for (length_label, dp_label), series in scomp.groupby(["length_label", "dp_label"]): - series = series.sort_values("streamlines") - ax.plot( - series["streamlines"], - series["file_mb"], - marker="o", - label=f"{length_label}, {dp_label}", - ) - ax.set_title(COMPRESSION_LABELS.get(compression, str(compression))) - ax.set_xlabel("streamlines") - ax.grid(True) - ax.legend(loc="best", fontsize="x-small") - - axes[0].set_ylabel("file size (MB)") - fig.suptitle("TRX file size vs streamlines (float16 positions)") - output_dir.mkdir(parents=True, exist_ok=True) - fig.savefig(output_dir / "trx_size_vs_streamlines.png", dpi=160, bbox_inches="tight") - plt.close(fig) - - -def _plot_translate_series(df: pd.DataFrame, output_dir: Path, metric: str, ylabel: str, filename: str) -> None: - sub = df[df["base"] == "BM_TrxStream_TranslateWrite"].copy() - if sub.empty: - return - sub["group_label"] = sub["group_case"].map(GROUP_LABELS) - sub["dp_label"] = "dpv=" + sub["dpv"].astype(int).astype(str) + ", dps=" + sub["dps"].astype(int).astype(str) - - fig, axes = plt.subplots(1, 3, figsize=(14, 4), sharey=True) - for ax, (group_label, gsub) in zip(axes, sub.groupby("group_label")): - for dp_label, series in gsub.groupby("dp_label"): - series = series.sort_values("streamlines") - ax.plot(series["streamlines"], series[metric], marker="o", label=dp_label) - ax.set_title(group_label) - ax.set_xlabel("streamlines") - ax.grid(True) - ax.legend(loc="best", fontsize="x-small") - axes[0].set_ylabel(ylabel) - fig.suptitle("Translate + stream write throughput") - output_dir.mkdir(parents=True, exist_ok=True) - fig.savefig(output_dir / filename, dpi=160, bbox_inches="tight") - plt.close(fig) - - -def plot_translate_write(df: pd.DataFrame, output_dir: Path) -> None: - sub = df[df["base"] == "BM_TrxStream_TranslateWrite"].copy() - if sub.empty: - return - sub["rss_mb"] = sub["max_rss_kb"] / 1024.0 - _plot_translate_series( - sub, - output_dir, - metric="real_time_ms", - ylabel="time (ms)", - filename="trx_translate_write_time.png", - ) - _plot_translate_series( - sub, - output_dir, - metric="rss_mb", - ylabel="max RSS (MB)", - filename="trx_translate_write_rss.png", - ) - - -def load_query_timings(path: Path) -> list[dict]: - if not path.exists(): - return [] - rows = [] - with path.open() as f: - for line in f: - line = line.strip() - if not line: - continue - rows.append(json.loads(line)) - return rows - - -def plot_query_timings(path: Path, output_dir: Path, group_case: int, dpv: int, dps: int) -> None: - rows = load_query_timings(path) - if not rows: - return - rows = [ - r - for r in rows - if r.get("group_case") == group_case and r.get("dpv") == dpv and r.get("dps") == dps - ] - if not rows: - return - rows.sort(key=lambda r: r["streamlines"]) - data = [r["timings_ms"] for r in rows] - labels = [str(r["streamlines"]) for r in rows] - - fig, ax = plt.subplots(figsize=(8, 4)) - ax.boxplot(data, labels=labels, showfliers=False) - ax.set_title( - f"Slab query timings ({GROUP_LABELS.get(group_case, group_case)}, dpv={dpv}, dps={dps})" - ) - ax.set_xlabel("streamlines") - ax.set_ylabel("per-slab query time (ms)") - ax.grid(True, axis="y") - output_dir.mkdir(parents=True, exist_ok=True) - fig.savefig(output_dir / "trx_query_slab_timings.png", dpi=160, bbox_inches="tight") - plt.close(fig) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Plot trx-cpp benchmark results.") - parser.add_argument("bench_json", type=Path, help="Path to benchmark JSON output.") - parser.add_argument("--query-json", type=Path, help="Path to slab timing JSONL file.") - parser.add_argument( - "--out-dir", - type=Path, - default=Path("docs/_static/benchmarks"), - help="Directory to save PNGs.", - ) - parser.add_argument("--group-case", type=int, default=0, help="Group case filter for query plot.") - parser.add_argument("--dpv", type=int, default=0, help="DPV filter for query plot.") - parser.add_argument("--dps", type=int, default=0, help="DPS filter for query plot.") - args = parser.parse_args() - - df = load_benchmarks(args.bench_json) - if df.empty: - raise SystemExit("No benchmarks found in JSON file.") - - plot_file_sizes(df, args.out_dir) - plot_translate_write(df, args.out_dir) - if args.query_json: - plot_query_timings(args.query_json, args.out_dir, args.group_case, args.dpv, args.dps) - - -if __name__ == "__main__": - main() diff --git a/bench/run_benchmarks.sh b/bench/run_benchmarks.sh new file mode 100755 index 0000000..52272d3 --- /dev/null +++ b/bench/run_benchmarks.sh @@ -0,0 +1,237 @@ +#!/bin/bash +# +# run_benchmarks.sh - Run trx-cpp benchmarks separately to minimize memory usage +# +# Usage: +# ./bench/run_benchmarks.sh [options] +# +# Options: +# --realdata Run real data benchmarks (bench_trx_realdata, default) +# --reference PATH Path to reference TRX file (for realdata) +# --out-dir DIR Output directory for JSON results (default: bench) +# --profile MODE Benchmark profile: core (default) or full +# --allow-synth-mp Allow synthetic multiprocessing (experimental) +# --verbose Enable verbose progress logging +# --help Show this help message +# +# Environment variables (optional): +# TRX_BENCH_BUFFER_MULTIPLIER Buffer size multiplier for slow storage (default: 1) +# TRX_BENCH_MAX_STREAMLINES Maximum streamline count to test (profile default) +# TRX_BENCH_PROCESSES Number of processes (synthetic only, default: 1) +# + +set -e # Exit on error + +# Default values +BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$BENCH_DIR")" +OUT_DIR="$BENCH_DIR" +RUN_SYNTHETIC=false +RUN_REALDATA=true +REFERENCE_TRX="$PROJECT_ROOT/test-data/10milHCP_dps-sift2.trx" +VERBOSE_FLAG="" +BUILD_DIR="$PROJECT_ROOT/build-release" +PROFILE="core" +ALLOW_SYNTH_MP=false + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --synthetic) + RUN_SYNTHETIC=true + shift + ;; + --realdata) + RUN_REALDATA=true + shift + ;; + --both) + RUN_SYNTHETIC=true + RUN_REALDATA=true + shift + ;; + --profile) + PROFILE="$2" + shift 2 + ;; + --allow-synth-mp) + ALLOW_SYNTH_MP=true + shift + ;; + --reference) + REFERENCE_TRX="$2" + shift 2 + ;; + --out-dir) + OUT_DIR="$2" + shift 2 + ;; + --verbose) + VERBOSE_FLAG="--verbose" + shift + ;; + --build-dir) + BUILD_DIR="$2" + shift 2 + ;; + --help) + head -n 20 "$0" | tail -n +2 | sed 's/^# //' | sed 's/^#//' + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +if [[ "$PROFILE" != "core" && "$PROFILE" != "full" ]]; then + echo "Error: --profile must be 'core' or 'full' (got '$PROFILE')" + exit 1 +fi + +# Create output directory +mkdir -p "$OUT_DIR" + +echo "========================================" +echo "TRX-CPP Benchmark Runner" +echo "========================================" +echo "Output directory: $OUT_DIR" +echo "Build directory: $BUILD_DIR" +echo "Run synthetic: $RUN_SYNTHETIC" +echo "Run realdata: $RUN_REALDATA" +echo "Profile: $PROFILE" +echo "Synthetic multiprocessing: $([[ "$ALLOW_SYNTH_MP" == "true" ]] && echo "experimental" || echo "disabled")" +if [[ "$RUN_REALDATA" == "true" ]]; then + echo "Reference TRX: $REFERENCE_TRX" +fi +echo "Verbose: ${VERBOSE_FLAG:-disabled}" +echo "" + +# Function to run a single benchmark +run_benchmark() { + local executable=$1 + local filter=$2 + local output_file=$3 + local extra_env=$4 + local extra_flags=$5 + + echo "----------------------------------------" + echo "Running: $(basename "$executable") --benchmark_filter=$filter" + echo "Output: $(basename "$output_file")" + echo "----------------------------------------" + + if [[ -n "$extra_env" ]]; then + eval "$extra_env" "$executable" $VERBOSE_FLAG $extra_flags \ + --benchmark_filter="$filter" \ + --benchmark_out="$output_file" \ + --benchmark_out_format=json + else + "$executable" $VERBOSE_FLAG $extra_flags \ + --benchmark_filter="$filter" \ + --benchmark_out="$output_file" \ + --benchmark_out_format=json + fi + + echo "✓ Completed: $(basename "$output_file")" + echo "" +} + +# Build profile environment defaults (users can override by exporting env vars) +if [[ "$PROFILE" == "core" ]]; then + CORE_ENV="TRX_BENCH_PROFILE=${TRX_BENCH_PROFILE:-core} TRX_BENCH_MAX_STREAMLINES=${TRX_BENCH_MAX_STREAMLINES:-5000000} TRX_BENCH_SKIP_DPV_AT=${TRX_BENCH_SKIP_DPV_AT:-1000000} TRX_BENCH_QUERY_CACHE_MAX=${TRX_BENCH_QUERY_CACHE_MAX:-5} TRX_BENCH_CORE_INCLUDE_BUNDLES=${TRX_BENCH_CORE_INCLUDE_BUNDLES:-0} TRX_BENCH_INCLUDE_CONNECTOME=${TRX_BENCH_INCLUDE_CONNECTOME:-0} TRX_BENCH_CORE_DPV_MAX_STREAMLINES=${TRX_BENCH_CORE_DPV_MAX_STREAMLINES:-1000000} TRX_BENCH_CORE_ZIP_MAX_STREAMLINES=${TRX_BENCH_CORE_ZIP_MAX_STREAMLINES:-1000000}" +else + CORE_ENV="TRX_BENCH_PROFILE=${TRX_BENCH_PROFILE:-full} TRX_BENCH_MAX_STREAMLINES=${TRX_BENCH_MAX_STREAMLINES:-10000000} TRX_BENCH_SKIP_DPV_AT=${TRX_BENCH_SKIP_DPV_AT:-10000000} TRX_BENCH_QUERY_CACHE_MAX=${TRX_BENCH_QUERY_CACHE_MAX:-10} TRX_BENCH_INCLUDE_CONNECTOME=${TRX_BENCH_INCLUDE_CONNECTOME:-1}" +fi + +SYNTH_ENV="$CORE_ENV" +if [[ "$ALLOW_SYNTH_MP" != "true" ]]; then + SYNTH_ENV="$SYNTH_ENV TRX_BENCH_PROCESSES=${TRX_BENCH_PROCESSES:-1}" +fi + +# Synthetic benchmarks +if [[ "$RUN_SYNTHETIC" == "true" ]]; then + SYNTHETIC_BIN="$BUILD_DIR/bench/bench_trx_stream" + + if [[ ! -f "$SYNTHETIC_BIN" ]]; then + echo "Error: Synthetic benchmark not found: $SYNTHETIC_BIN" + echo "Build with: cmake --build $BUILD_DIR --target bench_trx_stream" + exit 1 + fi + + echo "========================================" + echo "SYNTHETIC DATA BENCHMARKS" + echo "========================================" + echo "" + + run_benchmark "$SYNTHETIC_BIN" "BM_TrxFileSize_Float16" \ + "$OUT_DIR/results_synthetic_filesize.json" \ + "$SYNTH_ENV" + + run_benchmark "$SYNTHETIC_BIN" "BM_TrxStream_TranslateWrite" \ + "$OUT_DIR/results_synthetic_translate.json" \ + "$SYNTH_ENV" + + run_benchmark "$SYNTHETIC_BIN" "BM_TrxQueryAabb_Slabs" \ + "$OUT_DIR/results_synthetic_query.json" \ + "$SYNTH_ENV TRX_QUERY_TIMINGS_PATH=$OUT_DIR/query_timings_synthetic.jsonl" + + echo "✓ All synthetic benchmarks completed" + echo "" +fi + +# Real data benchmarks +if [[ "$RUN_REALDATA" == "true" ]]; then + REALDATA_BIN="$BUILD_DIR/bench/bench_trx_realdata" + + if [[ ! -f "$REALDATA_BIN" ]]; then + echo "Error: Real-data benchmark not found: $REALDATA_BIN" + echo "Build with: cmake --build $BUILD_DIR --target bench_trx_realdata" + exit 1 + fi + + if [[ ! -f "$REFERENCE_TRX" ]]; then + echo "Error: Reference TRX file not found: $REFERENCE_TRX" + echo "Use --reference to specify the path" + exit 1 + fi + + echo "========================================" + echo "REAL DATA BENCHMARKS" + echo "========================================" + echo "" + + # Reference flag for all realdata benchmarks + REALDATA_FLAGS="--reference-trx $REFERENCE_TRX" + REALDATA_ENV="$CORE_ENV" + + run_benchmark "$REALDATA_BIN" "BM_TrxFileSize_Float16" \ + "$OUT_DIR/results_realdata_filesize.json" \ + "$REALDATA_ENV" \ + "$REALDATA_FLAGS" + + run_benchmark "$REALDATA_BIN" "BM_TrxStream_TranslateWrite" \ + "$OUT_DIR/results_realdata_translate.json" \ + "$REALDATA_ENV" \ + "$REALDATA_FLAGS" + + run_benchmark "$REALDATA_BIN" "BM_TrxQueryAabb_Slabs" \ + "$OUT_DIR/results_realdata_query.json" \ + "$REALDATA_ENV TRX_QUERY_TIMINGS_PATH=$OUT_DIR/query_timings.jsonl" \ + "$REALDATA_FLAGS" + + echo "✓ All real-data benchmarks completed" + echo "" +fi + +echo "========================================" +echo "BENCHMARK SUMMARY" +echo "========================================" +echo "" +echo "Results saved to: $OUT_DIR" +ls -lh "$OUT_DIR"/results_*.json 2>/dev/null || echo "No result files found" +echo "" +echo "To generate plots:" +echo " Rscript bench/plot_bench.R --bench-dir $OUT_DIR --out-dir docs/_static/benchmarks" +echo "" diff --git a/codecov.yml b/codecov.yml index 62c2cc1..02ccda9 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,6 +2,7 @@ coverage: ignore: - "third_party" - "examples" + - "bench" status: project: default: diff --git a/docs/_static/benchmarks/trx_query_slab_timings.png b/docs/_static/benchmarks/trx_query_slab_timings.png new file mode 100644 index 0000000000000000000000000000000000000000..b950715fb3ef0f4133a3f483e5b695b12eae4450 GIT binary patch literal 42841 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 F|38%IVuSzy literal 0 HcmV?d00001 diff --git a/docs/_static/benchmarks/trx_size_vs_streamlines.png b/docs/_static/benchmarks/trx_size_vs_streamlines.png new file mode 100644 index 0000000000000000000000000000000000000000..f43a93f68b9d575a0d37dcd3bc83bfa9625c0a88 GIT binary patch literal 70328 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
%fsB!93|m!35N-k=n3S6z-dph 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 Iui5`U00j%xr2qf` literal 0 HcmV?d00001 diff --git a/docs/_static/benchmarks/trx_translate_write_rss.png b/docs/_static/benchmarks/trx_translate_write_rss.png new file mode 100644 index 0000000000000000000000000000000000000000..1a4b8bc36014683ca085af5a5fab6d7f1558143e GIT binary patch literal 64086 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 literal 0 HcmV?d00001 diff --git a/docs/_static/benchmarks/trx_translate_write_time.png b/docs/_static/benchmarks/trx_translate_write_time.png new file mode 100644 index 0000000000000000000000000000000000000000..cfed5821033e60f96bf24b097baa8fb5edef4500 GIT binary patch literal 67147 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
    {}n&u-mw_QH8JuZ3<5)-e_~Q5$gp>60DOm3uU1 z_VWJ0U3pgHlc=bd`1jw>8B<`$V?f<(T^!CStQ%|Rhjk%Zj~#y7JejD_AVr_hmBQZ>1(W!-FT-)ueb1LHS(Jn(A$t{W%n z6OvxoEBtSRzno2*U@U6QjhYhHX19koC9|#D{BaYza1;KIDlPNPNNt6IYkO=%(%a`6 z8nUY#iQj}{ngiGd81@Hauhu2ji*KwK;;adA-rw+XyJclMWYKp%xOVxX5rf|Kr-mvn zyE@T2_e22Fo8@U@K zZb!Eb^hGeF@c|1>07YIpr+sy=RCXEcul8}t=~e|l`~k;w{1!r z4+Pf2V)Cc)09|c`A<@Y1wnReV5~10?ga2VA#>Y%JRTaZ1Aijwz-=V>-nJu2JRlzwp zIDm)tZ?pVHy>xl3bF2MT$ecx&YU)?!dItTL6!?1#5y?{j`J4K)uL4ney#M=O}BlV=;0nT z-}ELtZb^BoYtv!4WLbav3~6&^p#Hm*$Ne1j+Cy5M+-$YMiqLl^?=LJ}JY3Yp`8G5Z zaCRVRZGqriR<^>3L2y``qaE$h>)-KeYpw*vnD7WcznWIA^vwf-tIo^pxtra>ovj_& z$uZd`_$%p~l3sdO&PH7fBR<~!v$^!m&fO=8etaVM@wU2c!y~&-WbD7+_<%HyS58X2 z{(O(Hu#0zPMaBDc>sotw{2m@>xIW4$r%}=fgj@h;e0;pmyY3iim!FNz#H{E+STW@Q zFl69looak{TwEMk`PkEYLBn4qBqV&o^^V8&_4UE0((l#!{3&nY=r}b$@9H=Cno6bC z)>@hmseGslZn{mBF_dO3n4+IYHVn$es61#`Sr6Klu|w~V?em*h9B!b7O{uE#hj)uW z7>9wq1qCxUt)Axt-QAU-0zoFt#N?cYMs0aH1WSs0r{BN7diar@)8^cP?yjy|cs%~_ zBU#BAzr5M+tl3!&6_upq6%5)U}kvIk+3XU6fJrH#F=9Z49m{#1~4G0_P|tFK_D867gJutGBl| z%qq7qH*ZnjJ9^d6AapT9Mf9gX?BSq9KGEmpKd%0gUth%YYsm^n8Y21Pd81F~*i%mbP+@45)bA@c45qqKdNw*UUZ zwab?u9@YQ)&mS`!`S|{TW0-hf%ohv?-tq1tyb&UB-GigKcz3=XaLOR>{;L4YrQluv f^Zoyye~U)VWab;SXSr=tyjOiqOYd?a+A8=ztrME3 literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst index 229a769..f10b93a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,8 +15,8 @@ tractography file format. overview building usage - downstream_usage benchmarks + downstream_usage linting .. toctree:: diff --git a/include/trx/trx.h b/include/trx/trx.h index 0bc1504..34c3bde 100644 --- a/include/trx/trx.h +++ b/include/trx/trx.h @@ -39,6 +39,15 @@ namespace fs = std::filesystem; using json = json11::Json; namespace trx { +enum class TrxSaveMode { Auto, Archive, Directory }; + +struct TrxSaveOptions { + zip_uint32_t compression_standard = ZIP_CM_STORE; + TrxSaveMode mode = TrxSaveMode::Auto; + size_t memory_limit_bytes = 0; // Reserved for future save-path tuning. + bool overwrite_existing = true; +}; + inline json::object _json_object(const json &value) { if (value.is_object()) { return value.object_items(); @@ -280,6 +289,14 @@ template class TrxFile { * @param compression_standard The compression standard to use, as defined by libzip (default: no compression) */ void save(const std::string &filename, zip_uint32_t compression_standard = ZIP_CM_STORE); + void save(const std::string &filename, const TrxSaveOptions &options); + /** + * @brief Normalize in-memory arrays for deterministic save semantics. + * + * This trims trailing preallocated rows when detected, rewrites lengths from + * offsets, and synchronizes header counts with the actual payload. + */ + void normalize_for_save(); void add_dps_from_text(const std::string &name, const std::string &dtype, const std::string &path); template @@ -352,6 +369,8 @@ template class TrxFile { * Each entry is {min_x, min_y, min_z, max_x, max_y, max_z} in TRX coordinates. */ std::vector> build_streamline_aabbs() const; + const std::vector> &get_or_build_streamline_aabbs() const; + void invalidate_aabb_cache() const; /** * @brief Extract a subset of streamlines intersecting an axis-aligned box. @@ -383,6 +402,11 @@ template class TrxFile { subset_streamlines(const std::vector &streamline_ids, bool build_cache_for_result = false) const; + const MMappedMatrix
    *get_dps(const std::string &name) const; + const ArraySequence
    *get_dpv(const std::string &name) const; + std::vector> get_streamline(size_t streamline_index) const; + template void for_each_streamline(Fn &&fn) const; + /** * @brief Add a data-per-group (DPG) field from a flat vector. * @@ -432,7 +456,6 @@ template class TrxFile { void remove_dpg_group(const std::string &group); private: - void invalidate_aabb_cache() const; mutable std::vector> aabb_cache_; /** * @brief Load a TrxFile from a zip archive. @@ -597,6 +620,11 @@ class AnyTrxFile { size_t num_streamlines() const; void close(); void save(const std::string &filename, zip_uint32_t compression_standard = ZIP_CM_STORE); + void save(const std::string &filename, const TrxSaveOptions &options); + + const TypedArray *get_dps(const std::string &name) const; + const TypedArray *get_dpv(const std::string &name) const; + std::vector> get_streamline(size_t streamline_index) const; using PositionsChunkCallback = std::function; @@ -713,6 +741,10 @@ class TrxStream { * @brief Finalize and write a TRX file. */ template void finalize(const std::string &filename, zip_uint32_t compression_standard = ZIP_CM_STORE); + void finalize(const std::string &filename, + TrxScalarType output_dtype, + zip_uint32_t compression_standard = ZIP_CM_STORE); + void finalize(const std::string &filename, const TrxSaveOptions &options); /** * @brief Finalize and write a TRX directory (no zip). @@ -875,6 +907,10 @@ struct PositionsOutputInfo { size_t points = 0; }; +struct PrepareOutputOptions { + bool overwrite_existing = true; +}; + /** * @brief Prepare an output directory with copied metadata and offsets. * @@ -882,7 +918,19 @@ struct PositionsOutputInfo { * metadata (groups, dps, dpv, dpg), and returns where the positions file * should be written. */ -PositionsOutputInfo prepare_positions_output(const AnyTrxFile &input, const std::string &output_directory); +PositionsOutputInfo prepare_positions_output(const AnyTrxFile &input, + const std::string &output_directory, + const PrepareOutputOptions &options = {}); + +struct MergeTrxShardsOptions { + std::vector shard_directories; + std::string output_path; + zip_uint32_t compression_standard = ZIP_CM_STORE; + bool output_directory = false; + bool overwrite_existing = true; +}; + +void merge_trx_shards(const MergeTrxShardsOptions &options); /** * @brief Detect the positions scalar type for a TRX path. diff --git a/include/trx/trx.tpp b/include/trx/trx.tpp index 07da736..7b365c2 100644 --- a/include/trx/trx.tpp +++ b/include/trx/trx.tpp @@ -1068,44 +1068,109 @@ auto with_trx_reader(const std::string &path, Fn &&fn) } template void TrxFile
    ::save(const std::string &filename, zip_uint32_t compression_standard) { + TrxSaveOptions options; + options.compression_standard = compression_standard; + save(filename, options); +} + +template void TrxFile
    ::normalize_for_save() { + if (!this->streamlines) { + throw std::runtime_error("Cannot normalize TRX without streamline data"); + } + if (this->streamlines->_offsets.size() == 0) { + throw std::runtime_error("Cannot normalize TRX without offsets data"); + } + + const size_t offsets_count = static_cast(this->streamlines->_offsets.size()); + if (offsets_count < 1) { + throw std::runtime_error("Invalid offsets array"); + } + const size_t total_streamlines = offsets_count - 1; + const uint64_t data_rows = static_cast(this->streamlines->_data.rows()); + + size_t used_streamlines = total_streamlines; + for (size_t i = 1; i < offsets_count; ++i) { + const uint64_t prev = static_cast(this->streamlines->_offsets(static_cast(i - 1))); + const uint64_t curr = static_cast(this->streamlines->_offsets(static_cast(i))); + if (curr < prev || curr > data_rows) { + used_streamlines = i - 1; + break; + } + } + + const uint64_t used_vertices = + static_cast(this->streamlines->_offsets(static_cast(used_streamlines))); + if (used_vertices > data_rows) { + throw std::runtime_error("TRX offsets exceed positions row count"); + } + if (used_vertices > static_cast(std::numeric_limits::max()) || + used_streamlines > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("TRX normalize_for_save exceeds supported int range"); + } + + if (used_streamlines < total_streamlines || used_vertices < data_rows) { + this->resize(static_cast(used_streamlines), static_cast(used_vertices)); + } + + const size_t normalized_streamlines = static_cast(this->streamlines->_offsets.size()) - 1; + for (size_t i = 0; i < normalized_streamlines; ++i) { + const uint64_t curr = static_cast(this->streamlines->_offsets(static_cast(i))); + const uint64_t next = static_cast(this->streamlines->_offsets(static_cast(i + 1))); + if (next < curr) { + throw std::runtime_error("TRX offsets must be monotonically increasing"); + } + const uint64_t diff = next - curr; + if (diff > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("TRX streamline length exceeds uint32 range"); + } + this->streamlines->_lengths(static_cast(i)) = static_cast(diff); + } + + const uint64_t sentinel = static_cast( + this->streamlines->_offsets(static_cast(this->streamlines->_offsets.size() - 1))); + this->header = _json_set(this->header, "NB_STREAMLINES", static_cast(normalized_streamlines)); + this->header = _json_set(this->header, "NB_VERTICES", static_cast(sentinel)); +} + +template void TrxFile
    ::save(const std::string &filename, const TrxSaveOptions &options) { std::string ext = get_ext(filename); - if (ext.size() > 0 && (ext != "zip" && ext != "trx")) { + if (ext.size() > 0 && (ext != "zip" && ext != "trx") && options.mode == TrxSaveMode::Archive) { throw std::invalid_argument("Unsupported extension." + ext); } - auto copy_trx = this->deepcopy(); - copy_trx->resize(); - if (!copy_trx->streamlines || copy_trx->streamlines->_offsets.size() == 0) { + TrxFile
    *save_trx = this; + + if (!save_trx->streamlines || save_trx->streamlines->_offsets.size() == 0) { throw std::runtime_error("Cannot save TRX without offsets data"); } - if (copy_trx->header["NB_STREAMLINES"].is_number()) { - const auto nb_streamlines = static_cast(copy_trx->header["NB_STREAMLINES"].int_value()); - if (copy_trx->streamlines->_offsets.size() != static_cast(nb_streamlines + 1)) { + if (save_trx->header["NB_STREAMLINES"].is_number()) { + const auto nb_streamlines = static_cast(save_trx->header["NB_STREAMLINES"].int_value()); + if (save_trx->streamlines->_offsets.size() != static_cast(nb_streamlines + 1)) { throw std::runtime_error("TRX offsets size does not match NB_STREAMLINES"); } } - if (copy_trx->header["NB_VERTICES"].is_number()) { - const auto nb_vertices = static_cast(copy_trx->header["NB_VERTICES"].int_value()); + if (save_trx->header["NB_VERTICES"].is_number()) { + const auto nb_vertices = static_cast(save_trx->header["NB_VERTICES"].int_value()); const auto last = - static_cast(copy_trx->streamlines->_offsets(copy_trx->streamlines->_offsets.size() - 1)); + static_cast(save_trx->streamlines->_offsets(save_trx->streamlines->_offsets.size() - 1)); if (last != nb_vertices) { throw std::runtime_error("TRX offsets sentinel does not match NB_VERTICES"); } } - for (Eigen::Index i = 1; i < copy_trx->streamlines->_offsets.size(); ++i) { - if (copy_trx->streamlines->_offsets(i) < copy_trx->streamlines->_offsets(i - 1)) { + for (Eigen::Index i = 1; i < save_trx->streamlines->_offsets.size(); ++i) { + if (save_trx->streamlines->_offsets(i) < save_trx->streamlines->_offsets(i - 1)) { throw std::runtime_error("TRX offsets must be monotonically increasing"); } } - if (copy_trx->streamlines->_data.size() > 0) { + if (save_trx->streamlines->_data.size() > 0) { const auto last = - static_cast(copy_trx->streamlines->_offsets(copy_trx->streamlines->_offsets.size() - 1)); - if (last != static_cast(copy_trx->streamlines->_data.rows())) { + static_cast(save_trx->streamlines->_offsets(save_trx->streamlines->_offsets.size() - 1)); + if (last != static_cast(save_trx->streamlines->_data.rows())) { throw std::runtime_error("TRX positions row count does not match offsets sentinel"); } } - std::string tmp_dir_name = copy_trx->_uncompressed_folder_handle; + std::string tmp_dir_name = save_trx->_uncompressed_folder_handle; if (!tmp_dir_name.empty()) { const std::string header_path = tmp_dir_name + SEPARATOR + "header.json"; @@ -1113,41 +1178,40 @@ template void TrxFile
    ::save(const std::string &filename, zip_u if (!out_json.is_open()) { throw std::runtime_error("Failed to write header.json to: " + header_path); } - out_json << copy_trx->header.dump() << std::endl; + out_json << save_trx->header.dump() << std::endl; out_json.close(); } - if (ext.size() > 0 && (ext == "zip" || ext == "trx")) { - auto sync_unmap_seq = [](auto &seq) { + const bool write_archive = options.mode == TrxSaveMode::Archive || + (options.mode == TrxSaveMode::Auto && ext.size() > 0 && (ext == "zip" || ext == "trx")); + if (write_archive) { + auto sync_unmap_seq = [&](auto &seq) { if (!seq) { return; } std::error_code ec; seq->mmap_pos.sync(ec); - seq->mmap_pos.unmap(); seq->mmap_off.sync(ec); - seq->mmap_off.unmap(); }; - auto sync_unmap_mat = [](auto &mat) { + auto sync_unmap_mat = [&](auto &mat) { if (!mat) { return; } std::error_code ec; mat->mmap.sync(ec); - mat->mmap.unmap(); }; - sync_unmap_seq(copy_trx->streamlines); - for (auto &kv : copy_trx->groups) { + sync_unmap_seq(save_trx->streamlines); + for (auto &kv : save_trx->groups) { sync_unmap_mat(kv.second); } - for (auto &kv : copy_trx->data_per_streamline) { + for (auto &kv : save_trx->data_per_streamline) { sync_unmap_mat(kv.second); } - for (auto &kv : copy_trx->data_per_vertex) { + for (auto &kv : save_trx->data_per_vertex) { sync_unmap_seq(kv.second); } - for (auto &group_kv : copy_trx->data_per_group) { + for (auto &group_kv : save_trx->data_per_group) { for (auto &kv : group_kv.second) { sync_unmap_mat(kv.second); } @@ -1158,7 +1222,7 @@ template void TrxFile
    ::save(const std::string &filename, zip_u if ((zf = zip_open(filename.c_str(), ZIP_CREATE + ZIP_TRUNCATE, &errorp)) == nullptr) { throw std::runtime_error("Could not open archive " + filename + ": " + strerror(errorp)); } else { - zip_from_folder(zf, tmp_dir_name, tmp_dir_name, compression_standard, nullptr); + zip_from_folder(zf, tmp_dir_name, tmp_dir_name, options.compression_standard, nullptr); if (zip_close(zf) != 0) { throw std::runtime_error("Unable to close archive " + filename + ": " + zip_strerror(zf)); } @@ -1169,6 +1233,9 @@ template void TrxFile
    ::save(const std::string &filename, zip_u throw std::runtime_error("Temporary TRX directory does not exist: " + tmp_dir_name); } if (trx::fs::exists(filename, ec) && trx::fs::is_directory(filename, ec)) { + if (!options.overwrite_existing) { + throw std::runtime_error("Output directory already exists: " + filename); + } if (rm_dir(filename) != 0) { throw std::runtime_error("Could not remove existing directory " + filename); } @@ -1190,7 +1257,6 @@ template void TrxFile
    ::save(const std::string &filename, zip_u if (!trx::fs::exists(header_path)) { throw std::runtime_error("Missing header.json in output directory: " + header_path.string()); } - copy_trx->close(); } } @@ -1938,6 +2004,45 @@ template void TrxStream::finalize(const std::string &filename, zip cleanup_tmp(); } +inline void TrxStream::finalize(const std::string &filename, + TrxScalarType output_dtype, + zip_uint32_t compression_standard) { + switch (output_dtype) { + case TrxScalarType::Float16: + finalize(filename, compression_standard); + break; + case TrxScalarType::Float64: + finalize(filename, compression_standard); + break; + case TrxScalarType::Float32: + default: + finalize(filename, compression_standard); + break; + } +} + +inline void TrxStream::finalize(const std::string &filename, const TrxSaveOptions &options) { + if (options.mode == TrxSaveMode::Directory) { + if (finalized_) { + throw std::runtime_error("TrxStream already finalized"); + } + if (options.overwrite_existing) { + finalize_directory(filename); + } else { + finalize_directory_persistent(filename); + } + return; + } + + TrxScalarType out_type = TrxScalarType::Float32; + if (positions_dtype_ == "float16") { + out_type = TrxScalarType::Float16; + } else if (positions_dtype_ == "float64") { + out_type = TrxScalarType::Float64; + } + finalize(filename, out_type, options.compression_standard); +} + inline void TrxStream::finalize_directory_impl(const std::string &directory, bool remove_existing) { if (finalized_) { throw std::runtime_error("TrxStream already finalized"); @@ -2600,6 +2705,14 @@ std::vector> TrxFile
    ::build_streamline_aabbs() co return aabbs; } +template +const std::vector> &TrxFile
    ::get_or_build_streamline_aabbs() const { + if (this->aabb_cache_.empty()) { + this->build_streamline_aabbs(); + } + return this->aabb_cache_; +} + template std::unique_ptr> TrxFile
    ::query_aabb( const std::array &min_corner, @@ -2667,6 +2780,64 @@ void TrxFile
    ::invalidate_aabb_cache() const { this->aabb_cache_.clear(); } +template +const MMappedMatrix
    *TrxFile
    ::get_dps(const std::string &name) const { + auto it = this->data_per_streamline.find(name); + if (it == this->data_per_streamline.end()) { + return nullptr; + } + return it->second.get(); +} + +template +const ArraySequence
    *TrxFile
    ::get_dpv(const std::string &name) const { + auto it = this->data_per_vertex.find(name); + if (it == this->data_per_vertex.end()) { + return nullptr; + } + return it->second.get(); +} + +template +std::vector> TrxFile
    ::get_streamline(size_t streamline_index) const { + if (!this->streamlines || this->streamlines->_offsets.size() == 0) { + throw std::runtime_error("TRX streamlines are not available"); + } + const size_t n_streamlines = static_cast(this->streamlines->_offsets.size() - 1); + if (streamline_index >= n_streamlines) { + throw std::out_of_range("Streamline index out of range"); + } + + const uint64_t start = static_cast(this->streamlines->_offsets(static_cast(streamline_index), 0)); + const uint64_t end = + static_cast(this->streamlines->_offsets(static_cast(streamline_index + 1), 0)); + std::vector> points; + if (end <= start) { + return points; + } + points.reserve(static_cast(end - start)); + for (uint64_t i = start; i < end; ++i) { + points.push_back({this->streamlines->_data(static_cast(i), 0), + this->streamlines->_data(static_cast(i), 1), + this->streamlines->_data(static_cast(i), 2)}); + } + return points; +} + +template +template +void TrxFile
    ::for_each_streamline(Fn &&fn) const { + if (!this->streamlines || this->streamlines->_offsets.size() == 0) { + return; + } + const size_t n_streamlines = static_cast(this->streamlines->_offsets.size() - 1); + for (size_t i = 0; i < n_streamlines; ++i) { + const uint64_t start = static_cast(this->streamlines->_offsets(static_cast(i), 0)); + const uint64_t end = static_cast(this->streamlines->_offsets(static_cast(i + 1), 0)); + fn(i, start, end - start); + } +} + template template void TrxFile
    ::add_dpg_from_vector(const std::string &group, diff --git a/src/trx.cpp b/src/trx.cpp index d3f81fe..5ca0241 100644 --- a/src/trx.cpp +++ b/src/trx.cpp @@ -94,6 +94,45 @@ bool is_path_within(const trx::fs::path &child, const trx::fs::path &parent) { return next == '/' || next == '\\'; } +TrxSaveMode resolve_save_mode(const std::string &filename, TrxSaveMode requested) { + if (requested != TrxSaveMode::Auto) { + return requested; + } + const std::string ext = get_ext(filename); + if (ext == "zip" || ext == "trx") { + return TrxSaveMode::Archive; + } + return TrxSaveMode::Directory; +} + +std::array read_xyz_as_double(const TypedArray &positions, size_t row_index) { + if (positions.cols != 3) { + throw std::runtime_error("Positions must have 3 columns."); + } + if (row_index >= static_cast(positions.rows)) { + throw std::out_of_range("Position row index out of range"); + } + if (positions.dtype == "float16") { + const auto view = positions.as_matrix(); + return {static_cast(view(static_cast(row_index), 0)), + static_cast(view(static_cast(row_index), 1)), + static_cast(view(static_cast(row_index), 2))}; + } + if (positions.dtype == "float32") { + const auto view = positions.as_matrix(); + return {static_cast(view(static_cast(row_index), 0)), + static_cast(view(static_cast(row_index), 1)), + static_cast(view(static_cast(row_index), 2))}; + } + if (positions.dtype == "float64") { + const auto view = positions.as_matrix(); + return {view(static_cast(row_index), 0), + view(static_cast(row_index), 1), + view(static_cast(row_index), 2)}; + } + throw std::runtime_error("Unsupported positions dtype for streamline extraction: " + positions.dtype); +} + TypedArray make_typed_array(const std::string &filename, int rows, int cols, const std::string &dtype) { TypedArray array; array.dtype = dtype; @@ -200,6 +239,40 @@ size_t AnyTrxFile::num_streamlines() const { return 0; } +const TypedArray *AnyTrxFile::get_dps(const std::string &name) const { + auto it = data_per_streamline.find(name); + if (it == data_per_streamline.end()) { + return nullptr; + } + return &it->second; +} + +const TypedArray *AnyTrxFile::get_dpv(const std::string &name) const { + auto it = data_per_vertex.find(name); + if (it == data_per_vertex.end()) { + return nullptr; + } + return &it->second; +} + +std::vector> AnyTrxFile::get_streamline(size_t streamline_index) const { + if (offsets_u64.empty()) { + throw std::runtime_error("TRX offsets are empty."); + } + const size_t n_streamlines = offsets_u64.size() - 1; + if (streamline_index >= n_streamlines) { + throw std::out_of_range("Streamline index out of range"); + } + const uint64_t start = offsets_u64[streamline_index]; + const uint64_t end = offsets_u64[streamline_index + 1]; + std::vector> out; + out.reserve(static_cast(end - start)); + for (uint64_t i = start; i < end; ++i) { + out.push_back(read_xyz_as_double(positions, static_cast(i))); + } + return out; +} + void AnyTrxFile::close() { _cleanup_temporary_directory(); positions = TypedArray(); @@ -458,8 +531,15 @@ AnyTrxFile::_create_from_pointer(json header, } void AnyTrxFile::save(const std::string &filename, zip_uint32_t compression_standard) { + TrxSaveOptions options; + options.compression_standard = compression_standard; + save(filename, options); +} + +void AnyTrxFile::save(const std::string &filename, const TrxSaveOptions &options) { const std::string ext = get_ext(filename); - if (ext.size() > 0 && (ext != "zip" && ext != "trx")) { + const TrxSaveMode save_mode = resolve_save_mode(filename, options.mode); + if (save_mode == TrxSaveMode::Archive && ext.size() > 0 && (ext != "zip" && ext != "trx")) { throw std::invalid_argument("Unsupported extension." + ext); } @@ -500,7 +580,7 @@ void AnyTrxFile::save(const std::string &filename, zip_uint32_t compression_stan throw std::runtime_error("TRX file has no backing directory to save from"); } - if (ext.size() > 0 && (ext == "zip" || ext == "trx")) { + if (save_mode == TrxSaveMode::Archive) { int errorp; zip_t *zf; if ((zf = zip_open(filename.c_str(), ZIP_CREATE + ZIP_TRUNCATE, &errorp)) == nullptr) { @@ -520,20 +600,23 @@ void AnyTrxFile::save(const std::string &filename, zip_uint32_t compression_stan zip_close(zf); throw std::runtime_error("Failed to add header.json to archive: " + std::string(zip_strerror(zf))); } - const zip_int32_t compression = static_cast(compression_standard); + const zip_int32_t compression = static_cast(options.compression_standard); if (zip_set_file_compression(zf, header_idx, compression, 0) < 0) { zip_close(zf); throw std::runtime_error("Failed to set compression for header.json: " + std::string(zip_strerror(zf))); } const std::unordered_set skip = {"header.json"}; - zip_from_folder(zf, source_dir, source_dir, compression_standard, &skip); + zip_from_folder(zf, source_dir, source_dir, options.compression_standard, &skip); if (zip_close(zf) != 0) { throw std::runtime_error("Unable to close archive " + filename + ": " + zip_strerror(zf)); } } else { std::error_code ec; if (trx::fs::exists(filename, ec) && trx::fs::is_directory(filename, ec)) { + if (!options.overwrite_existing) { + throw std::runtime_error("Output directory already exists: " + filename); + } if (rm_dir(filename) != 0) { throw std::runtime_error("Could not remove existing directory " + filename); } @@ -546,22 +629,28 @@ void AnyTrxFile::save(const std::string &filename, zip_uint32_t compression_stan throw std::runtime_error("Could not create output parent directory: " + dest_path.parent_path().string()); } } - std::string tmp_dir = make_temp_dir("trx_runtime"); - copy_dir(source_dir, tmp_dir); - const trx::fs::path tmp_header_path = trx::fs::path(tmp_dir) / "header.json"; - std::ofstream out_json(tmp_header_path); + std::error_code source_ec; + const trx::fs::path source_path = trx::fs::weakly_canonical(trx::fs::path(source_dir), source_ec); + std::error_code dest_ec; + const trx::fs::path normalized_dest = trx::fs::weakly_canonical(dest_path, dest_ec); + const bool same_directory = !source_ec && !dest_ec && source_path == normalized_dest; + + if (!same_directory) { + copy_dir(source_dir, filename); + } + + const trx::fs::path final_header_path = dest_path / "header.json"; + std::ofstream out_json(final_header_path, std::ios::out | std::ios::trunc); if (!out_json.is_open()) { - rm_dir(tmp_dir); - throw std::runtime_error("Failed to write header.json to: " + tmp_header_path.string()); + throw std::runtime_error("Failed to write header.json to: " + final_header_path.string()); } out_json << header.dump() << std::endl; - copy_dir(tmp_dir, filename); - rm_dir(tmp_dir); + out_json.close(); + ec.clear(); if (!trx::fs::exists(filename, ec) || !trx::fs::is_directory(filename, ec)) { throw std::runtime_error("Failed to create output directory: " + filename); } - const trx::fs::path final_header_path = dest_path / "header.json"; if (!trx::fs::exists(final_header_path)) { throw std::runtime_error("Missing header.json in output directory: " + final_header_path.string()); } @@ -1105,7 +1194,9 @@ void AnyTrxFile::for_each_positions_chunk_mutable(size_t chunk_bytes, const Posi } } -PositionsOutputInfo prepare_positions_output(const AnyTrxFile &input, const std::string &output_directory) { +PositionsOutputInfo prepare_positions_output(const AnyTrxFile &input, + const std::string &output_directory, + const PrepareOutputOptions &options) { if (input.positions.empty() || input.offsets.empty()) { throw std::runtime_error("Input TRX missing positions/offsets."); } @@ -1115,7 +1206,11 @@ PositionsOutputInfo prepare_positions_output(const AnyTrxFile &input, const std: std::error_code ec; if (trx::fs::exists(output_directory, ec)) { - trx::fs::remove_all(output_directory, ec); + if (options.overwrite_existing) { + trx::fs::remove_all(output_directory, ec); + } else { + throw std::runtime_error("Output directory already exists: " + output_directory); + } } ec.clear(); trx::fs::create_directories(output_directory, ec); @@ -1177,4 +1272,328 @@ PositionsOutputInfo prepare_positions_output(const AnyTrxFile &input, const std: info.positions_path = output_directory + SEPARATOR + typed_array_filename("positions", input.positions); return info; } + +void merge_trx_shards(const MergeTrxShardsOptions &options) { + if (options.shard_directories.empty()) { + throw std::invalid_argument("merge_trx_shards requires at least one shard directory"); + } + + auto read_header = [](const std::string &dir) { + const std::string path = dir + SEPARATOR + "header.json"; + std::ifstream in(path); + if (!in.is_open()) { + throw std::runtime_error("Failed to open shard header: " + path); + } + std::stringstream ss; + ss << in.rdbuf(); + std::string err; + json parsed = json::parse(ss.str(), err); + if (!err.empty()) { + throw std::runtime_error("Failed to parse shard header " + path + ": " + err); + } + return parsed; + }; + + auto find_file_with_prefix = [](const std::string &dir, const std::string &prefix) -> std::string { + std::error_code ec; + for (trx::fs::directory_iterator it(dir, ec), end; it != end; it.increment(ec)) { + if (ec) { + break; + } + if (!it->is_regular_file()) { + continue; + } + const std::string name = it->path().filename().string(); + if (name.rfind(prefix, 0) == 0) { + return it->path().string(); + } + } + return ""; + }; + + auto append_binary = [](const std::string &dst, const std::string &src) { + std::ifstream in(src, std::ios::binary); + if (!in.is_open()) { + throw std::runtime_error("Failed to open source for append: " + src); + } + std::ofstream out(dst, std::ios::binary | std::ios::app); + if (!out.is_open()) { + throw std::runtime_error("Failed to open destination for append: " + dst); + } + std::array buffer{}; + while (in) { + in.read(buffer.data(), static_cast(buffer.size())); + const auto n = in.gcount(); + if (n > 0) { + out.write(buffer.data(), n); + } + } + }; + + auto append_offsets_with_base = [](const std::string &dst, const std::string &src, uint64_t base_vertices, bool skip_first) { + std::ifstream in(src, std::ios::binary); + if (!in.is_open()) { + throw std::runtime_error("Failed to open source offsets: " + src); + } + std::ofstream out(dst, std::ios::binary | std::ios::app); + if (!out.is_open()) { + throw std::runtime_error("Failed to open destination offsets: " + dst); + } + constexpr size_t kChunkElems = (8 * 1024 * 1024) / sizeof(uint64_t); + std::vector buffer(kChunkElems); + bool first_value_pending = skip_first; + while (in) { + in.read(reinterpret_cast(buffer.data()), + static_cast(buffer.size() * sizeof(uint64_t))); + const std::streamsize bytes = in.gcount(); + if (bytes <= 0) { + break; + } + if (bytes % static_cast(sizeof(uint64_t)) != 0) { + throw std::runtime_error("Offsets file has invalid byte count: " + src); + } + const size_t count = static_cast(bytes) / sizeof(uint64_t); + size_t start_index = 0; + if (first_value_pending) { + if (count == 0) { + continue; + } + start_index = 1; + first_value_pending = false; + } + for (size_t i = start_index; i < count; ++i) { + buffer[i] += base_vertices; + } + if (count > start_index) { + out.write(reinterpret_cast(buffer.data() + start_index), + static_cast((count - start_index) * sizeof(uint64_t))); + } + } + }; + + auto append_group_indices_with_base = [](const std::string &dst, const std::string &src, uint32_t base_streamlines) { + std::ifstream in(src, std::ios::binary); + if (!in.is_open()) { + throw std::runtime_error("Failed to open source group file: " + src); + } + std::ofstream out(dst, std::ios::binary | std::ios::app); + if (!out.is_open()) { + throw std::runtime_error("Failed to open destination group file: " + dst); + } + constexpr size_t kChunkElems = (8 * 1024 * 1024) / sizeof(uint32_t); + std::vector buffer(kChunkElems); + while (in) { + in.read(reinterpret_cast(buffer.data()), + static_cast(buffer.size() * sizeof(uint32_t))); + const std::streamsize bytes = in.gcount(); + if (bytes <= 0) { + break; + } + if (bytes % static_cast(sizeof(uint32_t)) != 0) { + throw std::runtime_error("Group file has invalid byte count: " + src); + } + const size_t count = static_cast(bytes) / sizeof(uint32_t); + for (size_t i = 0; i < count; ++i) { + buffer[i] += base_streamlines; + } + out.write(reinterpret_cast(buffer.data()), static_cast(count * sizeof(uint32_t))); + } + }; + + auto list_subdir_files = [](const std::string &dir, const std::string &subdir) { + std::vector files; + std::error_code ec; + const trx::fs::path path = trx::fs::path(dir) / subdir; + if (!trx::fs::exists(path, ec)) { + return files; + } + if (!trx::fs::is_directory(path, ec)) { + throw std::runtime_error("Expected directory for subdir: " + path.string()); + } + for (trx::fs::directory_iterator it(path, ec), end; it != end; it.increment(ec)) { + if (ec) { + throw std::runtime_error("Failed to read directory: " + path.string()); + } + if (!it->is_regular_file()) { + continue; + } + files.push_back(it->path().filename().string()); + } + std::sort(files.begin(), files.end()); + return files; + }; + + auto ensure_schema_match = [&](const std::string &subdir, const std::vector &schema_files, const std::string &shard) { + const auto shard_files = list_subdir_files(shard, subdir); + if (shard_files != schema_files) { + throw std::runtime_error("Shard schema mismatch for subdir '" + subdir + "': " + shard); + } + }; + + std::error_code ec; + for (const auto &dir : options.shard_directories) { + if (!trx::fs::exists(dir, ec) || !trx::fs::is_directory(dir, ec)) { + throw std::runtime_error("Shard directory does not exist: " + dir); + } + } + + const std::string output_dir = options.output_directory ? options.output_path : make_temp_dir("trx_merge"); + if (trx::fs::exists(output_dir, ec)) { + if (!options.overwrite_existing) { + throw std::runtime_error("Output already exists: " + output_dir); + } + trx::fs::remove_all(output_dir, ec); + } + trx::fs::create_directories(output_dir, ec); + if (ec) { + throw std::runtime_error("Failed to create output directory: " + output_dir); + } + + for (const auto &dir : options.shard_directories) { + if (trx::fs::exists(dir + SEPARATOR + "dpg", ec)) { + throw std::runtime_error("merge_trx_shards currently does not support dpg/ merges"); + } + } + + json merged_header = read_header(options.shard_directories.front()); + const std::string first_positions = find_file_with_prefix(options.shard_directories.front(), "positions."); + const std::string first_offsets = find_file_with_prefix(options.shard_directories.front(), "offsets."); + if (first_positions.empty() || first_offsets.empty()) { + throw std::runtime_error("Shard missing positions/offsets: " + options.shard_directories.front()); + } + if (get_ext(first_offsets) != "uint64") { + throw std::runtime_error("merge_trx_shards currently requires offsets.uint64"); + } + + const std::string positions_filename = trx::fs::path(first_positions).filename().string(); + const std::string offsets_filename = trx::fs::path(first_offsets).filename().string(); + const std::string positions_out = output_dir + SEPARATOR + positions_filename; + const std::string offsets_out = output_dir + SEPARATOR + offsets_filename; + { + std::ofstream clear_positions(positions_out, std::ios::binary | std::ios::out | std::ios::trunc); + if (!clear_positions.is_open()) { + throw std::runtime_error("Failed to create output positions file: " + positions_out); + } + } + { + std::ofstream clear_offsets(offsets_out, std::ios::binary | std::ios::out | std::ios::trunc); + if (!clear_offsets.is_open()) { + throw std::runtime_error("Failed to create output offsets file: " + offsets_out); + } + } + + const auto dps_schema = list_subdir_files(options.shard_directories.front(), "dps"); + const auto dpv_schema = list_subdir_files(options.shard_directories.front(), "dpv"); + const auto groups_schema = list_subdir_files(options.shard_directories.front(), "groups"); + if (!dps_schema.empty()) { + trx::fs::create_directories(output_dir + SEPARATOR + "dps", ec); + } + if (!dpv_schema.empty()) { + trx::fs::create_directories(output_dir + SEPARATOR + "dpv", ec); + } + if (!groups_schema.empty()) { + trx::fs::create_directories(output_dir + SEPARATOR + "groups", ec); + } + for (const auto &name : dps_schema) { + std::ofstream clear_file(output_dir + SEPARATOR + "dps" + SEPARATOR + name, std::ios::binary | std::ios::out | std::ios::trunc); + if (!clear_file.is_open()) { + throw std::runtime_error("Failed to create merged dps file: " + name); + } + } + for (const auto &name : dpv_schema) { + std::ofstream clear_file(output_dir + SEPARATOR + "dpv" + SEPARATOR + name, std::ios::binary | std::ios::out | std::ios::trunc); + if (!clear_file.is_open()) { + throw std::runtime_error("Failed to create merged dpv file: " + name); + } + } + for (const auto &name : groups_schema) { + std::ofstream clear_file(output_dir + SEPARATOR + "groups" + SEPARATOR + name, + std::ios::binary | std::ios::out | std::ios::trunc); + if (!clear_file.is_open()) { + throw std::runtime_error("Failed to create merged group file: " + name); + } + } + + uint64_t total_vertices = 0; + uint64_t total_streamlines = 0; + for (size_t i = 0; i < options.shard_directories.size(); ++i) { + const std::string &shard_dir = options.shard_directories[i]; + ensure_schema_match("dps", dps_schema, shard_dir); + ensure_schema_match("dpv", dpv_schema, shard_dir); + ensure_schema_match("groups", groups_schema, shard_dir); + + const json shard_header = read_header(shard_dir); + const uint64_t shard_vertices = static_cast(shard_header["NB_VERTICES"].int_value()); + const uint64_t shard_streamlines = static_cast(shard_header["NB_STREAMLINES"].int_value()); + + const std::string shard_positions = find_file_with_prefix(shard_dir, "positions."); + const std::string shard_offsets = find_file_with_prefix(shard_dir, "offsets."); + if (shard_positions.empty() || shard_offsets.empty()) { + throw std::runtime_error("Shard missing positions/offsets: " + shard_dir); + } + if (trx::fs::path(shard_positions).filename().string() != positions_filename) { + throw std::runtime_error("Shard positions dtype mismatch: " + shard_dir); + } + if (trx::fs::path(shard_offsets).filename().string() != offsets_filename) { + throw std::runtime_error("Shard offsets dtype mismatch: " + shard_dir); + } + + append_binary(positions_out, shard_positions); + append_offsets_with_base(offsets_out, shard_offsets, total_vertices, i != 0); + + for (const auto &name : dps_schema) { + append_binary(output_dir + SEPARATOR + "dps" + SEPARATOR + name, shard_dir + SEPARATOR + "dps" + SEPARATOR + name); + } + for (const auto &name : dpv_schema) { + append_binary(output_dir + SEPARATOR + "dpv" + SEPARATOR + name, shard_dir + SEPARATOR + "dpv" + SEPARATOR + name); + } + for (const auto &name : groups_schema) { + if (total_streamlines > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Group index offset exceeds uint32 range during merge"); + } + append_group_indices_with_base( + output_dir + SEPARATOR + "groups" + SEPARATOR + name, + shard_dir + SEPARATOR + "groups" + SEPARATOR + name, + static_cast(total_streamlines)); + } + + total_vertices += shard_vertices; + total_streamlines += shard_streamlines; + } + + merged_header = _json_set(merged_header, "NB_VERTICES", static_cast(total_vertices)); + merged_header = _json_set(merged_header, "NB_STREAMLINES", static_cast(total_streamlines)); + { + const std::string merged_header_path = output_dir + SEPARATOR + "header.json"; + std::ofstream out(merged_header_path, std::ios::out | std::ios::trunc); + if (!out.is_open()) { + throw std::runtime_error("Failed to write merged header: " + merged_header_path); + } + out << merged_header.dump() << std::endl; + } + + if (options.output_directory) { + return; + } + + const trx::fs::path archive_path(options.output_path); + if (archive_path.has_parent_path()) { + std::error_code parent_ec; + trx::fs::create_directories(archive_path.parent_path(), parent_ec); + if (parent_ec) { + throw std::runtime_error("Could not create archive parent directory: " + archive_path.parent_path().string()); + } + } + + int errorp = 0; + zip_t *zf = zip_open(options.output_path.c_str(), ZIP_CREATE + ZIP_TRUNCATE, &errorp); + if (zf == nullptr) { + throw std::runtime_error("Could not open archive " + options.output_path + ": " + strerror(errorp)); + } + zip_from_folder(zf, output_dir, output_dir, options.compression_standard, nullptr); + if (zip_close(zf) != 0) { + throw std::runtime_error("Unable to close archive " + options.output_path + ": " + zip_strerror(zf)); + } + rm_dir(output_dir); +} }; // namespace trx \ No newline at end of file diff --git a/tests/test_trx_anytrxfile.cpp b/tests/test_trx_anytrxfile.cpp index 6d66463..5ff92a2 100644 --- a/tests/test_trx_anytrxfile.cpp +++ b/tests/test_trx_anytrxfile.cpp @@ -232,6 +232,75 @@ void expect_basic_consistency(const AnyTrxFile &trx) { EXPECT_NE(bytes.data, nullptr); EXPECT_GT(bytes.size, 0U); } + +fs::path write_test_shard(const fs::path &root, + const std::string &name, + const std::vector> &points, + const std::vector &offsets, + const std::vector &dps_values, + const std::vector &dpv_values, + const std::vector &group_indices) { + const fs::path shard_dir = root / name; + std::error_code ec; + fs::create_directories(shard_dir, ec); + if (ec) { + throw std::runtime_error("Failed to create shard directory: " + shard_dir.string()); + } + + json::object header_obj; + header_obj["DIMENSIONS"] = json::array{1, 1, 1}; + header_obj["NB_STREAMLINES"] = static_cast(offsets.size() - 1); + header_obj["NB_VERTICES"] = static_cast(points.size()); + header_obj["VOXEL_TO_RASMM"] = json::array{ + json::array{1.0, 0.0, 0.0, 0.0}, + json::array{0.0, 1.0, 0.0, 0.0}, + json::array{0.0, 0.0, 1.0, 0.0}, + json::array{0.0, 0.0, 0.0, 1.0}, + }; + write_header_file(shard_dir, json(header_obj)); + + Eigen::Matrix positions( + static_cast(points.size()), 3); + for (size_t i = 0; i < points.size(); ++i) { + positions(static_cast(i), 0) = points[i][0]; + positions(static_cast(i), 1) = points[i][1]; + positions(static_cast(i), 2) = points[i][2]; + } + trx::write_binary((shard_dir / "positions.3.float32").string(), positions); + + Eigen::Matrix offsets_mat( + static_cast(offsets.size()), 1); + for (size_t i = 0; i < offsets.size(); ++i) { + offsets_mat(static_cast(i), 0) = offsets[i]; + } + trx::write_binary((shard_dir / "offsets.uint64").string(), offsets_mat); + + fs::create_directories(shard_dir / "dps", ec); + Eigen::Matrix dps_mat( + static_cast(dps_values.size()), 1); + for (size_t i = 0; i < dps_values.size(); ++i) { + dps_mat(static_cast(i), 0) = dps_values[i]; + } + trx::write_binary((shard_dir / "dps" / "weight.float32").string(), dps_mat); + + fs::create_directories(shard_dir / "dpv", ec); + Eigen::Matrix dpv_mat( + static_cast(dpv_values.size()), 1); + for (size_t i = 0; i < dpv_values.size(); ++i) { + dpv_mat(static_cast(i), 0) = dpv_values[i]; + } + trx::write_binary((shard_dir / "dpv" / "signal.float32").string(), dpv_mat); + + fs::create_directories(shard_dir / "groups", ec); + Eigen::Matrix groups_mat( + static_cast(group_indices.size()), 1); + for (size_t i = 0; i < group_indices.size(); ++i) { + groups_mat(static_cast(i), 0) = group_indices[i]; + } + trx::write_binary((shard_dir / "groups" / "Bundle.uint32").string(), groups_mat); + + return shard_dir; +} } // namespace TEST(AnyTrxFile, LoadZipAndValidate) { @@ -639,3 +708,297 @@ TEST(AnyTrxFile, SaveRejectsMissingBackingDirectory) { std::error_code ec; fs::remove_all(temp_dir, ec); } + +TEST(AnyTrxFile, MergeTrxShardsDirectoryOutput) { + const fs::path temp_root = make_temp_test_dir("trx_merge_shards"); + const fs::path shard1 = write_test_shard(temp_root, + "shard1", + {{0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}}, + {0, 2}, + {10.0F}, + {0.1F, 0.2F}, + {0}); + const fs::path shard2 = write_test_shard(temp_root, + "shard2", + {{2.0F, 0.0F, 0.0F}, {3.0F, 0.0F, 0.0F}, {4.0F, 0.0F, 0.0F}}, + {0, 1, 3}, + {20.0F, 30.0F}, + {0.3F, 0.4F, 0.5F}, + {1}); + + const fs::path output_dir = temp_root / "merged"; + MergeTrxShardsOptions options; + options.shard_directories = {shard1.string(), shard2.string()}; + options.output_path = output_dir.string(); + options.output_directory = true; + merge_trx_shards(options); + + auto merged = load_any(output_dir.string()); + EXPECT_EQ(merged.num_streamlines(), 3U); + EXPECT_EQ(merged.num_vertices(), 5U); + ASSERT_EQ(merged.offsets_u64.size(), 4U); + EXPECT_EQ(merged.offsets_u64[0], 0U); + EXPECT_EQ(merged.offsets_u64[1], 2U); + EXPECT_EQ(merged.offsets_u64[2], 3U); + EXPECT_EQ(merged.offsets_u64[3], 5U); + + const auto pos = merged.positions.as_matrix(); + EXPECT_FLOAT_EQ(pos(0, 0), 0.0F); + EXPECT_FLOAT_EQ(pos(4, 0), 4.0F); + + auto dps_it = merged.data_per_streamline.find("weight"); + ASSERT_NE(dps_it, merged.data_per_streamline.end()); + auto dps = dps_it->second.as_matrix(); + EXPECT_EQ(dps.rows(), 3); + EXPECT_FLOAT_EQ(dps(0, 0), 10.0F); + EXPECT_FLOAT_EQ(dps(2, 0), 30.0F); + + auto dpv_it = merged.data_per_vertex.find("signal"); + ASSERT_NE(dpv_it, merged.data_per_vertex.end()); + auto dpv = dpv_it->second.as_matrix(); + EXPECT_EQ(dpv.rows(), 5); + EXPECT_FLOAT_EQ(dpv(0, 0), 0.1F); + EXPECT_FLOAT_EQ(dpv(4, 0), 0.5F); + + auto group_it = merged.groups.find("Bundle"); + ASSERT_NE(group_it, merged.groups.end()); + auto grp = group_it->second.as_matrix(); + ASSERT_EQ(grp.rows(), 2); + EXPECT_EQ(grp(0, 0), 0U); + EXPECT_EQ(grp(1, 0), 2U); + merged.close(); + + std::error_code ec; + fs::remove_all(temp_root, ec); +} + +TEST(AnyTrxFile, MergeTrxShardsSchemaMismatchThrows) { + const fs::path temp_root = make_temp_test_dir("trx_merge_schema"); + const fs::path shard1 = write_test_shard(temp_root, + "shard1", + {{0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}}, + {0, 2}, + {10.0F}, + {0.1F, 0.2F}, + {0}); + const fs::path shard2 = write_test_shard(temp_root, + "shard2", + {{2.0F, 0.0F, 0.0F}}, + {0, 1}, + {20.0F}, + {0.3F}, + {0}); + + std::error_code ec; + fs::remove(shard2 / "dpv" / "signal.float32", ec); + + const fs::path output_dir = temp_root / "merged"; + MergeTrxShardsOptions options; + options.shard_directories = {shard1.string(), shard2.string()}; + options.output_path = output_dir.string(); + options.output_directory = true; + EXPECT_THROW(merge_trx_shards(options), std::runtime_error); + + fs::remove_all(temp_root, ec); +} + +TEST(AnyTrxFile, PreparePositionsOutputCopiesMetadataAndOffsets) { + const fs::path temp_root = make_temp_test_dir("trx_prepare_positions"); + const fs::path shard1 = write_test_shard(temp_root, + "shard1", + {{0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}}, + {0, 2}, + {10.0F}, + {0.1F, 0.2F}, + {0}); + auto input = load_any(shard1.string()); + + const fs::path output_dir = temp_root / "prepared"; + PrepareOutputOptions options; + options.overwrite_existing = true; + const auto info = prepare_positions_output(input, output_dir.string(), options); + + EXPECT_EQ(info.directory, output_dir.string()); + EXPECT_EQ(info.dtype, "float32"); + EXPECT_EQ(info.points, 2U); + EXPECT_EQ(fs::path(info.positions_path).filename().string(), "positions.3.float32"); + EXPECT_TRUE(fs::exists(output_dir / "header.json")); + EXPECT_TRUE(fs::exists(output_dir / "offsets.uint64")); + EXPECT_TRUE(fs::exists(output_dir / "dps" / "weight.float32")); + EXPECT_TRUE(fs::exists(output_dir / "dpv" / "signal.float32")); + EXPECT_TRUE(fs::exists(output_dir / "groups" / "Bundle.uint32")); + EXPECT_FALSE(fs::exists(info.positions_path)); + + input.close(); + std::error_code ec; + fs::remove_all(temp_root, ec); +} + +TEST(AnyTrxFile, PositionsChunkIterationAndMutation) { + const fs::path temp_root = make_temp_test_dir("trx_positions_chunk"); + const fs::path shard1 = write_test_shard(temp_root, + "shard1", + {{0.0F, 0.0F, 0.0F}, {1.0F, 2.0F, 3.0F}}, + {0, 2}, + {10.0F}, + {0.1F, 0.2F}, + {0}); + auto trx = load_any(shard1.string()); + + size_t total_points = 0; + size_t callbacks = 0; + trx.for_each_positions_chunk(12, [&](TrxScalarType dtype, const void *data, size_t point_offset, size_t point_count) { + EXPECT_EQ(dtype, TrxScalarType::Float32); + EXPECT_NE(data, nullptr); + EXPECT_EQ(point_count, 1U); + EXPECT_LT(point_offset, 2U); + total_points += point_count; + callbacks += 1; + }); + EXPECT_EQ(total_points, 2U); + EXPECT_EQ(callbacks, 2U); + + trx.for_each_positions_chunk_mutable(12, [&](TrxScalarType dtype, void *data, size_t, size_t point_count) { + EXPECT_EQ(dtype, TrxScalarType::Float32); + auto *vals = reinterpret_cast(data); + for (size_t i = 0; i < point_count * 3; ++i) { + vals[i] += 1.0F; + } + }); + + const auto positions = trx.positions.as_matrix(); + EXPECT_FLOAT_EQ(positions(0, 0), 1.0F); + EXPECT_FLOAT_EQ(positions(1, 0), 2.0F); + EXPECT_FLOAT_EQ(positions(1, 1), 3.0F); + EXPECT_FLOAT_EQ(positions(1, 2), 4.0F); + trx.close(); + + std::error_code ec; + fs::remove_all(temp_root, ec); +} + +TEST(AnyTrxFile, MergeTrxShardsArchiveOutput) { + const fs::path temp_root = make_temp_test_dir("trx_merge_shards_archive"); + const fs::path shard1 = write_test_shard(temp_root, + "shard1", + {{0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}}, + {0, 2}, + {10.0F}, + {0.1F, 0.2F}, + {0}); + const fs::path shard2 = write_test_shard(temp_root, + "shard2", + {{2.0F, 0.0F, 0.0F}, {3.0F, 0.0F, 0.0F}}, + {0, 2}, + {20.0F}, + {0.3F, 0.4F}, + {0}); + + const fs::path output_archive = temp_root / "merged.trx"; + MergeTrxShardsOptions options; + options.shard_directories = {shard1.string(), shard2.string()}; + options.output_path = output_archive.string(); + options.output_directory = false; + merge_trx_shards(options); + + ASSERT_TRUE(fs::exists(output_archive)); + auto merged = load_any(output_archive.string()); + EXPECT_EQ(merged.num_streamlines(), 2U); + EXPECT_EQ(merged.num_vertices(), 4U); + merged.close(); + + std::error_code ec; + fs::remove_all(temp_root, ec); +} + +TEST(AnyTrxFile, MergeTrxShardsRejectsDpg) { + const fs::path temp_root = make_temp_test_dir("trx_merge_shards_dpg"); + const fs::path shard1 = write_test_shard(temp_root, + "shard1", + {{0.0F, 0.0F, 0.0F}}, + {0, 1}, + {10.0F}, + {0.1F}, + {0}); + const fs::path shard2 = write_test_shard(temp_root, + "shard2", + {{1.0F, 0.0F, 0.0F}}, + {0, 1}, + {20.0F}, + {0.2F}, + {0}); + std::error_code ec; + fs::create_directories(shard2 / "dpg" / "Bundle", ec); + std::ofstream out((shard2 / "dpg" / "Bundle" / "mean.float32").string(), std::ios::binary | std::ios::trunc); + float one = 1.0F; + out.write(reinterpret_cast(&one), sizeof(float)); + out.close(); + + MergeTrxShardsOptions options; + options.shard_directories = {shard1.string(), shard2.string()}; + options.output_path = (temp_root / "merged").string(); + options.output_directory = true; + EXPECT_THROW(merge_trx_shards(options), std::runtime_error); + + fs::remove_all(temp_root, ec); +} + +TEST(AnyTrxFile, PreparePositionsOutputOverwriteFalseThrows) { + const fs::path temp_root = make_temp_test_dir("trx_prepare_positions_overwrite"); + const fs::path shard1 = write_test_shard(temp_root, + "shard1", + {{0.0F, 0.0F, 0.0F}}, + {0, 1}, + {10.0F}, + {0.1F}, + {0}); + auto input = load_any(shard1.string()); + const fs::path output_dir = temp_root / "prepared"; + std::error_code ec; + fs::create_directories(output_dir, ec); + ASSERT_TRUE(fs::exists(output_dir)); + + PrepareOutputOptions options; + options.overwrite_existing = false; + EXPECT_THROW(prepare_positions_output(input, output_dir.string(), options), std::runtime_error); + + input.close(); + fs::remove_all(temp_root, ec); +} + +TEST(AnyTrxFile, SaveRespectsExplicitMode) { + const fs::path temp_root = make_temp_test_dir("trx_any_save_modes"); + const fs::path shard1 = write_test_shard(temp_root, + "shard1", + {{0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}}, + {0, 2}, + {10.0F}, + {0.1F, 0.2F}, + {0}); + auto trx = load_any(shard1.string()); + + const fs::path dir_out = temp_root / "save_dir_mode"; + TrxSaveOptions dir_opts; + dir_opts.mode = TrxSaveMode::Directory; + trx.save(dir_out.string(), dir_opts); + EXPECT_TRUE(fs::is_directory(dir_out)); + EXPECT_TRUE(fs::exists(dir_out / "header.json")); + + const fs::path archive_out = temp_root / "save_archive_mode.trx"; + TrxSaveOptions archive_opts; + archive_opts.mode = TrxSaveMode::Archive; + archive_opts.compression_standard = ZIP_CM_STORE; + trx.save(archive_out.string(), archive_opts); + EXPECT_TRUE(fs::is_regular_file(archive_out)); + + auto dir_loaded = load_any(dir_out.string()); + auto arc_loaded = load_any(archive_out.string()); + EXPECT_EQ(dir_loaded.num_streamlines(), trx.num_streamlines()); + EXPECT_EQ(arc_loaded.num_vertices(), trx.num_vertices()); + dir_loaded.close(); + arc_loaded.close(); + trx.close(); + + std::error_code ec; + fs::remove_all(temp_root, ec); +} diff --git a/tests/test_trx_trxfile.cpp b/tests/test_trx_trxfile.cpp index 43edd7b..6f86583 100644 --- a/tests/test_trx_trxfile.cpp +++ b/tests/test_trx_trxfile.cpp @@ -365,3 +365,43 @@ TEST(TrxFileTpp, ResizeDeleteDpgCloses) { std::error_code ec; fs::remove_all(data_dir, ec); } + +TEST(TrxFileTpp, NormalizeForSaveRejectsNonMonotonicOffsets) { + const fs::path data_dir = create_float_trx_dir(); + auto src_reader = load_trx_dir(data_dir); + auto *src = src_reader.get(); + + ASSERT_GE(src->streamlines->_offsets.size(), 3); + src->streamlines->_offsets(1) = 5; + src->streamlines->_offsets(2) = 4; + + EXPECT_THROW(src->normalize_for_save(), std::runtime_error); + + src->close(); + + std::error_code ec; + fs::remove_all(data_dir, ec); +} + +TEST(TrxFileTpp, NormalizeForSaveRecomputesLengthsAndHeader) { + const fs::path data_dir = create_float_trx_dir(); + auto reader = load_trx_dir(data_dir); + auto *trx = reader.get(); + + ASSERT_EQ(trx->streamlines->_offsets.size(), 3); + trx->streamlines->_lengths(0) = 99; + trx->streamlines->_lengths(1) = 99; + trx->header = _json_set(trx->header, "NB_STREAMLINES", 123); + trx->header = _json_set(trx->header, "NB_VERTICES", 456); + + trx->normalize_for_save(); + + EXPECT_EQ(trx->streamlines->_lengths(0), 2u); + EXPECT_EQ(trx->streamlines->_lengths(1), 2u); + EXPECT_EQ(trx->header["NB_STREAMLINES"].int_value(), 2); + EXPECT_EQ(trx->header["NB_VERTICES"].int_value(), 4); + + trx->close(); + std::error_code ec; + fs::remove_all(data_dir, ec); +} From 16f294ccbb5eb7fd33794e8a3b904bb029902eea Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Sat, 21 Feb 2026 11:18:43 -0500 Subject: [PATCH 05/11] fix CI errors --- include/trx/trx.tpp | 9 ++++++--- src/trx.cpp | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/include/trx/trx.tpp b/include/trx/trx.tpp index 7b365c2..fff2f05 100644 --- a/include/trx/trx.tpp +++ b/include/trx/trx.tpp @@ -1016,7 +1016,10 @@ template std::unique_ptr> TrxFile
    ::load_from_direc std::map> files_pointer_size; populate_fps(directory, files_pointer_size); - return TrxFile
    ::_create_trx_from_pointer(header, files_pointer_size, "", directory); + auto trx = TrxFile
    ::_create_trx_from_pointer(header, files_pointer_size, "", directory); + trx->_uncompressed_folder_handle = directory; + trx->_owns_uncompressed_folder = false; + return trx; } template std::unique_ptr> TrxFile
    ::load(const std::string &path) { @@ -1135,8 +1138,8 @@ template void TrxFile
    ::normalize_for_save() { template void TrxFile
    ::save(const std::string &filename, const TrxSaveOptions &options) { std::string ext = get_ext(filename); - if (ext.size() > 0 && (ext != "zip" && ext != "trx") && options.mode == TrxSaveMode::Archive) { - throw std::invalid_argument("Unsupported extension." + ext); + if (ext.size() > 0 && ext != "zip" && ext != "trx") { + throw std::invalid_argument("Unsupported extension: " + ext); } TrxFile
    *save_trx = this; diff --git a/src/trx.cpp b/src/trx.cpp index 5ca0241..15e06f6 100644 --- a/src/trx.cpp +++ b/src/trx.cpp @@ -539,8 +539,8 @@ void AnyTrxFile::save(const std::string &filename, zip_uint32_t compression_stan void AnyTrxFile::save(const std::string &filename, const TrxSaveOptions &options) { const std::string ext = get_ext(filename); const TrxSaveMode save_mode = resolve_save_mode(filename, options.mode); - if (save_mode == TrxSaveMode::Archive && ext.size() > 0 && (ext != "zip" && ext != "trx")) { - throw std::invalid_argument("Unsupported extension." + ext); + if (ext.size() > 0 && ext != "zip" && ext != "trx") { + throw std::invalid_argument("Unsupported extension: " + ext); } if (offsets.empty()) { From 84cc59cbd4451fed9f0bf4bf8018dbf44539f590 Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Sat, 21 Feb 2026 14:31:16 -0500 Subject: [PATCH 06/11] change codecov --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b812f0a..966e91e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,15 +99,15 @@ jobs: run: | lcov --capture --directory build \ --rc geninfo_unexecuted_blocks=1 \ - --ignore-errors mismatch,inconsistent,unsupported,format \ + --ignore-errors mismatch,inconsistent,inconsistent,unsupported,format,format \ --output-file coverage.info lcov --remove coverage.info "/opt/homebrew/*" "/Applications/Xcode_*.app/*" \ "/Users/runner/work/trx-cpp/trx-cpp/third_party/*" \ "/Users/runner/work/trx-cpp/trx-cpp/examples/*" \ "/Users/runner/work/trx-cpp/trx-cpp/bench/*" \ - --ignore-errors mismatch,inconsistent,unsupported,format \ + --ignore-errors mismatch,inconsistent,inconsistent,unsupported,format,format,unused \ --output-file coverage.info - lcov --summary coverage.info --ignore-errors mismatch,inconsistent,unsupported,format + lcov --summary coverage.info --ignore-errors mismatch,inconsistent,inconsistent,unsupported,format,format - name: Upload coverage to Codecov (macOS) if: runner.os == 'macOS' From 3384cbb4f3bf90afdacb3ed447544d40a38cb18f Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Sat, 21 Feb 2026 14:34:28 -0500 Subject: [PATCH 07/11] more ci --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 966e91e..45118c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,15 +99,15 @@ jobs: run: | lcov --capture --directory build \ --rc geninfo_unexecuted_blocks=1 \ - --ignore-errors mismatch,inconsistent,inconsistent,unsupported,format,format \ + --ignore-errors mismatch,mismatch,inconsistent,inconsistent,unsupported,format,format \ --output-file coverage.info lcov --remove coverage.info "/opt/homebrew/*" "/Applications/Xcode_*.app/*" \ "/Users/runner/work/trx-cpp/trx-cpp/third_party/*" \ "/Users/runner/work/trx-cpp/trx-cpp/examples/*" \ "/Users/runner/work/trx-cpp/trx-cpp/bench/*" \ - --ignore-errors mismatch,inconsistent,inconsistent,unsupported,format,format,unused \ + --ignore-errors mismatch,mismatch,inconsistent,inconsistent,unsupported,format,format,unused \ --output-file coverage.info - lcov --summary coverage.info --ignore-errors mismatch,inconsistent,inconsistent,unsupported,format,format + lcov --summary coverage.info --ignore-errors mismatch,mismatch,inconsistent,inconsistent,unsupported,format,format - name: Upload coverage to Codecov (macOS) if: runner.os == 'macOS' From 819fa9c95cdb9d4e417e6f03dfcda62295cfdc9d Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Sat, 21 Feb 2026 14:38:37 -0500 Subject: [PATCH 08/11] linux ci --- .github/workflows/trx-cpp-tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/trx-cpp-tests.yml b/.github/workflows/trx-cpp-tests.yml index 687ba51..03656f3 100644 --- a/.github/workflows/trx-cpp-tests.yml +++ b/.github/workflows/trx-cpp-tests.yml @@ -66,15 +66,15 @@ jobs: run: | lcov --capture --directory build \ --rc geninfo_unexecuted_blocks=1 \ - --ignore-errors mismatch \ + --ignore-errors mismatch,mismatch,inconsistent,inconsistent,unsupported,format,format \ --output-file coverage.info lcov --remove coverage.info \ "*/third_party/*" \ "*/examples/*" \ "*/bench/*" \ - --ignore-errors mismatch \ + --ignore-errors mismatch,mismatch,inconsistent,inconsistent,unsupported,format,format,unused \ --output-file coverage.info - lcov --summary coverage.info + lcov --summary coverage.info --ignore-errors mismatch,mismatch,inconsistent,inconsistent,unsupported,format,format - name: Upload to Codecov uses: codecov/codecov-action@v4 From e09c20f222675f3e8889b183a6368000d7b591a9 Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Sat, 21 Feb 2026 14:49:08 -0500 Subject: [PATCH 09/11] windows stack is 1MB! use the heap --- bench/bench_trx_stream.cpp | 1654 ------------------------------------ src/trx.cpp | 2 +- 2 files changed, 1 insertion(+), 1655 deletions(-) delete mode 100644 bench/bench_trx_stream.cpp diff --git a/bench/bench_trx_stream.cpp b/bench/bench_trx_stream.cpp deleted file mode 100644 index 871303e..0000000 --- a/bench/bench_trx_stream.cpp +++ /dev/null @@ -1,1654 +0,0 @@ -// Benchmark TRX streaming workloads for realistic datasets. -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#if defined(__unix__) || defined(__APPLE__) -#include -#include -#include -#endif - -namespace { -using Eigen::half; - -constexpr float kMinLengthMm = 20.0f; -constexpr float kMaxLengthMm = 500.0f; -constexpr float kStepMm = 2.0f; -constexpr float kCurvatureSigma = 0.08f; -constexpr float kSlabThicknessMm = 5.0f; -constexpr size_t kSlabCount = 20; - -constexpr std::array kStreamlineCounts = {100000, 500000, 1000000, 5000000, 10000000}; - -struct Fov { - float min_x; - float max_x; - float min_y; - float max_y; - float min_z; - float max_z; -}; - -constexpr Fov kFov{-70.0f, 70.0f, -108.0f, 79.0f, -60.0f, 75.0f}; -constexpr float kRandomMinMm = 10.0f; -constexpr float kRandomMaxMm = 400.0f; - -enum class GroupScenario : int { None = 0, Bundles = 1, Connectome = 2 }; -enum class LengthProfile : int { Mixed = 0, Short = 1, Medium = 2, Long = 3 }; - -constexpr size_t kBundleCount = 80; -constexpr size_t kConnectomeRegions = 100; - -std::string make_temp_path(const std::string &prefix) { - static std::atomic counter{0}; - const auto id = counter.fetch_add(1, std::memory_order_relaxed); - const auto dir = std::filesystem::temp_directory_path(); - return (dir / (prefix + "_" + std::to_string(id) + ".trx")).string(); -} - -std::string make_temp_dir_name(const std::string &prefix) { - static std::atomic counter{0}; - const auto id = counter.fetch_add(1, std::memory_order_relaxed); - const auto dir = std::filesystem::temp_directory_path(); - return (dir / (prefix + "_" + std::to_string(id))).string(); -} - -std::string make_work_dir_name(const std::string &prefix) { - static std::atomic counter{0}; - const auto id = counter.fetch_add(1, std::memory_order_relaxed); -#if defined(__unix__) || defined(__APPLE__) - const auto pid = static_cast(getpid()); -#else - const auto pid = static_cast(0); -#endif - const auto dir = std::filesystem::current_path(); - return (dir / (prefix + "_" + std::to_string(pid) + "_" + std::to_string(id))).string(); -} - -std::string make_status_path(const std::string &prefix) { - static std::atomic counter{0}; - const auto id = counter.fetch_add(1, std::memory_order_relaxed); - const auto dir = std::filesystem::temp_directory_path(); - return (dir / (prefix + "_" + std::to_string(id) + ".txt")).string(); -} - -std::string make_temp_dir_path(const std::string &prefix) { - return trx::make_temp_dir(prefix); -} - -void register_cleanup(const std::string &path); -std::vector list_files(const std::string &dir); - -std::string find_file_by_prefix(const std::string &dir, const std::string &prefix) { - std::error_code ec; - for (const auto &entry : trx::fs::directory_iterator(dir, ec)) { - if (ec) { - break; - } - if (!entry.is_regular_file()) { - continue; - } - const auto filename = entry.path().filename().string(); - if (filename.rfind(prefix, 0) == 0) { - return entry.path().string(); - } - } - return ""; -} - -std::vector list_files(const std::string &dir) { - std::vector files; - std::error_code ec; - if (!trx::fs::exists(dir, ec)) { - return files; - } - for (const auto &entry : trx::fs::directory_iterator(dir, ec)) { - if (ec) { - break; - } - if (!entry.is_regular_file()) { - continue; - } - files.push_back(entry.path().filename().string()); - } - std::sort(files.begin(), files.end()); - return files; -} - -size_t file_size_bytes(const std::string &path) { - std::error_code ec; - if (!trx::fs::exists(path, ec)) { - return 0; - } - if (trx::fs::is_directory(path, ec)) { - size_t total = 0; - for (trx::fs::recursive_directory_iterator it(path, ec), end; it != end; it.increment(ec)) { - if (ec) { - break; - } - if (!it->is_regular_file(ec)) { - continue; - } - total += static_cast(trx::fs::file_size(it->path(), ec)); - if (ec) { - break; - } - } - return total; - } - return static_cast(trx::fs::file_size(path, ec)); -} - -void wait_for_shard_ok(const std::vector &shard_paths, - const std::vector &status_paths, - size_t timeout_ms) { - const auto start = std::chrono::steady_clock::now(); - while (true) { - bool all_ok = true; - for (size_t i = 0; i < shard_paths.size(); ++i) { - const auto ok_path = trx::fs::path(shard_paths[i]) / "SHARD_OK"; - std::error_code ec; - if (!trx::fs::exists(ok_path, ec)) { - all_ok = false; - break; - } - } - if (all_ok) { - return; - } - const auto now = std::chrono::steady_clock::now(); - const auto elapsed_ms = - std::chrono::duration_cast(now - start).count(); - if (elapsed_ms > static_cast(timeout_ms)) { - std::string detail = "Timed out waiting for SHARD_OK"; - for (size_t i = 0; i < status_paths.size(); ++i) { - std::ifstream in(status_paths[i]); - std::string line; - if (in.is_open()) { - std::getline(in, line); - } - if (!line.empty()) { - detail += " shard_" + std::to_string(i) + "=" + line; - } - } - throw std::runtime_error(detail); - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } -} - -std::pair read_header_counts(const std::string &dir) { - const auto header_path = trx::fs::path(dir) / "header.json"; - std::ifstream in; - for (int attempt = 0; attempt < 5; ++attempt) { - in.open(header_path); - if (in.is_open()) { - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - if (!in.is_open()) { - std::error_code ec; - const bool exists = trx::fs::exists(dir, ec); - const auto files = list_files(dir); - const int open_err = errno; - std::string detail = "Failed to open header.json at: " + header_path.string(); - detail += " exists=" + std::string(exists ? "true" : "false"); - detail += " errno=" + std::to_string(open_err) + " msg=" + std::string(std::strerror(open_err)); - if (!files.empty()) { - detail += " files=["; - for (size_t i = 0; i < files.size(); ++i) { - if (i > 0) { - detail += ","; - } - detail += files[i]; - } - detail += "]"; - } - throw std::runtime_error(detail); - } - std::string contents((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - std::string err; - const auto header = json::parse(contents, err); - if (!err.empty()) { - throw std::runtime_error("Failed to parse header.json: " + err); - } - const auto nb_streamlines = static_cast(header["NB_STREAMLINES"].int_value()); - const auto nb_vertices = static_cast(header["NB_VERTICES"].int_value()); - return {nb_streamlines, nb_vertices}; -} - -double get_max_rss_kb() { -#if defined(__unix__) || defined(__APPLE__) - rusage usage{}; - if (getrusage(RUSAGE_SELF, &usage) != 0) { - return 0.0; - } -#if defined(__APPLE__) - return static_cast(usage.ru_maxrss) / 1024.0; -#else - return static_cast(usage.ru_maxrss); -#endif -#else - return 0.0; -#endif -} - -size_t parse_env_size(const char *name, size_t default_value) { - const char *raw = std::getenv(name); - if (!raw || raw[0] == '\0') { - return default_value; - } - char *end = nullptr; - const unsigned long long value = std::strtoull(raw, &end, 10); - if (end == raw) { - return default_value; - } - return static_cast(value); -} - -bool parse_env_bool(const char *name, bool default_value) { - const char *raw = std::getenv(name); - if (!raw || raw[0] == '\0') { - return default_value; - } - return std::string(raw) != "0"; -} - -int parse_env_int(const char *name, int default_value) { - const char *raw = std::getenv(name); - if (!raw || raw[0] == '\0') { - return default_value; - } - char *end = nullptr; - const long value = std::strtol(raw, &end, 10); - if (end == raw) { - return default_value; - } - return static_cast(value); -} - -bool is_core_profile() { - const char *raw = std::getenv("TRX_BENCH_PROFILE"); - return raw && std::string(raw) == "core"; -} - -bool include_bundles_in_core_profile() { - return parse_env_bool("TRX_BENCH_CORE_INCLUDE_BUNDLES", false); -} - -size_t core_dpv_max_streamlines() { - return parse_env_size("TRX_BENCH_CORE_DPV_MAX_STREAMLINES", 1000000); -} - -size_t core_zip_max_streamlines() { - return parse_env_size("TRX_BENCH_CORE_ZIP_MAX_STREAMLINES", 1000000); -} - -std::vector group_cases_for_benchmarks() { - std::vector groups = {static_cast(GroupScenario::None)}; - if (!is_core_profile() || include_bundles_in_core_profile()) { - groups.push_back(static_cast(GroupScenario::Bundles)); - } - return groups; -} - -size_t group_count_for(GroupScenario scenario) { - switch (scenario) { - case GroupScenario::Bundles: - return kBundleCount; - case GroupScenario::Connectome: - return (kConnectomeRegions * (kConnectomeRegions - 1)) / 2; - case GroupScenario::None: - default: - return 0; - } -} - -// Compute position buffer size based on streamline count. -// For slow storage (spinning disks, network filesystems), set TRX_BENCH_BUFFER_MULTIPLIER -// to 2-8 to reduce I/O frequency at the cost of higher memory usage. -// Example: multiplier=4 scales 256 MB → 1 GB for 1M streamlines. -std::size_t buffer_bytes_for_streamlines(std::size_t streamlines) { - std::size_t base_bytes; - if (streamlines >= 5000000) { - base_bytes = 2ULL * 1024ULL * 1024ULL * 1024ULL; // 2 GB - } else if (streamlines >= 1000000) { - base_bytes = 256ULL * 1024ULL * 1024ULL; // 256 MB - } else { - base_bytes = 16ULL * 1024ULL * 1024ULL; // 16 MB - } - - // Allow scaling buffer sizes for slower storage (HDD, NFS) to amortize I/O latency - const size_t multiplier = std::max(1, parse_env_size("TRX_BENCH_BUFFER_MULTIPLIER", 1)); - return base_bytes * multiplier; -} - -std::vector streamlines_for_benchmarks() { - const size_t only = parse_env_size("TRX_BENCH_ONLY_STREAMLINES", 0); - if (only > 0) { - return {only}; - } - const size_t max_val = parse_env_size("TRX_BENCH_MAX_STREAMLINES", 10000000); - std::vector counts = {10000000, 5000000, 1000000, 500000, 100000}; - counts.erase(std::remove_if(counts.begin(), counts.end(), [&](size_t v) { return v > max_val; }), counts.end()); - if (counts.empty()) { - counts.push_back(max_val); - } - return counts; -} - -void log_bench_start(const std::string &name, const std::string &details) { - if (!parse_env_bool("TRX_BENCH_LOG", false)) { - return; - } - std::cerr << "[trx-bench] start " << name << " " << details << std::endl; -} - -void log_bench_end(const std::string &name, const std::string &details) { - if (!parse_env_bool("TRX_BENCH_LOG", false)) { - return; - } - std::cerr << "[trx-bench] end " << name << " " << details << std::endl; -} - -void log_bench_config(const std::string &name, size_t threads, size_t batch_size) { - if (!parse_env_bool("TRX_BENCH_LOG", false)) { - return; - } - std::cerr << "[trx-bench] config " << name << " threads=" << threads << " batch=" << batch_size << std::endl; -} - -const std::vector &group_names_for(GroupScenario scenario) { - static const std::vector empty; - static const std::vector bundle_names = []() { - std::vector names; - names.reserve(kBundleCount); - for (size_t i = 1; i <= kBundleCount; ++i) { - names.push_back("Bundle" + std::to_string(i)); - } - return names; - }(); - static const std::vector connectome_names = []() { - std::vector names; - names.reserve((kConnectomeRegions * (kConnectomeRegions - 1)) / 2); - for (size_t i = 1; i <= kConnectomeRegions; ++i) { - for (size_t j = i + 1; j <= kConnectomeRegions; ++j) { - names.push_back("conn_" + std::to_string(i) + "_" + std::to_string(j)); - } - } - return names; - }(); - - switch (scenario) { - case GroupScenario::Bundles: - return bundle_names; - case GroupScenario::Connectome: - return connectome_names; - case GroupScenario::None: - default: - return empty; - } -} - -float sample_length_mm(std::mt19937 &rng, LengthProfile profile) { - auto sample_uniform = [&](float min_val, float max_val) { - std::uniform_real_distribution dist(min_val, max_val); - return dist(rng); - }; - switch (profile) { - case LengthProfile::Short: - return sample_uniform(20.0f, 120.0f); - case LengthProfile::Medium: - return sample_uniform(80.0f, 260.0f); - case LengthProfile::Long: - return sample_uniform(200.0f, 500.0f); - case LengthProfile::Mixed: - default: - return sample_uniform(kMinLengthMm, kMaxLengthMm); - } -} - -size_t estimate_points_per_streamline(LengthProfile profile) { - float mean_length = 0.0f; - switch (profile) { - case LengthProfile::Short: - mean_length = 70.0f; - break; - case LengthProfile::Medium: - mean_length = 170.0f; - break; - case LengthProfile::Long: - mean_length = 350.0f; - break; - case LengthProfile::Mixed: - default: - mean_length = 260.0f; - break; - } - return static_cast(std::ceil(mean_length / kStepMm)) + 1; -} - -std::array random_unit_vector(std::mt19937 &rng) { - std::normal_distribution dist(0.0f, 1.0f); - std::array v{dist(rng), dist(rng), dist(rng)}; - const float norm = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); - if (norm < 1e-6f) { - return {1.0f, 0.0f, 0.0f}; - } - v[0] /= norm; - v[1] /= norm; - v[2] /= norm; - return v; -} - -std::vector> generate_streamline_points(std::mt19937 &rng, LengthProfile profile) { - const float length_mm = sample_length_mm(rng, profile); - const size_t point_count = std::max(2, static_cast(std::ceil(length_mm / kStepMm)) + 1); - std::vector> points; - points.reserve(point_count); - - std::uniform_real_distribution dist_x(kRandomMinMm, kRandomMaxMm); - std::uniform_real_distribution dist_y(kRandomMinMm, kRandomMaxMm); - std::uniform_real_distribution dist_z(kRandomMinMm, kRandomMaxMm); - - for (size_t i = 0; i < point_count; ++i) { - points.push_back({dist_x(rng), dist_y(rng), dist_z(rng)}); - } - - return points; -} - -std::vector> generate_streamline_points_seeded(uint32_t seed, LengthProfile profile) { - std::mt19937 rng(seed); - return generate_streamline_points(rng, profile); -} - -size_t bench_threads() { - const size_t requested = parse_env_size("TRX_BENCH_THREADS", 0); - if (requested > 0) { - return requested; - } - const unsigned int hc = std::thread::hardware_concurrency(); - return hc == 0 ? 1U : static_cast(hc); -} - -size_t bench_batch_size() { - return parse_env_size("TRX_BENCH_BATCH", 1000); -} - -template -void generate_streamlines_parallel(size_t streamlines, - LengthProfile profile, - size_t threads, - size_t batch_size, - uint32_t base_seed, - BatchConsumer consumer) { - const size_t total_batches = (streamlines + batch_size - 1) / batch_size; - std::atomic next_batch{0}; - std::mutex mutex; - std::condition_variable cv; - std::map>>> completed; - std::condition_variable cv_producer; - size_t inflight_batches = 0; - const size_t max_inflight = std::max(1, parse_env_size("TRX_BENCH_QUEUE_MAX", 8)); - - auto worker = [&]() { - for (;;) { - size_t batch_idx; - { - // Wait for queue space BEFORE grabbing batch index to avoid missed notifications - std::unique_lock lock(mutex); - cv_producer.wait(lock, [&]() { return inflight_batches < max_inflight || next_batch.load() >= total_batches; }); - batch_idx = next_batch.fetch_add(1); - if (batch_idx >= total_batches) { - return; - } - ++inflight_batches; - } - const size_t start = batch_idx * batch_size; - const size_t count = std::min(batch_size, streamlines - start); - std::vector>> batch; - batch.reserve(count); - for (size_t i = 0; i < count; ++i) { - const uint32_t seed = base_seed + static_cast(start + i); - batch.push_back(generate_streamline_points_seeded(seed, profile)); - } - { - std::lock_guard lock(mutex); - completed.emplace(batch_idx, std::move(batch)); - } - cv.notify_one(); - } - }; - - std::vector workers; - workers.reserve(threads); - for (size_t t = 0; t < threads; ++t) { - workers.emplace_back(worker); - } - - for (size_t expected = 0; expected < total_batches; ++expected) { - std::unique_lock lock(mutex); - cv.wait(lock, [&]() { return completed.find(expected) != completed.end(); }); - auto batch = std::move(completed[expected]); - completed.erase(expected); - if (inflight_batches > 0) { - --inflight_batches; - } - lock.unlock(); - cv_producer.notify_all(); // Wake all waiting workers, not just one, to avoid deadlock - - const size_t start = expected * batch_size; - consumer(start, batch); - } - - for (auto &worker_thread : workers) { - worker_thread.join(); - } -} - -struct TrxWriteStats { - double write_ms = 0.0; - double file_size_bytes = 0.0; -}; - -struct RssSample { - double elapsed_ms = 0.0; - double rss_kb = 0.0; - std::string phase; -}; - -struct FileSizeScenario { - size_t streamlines = 0; - LengthProfile profile = LengthProfile::Mixed; - bool add_dps = false; - bool add_dpv = false; - zip_uint32_t compression = ZIP_CM_STORE; -}; - -std::mutex g_rss_samples_mutex; - -void append_rss_samples(const FileSizeScenario &scenario, const std::vector &samples) { - if (samples.empty()) { - return; - } - const char *path = std::getenv("TRX_RSS_SAMPLES_PATH"); - if (!path || path[0] == '\0') { - return; - } - std::lock_guard lock(g_rss_samples_mutex); - std::ofstream out(path, std::ios::app); - if (!out.is_open()) { - return; - } - - out << "{" - << "\"streamlines\":" << scenario.streamlines << "," - << "\"length_profile\":" << static_cast(scenario.profile) << "," - << "\"dps\":" << (scenario.add_dps ? 1 : 0) << "," - << "\"dpv\":" << (scenario.add_dpv ? 1 : 0) << "," - << "\"compression\":" << (scenario.compression == ZIP_CM_DEFLATE ? 1 : 0) << "," - << "\"samples\":["; - for (size_t i = 0; i < samples.size(); ++i) { - if (i > 0) { - out << ","; - } - out << "{" - << "\"elapsed_ms\":" << samples[i].elapsed_ms << "," - << "\"rss_kb\":" << samples[i].rss_kb << "," - << "\"phase\":\"" << samples[i].phase << "\"" - << "}"; - } - out << "]}\n"; -} - -std::mutex g_cleanup_mutex; -std::vector g_cleanup_paths; -pid_t g_cleanup_owner_pid = 0; -bool g_cleanup_only_on_success = true; -bool g_run_success = false; - -void cleanup_temp_paths() { - if (g_cleanup_only_on_success && !g_run_success) { - return; - } - if (g_cleanup_owner_pid != 0 && getpid() != g_cleanup_owner_pid) { - return; - } - std::error_code ec; - for (const auto &p : g_cleanup_paths) { - std::filesystem::remove_all(p, ec); - } -} - -void register_cleanup(const std::string &path) { - static bool registered = false; - { - std::lock_guard lock(g_cleanup_mutex); - if (g_cleanup_owner_pid == 0) { - g_cleanup_owner_pid = getpid(); - } - g_cleanup_paths.push_back(path); - } - if (!registered) { - registered = true; - std::atexit(cleanup_temp_paths); - } -} - -TrxWriteStats run_trx_file_size(size_t streamlines, - LengthProfile profile, - bool add_dps, - bool add_dpv, - zip_uint32_t compression) { - trx::TrxStream stream("float16"); - stream.set_metadata_mode(trx::TrxStream::MetadataMode::OnDisk); - - // Scale metadata buffer with TRX_BENCH_BUFFER_MULTIPLIER for slow storage - const size_t buffer_multiplier = std::max(1, parse_env_size("TRX_BENCH_BUFFER_MULTIPLIER", 1)); - stream.set_metadata_buffer_max_bytes(64ULL * 1024ULL * 1024ULL * buffer_multiplier); - stream.set_positions_buffer_max_bytes(buffer_bytes_for_streamlines(streamlines)); - - const size_t threads = bench_threads(); - const size_t batch_size = std::max(1, bench_batch_size()); - const uint32_t base_seed = static_cast(1337 + streamlines + static_cast(profile) * 13); - const size_t progress_every = parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 0); - log_bench_config("file_size_generate", threads, batch_size); - - const bool collect_rss = std::getenv("TRX_RSS_SAMPLES_PATH") != nullptr; - const size_t sample_every = parse_env_size("TRX_RSS_SAMPLE_EVERY", 50000); - const int sample_interval_ms = parse_env_int("TRX_RSS_SAMPLE_MS", 500); - std::vector samples; - std::mutex samples_mutex; - const auto bench_start = std::chrono::steady_clock::now(); - auto record_sample = [&](const std::string &phase) { - if (!collect_rss) { - return; - } - const auto now = std::chrono::steady_clock::now(); - const std::chrono::duration elapsed = now - bench_start; - std::lock_guard lock(samples_mutex); - samples.push_back({elapsed.count(), get_max_rss_kb(), phase}); - }; - - std::vector dps; - std::vector dpv; - if (add_dps) { - dps.reserve(streamlines); - } - if (add_dpv) { - const size_t estimated_vertices = streamlines * estimate_points_per_streamline(profile); - dpv.reserve(estimated_vertices); - } - - generate_streamlines_parallel( - streamlines, - profile, - threads, - batch_size, - base_seed, - [&](size_t start, const std::vector>> &batch) { - if (parse_env_bool("TRX_BENCH_LOG", false)) { - std::cerr << "[trx-bench] batch file_size start=" << start << " count=" << batch.size() << std::endl; - } - for (size_t i = 0; i < batch.size(); ++i) { - const auto &points = batch[i]; - stream.push_streamline(points); - if (add_dps) { - dps.push_back(1.0f); - } - if (add_dpv) { - dpv.insert(dpv.end(), points.size(), 0.5f); - } - const size_t global_idx = start + i + 1; - if (progress_every > 0 && (global_idx % progress_every == 0)) { - std::cerr << "[trx-bench] progress file_size streamlines=" << global_idx << " / " << streamlines - << std::endl; - } - if (collect_rss && sample_every > 0 && (global_idx % sample_every == 0)) { - record_sample("generate"); - } - } - }); - - if (add_dps) { - stream.push_dps_from_vector("dps_scalar", "float32", dps); - } - if (add_dpv) { - stream.push_dpv_from_vector("dpv_scalar", "float32", dpv); - } - - const std::string out_path = make_temp_path("trx_size"); - record_sample("before_finalize"); - - std::atomic sampling{false}; - std::thread sampler; - if (collect_rss) { - sampling.store(true, std::memory_order_relaxed); - sampler = std::thread([&]() { - while (sampling.load(std::memory_order_relaxed)) { - record_sample("finalize"); - std::this_thread::sleep_for(std::chrono::milliseconds(sample_interval_ms)); - } - }); - } - - const auto start = std::chrono::steady_clock::now(); - stream.finalize(out_path, compression); - const auto end = std::chrono::steady_clock::now(); - - if (collect_rss) { - sampling.store(false, std::memory_order_relaxed); - if (sampler.joinable()) { - sampler.join(); - } - } - record_sample("after_finalize"); - - TrxWriteStats stats; - stats.write_ms = std::chrono::duration(end - start).count(); - std::error_code size_ec; - const auto size = std::filesystem::file_size(out_path, size_ec); - stats.file_size_bytes = size_ec ? 0.0 : static_cast(size); - std::error_code ec; - std::filesystem::remove(out_path, ec); - - if (collect_rss) { - FileSizeScenario scenario; - scenario.streamlines = streamlines; - scenario.profile = profile; - scenario.add_dps = add_dps; - scenario.add_dpv = add_dpv; - scenario.compression = compression; - append_rss_samples(scenario, samples); - } - return stats; -} - -struct TrxOnDisk { - std::string path; - size_t streamlines = 0; - size_t vertices = 0; - double shard_merge_ms = 0.0; - size_t shard_processes = 1; -}; - -TrxOnDisk build_trx_file_on_disk_single(size_t streamlines, - GroupScenario scenario, - bool add_dps, - bool add_dpv, - LengthProfile profile, - zip_uint32_t compression, - const std::string &out_path_override = "", - bool finalize_to_directory = false) { - trx::TrxStream stream("float16"); - stream.set_metadata_mode(trx::TrxStream::MetadataMode::OnDisk); - - // Scale buffers with TRX_BENCH_BUFFER_MULTIPLIER for slow storage - const size_t buffer_multiplier = std::max(1, parse_env_size("TRX_BENCH_BUFFER_MULTIPLIER", 1)); - stream.set_metadata_buffer_max_bytes(64ULL * 1024ULL * 1024ULL * buffer_multiplier); - stream.set_positions_buffer_max_bytes(buffer_bytes_for_streamlines(streamlines)); - const size_t progress_every = parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 0); - - const auto group_count = group_count_for(scenario); - const auto &group_names = group_names_for(scenario); - std::vector> groups(group_count); - - const size_t threads = bench_threads(); - const size_t batch_size = std::max(1, bench_batch_size()); - const uint32_t base_seed = static_cast(1337 + streamlines + static_cast(scenario) * 31); - log_bench_config("build_trx_generate", threads, batch_size); - - std::vector dps; - std::vector dpv; - if (add_dps) { - dps.reserve(streamlines); - } - if (add_dpv) { - const size_t estimated_vertices = streamlines * estimate_points_per_streamline(profile); - dpv.reserve(estimated_vertices); - } - - size_t total_vertices = 0; - generate_streamlines_parallel( - streamlines, - profile, - threads, - batch_size, - base_seed, - [&](size_t start, const std::vector>> &batch) { - if (parse_env_bool("TRX_BENCH_LOG", false)) { - std::cerr << "[trx-bench] batch build_trx start=" << start << " count=" << batch.size() << std::endl; - } - for (size_t i = 0; i < batch.size(); ++i) { - const auto &points = batch[i]; - total_vertices += points.size(); - stream.push_streamline(points); - if (add_dps) { - dps.push_back(1.0f); - } - if (add_dpv) { - dpv.insert(dpv.end(), points.size(), 0.5f); - } - const size_t global_idx = start + i; - if (group_count > 0) { - groups[global_idx % group_count].push_back(static_cast(global_idx)); - } - if (progress_every > 0 && ((global_idx + 1) % progress_every == 0)) { - if (parse_env_bool("TRX_BENCH_CHILD_LOG", false) || parse_env_bool("TRX_BENCH_LOG", false)) { - const char *shard_env = std::getenv("TRX_BENCH_SHARD_INDEX"); - const std::string shard_prefix = shard_env ? std::string(" shard=") + shard_env : ""; - std::cerr << "[trx-bench] progress build_trx" << shard_prefix << " streamlines=" << (global_idx + 1) - << " / " << streamlines << std::endl; - } - } - } - }); - - if (add_dps) { - stream.push_dps_from_vector("dps_scalar", "float32", dps); - } - if (add_dpv) { - stream.push_dpv_from_vector("dpv_scalar", "float32", dpv); - } - if (group_count > 0) { - for (size_t g = 0; g < group_count; ++g) { - stream.push_group_from_indices(group_names[g], groups[g]); - } - } - - const std::string out_path = out_path_override.empty() ? make_temp_path("trx_input") : out_path_override; - if (finalize_to_directory) { - // Use persistent variant to avoid removing pre-created shard directories - stream.finalize_directory_persistent(out_path); - } else { - stream.finalize(out_path, compression); - } - if (out_path_override.empty() && !finalize_to_directory) { - register_cleanup(out_path); - } - return {out_path, streamlines, total_vertices, 0.0, 1}; -} - -void build_trx_shard(const std::string &out_path, - size_t streamlines, - GroupScenario scenario, - bool add_dps, - bool add_dpv, - LengthProfile profile, - zip_uint32_t compression) { - (void)build_trx_file_on_disk_single(streamlines, - scenario, - add_dps, - add_dpv, - profile, - compression, - out_path, - true); - - // Defensive validation: ensure all required files were written by finalize_directory_persistent - std::error_code ec; - const auto header_path = trx::fs::path(out_path) / "header.json"; - if (!trx::fs::exists(header_path, ec)) { - throw std::runtime_error("Shard missing header.json after finalize_directory_persistent: " + header_path.string()); - } - const auto positions_path = find_file_by_prefix(out_path, "positions."); - if (positions_path.empty()) { - throw std::runtime_error("Shard missing positions after finalize_directory_persistent: " + out_path); - } - const auto offsets_path = find_file_by_prefix(out_path, "offsets."); - if (offsets_path.empty()) { - throw std::runtime_error("Shard missing offsets after finalize_directory_persistent: " + out_path); - } - const auto ok_path = trx::fs::path(out_path) / "SHARD_OK"; - std::ofstream ok(ok_path, std::ios::out | std::ios::trunc); - if (ok.is_open()) { - ok << "ok\n"; - ok.flush(); - ok.close(); - } - - // Force filesystem sync to ensure all shard data is visible to parent process -#if defined(__unix__) || defined(__APPLE__) - sync(); - // Brief sleep to ensure filesystem metadata updates are visible across processes - std::this_thread::sleep_for(std::chrono::milliseconds(50)); -#endif -} - -TrxOnDisk build_trx_file_on_disk(size_t streamlines, - GroupScenario scenario, - bool add_dps, - bool add_dpv, - LengthProfile profile, - zip_uint32_t compression) { - size_t processes = parse_env_size("TRX_BENCH_PROCESSES", 1); - const size_t mp_min_streamlines = parse_env_size("TRX_BENCH_MP_MIN_STREAMLINES", 1000000); - if (streamlines < mp_min_streamlines) { - processes = 1; - } - if (processes <= 1) { - return build_trx_file_on_disk_single(streamlines, scenario, add_dps, add_dpv, profile, compression); - } -#if defined(__unix__) || defined(__APPLE__) - g_cleanup_owner_pid = getpid(); - const std::string shard_root = make_work_dir_name("trx_shards"); - { - std::error_code ec; - trx::fs::create_directories(shard_root, ec); - if (ec) { - throw std::runtime_error("Failed to create shard root: " + shard_root); - } - } - { - const std::string marker = shard_root + trx::SEPARATOR + "SHARD_ROOT_CREATED"; - std::ofstream out(marker, std::ios::out | std::ios::trunc); - if (out.is_open()) { - out << "ok\n"; - out.flush(); - out.close(); - } - } - if (parse_env_bool("TRX_BENCH_LOG", false)) { - std::cerr << "[trx-bench] shard_root " << shard_root << std::endl; - } - std::vector counts(processes, streamlines / processes); - const size_t remainder = streamlines % processes; - for (size_t i = 0; i < remainder; ++i) { - counts[i] += 1; - } - - std::vector shard_paths(processes); - std::vector status_paths(processes); - for (size_t i = 0; i < processes; ++i) { - shard_paths[i] = shard_root + trx::SEPARATOR + "shard_" + std::to_string(i); - status_paths[i] = shard_root + trx::SEPARATOR + "shard_" + std::to_string(i) + ".status"; - - // Pre-create shard directories to validate filesystem writability before forking. - // finalize_directory_persistent() will use these existing directories without - // removing them, avoiding race conditions in the multiprocess workflow. - std::error_code ec; - trx::fs::create_directories(shard_paths[i], ec); - if (ec) { - throw std::runtime_error("Failed to create shard dir: " + shard_paths[i] + " " + ec.message()); - } - std::ofstream status(status_paths[i], std::ios::out | std::ios::trunc); - if (status.is_open()) { - status << "pending\n"; - } - } - if (parse_env_bool("TRX_BENCH_LOG", false)) { - for (size_t i = 0; i < processes; ++i) { - std::cerr << "[trx-bench] shard_path[" << i << "] " << shard_paths[i] << std::endl; - } - } - - std::vector pids; - pids.reserve(processes); - for (size_t i = 0; i < processes; ++i) { - const pid_t pid = fork(); - if (pid == 0) { - try { - setenv("TRX_BENCH_THREADS", "1", 1); - setenv("TRX_BENCH_BATCH", "1000", 1); - setenv("TRX_BENCH_LOG", "0", 1); - setenv("TRX_BENCH_SHARD_INDEX", std::to_string(i).c_str(), 1); - if (parse_env_bool("TRX_BENCH_LOG", false)) { - std::cerr << "[trx-bench] shard_child_start path=" << shard_paths[i] << std::endl; - } - { - std::ofstream status(status_paths[i], std::ios::out | std::ios::trunc); - if (status.is_open()) { - status << "started pid=" << getpid() << "\n"; - status.flush(); - } - } - build_trx_shard(shard_paths[i], counts[i], scenario, add_dps, add_dpv, profile, compression); - { - std::ofstream status(status_paths[i], std::ios::out | std::ios::trunc); - if (status.is_open()) { - status << "ok\n"; - status.flush(); - } - } - _exit(0); - } catch (const std::exception &ex) { - std::ofstream out(status_paths[i], std::ios::out | std::ios::trunc); - if (out.is_open()) { - out << ex.what() << "\n"; - out.flush(); - out.close(); - } - _exit(1); - } catch (...) { - std::ofstream out(status_paths[i], std::ios::out | std::ios::trunc); - if (out.is_open()) { - out << "Unknown error\n"; - out.flush(); - out.close(); - } - _exit(1); - } - } - if (pid < 0) { - throw std::runtime_error("Failed to fork shard process"); - } - pids.push_back(pid); - } - - for (size_t i = 0; i < pids.size(); ++i) { - const auto pid = pids[i]; - int status = 0; - waitpid(pid, &status, 0); - if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { - std::string detail; - std::ifstream in(status_paths[i]); - if (in.is_open()) { - std::getline(in, detail); - } - if (detail.empty()) { - detail = "No status file content"; - } - throw std::runtime_error("Shard process failed: " + detail); - } - } - - const size_t shard_wait_ms = parse_env_size("TRX_BENCH_SHARD_WAIT_MS", 10000); - wait_for_shard_ok(shard_paths, status_paths, shard_wait_ms); - - size_t total_vertices = 0; - size_t total_streamlines = 0; - std::vector shard_vertices(processes, 0); - std::vector shard_streamlines(processes, 0); - for (size_t i = 0; i < processes; ++i) { - const auto ok_path = trx::fs::path(shard_paths[i]) / "SHARD_OK"; - std::error_code ok_ec; - if (!trx::fs::exists(ok_path, ok_ec)) { - std::string detail; - std::ifstream in(status_paths[i]); - if (in.is_open()) { - std::getline(in, detail); - } - if (detail.empty()) { - detail = "SHARD_OK missing for " + shard_paths[i]; - } - throw std::runtime_error("Shard process failed: " + detail); - } - std::error_code ec; - if (!trx::fs::exists(shard_paths[i], ec) || !trx::fs::is_directory(shard_paths[i], ec)) { - const auto root_files = list_files(shard_root); - std::string detail = "Shard output directory missing: " + shard_paths[i]; - if (!root_files.empty()) { - detail += " root_files=["; - for (size_t j = 0; j < root_files.size(); ++j) { - if (j > 0) { - detail += ","; - } - detail += root_files[j]; - } - detail += "]"; - } - throw std::runtime_error(detail); - } - const auto header_path = trx::fs::path(shard_paths[i]) / "header.json"; - if (!trx::fs::exists(header_path, ec)) { - const auto files = list_files(shard_paths[i]); - std::string detail = "Shard missing header.json: " + header_path.string(); - if (!files.empty()) { - detail += " files=["; - for (size_t j = 0; j < files.size(); ++j) { - if (j > 0) { - detail += ","; - } - detail += files[j]; - } - detail += "]"; - } - const auto root_files = list_files(shard_root); - if (!root_files.empty()) { - detail += " root_files=["; - for (size_t j = 0; j < root_files.size(); ++j) { - if (j > 0) { - detail += ","; - } - detail += root_files[j]; - } - detail += "]"; - } - throw std::runtime_error(detail); - } - const auto counts = read_header_counts(shard_paths[i]); - shard_streamlines[i] = counts.first; - shard_vertices[i] = counts.second; - total_streamlines += counts.first; - total_vertices += counts.second; - } - - const auto merge_start = std::chrono::steady_clock::now(); - const std::string out_path = make_temp_path("trx_input"); - - trx::MergeTrxShardsOptions merge_opts; - merge_opts.shard_directories = shard_paths; - merge_opts.output_path = out_path; - merge_opts.compression_standard = compression; - merge_opts.output_directory = false; - merge_opts.overwrite_existing = true; - trx::merge_trx_shards(merge_opts); - - register_cleanup(out_path); - const auto merge_end = std::chrono::steady_clock::now(); - const std::chrono::duration merge_elapsed = merge_end - merge_start; - - // Final cleanup of shard directories after merge is complete - if (!parse_env_bool("TRX_BENCH_KEEP_SHARDS", false)) { - std::error_code ec; - trx::fs::remove_all(shard_root, ec); - } - return {out_path, streamlines, total_vertices, merge_elapsed.count(), processes}; -#else - (void)processes; - return build_trx_file_on_disk_single(streamlines, scenario, add_dps, add_dpv, profile, compression); -#endif -} - -struct QueryDataset { - std::unique_ptr> trx; - std::vector> slab_mins; - std::vector> slab_maxs; -}; - -void build_slabs(std::vector> &mins, std::vector> &maxs) { - mins.clear(); - maxs.clear(); - mins.reserve(kSlabCount); - maxs.reserve(kSlabCount); - const float z_range = kFov.max_z - kFov.min_z; - for (size_t i = 0; i < kSlabCount; ++i) { - const float t = (kSlabCount == 1) ? 0.5f : static_cast(i) / static_cast(kSlabCount - 1); - const float center_z = kFov.min_z + t * z_range; - const float min_z = std::max(kFov.min_z, center_z - kSlabThicknessMm * 0.5f); - const float max_z = std::min(kFov.max_z, center_z + kSlabThicknessMm * 0.5f); - mins.push_back({kFov.min_x, kFov.min_y, min_z}); - maxs.push_back({kFov.max_x, kFov.max_y, max_z}); - } -} - -struct ScenarioParams { - size_t streamlines = 0; - GroupScenario scenario = GroupScenario::None; - bool add_dps = false; - bool add_dpv = false; - LengthProfile profile = LengthProfile::Mixed; -}; - -struct KeyHash { - using Key = std::tuple; - size_t operator()(const Key &key) const { - size_t h = 0; - auto hash_combine = [&](size_t v) { - h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); - }; - hash_combine(std::hash{}(std::get<0>(key))); - hash_combine(std::hash{}(std::get<1>(key))); - hash_combine(std::hash{}(std::get<2>(key))); - hash_combine(std::hash{}(std::get<3>(key))); - return h; - } -}; - -void maybe_write_query_timings(const ScenarioParams &scenario, const std::vector &timings_ms) { - static std::mutex mutex; - static std::unordered_set seen; - const KeyHash::Key key{scenario.streamlines, - static_cast(scenario.scenario), - scenario.add_dps ? 1 : 0, - scenario.add_dpv ? 1 : 0}; - - std::lock_guard lock(mutex); - if (!seen.insert(key).second) { - return; - } - - const char *env_path = std::getenv("TRX_QUERY_TIMINGS_PATH"); - const std::filesystem::path out_path = env_path ? env_path : "bench/query_timings.jsonl"; - std::error_code ec; - if (!out_path.parent_path().empty()) { - std::filesystem::create_directories(out_path.parent_path(), ec); - } - std::ofstream out(out_path, std::ios::app); - if (!out.is_open()) { - return; - } - - out << "{" - << "\"streamlines\":" << scenario.streamlines << "," - << "\"group_case\":" << static_cast(scenario.scenario) << "," - << "\"group_count\":" << group_count_for(scenario.scenario) << "," - << "\"dps\":" << (scenario.add_dps ? 1 : 0) << "," - << "\"dpv\":" << (scenario.add_dpv ? 1 : 0) << "," - << "\"slab_thickness_mm\":" << kSlabThicknessMm << "," - << "\"timings_ms\":["; - for (size_t i = 0; i < timings_ms.size(); ++i) { - if (i > 0) { - out << ","; - } - out << timings_ms[i]; - } - out << "]}\n"; -} -} // namespace - -static void BM_TrxFileSize_Float16(benchmark::State &state) { - const size_t streamlines = static_cast(state.range(0)); - const auto profile = static_cast(state.range(1)); - const bool add_dps = state.range(2) != 0; - const bool add_dpv = state.range(3) != 0; - const bool use_zip = state.range(4) != 0; - const auto compression = use_zip ? ZIP_CM_DEFLATE : ZIP_CM_STORE; - const size_t skip_zip_at = parse_env_size("TRX_BENCH_SKIP_ZIP_AT", 5000000); - if (use_zip && streamlines >= skip_zip_at) { - state.SkipWithMessage("zip compression skipped for large streamlines"); - return; - } - log_bench_start("BM_TrxFileSize_Float16", - "streamlines=" + std::to_string(streamlines) + " profile=" + std::to_string(state.range(1)) + - " dps=" + std::to_string(static_cast(add_dps)) + - " dpv=" + std::to_string(static_cast(add_dpv)) + - " compression=" + std::to_string(static_cast(use_zip))); - - double total_write_ms = 0.0; - double total_file_bytes = 0.0; - double total_merge_ms = 0.0; - double total_build_ms = 0.0; - double total_merge_processes = 0.0; - for (auto _ : state) { - const auto start = std::chrono::steady_clock::now(); - const auto on_disk = - build_trx_file_on_disk(streamlines, GroupScenario::None, add_dps, add_dpv, profile, compression); - const auto end = std::chrono::steady_clock::now(); - const std::chrono::duration elapsed = end - start; - total_build_ms += elapsed.count(); - total_merge_ms += on_disk.shard_merge_ms; - total_merge_processes += static_cast(on_disk.shard_processes); - total_write_ms += elapsed.count(); - total_file_bytes += static_cast(file_size_bytes(on_disk.path)); - } - - state.counters["streamlines"] = static_cast(streamlines); - state.counters["length_profile"] = static_cast(state.range(1)); - state.counters["dps"] = add_dps ? 1.0 : 0.0; - state.counters["dpv"] = add_dpv ? 1.0 : 0.0; - state.counters["compression"] = use_zip ? 1.0 : 0.0; - state.counters["positions_dtype"] = 16.0; - state.counters["write_ms"] = total_write_ms / static_cast(state.iterations()); - state.counters["build_ms"] = total_build_ms / static_cast(state.iterations()); - if (total_merge_ms > 0.0) { - state.counters["shard_merge_ms"] = total_merge_ms / static_cast(state.iterations()); - state.counters["shard_processes"] = total_merge_processes / static_cast(state.iterations()); - } - state.counters["file_bytes"] = total_file_bytes / static_cast(state.iterations()); - state.counters["max_rss_kb"] = get_max_rss_kb(); - - log_bench_end("BM_TrxFileSize_Float16", - "streamlines=" + std::to_string(streamlines) + " profile=" + std::to_string(state.range(1))); -} - -static void BM_TrxStream_TranslateWrite(benchmark::State &state) { - const size_t streamlines = static_cast(state.range(0)); - const auto scenario = static_cast(state.range(1)); - const bool add_dps = state.range(2) != 0; - const bool add_dpv = state.range(3) != 0; - const size_t progress_every = parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 0); - log_bench_config("translate_write", bench_threads(), std::max(1, bench_batch_size())); - log_bench_start("BM_TrxStream_TranslateWrite", - "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1)) + - " dps=" + std::to_string(static_cast(add_dps)) + - " dpv=" + std::to_string(static_cast(add_dpv))); - - using Key = KeyHash::Key; - static std::unordered_map cache; - - const Key key{streamlines, static_cast(scenario), add_dps ? 1 : 0, add_dpv ? 1 : 0}; - if (cache.find(key) == cache.end()) { - state.PauseTiming(); - cache.emplace(key, - build_trx_file_on_disk(streamlines, scenario, add_dps, add_dpv, LengthProfile::Mixed, ZIP_CM_STORE)); - state.ResumeTiming(); - } - - const auto &dataset = cache.at(key); - if (dataset.shard_processes > 1 && dataset.shard_merge_ms > 0.0) { - state.counters["shard_merge_ms"] = dataset.shard_merge_ms; - state.counters["shard_processes"] = static_cast(dataset.shard_processes); - } - for (auto _ : state) { - const auto start = std::chrono::steady_clock::now(); - auto trx = trx::load_any(dataset.path); - const size_t chunk_bytes = parse_env_size("TRX_BENCH_CHUNK_BYTES", 1024ULL * 1024ULL * 1024ULL); - const std::string out_dir = make_work_dir_name("trx_translate_chunk"); - trx::PrepareOutputOptions prep_opts; - prep_opts.overwrite_existing = true; - const auto out_info = trx::prepare_positions_output(trx, out_dir, prep_opts); - - std::ofstream out_positions(out_info.positions_path, std::ios::binary | std::ios::out | std::ios::trunc); - if (!out_positions.is_open()) { - throw std::runtime_error("Failed to open output positions file: " + out_info.positions_path); - } - - trx.for_each_positions_chunk(chunk_bytes, - [&](trx::TrxScalarType dtype, const void *data, size_t offset, size_t count) { - (void)offset; - if (progress_every > 0 && ((offset + count) % progress_every == 0)) { - std::cerr << "[trx-bench] progress translate points=" << (offset + count) - << " / " << out_info.points << std::endl; - } - const size_t total_vals = count * 3; - if (dtype == trx::TrxScalarType::Float16) { - const auto *src = reinterpret_cast(data); - std::vector tmp(total_vals); - for (size_t i = 0; i < total_vals; ++i) { - tmp[i] = static_cast(static_cast(src[i]) + 1.0f); - } - out_positions.write(reinterpret_cast(tmp.data()), - static_cast(tmp.size() * sizeof(Eigen::half))); - } else if (dtype == trx::TrxScalarType::Float32) { - const auto *src = reinterpret_cast(data); - std::vector tmp(total_vals); - for (size_t i = 0; i < total_vals; ++i) { - tmp[i] = src[i] + 1.0f; - } - out_positions.write(reinterpret_cast(tmp.data()), - static_cast(tmp.size() * sizeof(float))); - } else { - const auto *src = reinterpret_cast(data); - std::vector tmp(total_vals); - for (size_t i = 0; i < total_vals; ++i) { - tmp[i] = src[i] + 1.0; - } - out_positions.write(reinterpret_cast(tmp.data()), - static_cast(tmp.size() * sizeof(double))); - } - }); - out_positions.flush(); - out_positions.close(); - - const std::string out_path = make_temp_path("trx_translate"); - trx::TrxSaveOptions save_opts; - save_opts.mode = trx::TrxSaveMode::Archive; - save_opts.compression_standard = ZIP_CM_STORE; - save_opts.overwrite_existing = true; - trx.save(out_path, save_opts); - trx::rm_dir(out_dir); - const auto end = std::chrono::steady_clock::now(); - const std::chrono::duration elapsed = end - start; - state.SetIterationTime(elapsed.count()); - - std::error_code ec; - std::filesystem::remove(out_path, ec); - benchmark::DoNotOptimize(trx); - } - - state.counters["streamlines"] = static_cast(streamlines); - state.counters["group_case"] = static_cast(state.range(1)); - state.counters["group_count"] = static_cast(group_count_for(scenario)); - state.counters["dps"] = add_dps ? 1.0 : 0.0; - state.counters["dpv"] = add_dpv ? 1.0 : 0.0; - state.counters["length_profile"] = static_cast(static_cast(LengthProfile::Mixed)); - state.counters["positions_dtype"] = 16.0; - state.counters["max_rss_kb"] = get_max_rss_kb(); - - log_bench_end("BM_TrxStream_TranslateWrite", - "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1))); -} - -static void BM_TrxQueryAabb_Slabs(benchmark::State &state) { - const size_t streamlines = static_cast(state.range(0)); - const auto scenario = static_cast(state.range(1)); - const bool add_dps = state.range(2) != 0; - const bool add_dpv = state.range(3) != 0; - log_bench_start("BM_TrxQueryAabb_Slabs", - "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1)) + - " dps=" + std::to_string(static_cast(add_dps)) + - " dpv=" + std::to_string(static_cast(add_dpv))); - - using Key = KeyHash::Key; - static std::unordered_map cache; - - const Key key{streamlines, static_cast(scenario), add_dps ? 1 : 0, add_dpv ? 1 : 0}; - if (cache.find(key) == cache.end()) { - state.PauseTiming(); - QueryDataset dataset; - auto on_disk = build_trx_file_on_disk(streamlines, scenario, add_dps, add_dpv, LengthProfile::Mixed, ZIP_CM_STORE); - dataset.trx = trx::load(on_disk.path); - dataset.trx->get_or_build_streamline_aabbs(); - build_slabs(dataset.slab_mins, dataset.slab_maxs); - cache.emplace(key, std::move(dataset)); - state.ResumeTiming(); - } - - auto &dataset = cache.at(key); - for (auto _ : state) { - std::vector slab_times_ms; - slab_times_ms.reserve(kSlabCount); - - const auto start = std::chrono::steady_clock::now(); - size_t total = 0; - for (size_t i = 0; i < kSlabCount; ++i) { - const auto &min_corner = dataset.slab_mins[i]; - const auto &max_corner = dataset.slab_maxs[i]; - const auto q_start = std::chrono::steady_clock::now(); - auto subset = dataset.trx->query_aabb(min_corner, max_corner); - const auto q_end = std::chrono::steady_clock::now(); - const std::chrono::duration q_elapsed = q_end - q_start; - slab_times_ms.push_back(q_elapsed.count()); - total += subset->num_streamlines(); - subset->close(); - } - const auto end = std::chrono::steady_clock::now(); - const std::chrono::duration elapsed = end - start; - state.SetIterationTime(elapsed.count()); - benchmark::DoNotOptimize(total); - - auto sorted = slab_times_ms; - std::sort(sorted.begin(), sorted.end()); - const auto p50 = sorted[sorted.size() / 2]; - const auto p95_idx = static_cast(std::ceil(0.95 * sorted.size())) - 1; - const auto p95 = sorted[std::min(p95_idx, sorted.size() - 1)]; - state.counters["query_p50_ms"] = p50; - state.counters["query_p95_ms"] = p95; - - ScenarioParams params; - params.streamlines = streamlines; - params.scenario = scenario; - params.add_dps = add_dps; - params.add_dpv = add_dpv; - params.profile = LengthProfile::Mixed; - maybe_write_query_timings(params, slab_times_ms); - } - - state.counters["streamlines"] = static_cast(streamlines); - state.counters["group_case"] = static_cast(state.range(1)); - state.counters["group_count"] = static_cast(group_count_for(scenario)); - state.counters["dps"] = add_dps ? 1.0 : 0.0; - state.counters["dpv"] = add_dpv ? 1.0 : 0.0; - state.counters["query_count"] = static_cast(kSlabCount); - state.counters["slab_thickness_mm"] = kSlabThicknessMm; - state.counters["positions_dtype"] = 16.0; - state.counters["max_rss_kb"] = get_max_rss_kb(); - - log_bench_end("BM_TrxQueryAabb_Slabs", - "streamlines=" + std::to_string(streamlines) + " group_case=" + std::to_string(state.range(1))); -} - -static void ApplySizeArgs(benchmark::internal::Benchmark *bench) { - const std::array profiles = {static_cast(LengthProfile::Short), - static_cast(LengthProfile::Medium), - static_cast(LengthProfile::Long)}; - const std::array flags = {0, 1}; - const bool core_profile = is_core_profile(); - const size_t dpv_limit = core_dpv_max_streamlines(); - const size_t zip_limit = core_zip_max_streamlines(); - const auto counts_desc = streamlines_for_benchmarks(); - for (const auto count : counts_desc) { - const std::vector dpv_flags = (!core_profile || count <= dpv_limit) - ? std::vector{0, 1} - : std::vector{0}; - const std::vector compression_flags = (!core_profile || count <= zip_limit) - ? std::vector{0, 1} - : std::vector{0}; - for (const auto profile : profiles) { - for (const auto dps : flags) { - for (const auto dpv : dpv_flags) { - for (const auto compression : compression_flags) { - bench->Args({static_cast(count), profile, dps, dpv, compression}); - } - } - } - } - } -} - -static void ApplyStreamArgs(benchmark::internal::Benchmark *bench) { - const std::array flags = {0, 1}; - const bool core_profile = is_core_profile(); - const size_t dpv_limit = core_dpv_max_streamlines(); - const auto groups = group_cases_for_benchmarks(); - const auto counts_desc = streamlines_for_benchmarks(); - for (const auto count : counts_desc) { - const std::vector dpv_flags = (!core_profile || count <= dpv_limit) - ? std::vector{0, 1} - : std::vector{0}; - for (const auto group_case : groups) { - for (const auto dps : flags) { - for (const auto dpv : dpv_flags) { - bench->Args({static_cast(count), group_case, dps, dpv}); - } - } - } - } -} - -static void ApplyQueryArgs(benchmark::internal::Benchmark *bench) { - const std::array flags = {0, 1}; - const bool core_profile = is_core_profile(); - const size_t dpv_limit = core_dpv_max_streamlines(); - const auto groups = group_cases_for_benchmarks(); - const auto counts_desc = streamlines_for_benchmarks(); - for (const auto count : counts_desc) { - const std::vector dpv_flags = (!core_profile || count <= dpv_limit) - ? std::vector{0, 1} - : std::vector{0}; - for (const auto group_case : groups) { - for (const auto dps : flags) { - for (const auto dpv : dpv_flags) { - bench->Args({static_cast(count), group_case, dps, dpv}); - } - } - } - } - bench->Iterations(1); -} - -BENCHMARK(BM_TrxFileSize_Float16) - ->Apply(ApplySizeArgs) - ->Unit(benchmark::kMillisecond); - -BENCHMARK(BM_TrxStream_TranslateWrite) - ->Apply(ApplyStreamArgs) - ->UseManualTime() - ->Unit(benchmark::kMillisecond); - -BENCHMARK(BM_TrxQueryAabb_Slabs) - ->Apply(ApplyQueryArgs) - ->UseManualTime() - ->Unit(benchmark::kMillisecond); - -int main(int argc, char **argv) { - // Parse custom flags before benchmark::Initialize - bool verbose = false; - bool show_help = false; - - // First pass: detect custom flags - for (int i = 1; i < argc; ++i) { - const std::string arg = argv[i]; - if (arg == "--verbose" || arg == "-v") { - verbose = true; - } else if (arg == "--help-custom") { - show_help = true; - } - } - - if (show_help) { - std::cout << "\nCustom benchmark options:\n" - << " --verbose, -v Enable verbose progress logging (prints every 50k streamlines)\n" - << " Equivalent to: TRX_BENCH_LOG=1 TRX_BENCH_CHILD_LOG=1 \n" - << " TRX_BENCH_LOG_PROGRESS_EVERY=50000\n" - << " --help-custom Show this help message\n" - << "\nFor standard benchmark options, use --help\n" - << std::endl; - return 0; - } - - // Enable verbose logging if requested - if (verbose) { - setenv("TRX_BENCH_LOG", "1", 0); // Don't override if already set - setenv("TRX_BENCH_CHILD_LOG", "1", 0); - if (std::getenv("TRX_BENCH_LOG_PROGRESS_EVERY") == nullptr) { - setenv("TRX_BENCH_LOG_PROGRESS_EVERY", "50000", 1); - } - std::cerr << "[trx-bench] Verbose mode enabled (progress every " - << parse_env_size("TRX_BENCH_LOG_PROGRESS_EVERY", 50000) - << " streamlines)\n" << std::endl; - } - - // Second pass: remove custom flags from argv before passing to benchmark::Initialize - std::vector filtered_argv; - filtered_argv.push_back(argv[0]); // Keep program name - for (int i = 1; i < argc; ++i) { - const std::string arg = argv[i]; - if (arg != "--verbose" && arg != "-v" && arg != "--help-custom") { - filtered_argv.push_back(argv[i]); - } - } - int filtered_argc = static_cast(filtered_argv.size()); - - ::benchmark::Initialize(&filtered_argc, filtered_argv.data()); - if (::benchmark::ReportUnrecognizedArguments(filtered_argc, filtered_argv.data())) { - return 1; - } - try { - ::benchmark::RunSpecifiedBenchmarks(); - g_run_success = true; - } catch (const std::exception &ex) { - std::cerr << "Benchmark failed: " << ex.what() << std::endl; - return 1; - } catch (...) { - std::cerr << "Benchmark failed with unknown exception." << std::endl; - return 1; - } - return 0; -} diff --git a/src/trx.cpp b/src/trx.cpp index 15e06f6..67f0a58 100644 --- a/src/trx.cpp +++ b/src/trx.cpp @@ -1320,7 +1320,7 @@ void merge_trx_shards(const MergeTrxShardsOptions &options) { if (!out.is_open()) { throw std::runtime_error("Failed to open destination for append: " + dst); } - std::array buffer{}; + std::vector buffer(1 << 20); while (in) { in.read(buffer.data(), static_cast(buffer.size())); const auto n = in.gcount(); From 01b1827298a0efb0010b731df1a1e06728dd0af4 Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Sat, 21 Feb 2026 18:07:47 -0500 Subject: [PATCH 10/11] improve coverage --- tests/test_trx_anytrxfile.cpp | 158 ++++++++++++++++++++++++++++++++++ tests/test_trx_trxfile.cpp | 42 +++++++++ 2 files changed, 200 insertions(+) diff --git a/tests/test_trx_anytrxfile.cpp b/tests/test_trx_anytrxfile.cpp index 5ff92a2..9803d53 100644 --- a/tests/test_trx_anytrxfile.cpp +++ b/tests/test_trx_anytrxfile.cpp @@ -233,6 +233,67 @@ void expect_basic_consistency(const AnyTrxFile &trx) { EXPECT_GT(bytes.size, 0U); } +fs::path write_test_shard_with_dtype(const fs::path &root, + const std::string &name, + const std::vector> &points, + const std::vector &offsets, + const std::string &positions_dtype) { + const fs::path shard_dir = root / name; + std::error_code ec; + fs::create_directories(shard_dir, ec); + + json::object header_obj; + header_obj["DIMENSIONS"] = json::array{1, 1, 1}; + header_obj["NB_STREAMLINES"] = static_cast(offsets.size() - 1); + header_obj["NB_VERTICES"] = static_cast(points.size()); + header_obj["VOXEL_TO_RASMM"] = json::array{ + json::array{1.0, 0.0, 0.0, 0.0}, + json::array{0.0, 1.0, 0.0, 0.0}, + json::array{0.0, 0.0, 1.0, 0.0}, + json::array{0.0, 0.0, 0.0, 1.0}, + }; + write_header_file(shard_dir, json(header_obj)); + + const std::string pos_file = (shard_dir / ("positions.3." + positions_dtype)).string(); + if (positions_dtype == "float16") { + Eigen::Matrix m( + static_cast(points.size()), 3); + for (size_t i = 0; i < points.size(); ++i) { + m(static_cast(i), 0) = static_cast(points[i][0]); + m(static_cast(i), 1) = static_cast(points[i][1]); + m(static_cast(i), 2) = static_cast(points[i][2]); + } + trx::write_binary(pos_file, m); + } else if (positions_dtype == "float64") { + Eigen::Matrix m( + static_cast(points.size()), 3); + for (size_t i = 0; i < points.size(); ++i) { + m(static_cast(i), 0) = static_cast(points[i][0]); + m(static_cast(i), 1) = static_cast(points[i][1]); + m(static_cast(i), 2) = static_cast(points[i][2]); + } + trx::write_binary(pos_file, m); + } else { + Eigen::Matrix m( + static_cast(points.size()), 3); + for (size_t i = 0; i < points.size(); ++i) { + m(static_cast(i), 0) = points[i][0]; + m(static_cast(i), 1) = points[i][1]; + m(static_cast(i), 2) = points[i][2]; + } + trx::write_binary(pos_file, m); + } + + Eigen::Matrix offsets_mat( + static_cast(offsets.size()), 1); + for (size_t i = 0; i < offsets.size(); ++i) { + offsets_mat(static_cast(i), 0) = offsets[i]; + } + trx::write_binary((shard_dir / "offsets.uint64").string(), offsets_mat); + + return shard_dir; +} + fs::path write_test_shard(const fs::path &root, const std::string &name, const std::vector> &points, @@ -1002,3 +1063,100 @@ TEST(AnyTrxFile, SaveRespectsExplicitMode) { std::error_code ec; fs::remove_all(temp_root, ec); } + +TEST(AnyTrxFile, GetDpsAndDpvReturnCorrectArrays) { + const fs::path temp_root = make_temp_test_dir("trx_get_dps_dpv"); + const fs::path shard = write_test_shard(temp_root, + "s1", + {{0.0F, 0.0F, 0.0F}, {1.0F, 0.0F, 0.0F}}, + {0, 2}, + {42.0F}, + {0.1F, 0.2F}, + {0}); + + auto trx = load_any(shard.string()); + + const TypedArray *dps = trx.get_dps("weight"); + ASSERT_NE(dps, nullptr); + EXPECT_EQ(dps->rows, 1); + EXPECT_EQ(dps->cols, 1); + + const TypedArray *dps_missing = trx.get_dps("no_such_field"); + EXPECT_EQ(dps_missing, nullptr); + + const TypedArray *dpv = trx.get_dpv("signal"); + ASSERT_NE(dpv, nullptr); + EXPECT_EQ(dpv->rows, 2); + + const TypedArray *dpv_missing = trx.get_dpv("no_such_field"); + EXPECT_EQ(dpv_missing, nullptr); + + trx.close(); + std::error_code ec; + fs::remove_all(temp_root, ec); +} + +TEST(AnyTrxFile, GetStreamlineFloat32) { + const fs::path temp_root = make_temp_test_dir("trx_get_sl_f32"); + const fs::path shard = write_test_shard_with_dtype(temp_root, + "s1", + {{1.0F, 2.0F, 3.0F}, {4.0F, 5.0F, 6.0F}}, + {0, 2}, + "float32"); + + auto trx = load_any(shard.string()); + ASSERT_EQ(trx.num_streamlines(), 1u); + + auto sl = trx.get_streamline(0); + ASSERT_EQ(sl.size(), 2u); + EXPECT_NEAR(sl[0][0], 1.0, 1e-5); + EXPECT_NEAR(sl[1][2], 6.0, 1e-5); + + EXPECT_THROW(trx.get_streamline(1), std::out_of_range); + + trx.close(); + std::error_code ec; + fs::remove_all(temp_root, ec); +} + +TEST(AnyTrxFile, GetStreamlineFloat16) { + const fs::path temp_root = make_temp_test_dir("trx_get_sl_f16"); + const fs::path shard = write_test_shard_with_dtype(temp_root, + "s1", + {{1.0F, 2.0F, 3.0F}, {4.0F, 5.0F, 6.0F}}, + {0, 2}, + "float16"); + + auto trx = load_any(shard.string()); + ASSERT_EQ(trx.num_streamlines(), 1u); + + auto sl = trx.get_streamline(0); + ASSERT_EQ(sl.size(), 2u); + EXPECT_NEAR(sl[0][0], 1.0, 1e-2); + EXPECT_NEAR(sl[1][2], 6.0, 1e-2); + + trx.close(); + std::error_code ec; + fs::remove_all(temp_root, ec); +} + +TEST(AnyTrxFile, GetStreamlineFloat64) { + const fs::path temp_root = make_temp_test_dir("trx_get_sl_f64"); + const fs::path shard = write_test_shard_with_dtype(temp_root, + "s1", + {{1.0F, 2.0F, 3.0F}, {4.0F, 5.0F, 6.0F}}, + {0, 2}, + "float64"); + + auto trx = load_any(shard.string()); + ASSERT_EQ(trx.num_streamlines(), 1u); + + auto sl = trx.get_streamline(0); + ASSERT_EQ(sl.size(), 2u); + EXPECT_NEAR(sl[0][0], 1.0, 1e-9); + EXPECT_NEAR(sl[1][2], 6.0, 1e-9); + + trx.close(); + std::error_code ec; + fs::remove_all(temp_root, ec); +} diff --git a/tests/test_trx_trxfile.cpp b/tests/test_trx_trxfile.cpp index 6f86583..01af8c7 100644 --- a/tests/test_trx_trxfile.cpp +++ b/tests/test_trx_trxfile.cpp @@ -292,6 +292,48 @@ TEST(TrxFileTpp, TrxStreamFinalize) { fs::remove_all(tmp_dir, ec); } +TEST(TrxFileTpp, TrxStreamOnDiskMetadataAllDtypes) { + auto tmp_dir = make_temp_test_dir("trx_ondisk_dtypes"); + const fs::path out_path = tmp_dir / "ondisk.trx"; + + TrxStream proto; + proto.set_metadata_mode(TrxStream::MetadataMode::OnDisk); + + std::vector sl1 = {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f}; + std::vector sl2 = {2.0f, 0.0f, 0.0f}; + proto.push_streamline(sl1); + proto.push_streamline(sl2); + + proto.push_dps_from_vector("w_f16", "float16", std::vector{0.5f, 1.5f}); + proto.push_dps_from_vector("w_f32", "float32", std::vector{0.5f, 1.5f}); + proto.push_dps_from_vector("w_f64", "float64", std::vector{0.5, 1.5}); + + proto.push_dpv_from_vector("s_f16", "float16", std::vector{1.0f, 2.0f, 3.0f}); + proto.push_dpv_from_vector("s_f32", "float32", std::vector{1.0f, 2.0f, 3.0f}); + proto.push_dpv_from_vector("s_f64", "float64", std::vector{1.0, 2.0, 3.0}); + + proto.finalize(out_path.string(), ZIP_CM_STORE); + + auto trx = load_any(out_path.string()); + EXPECT_EQ(trx.num_streamlines(), 2u); + EXPECT_EQ(trx.num_vertices(), 3u); + + for (const auto &key : {"w_f16", "w_f32", "w_f64"}) { + auto it = trx.data_per_streamline.find(key); + ASSERT_NE(it, trx.data_per_streamline.end()) << "missing dps key: " << key; + EXPECT_EQ(it->second.rows, 2); + } + for (const auto &key : {"s_f16", "s_f32", "s_f64"}) { + auto it = trx.data_per_vertex.find(key); + ASSERT_NE(it, trx.data_per_vertex.end()) << "missing dpv key: " << key; + EXPECT_EQ(it->second.rows, 3); + } + + trx.close(); + std::error_code ec; + fs::remove_all(tmp_dir, ec); +} + TEST(TrxFileTpp, QueryAabbCounts) { constexpr int kStreamlineCount = 1000; constexpr int kInsideCount = 250; From bdd663299160a32b26106d7b7b96aee97044551b Mon Sep 17 00:00:00 2001 From: mattcieslak Date: Sat, 21 Feb 2026 20:50:17 -0500 Subject: [PATCH 11/11] more coverage --- tests/test_trx_trxfile.cpp | 64 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/test_trx_trxfile.cpp b/tests/test_trx_trxfile.cpp index 01af8c7..2336535 100644 --- a/tests/test_trx_trxfile.cpp +++ b/tests/test_trx_trxfile.cpp @@ -447,3 +447,67 @@ TEST(TrxFileTpp, NormalizeForSaveRecomputesLengthsAndHeader) { std::error_code ec; fs::remove_all(data_dir, ec); } + +TEST(TrxFileTpp, LoadFromDirectoryMissingHeader) { + // Directory exists and has files in it, but no header.json. + // Covers the detailed error-diagnostic branch in load_from_directory (lines 980-1006). + auto tmp_dir = make_temp_test_dir("trx_no_header"); + const fs::path dummy = tmp_dir / "positions.3.float32"; + std::ofstream f(dummy.string(), std::ios::binary); + f.close(); + + EXPECT_THROW(TrxFile::load_from_directory(tmp_dir.string()), std::runtime_error); + + std::error_code ec; + fs::remove_all(tmp_dir, ec); +} + +TEST(TrxFileTpp, TrxStreamFloat16Unbuffered) { + // TrxStream("float16") with default unbuffered mode. + // Covers the float16 unbuffered write path in push_streamline (lines 1642-1650) + // and the float16 read-back loop in finalize (lines 1958-1966). + auto tmp_dir = make_temp_test_dir("trx_f16_unbuf"); + const fs::path out_path = tmp_dir / "f16.trx"; + + TrxStream proto("float16"); + std::vector sl1 = {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f}; + std::vector sl2 = {2.0f, 0.0f, 0.0f}; + proto.push_streamline(sl1); + proto.push_streamline(sl2); + proto.finalize(out_path.string(), ZIP_CM_STORE); + + auto trx = load_any(out_path.string()); + EXPECT_EQ(trx.num_streamlines(), 2u); + EXPECT_EQ(trx.num_vertices(), 3u); + trx.close(); + + std::error_code ec; + fs::remove_all(tmp_dir, ec); +} + +TEST(TrxFileTpp, TrxStreamFloat16Buffered) { + // TrxStream("float16") with a small position buffer. + // Pushing two single-point streamlines fills the buffer (6 half-values >= max 6) + // and triggers flush_positions_buffer mid-stream (lines 1592-1603, 1660-1673). + // finalize then calls flush_positions_buffer again with an empty buffer, + // hitting the early-return path (lines 1592-1593). + auto tmp_dir = make_temp_test_dir("trx_f16_buf"); + const fs::path out_path = tmp_dir / "f16_buf.trx"; + + TrxStream proto("float16"); + // 12 bytes / 2 bytes per half = 6 half entries = 2 xyz triplets → flush after 2 points + proto.set_positions_buffer_max_bytes(12); + std::vector pt = {1.0f, 2.0f, 3.0f}; + proto.push_streamline(pt); // buffer: 3 halves + proto.push_streamline(pt); // buffer: 6 halves >= 6 → flush + proto.push_streamline(pt); // buffer: 3 halves (after flush) + proto.finalize(out_path.string(), ZIP_CM_STORE); // flush remainder, then early-return + + auto trx = load_any(out_path.string()); + EXPECT_EQ(trx.num_streamlines(), 3u); + EXPECT_EQ(trx.num_vertices(), 3u); + trx.close(); + + std::error_code ec; + fs::remove_all(tmp_dir, ec); +}