// This file is part of Eigen, a lightweight C++ template library // for linear algebra. // // Copyright (C) 2026 Rasmus Munk Larsen // // This Source Code Form is subject to the terms of the Mozilla // Public License v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 // Benchmarks for ForkJoinScheduler::ParallelFor / ParallelForAsync. // // Four scenarios are covered: // // BM_ParallelFor_Throughput Dispatch overhead with cheap (single-add) tasks, // swept over (num_threads, num_tasks, granularity). // BM_ParallelFor_Granularity Fixed total work, sweep granularity. Shows the // classic too-fine-vs-too-coarse trade-off. // BM_ParallelFor_HeavyCapture Functor with a 2 KiB by-value capture, std::move'd // into ParallelFor. Exercises the entry-point // forwarding path (the one fix in MR !2556 that has // observable codegen). // BM_ParallelForAsync_Batch Async API: schedule N concurrent ParallelForAsync // calls and wait on a single Barrier. #define EIGEN_USE_THREADS #include #include #include #include #include #include #include "Eigen/ThreadPool" using Eigen::Barrier; using Eigen::ForkJoinScheduler; using Eigen::Index; using Eigen::ThreadPool; namespace { // One ThreadPool per (benchmark_name, num_threads) so we don't pay pool-startup // cost inside the timed loop. Google Benchmark calls the benchmark function once // per Args() row; the pool lives across all `for (auto _ : state)` iterations // of that row and is destroyed when the function returns. std::unique_ptr MakePool(int num_threads) { return std::make_unique(num_threads); } } // namespace // --------------------------------------------------------------------------- // 1. Dispatch throughput: cheap per-task body (a single atomic increment). // Args: {num_threads, num_tasks, granularity} // --------------------------------------------------------------------------- static void BM_ParallelFor_Throughput(benchmark::State& state) { const int num_threads = static_cast(state.range(0)); const Index num_tasks = state.range(1); const Index granularity = state.range(2); auto pool = MakePool(num_threads); std::atomic counter{0}; auto do_func = [&counter](Index i, Index j) { counter.fetch_add(j - i, std::memory_order_relaxed); }; for (auto _ : state) { ForkJoinScheduler::ParallelFor(0, num_tasks, granularity, do_func, pool.get()); benchmark::DoNotOptimize(counter); } state.SetItemsProcessed(state.iterations() * num_tasks); } BENCHMARK(BM_ParallelFor_Throughput) ->ArgNames({"threads", "tasks", "gran"}) ->Args({1, 1024, 1}) ->Args({4, 1024, 1}) ->Args({8, 1024, 1}) ->Args({4, 1024, 16}) ->Args({4, 8192, 1}) ->Args({4, 8192, 64}) ->Args({8, 8192, 64}) ->UseRealTime(); // --------------------------------------------------------------------------- // 2. Granularity sweep: fixed 65536 trivial tasks across 8 threads, sweep // granularity through the regime where too-fine == dispatch-bound and // too-coarse == load-imbalance. // Args: {granularity} // --------------------------------------------------------------------------- static void BM_ParallelFor_Granularity(benchmark::State& state) { constexpr int kNumThreads = 8; constexpr Index kNumTasks = 1 << 16; const Index granularity = state.range(0); auto pool = MakePool(kNumThreads); std::atomic counter{0}; auto do_func = [&counter](Index i, Index j) { counter.fetch_add(j - i, std::memory_order_relaxed); }; for (auto _ : state) { ForkJoinScheduler::ParallelFor(0, kNumTasks, granularity, do_func, pool.get()); benchmark::DoNotOptimize(counter); } state.SetItemsProcessed(state.iterations() * kNumTasks); } BENCHMARK(BM_ParallelFor_Granularity)->ArgName("gran")->RangeMultiplier(4)->Range(1, 1 << 12)->UseRealTime(); // --------------------------------------------------------------------------- // 3. Heavy capture: a do_func with a ~2 KiB by-value payload, std::move'd // into ParallelFor on every iteration. The functor is copy/move-constructed // exactly once per call (when ParallelForAsync captures it into the entry- // point lambda). The per-task body is cheap so the copy/move is visible at // the call rate. // Args: {num_threads, num_tasks, granularity} // --------------------------------------------------------------------------- static void BM_ParallelFor_HeavyCapture(benchmark::State& state) { const int num_threads = static_cast(state.range(0)); const Index num_tasks = state.range(1); const Index granularity = state.range(2); auto pool = MakePool(num_threads); std::atomic counter{0}; // ~2 KiB of by-value capture. The destination address of the data dictates // the per-task observable, so the captured array isn't dead-stripped. struct HeavyFn { std::array payload; std::atomic* counter; void operator()(Index i, Index j) const { // Use one element so the payload isn't optimized away; correctness of the // count is preserved by adding (j - i). counter->fetch_add(static_cast(j - i) + static_cast(payload[0] == 0.0), std::memory_order_relaxed); } }; HeavyFn proto; proto.payload.fill(1.0); proto.counter = &counter; for (auto _ : state) { // Fresh copy each iteration so std::move into ParallelFor is meaningful. HeavyFn fn = proto; ForkJoinScheduler::ParallelFor(0, num_tasks, granularity, std::move(fn), pool.get()); benchmark::DoNotOptimize(counter); } state.SetItemsProcessed(state.iterations() * num_tasks); state.SetBytesProcessed(state.iterations() * static_cast(sizeof(HeavyFn))); } BENCHMARK(BM_ParallelFor_HeavyCapture) ->ArgNames({"threads", "tasks", "gran"}) ->Args({4, 64, 1}) ->Args({4, 1024, 16}) ->Args({8, 1024, 16}) ->Args({8, 8192, 64}) ->UseRealTime(); // --------------------------------------------------------------------------- // 4. Async batch: schedule kBatch concurrent ParallelForAsync calls and // wait on a single Barrier. Mirrors the dominant usage pattern from // TensorDeviceThreadPool. // Args: {num_threads, batch, tasks_per_call, granularity} // --------------------------------------------------------------------------- static void BM_ParallelForAsync_Batch(benchmark::State& state) { const int num_threads = static_cast(state.range(0)); const int batch = static_cast(state.range(1)); const Index num_tasks = state.range(2); const Index granularity = state.range(3); auto pool = MakePool(num_threads); std::atomic counter{0}; std::function do_func = [&counter](Index i, Index j) { counter.fetch_add(j - i, std::memory_order_relaxed); }; for (auto _ : state) { Barrier barrier(batch); std::function done = [&barrier]() { barrier.Notify(); }; for (int k = 0; k < batch; ++k) { ForkJoinScheduler::ParallelForAsync(0, num_tasks, granularity, do_func, done, pool.get()); } barrier.Wait(); benchmark::DoNotOptimize(counter); } state.SetItemsProcessed(state.iterations() * static_cast(batch) * num_tasks); } BENCHMARK(BM_ParallelForAsync_Batch) ->ArgNames({"threads", "batch", "tasks", "gran"}) ->Args({4, 4, 1024, 16}) ->Args({8, 8, 1024, 16}) ->Args({8, 16, 1024, 16}) ->Args({8, 8, 8192, 64}) ->UseRealTime();