libeigen/eigen!2556 Co-authored-by: Rasmus Munk Larsen <rmlarsen@gmail.com>
184 lines
7.6 KiB
C++
184 lines
7.6 KiB
C++
// This file is part of Eigen, a lightweight C++ template library
|
|
// for linear algebra.
|
|
//
|
|
// Copyright (C) 2026 Rasmus Munk Larsen <rmlarsen@gmail.com>
|
|
//
|
|
// 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 <benchmark/benchmark.h>
|
|
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <utility>
|
|
|
|
#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<ThreadPool> MakePool(int num_threads) { return std::make_unique<ThreadPool>(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<int>(state.range(0));
|
|
const Index num_tasks = state.range(1);
|
|
const Index granularity = state.range(2);
|
|
auto pool = MakePool(num_threads);
|
|
std::atomic<int64_t> 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<int64_t> 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<int>(state.range(0));
|
|
const Index num_tasks = state.range(1);
|
|
const Index granularity = state.range(2);
|
|
auto pool = MakePool(num_threads);
|
|
std::atomic<int64_t> 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<double, 256> payload;
|
|
std::atomic<int64_t>* 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<int64_t>(j - i) + static_cast<int64_t>(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<int64_t>(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<int>(state.range(0));
|
|
const int batch = static_cast<int>(state.range(1));
|
|
const Index num_tasks = state.range(2);
|
|
const Index granularity = state.range(3);
|
|
auto pool = MakePool(num_threads);
|
|
std::atomic<int64_t> counter{0};
|
|
std::function<void(Index, Index)> do_func = [&counter](Index i, Index j) {
|
|
counter.fetch_add(j - i, std::memory_order_relaxed);
|
|
};
|
|
for (auto _ : state) {
|
|
Barrier barrier(batch);
|
|
std::function<void()> 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<int64_t>(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();
|