Skip to content

perf: hoist loop-invariant division out of reduce's cell loop - #439

Open
henryiii wants to merge 2 commits into
boostorg:developfrom
henryiii:perf-reduce-cell-loop
Open

perf: hoist loop-invariant division out of reduce's cell loop#439
henryiii wants to merge 2 commits into
boostorg:developfrom
henryiii:perf-reduce-cell-loop

Conversation

@henryiii

@henryiii henryiii commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

This is fairly small change for a nice small speedup in reduction. From #437.

🤖 AI text below 🤖

reduce recomputed (end - begin) / merge for every axis of every cell. This precomputes the reduced extent once per axis in the command buffer.

Benchmarked on 1M-cell histograms (Apple clang -O3, M-series; both branches compiled separately, binaries run interleaved): with dense_storage<double>, 3d rebin/shrink are ~6-11% faster and 2d rebin ~4% faster; with the default unlimited_storage and for small histograms, no change (never slower than develop in any measured case). Benchmark code and full numbers are in the comments below.

An earlier version of this PR also skipped the division when merge == 1; that branch turned out to cost more than the division it avoids and was removed.

Precompute the reduced axis extent once per axis instead of dividing
per cell per axis, and skip the merge division when merge == 1.
About 5-10% faster on large histograms (Apple clang -O3, M-series).

Assisted-by: ClaudeCode:claude-fable-5
@HDembinski

HDembinski commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

How was the benchmark done? Would be good to have the code. Is this incurring penalties for small histograms or is it a consistent win whatever the size / dimension?

Benchmarks show the branch costs more than the division it skips:
with the default unlimited_storage, shrink-only reduce was 5-9%
slower than develop. Always dividing keeps the win from hoisting
reduced_end and is never slower than develop in any measured case.

Assisted-by: ClaudeCode:claude-fable-5
@henryiii

henryiii commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

There was a small regression for Unlimited histograms of small size, fixed by avoiding branch (most recent commit). Code below (let me know if you want it somewhere in the library).

🤖 AI text below 🤖

Good questions - re-benchmarking to answer them found a real problem, now fixed in the latest push. Details:

How the benchmark was done. Standalone translation units (code below), Apple clang 21 -O3 -DNDEBUG, Apple M5 Pro. The library is header-only, so each configuration is the same source compiled with -I pointing at the respective branch. The binaries were run interleaved (A, B, A, B, ...) three times in both orders to rule out thermal/ordering effects; numbers are best-of-N per case.

Small histograms. No penalty. The only per-call overhead is one loop over the axes to precompute reduced_end before the cell loop. Small-histogram reduce calls are dominated by allocating the result histogram (~40 ns for 1d/10 bins, ~300 ns for 2d 10x10), and the differences between develop and this PR are within noise there.

What re-benchmarking found. The speedup as originally pushed reproduced with dense_storage<double> (which is what the numbers in the description came from), but with the default unlimited_storage the merge > 1 branch that skipped the no-op division made shrink-only reduction 5-9% slower than develop. The branch cost more than the division it avoided. The latest push removes it and keeps only the hoisting; with that, the PR is never slower than develop in any case measured, and the hoisting is where the whole win was anyway.

Results. develop vs. this PR (after the fix), range over 3 interleaved runs:

dense_storage<double>, ~1M cells, ns/cell:

case develop this PR
3d 100^3 rebin(2,2,2) 3.05-3.29 2.69-3.03
3d 100^3 shrink x3 3.26-3.29 3.02-3.09
2d 1000^2 rebin(2,2) 2.21-2.24 2.09-2.12
2d 1000^2 shrink x2 2.48-2.50 2.48-2.50

default unlimited_storage, ~1M cells, ns/call:

case develop this PR
2d 1k x 1k rebin 2.72-2.75M 2.74-2.77M
2d 1k x 1k shrink 2.80-2.82M 2.80-2.83M
3d 100^3 rebin 4.10-4.23M 4.09-4.18M
3d 100^3 shrink 4.24-4.31M 4.24-4.26M
3d 100^3 rebin+shrink mixed 4.12-4.17M 4.06-4.10M

Small histograms (dense_storage, ns/call, includes result allocation): 1d 10 bins ~37-43 both; 2d 10x10 ~285-315 both; 3d 10^3 rebin 5230 -> 4830.

bench_reduce.cpp (dense_storage, ns/cell)
// Benchmark: algorithm::reduce per-cell loop cost.
#include <boost/histogram.hpp>
#include <boost/histogram/algorithm/reduce.hpp>
#include <chrono>
#include <cstdio>
#include <random>
#include <vector>

namespace bh = boost::histogram;
using namespace bh::algorithm;
using reg = bh::axis::regular<>;

int main() {
  auto h3 = bh::make_histogram_with(bh::dense_storage<double>(), reg(100, 0, 1),
                                    reg(100, 0, 1), reg(100, 0, 1));
  auto h2 = bh::make_histogram_with(bh::dense_storage<double>(), reg(1000, 0, 1),
                                    reg(1000, 0, 1));
  std::default_random_engine gen(1);
  std::uniform_real_distribution<double> dis(0, 1);
  for (int i = 0; i < 1000000; ++i) h3(dis(gen), dis(gen), dis(gen));
  for (int i = 0; i < 1000000; ++i) h2(dis(gen), dis(gen));

  auto bench = [](const char* name, std::size_t ncells, auto&& run) {
    double best = 1e30, total = 0;
    for (int rep = 0; rep < 8; ++rep) {
      auto t0 = std::chrono::high_resolution_clock::now();
      total = run();
      auto t1 = std::chrono::high_resolution_clock::now();
      double dt = std::chrono::duration<double>(t1 - t0).count();
      if (rep > 0 && dt < best) best = dt;
    }
    std::printf("%-30s %8.2f ns/cell   (sum=%g)\n", name, best / ncells * 1e9, total);
  };

  const std::size_t n3 = 102ul * 102 * 102;
  const std::size_t n2 = 1002ul * 1002;

  bench("3d rebin(2,2,2)", n3, [&] {
    auto r = reduce(h3, rebin(2), rebin(2), rebin(2));
    return sum(r);
  });
  bench("3d shrink only (merge=1)", n3, [&] {
    auto r = reduce(h3, shrink(0.1, 0.9), shrink(0.1, 0.9), shrink(0.1, 0.9));
    return sum(r);
  });
  bench("2d rebin(2,2)", n2, [&] {
    auto r = reduce(h2, rebin(2), rebin(2));
    return sum(r);
  });
  bench("2d shrink only (merge=1)", n2, [&] {
    auto r = reduce(h2, shrink(0.1, 0.9), shrink(0.1, 0.9));
    return sum(r);
  });
  return 0;
}
bench_reduce_sizes.cpp (default storage, size sweep incl. small histograms)
// Benchmark for boost::histogram::algorithm::reduce (PR #439).
// Build: c++ -std=c++17 -O3 -DNDEBUG -I<histogram>/include -I<boost> bench_reduce.cpp
#include <boost/histogram.hpp>
#include <boost/histogram/algorithm/reduce.hpp>
#include <chrono>
#include <cstdio>
#include <random>
#include <vector>

using namespace boost::histogram;
using algorithm::rebin;
using algorithm::shrink;
using clk = std::chrono::steady_clock;

template <class H>
void fill_random(H& h) {
  std::mt19937 gen(1);
  std::uniform_real_distribution<> d(0, 10);
  for (auto&& v : unsafe_access::storage(h)) v = d(gen);
}

// Run op() repeatedly for at least min_time; report best ns/call over reps.
template <class Op>
double bench(Op&& op) {
  // warm-up and calibration
  op();
  auto t0 = clk::now();
  op();
  auto once = std::chrono::duration<double>(clk::now() - t0).count();
  unsigned n = once > 0 ? static_cast<unsigned>(0.02 / once) + 1 : 1000;
  double best = 1e300;
  for (int rep = 0; rep < 7; ++rep) {
    t0 = clk::now();
    for (unsigned i = 0; i < n; ++i) op();
    auto dt = std::chrono::duration<double>(clk::now() - t0).count() / n;
    if (dt < best) best = dt;
  }
  return best * 1e9;
}

volatile unsigned sink;

template <class H, class Opts>
void run_case(const char* name, H& h, const Opts& opts) {
  auto ns = bench([&] {
    auto r = algorithm::reduce(h, opts);
    sink += static_cast<unsigned>(r.size());
  });
  std::printf("%-28s %12.0f ns/call\n", name, ns);
}

int main() {
  auto ax10 = axis::regular<>(10, 0, 1);
  auto ax100 = axis::regular<>(100, 0, 1);
  auto ax1000 = axis::regular<>(1000, 0, 1);

  { // 1d, 10 bins
    auto h = make_histogram(ax10);
    fill_random(h);
    run_case("1d 10      rebin(2)", h, std::vector<algorithm::reduce_command>{rebin(0, 2)});
    run_case("1d 10      shrink", h, std::vector<algorithm::reduce_command>{shrink(0, 0.2, 0.8)});
  }
  { // 2d, 10x10
    auto h = make_histogram(ax10, ax10);
    fill_random(h);
    run_case("2d 10x10   rebin(2)", h,
             std::vector<algorithm::reduce_command>{rebin(0, 2), rebin(1, 2)});
    run_case("2d 10x10   shrink", h,
             std::vector<algorithm::reduce_command>{shrink(0, 0.2, 0.8), shrink(1, 0.2, 0.8)});
  }
  { // 2d, 100x100
    auto h = make_histogram(ax100, ax100);
    fill_random(h);
    run_case("2d 100x100 rebin(2)", h,
             std::vector<algorithm::reduce_command>{rebin(0, 2), rebin(1, 2)});
    run_case("2d 100x100 shrink", h,
             std::vector<algorithm::reduce_command>{shrink(0, 0.2, 0.8), shrink(1, 0.2, 0.8)});
  }
  { // 2d, 1000x1000 (1M cells)
    auto h = make_histogram(ax1000, ax1000);
    fill_random(h);
    run_case("2d 1kx1k   rebin(2)", h,
             std::vector<algorithm::reduce_command>{rebin(0, 2), rebin(1, 2)});
    run_case("2d 1kx1k   shrink", h,
             std::vector<algorithm::reduce_command>{shrink(0, 0.2, 0.8), shrink(1, 0.2, 0.8)});
  }
  { // 3d, 10x10x10
    auto h = make_histogram(ax10, ax10, ax10);
    fill_random(h);
    run_case("3d 10^3    rebin(2)", h,
             std::vector<algorithm::reduce_command>{rebin(0, 2), rebin(1, 2), rebin(2, 2)});
    run_case("3d 10^3    shrink", h,
             std::vector<algorithm::reduce_command>{shrink(0, 0.2, 0.8), shrink(1, 0.2, 0.8),
                                                    shrink(2, 0.2, 0.8)});
  }
  { // 3d, 100x100x100 (1M cells)
    auto h = make_histogram(ax100, ax100, ax100);
    fill_random(h);
    run_case("3d 100^3   rebin(2)", h,
             std::vector<algorithm::reduce_command>{rebin(0, 2), rebin(1, 2), rebin(2, 2)});
    run_case("3d 100^3   shrink", h,
             std::vector<algorithm::reduce_command>{shrink(0, 0.2, 0.8), shrink(1, 0.2, 0.8),
                                                    shrink(2, 0.2, 0.8)});
    run_case("3d 100^3   mixed", h,
             std::vector<algorithm::reduce_command>{rebin(0, 2), shrink(1, 0.2, 0.8),
                                                    rebin(2, 4)});
  }
  return 0;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants