diff --git a/tests/test_files b/tests/test_files index a3caf0af..ca579198 160000 --- a/tests/test_files +++ b/tests/test_files @@ -1 +1 @@ -Subproject commit a3caf0af3f128944c4d6eac93b481df6e4efd97c +Subproject commit ca57919851224047ef86fab177a0bfe9fa920127 diff --git a/tools/ray_benchmark/CMakeLists.txt b/tools/ray_benchmark/CMakeLists.txt new file mode 100644 index 00000000..9566a620 --- /dev/null +++ b/tools/ray_benchmark/CMakeLists.txt @@ -0,0 +1,33 @@ +#=============================================================================== +# ray-benchmark (special case - requires linking directly to GPRT) +#=============================================================================== +if (XDG_ENABLE_GPRT) + # Embed and compile the device code + embed_devicecode( + OUTPUT_TARGET + ray_benchmark_deviceCode + HEADERS + ${CMAKE_CURRENT_SOURCE_DIR}/ray_benchmark_shared.h + SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/ray_benchmark_deviceCode.slang + ) + + # Create the ray-benchmark executable + add_executable(ray-benchmark ray_benchmark.cpp) + target_link_libraries(ray-benchmark xdg argparse ray_benchmark_deviceCode) + # Keep the runtime output alongside other tools for single- and multi-config generators. + get_filename_component(TOOLS_BIN_DIR "${CMAKE_CURRENT_BINARY_DIR}" DIRECTORY) + set_target_properties(ray-benchmark PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${TOOLS_BIN_DIR}" + ) + foreach(config DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) + set_target_properties(ray-benchmark PROPERTIES + RUNTIME_OUTPUT_DIRECTORY_${config} "${TOOLS_BIN_DIR}" + ) + endforeach() + if (OpenMP_CXX_FOUND) + target_link_libraries(ray-benchmark OpenMP::OpenMP_CXX) + target_compile_definitions(ray-benchmark PUBLIC XDG_OPENMP) + endif() + install(TARGETS ray-benchmark DESTINATION ${CMAKE_INSTALL_BINDIR}/tools) +endif() diff --git a/tools/ray_benchmark/ray_benchmark.cpp b/tools/ray_benchmark/ray_benchmark.cpp new file mode 100644 index 00000000..afc975c1 --- /dev/null +++ b/tools/ray_benchmark/ray_benchmark.cpp @@ -0,0 +1,231 @@ +#include +#include +#include +#include +#include + +#include "xdg/error.h" +#include "xdg/mesh_manager_interface.h" +#include "xdg/moab/mesh_manager.h" +#include "xdg/vec3da.h" +#include "xdg/xdg.h" +#include "xdg/ray_tracers.h" +#include "xdg/timer.h" + +#include "argparse/argparse.hpp" + +#include "ray_benchmark.h" + +#include + +using namespace xdg; + +int main(int argc, char** argv) { + + argparse::ArgumentParser args("XDG Ray Tracing throughput benchmarking tool", "1.0", argparse::default_arguments::help); + + args.add_argument("filename") + .help("Path to the input file"); + + args.add_argument("volume") + .help("Volume ID to query") + .scan<'i', int>(); + + args.add_argument("-n", "--num-rays") + .default_value(10'000'000) + .help("Number of rays to be cast for the benchmark (default - 10 million)") + .scan<'u', uint32_t>(); + + args.add_argument("-s", "--seed") + .default_value(12345) + .help("Seed for random number generator (default - 12345)") + .scan<'u', uint32_t>(); + + args.add_argument("-o", "-p", "--origin", "--position") + .default_value(std::vector{0.0, 0.0, 0.0}) + .help("Ray origin/position (default - {0.0, 0.0, 0.0} )") + .scan<'g', double>().nargs(3); + + args.add_argument("-m", "--mesh-library") + .help("Mesh library to use. One of (MOAB, LIBMESH)") + .default_value("MOAB"); + + args.add_argument("-rt", "--rt-library") + .help("Ray tracing library to use. One of (EMBREE, GPRT)") + .default_value("EMBREE"); + + args.add_argument("-l", "--list") + .default_value(false) + .implicit_value(true) + .help("List all volumes in the file and exit"); + + args.add_argument("-sr", "--source-radius") + .default_value(0.0) + .help("Radius of a scattered source blob around the origin (0.0 = point source)") + .scan<'g', double>(); + + args.add_description( + "This tool supports can be used to benchmark XDG ray tracing throughput on a given mesh against" + "a given volume \n." + "A single origin/seed point is provided and ray directions are randomly generated in 360 degrees from that position" + ); + + try { + args.parse_args(argc, argv); + } + catch (const std::runtime_error& err) { + std::cout << err.what() << std::endl; + std::cout << args; + return 1; + } + + std::string mesh_str = args.get("--mesh-library"); + std::string rt_str = args.get("--rt-library"); + + RTLibrary rt_lib; + if (rt_str == "EMBREE") + rt_lib = RTLibrary::EMBREE; + else if (rt_str == "GPRT") + rt_lib = RTLibrary::GPRT; + else + fatal_error("Invalid ray tracing library '{}' specified", rt_str); + + MeshLibrary mesh_lib; + if (mesh_str == "MOAB") { + mesh_lib = MeshLibrary::MOAB; + } else if (mesh_str == "LIBMESH") { + mesh_lib = MeshLibrary::LIBMESH; + if (rt_lib == RTLibrary::GPRT) + fatal_error("LibMesh is not currently supported with GPRT"); + } else { + fatal_error("Invalid mesh library '{}' specified", mesh_str); + } + + // Full wall-clock timer (post-argparse) + Timer wall_timer; + wall_timer.start(); + + // Separate timers for setup, generation, and tracing + Timer setup_timer; + Timer gen_timer; + Timer trace_timer; + + // -------------------------- + // XDG setup timing + // -------------------------- + setup_timer.start(); + + std::shared_ptr xdg = XDG::create(mesh_lib, rt_lib); + const auto& mm = xdg->mesh_manager(); + mm->load_file(args.get("filename")); + mm->init(); + + MeshID volume = args.get("volume"); + xdg->prepare_raytracer(); + xdg->prepare_volume_for_raytracing(volume); + auto rti = xdg->ray_tracing_interface(); + + setup_timer.stop(); + + std::size_t N = args.get("--num-rays"); + uint32_t seed = args.get("--seed"); + Position origin = args.get>("--origin"); + double source_radius = args.get("--source-radius"); + + std::cout << "Volume ID: " << volume << " with: " + << mm->num_volume_faces(volume) << " faces" << std::endl; + + + if (rt_lib == RTLibrary::EMBREE) { + int num_threads = omp_get_max_threads(); + rt_str += " (" + std::to_string(num_threads) + " CPU threads)"; + } + std::cout << "Starting ray fire benchmark with " << N << " rays" + << " using " << rt_str << ": \n" << std::endl; + + std::cout << "XDG initalisation Time = " << setup_timer.elapsed() << "s" << std::endl; + + std::shared_ptr gprt_rt; + if (rt_lib == RTLibrary::GPRT) { + // ---- Random ray generation on device via callback method ---- + gen_timer.start(); + + gprt_rt = std::dynamic_pointer_cast(xdg->ray_tracing_interface()); + auto generateRaysCallback = + tools::benchmark::make_generate_rays_callback(gprt_rt->context(), origin, source_radius, seed, volume); + + // Let XDG internally allocate buffers and invoke the callback to populate them + xdg->populate_rays_external(N, generateRaysCallback); + + gen_timer.stop(); + std::cout << "Random ray generation (via external compute shader) Time = " + << gen_timer.elapsed() << "s" << std::endl; + + // ---- Ray tracing on device ---- + trace_timer.start(); + xdg->ray_fire_prepared(N); // ray_fire against pre-packed rays on device + trace_timer.stop(); + + } + else { // EMBREE / CPU backend + + // ---- Random ray generation on host ---- + gen_timer.start(); + std::vector directions(N); + std::vector origins(N); + + #pragma omp parallel for schedule(static) + for (uint32_t i = 0; i < N; ++i) { + uint32_t state = seed ^ i; + auto [pos,dir] = tools::benchmark::random_spherical_source(origin, state, source_radius); + origins[i] = pos; + directions[i] = dir; + } + gen_timer.stop(); + + std::cout << "Random ray generation Time = " + << gen_timer.elapsed() << "s" << std::endl; + + // ---- Ray tracing on host ---- + trace_timer.start(); + #pragma omp parallel for schedule(static) + for (std::size_t i = 0; i < N; ++i) { + auto result = xdg->ray_fire(volume, origins[i], directions[i]); + } + trace_timer.stop(); + } + + // -------------------------- + // Final reporting + // -------------------------- + double setup_time = setup_timer.elapsed(); + double gen_time = gen_timer.elapsed(); + double trace_time = trace_timer.elapsed(); + + double trace_only_rps = (trace_time > 0.0) + ? static_cast(N) / trace_time + : 0.0; + + double end_to_end_time = gen_time + trace_time; + double end_to_end_rps = (end_to_end_time > 0.0) + ? static_cast(N) / end_to_end_time + : 0.0; + + wall_timer.stop(); + double wall_time = wall_timer.elapsed(); + + std::cout << "Generation + tracing time = " << end_to_end_time + << "s" << std::endl; + std::cout << "End-to-end throughput = " << end_to_end_rps + << " rays/s" << std::endl; + std::cout << "Full wall-clock time = " << wall_time + << "s (post-argparse)" << std::endl; + + std::cout << "----------------------------------------" << std::endl; + std::cout << "Ray Tracing Time (trace-only) = " << trace_time + << "s for " << N << " rays" << std::endl; + std::cout << "Trace-only throughput = " << trace_only_rps + << " rays/s" << std::endl; + std::cout << "---------------------------------------- \n" << std::endl; + return 0; +} diff --git a/tools/ray_benchmark/ray_benchmark.h b/tools/ray_benchmark/ray_benchmark.h new file mode 100644 index 00000000..52b29d62 --- /dev/null +++ b/tools/ray_benchmark/ray_benchmark.h @@ -0,0 +1,97 @@ +#ifndef _XDG_RAY_BENCHMARK_H +#define _XDG_RAY_BENCHMARK_H + +#include +#include +#include + +#include "gprt/gprt.h" +#include "xdg/gprt/ray.h" +#include "xdg/gprt/ray_tracer.h" +#include "xdg/vec3da.h" +#include "xdg/xdg.h" + +#include "ray_benchmark_shared.h" + +extern GPRTProgram ray_benchmark_deviceCode; + +namespace xdg::tools::benchmark { + +inline double rand01(uint32_t &state) +{ + state = state * 1664525u + 1013904223u; + return double(state) * (1.0 / 4294967296.0); +} + +inline Direction random_unit_dir_lcg(uint32_t &state) +{ + double x1, x2, s; + do { + x1 = rand01(state) * 2.0 - 1.0; + x2 = rand01(state) * 2.0 - 1.0; + s = x1 * x1 + x2 * x2; + } while (s <= 0.0 || s >= 1.0); + + double t = 2.0 * std::sqrt(1.0 - s); + return { x1 * t, x2 * t, 1.0 - 2.0 * s }; +} + +// Generates a random point cloud with radius (--source-radius) +inline std::pair random_spherical_source(const Position& origin, + std::uint32_t state, + double source_radius) +{ + // Always generate random direction + Direction dir = random_unit_dir_lcg(state); + Position pos = origin; + if (source_radius > 0.0) { + // random origins (spherical source) + double r = source_radius * std::cbrt(rand01(state)); // uniform in ball + pos += dir * r; + } + return {pos, dir}; +} + +// - User creates their own GPU compute API method to populate rays and passes that to XDG +// - In this miniapp we are using GPRT as a demonstration +// - This callback runs inside populate_rays_external and receives XDG's device buffers +inline RayPopulationCallback make_generate_rays_callback(GPRTContext gprt_context, + Position origin, + double source_radius, + uint32_t seed, + MeshID volume) +{ + return [gprt_context, origin, source_radius, seed, volume](const DeviceRayHitBuffers& buffer, size_t numRays) { + GPRTContext context = gprt_context; + GPRTModule module = gprtModuleCreate(context, ray_benchmark_deviceCode); + auto genRandomRays = gprtComputeCreate( + context, module, "generate_random_rays"); + + constexpr int threadsPerGroup = 64; + const int neededGroups = static_cast((numRays + threadsPerGroup - 1) / threadsPerGroup); + const int groups = std::min(neededGroups, WORKGROUP_LIMIT); + + GenerateRandomRayParams randomRayParams = {}; + randomRayParams.rays = static_cast(buffer.rayDevPtr); // Cast opaque pointer to typed dblRay* + randomRayParams.numRays = static_cast(numRays); + randomRayParams.source_radius = source_radius; + randomRayParams.origin = { origin.x, origin.y, origin.z }; + randomRayParams.seed = seed; + randomRayParams.total_threads = static_cast(groups * threadsPerGroup); + randomRayParams.volume_mesh_id = volume; + randomRayParams.enabled = 1u; + + gprtComputeLaunch(genRandomRays, + { static_cast(groups), 1, 1 }, + { static_cast(threadsPerGroup), 1, 1 }, + randomRayParams); + gprtComputeSynchronize(context); + + gprtComputeDestroy(genRandomRays); + gprtModuleDestroy(module); + }; +} + +} // namespace xdg::tools::benchmark + +#endif // _XDG_RAY_BENCHMARK_H diff --git a/tools/ray_benchmark/ray_benchmark_deviceCode.slang b/tools/ray_benchmark/ray_benchmark_deviceCode.slang new file mode 100644 index 00000000..e9a6aefb --- /dev/null +++ b/tools/ray_benchmark/ray_benchmark_deviceCode.slang @@ -0,0 +1,57 @@ +#include "ray_benchmark_shared.h" + +/* +For this simple benchmark case we are mocking what a downstream application would do in terms of populating +ray buffers. The idea is that the downstream application generates rays (origins + directions). +*/ +[shader("compute")] +[numthreads(64, 1, 1)] +void generate_random_rays(uint3 DispatchThreadID: SV_DispatchThreadID, + uniform GenerateRandomRayParams params) +{ + uint globalThreadID = DispatchThreadID.x; + uint stride = params.total_threads; + uint nRays = params.numRays; + + for (uint idx = globalThreadID; idx < nRays; idx += stride) + { + uint state = params.seed ^ idx; + + double3 dir = random_unit_dir_lcg(state); + + double3 pos = params.origin; + if (params.source_radius > 0.0) { + double u = float(rand01(state)); + float r = float(params.source_radius) * pow(float(u), 1.0f / 3.0f); // cbrt(u) + pos += dir * double(r); + } + + params.rays[idx].origin = pos; + params.rays[idx].direction = dir; + params.rays[idx].exclude_primitives = nullptr; + params.rays[idx].exclude_count = 0; + params.rays[idx].enabled = params.enabled; + params.rays[idx].volume_mesh_id = params.volume_mesh_id; + } +} + +// Simple LCG random number generator +double rand01(inout uint state) +{ + state = state * 1664525u + 1013904223u; + return double(state) * double(1.0 / 4294967296.0); +} + +// return random unit dir +double3 random_unit_dir_lcg(inout uint state) +{ + double x1, x2, s; + do { + x1 = rand01(state) * 2.0 - 1.0; + x2 = rand01(state) * 2.0 - 1.0; + s = x1 * x1 + x2 * x2; + } while (s <= 0.0 || s >= 1.0); + + double t = 2.0 * sqrt(1.0 - s); + return double3(x1 * t, x2 * t, 1.0 - 2.0 * s); +} \ No newline at end of file diff --git a/tools/ray_benchmark/ray_benchmark_driver.py b/tools/ray_benchmark/ray_benchmark_driver.py new file mode 100644 index 00000000..1839418f --- /dev/null +++ b/tools/ray_benchmark/ray_benchmark_driver.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +import subprocess +import statistics +import sys +import csv +import os + +# --- CONFIG --- + +BENCHMARK = "./tools/ray-benchmark" +MESH_PATH = "../dagmc_xdg_test.h5m" +VOLUME_ID = "2" +NUM_RAYS = "80000000" +ORIGIN = ["-o", "180", "250", "-27"] # x y z as strings + +# --- PARSING HELPERS --- + +def parse_float_before_s(s: str) -> float: + """ + Given a string like 'XDG initalisation Time = 1.25017s', + pull out 1.25017 as float. + """ + try: + after_eq = s.split('=', 1)[1] + number_str = after_eq.split('s', 1)[0].strip() + return float(number_str) + except Exception as e: + raise ValueError(f"Failed to parse float from line: {s!r}") from e + +def parse_throughput_line(s: str) -> float: + """ + Given a string like 'Trace-only throughput = 2.64065e+09 rays/s', + pull out 2.64065e+09 as float. + """ + try: + after_eq = s.split('=', 1)[1] + number_str = after_eq.split('rays', 1)[0].strip() + return float(number_str) + except Exception as e: + raise ValueError(f"Failed to parse throughput from line: {s!r}") from e + +def parse_benchmark_output(output: str): + """ + Parse the benchmark stdout text and return a dict of metrics. + Expected keys: + - xdg_init + - gen + - gen_trace + - end_to_end + - wall_clock + - trace_only + - trace_only_throughput + """ + metrics = {} + + for line in output.splitlines(): + line = line.strip() + + if line.startswith("XDG initalisation Time"): + metrics["xdg_init"] = parse_float_before_s(line) + + elif line.startswith("Random ray generation"): + metrics["gen"] = parse_float_before_s(line) + + elif line.startswith("Generation + tracing time"): + metrics["gen_trace"] = parse_float_before_s(line) + + elif line.startswith("End-to-end throughput"): + metrics["end_to_end"] = parse_throughput_line(line) + + elif line.startswith("Full wall-clock time"): + metrics["wall_clock"] = parse_float_before_s(line) + + elif line.startswith("Ray Tracing Time (trace-only)"): + metrics["trace_only"] = parse_float_before_s(line) + + elif line.startswith("Trace-only throughput"): + metrics["trace_only_throughput"] = parse_throughput_line(line) + + required = [ + "xdg_init", "gen", "gen_trace", "end_to_end", + "wall_clock", "trace_only", "trace_only_throughput" + ] + missing = [k for k in required if k not in metrics] + if missing: + raise RuntimeError(f"Missing metrics in output: {missing}") + + return metrics + +# --- MAIN DRIVER --- + +def main(): + # Ask for backend + backend_in = input("Choose backend (embree/gprt): ").strip().lower() + if backend_in not in ("embree", "gprt"): + print("Invalid backend, please choose 'embree' or 'gprt'.") + sys.exit(1) + + base_backend = backend_in.upper() # what we pass to -r: EMBREE or GPRT + + # If GPRT, ask for which variant + if backend_in == "gprt": + mode_in = input( + "GPRT mode: [1] GPRT (FP64), [2] GPRT (FP32) + RT cores [1]: " + ).strip() + if mode_in == "2": + variant = "fp32_rt" + label = "GPRT (FP32) + RT cores" + else: + variant = "fp64" + label = "GPRT (FP64)" + else: + # Embree is effectively FP64 for your purposes + variant = "fp64" + label = "Embree" + + runs_str = input("How many runs? ").strip() + try: + num_runs = int(runs_str) + if num_runs <= 0: + raise ValueError + except ValueError: + print("Number of runs must be a positive integer.") + sys.exit(1) + + # Ask for CSV filename + csv_filename = input("CSV output file [benchmarks.csv]: ").strip() + if not csv_filename: + csv_filename = "benchmarks.csv" + + mesh_name = os.path.basename(MESH_PATH) + + all_metrics = { + "xdg_init": [], + "gen": [], + "gen_trace": [], + "end_to_end": [], + "wall_clock": [], + "trace_only": [], + "trace_only_throughput": [], + } + + # CSV header: machine-friendly backend/variant, plus pretty label + header = [ + "backend", # EMBREE / GPRT + "variant", # fp64 / fp32_rt + "label", # Embree / GPRT (FP64) / GPRT (FP32) + RT cores + "mesh_name", + "volume_id", + "num_rays", + "run_index", + "xdg_init", + "gen", + "gen_trace", + "end_to_end", + "wall_clock", + "trace_only", + "trace_only_throughput", + ] + + # Decide whether to append or overwrite + file_exists = os.path.exists(csv_filename) + write_header = False + file_mode = "w" + append_mode = False + + if file_exists: + choice = input( + f"File '{csv_filename}' already exists. " + "[o]verwrite, [a]ppend, or e[x]it? [a]: " + ).strip().lower() + + if choice in ("x", "q"): + print("Aborting, no benchmarks run.") + sys.exit(0) + elif choice in ("", "a"): + file_mode = "a" + write_header = False # assume header already there + append_mode = True + elif choice == "o": + file_mode = "w" + write_header = True + append_mode = False + else: + print("Unrecognized choice, aborting.") + sys.exit(1) + else: + # new file: write header + file_mode = "w" + write_header = True + append_mode = False + + csv_file = open(csv_filename, file_mode, newline="") + + # If appending, add a separation comment line so it's obvious this is a new batch + if append_mode: + csv_file.write( + f"\n# --- New benchmark batch: " + f"label={label}, backend={base_backend}, variant={variant}, " + f"mesh={mesh_name}, volume={VOLUME_ID}, " + f"rays={NUM_RAYS}, runs={num_runs} ---\n" + ) + + writer = csv.writer(csv_file) + + if write_header: + writer.writerow(header) + + try: + for i in range(1, num_runs + 1): + print(f"\n=== Run {i}/{num_runs} ({label}) ===") + + cmd = [ + BENCHMARK, + MESH_PATH, + VOLUME_ID, + "-r", base_backend, # EMBREE or GPRT + "-n", NUM_RAYS, + *ORIGIN, + ] + + print("Running:", " ".join(cmd)) + + try: + result = subprocess.run( + cmd, + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + print("Benchmark command failed!") + print("STDOUT:\n", e.stdout) + print("STDERR:\n", e.stderr) + sys.exit(1) + + try: + metrics = parse_benchmark_output(result.stdout) + except Exception as e: + print("Failed to parse benchmark output:", e) + print("Raw output:\n", result.stdout) + sys.exit(1) + + # store for averages + for k in all_metrics.keys(): + all_metrics[k].append(metrics[k]) + + # write CSV row + writer.writerow([ + base_backend, # backend + variant, # variant + label, # label + mesh_name, + VOLUME_ID, + NUM_RAYS, + i, # run_index + metrics["xdg_init"], + metrics["gen"], + metrics["gen_trace"], + metrics["end_to_end"], + metrics["wall_clock"], + metrics["trace_only"], + metrics["trace_only_throughput"], + ]) + + # per-run summary + print(f"XDG init : {metrics['xdg_init']:.6f} s") + print(f"Generation : {metrics['gen']:.6f} s") + print(f"Gen + trace : {metrics['gen_trace']:.6f} s") + print(f"End-to-end : {metrics['end_to_end']:.3e} rays/s") + print(f"Wall-clock : {metrics['wall_clock']:.6f} s") + print(f"Trace-only : {metrics['trace_only']:.6f} s") + print(f"Trace-only thrpt : {metrics['trace_only_throughput']:.3e} rays/s") + + finally: + csv_file.close() + + # Averages + print( + "\n=== Averages over", + num_runs, + f"runs (label: {label}) ===" + ) + + def avg(key): return statistics.mean(all_metrics[key]) + + print(f"Avg XDG init : {avg('xdg_init'):.6f} s") + print(f"Avg Generation : {avg('gen'):.6f} s") + print(f"Avg Gen + trace : {avg('gen_trace'):.6f} s") + print(f"Avg End-to-end : {avg('end_to_end'):.3e} rays/s") + print(f"Avg Wall-clock : {avg('wall_clock'):.6f} s") + print(f"Avg Trace-only : {avg('trace_only'):.6f} s") + print(f"Avg Trace-only thrpt : {avg('trace_only_throughput'):.3e} rays/s") + print(f"\nResults written to: {csv_filename}") + +if __name__ == "__main__": + main() diff --git a/tools/ray_benchmark/ray_benchmark_shared.h b/tools/ray_benchmark/ray_benchmark_shared.h new file mode 100644 index 00000000..a3fffcf0 --- /dev/null +++ b/tools/ray_benchmark/ray_benchmark_shared.h @@ -0,0 +1,14 @@ +#include "gprt.h" + +#include "../../include/xdg/gprt/ray.h" + +struct GenerateRandomRayParams { + xdg::dblRay* rays; + uint numRays; + double3 origin; + uint seed; + uint total_threads; + double source_radius; + int volume_mesh_id; + uint enabled; +}; diff --git a/vendor/GPRT b/vendor/GPRT index f1e95e41..405d9ee9 160000 --- a/vendor/GPRT +++ b/vendor/GPRT @@ -1 +1 @@ -Subproject commit f1e95e4188cde591547d6b4a33a70bf2afaeec59 +Subproject commit 405d9ee9f5ee8e1a0455f776f9e2c3adffb64160