Skip to content

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

Open
henryiii wants to merge 4 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 4 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;
}

@HDembinski

Copy link
Copy Markdown
Collaborator

We have a benchmarking system in the repo, integrate those benchmarks in there and commit them along with the patch.

Covers rebin, shrink, and shrink_and_rebin for 1d-3d histograms with
dense_storage and unlimited_storage, 4-256 bins per axis.

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

henryiii commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

The benchmark is now integrated into the repo as benchmark/histogram_reduce.cpp (built as benchmark_histogram_reduce with the other Google Benchmark targets). It covers rebin, shrink, and shrink_and_rebin for 1d-3d, 4-256 bins per axis, with both dense_storage<double> and unlimited_storage, and reports throughput as input cells per second.

@henryiii

henryiii commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Ahh, I did not ask for it to comment (or even push yet). Did you want cells per second as the report? Also there a a lot of parameterizations, I can cut them down a bit if you'd prefer. Here's what it looks like when I run it manually on my M5Pro:

Unable to determine clock rate from sysctl: hw.cpufrequency: No such file or directory
This does not affect benchmark measurements, only the metadata output.
***WARNING*** Failed to set thread affinity. Estimated CPU frequency may be incorrect.
2026-08-05T13:43:01-04:00
Running ./build/benchmark/benchmark_histogram_reduce
Run on (18 X 24 MHz CPU s)
CPU Caches:
  L1 Data 64 KiB
  L1 Instruction 128 KiB
  L2 Unified 8192 KiB (x18)
Load Average: 0.90, 1.38, 2.20
--------------------------------------------------------------------------------------------
Benchmark                                  Time             CPU   Iterations UserCounters...
--------------------------------------------------------------------------------------------
Rebin/(dense, 1)/4                      94.0 ns         93.9 ns      7372612 items_per_second=63.8958M/s
Rebin/(dense, 1)/16                      138 ns          138 ns      5099736 items_per_second=130.543M/s
Rebin/(dense, 1)/64                      295 ns          295 ns      2371362 items_per_second=224.036M/s
Rebin/(dense, 1)/256                     892 ns          891 ns       770764 items_per_second=289.527M/s
Shrink/(dense, 1)/4                     99.2 ns         99.1 ns      6937906 items_per_second=60.5238M/s
Shrink/(dense, 1)/16                     148 ns          148 ns      4757342 items_per_second=121.703M/s
Shrink/(dense, 1)/64                     313 ns          313 ns      2235108 items_per_second=210.795M/s
Shrink/(dense, 1)/256                    927 ns          927 ns       757395 items_per_second=278.419M/s
ShrinkAndRebin/(dense, 1)/4             99.5 ns         99.4 ns      6996781 items_per_second=60.3557M/s
ShrinkAndRebin/(dense, 1)/16             146 ns          146 ns      4827320 items_per_second=123.591M/s
ShrinkAndRebin/(dense, 1)/64             312 ns          312 ns      2248851 items_per_second=211.757M/s
ShrinkAndRebin/(dense, 1)/256            926 ns          926 ns       758750 items_per_second=278.764M/s
Rebin/(dense, 2)/4                       258 ns          258 ns      2656335 items_per_second=139.42M/s
Rebin/(dense, 2)/16                     1558 ns         1557 ns       450665 items_per_second=208.127M/s
Rebin/(dense, 2)/64                    17859 ns        17847 ns        38906 items_per_second=244.081M/s
Rebin/(dense, 2)/256                  266507 ns       266289 ns         2627 items_per_second=249.969M/s
Shrink/(dense, 2)/4                      260 ns          260 ns      2678616 items_per_second=138.39M/s
Shrink/(dense, 2)/16                    1707 ns         1706 ns       411317 items_per_second=189.949M/s
Shrink/(dense, 2)/64                   18989 ns        18975 ns        36619 items_per_second=229.563M/s
Shrink/(dense, 2)/256                 270317 ns       270115 ns         2600 items_per_second=246.429M/s
ShrinkAndRebin/(dense, 2)/4              254 ns          254 ns      2750988 items_per_second=141.604M/s
ShrinkAndRebin/(dense, 2)/16            1722 ns         1721 ns       406795 items_per_second=188.306M/s
ShrinkAndRebin/(dense, 2)/64           18567 ns        18555 ns        37723 items_per_second=234.76M/s
ShrinkAndRebin/(dense, 2)/256         268472 ns       268246 ns         2602 items_per_second=248.145M/s
Rebin/(dense, 3)/4                      1457 ns         1455 ns       473581 items_per_second=148.413M/s
Rebin/(dense, 3)/16                    34336 ns        34311 ns        20674 items_per_second=169.972M/s
Rebin/(dense, 3)/64                  1572415 ns      1571154 ns          447 items_per_second=182.984M/s
Rebin/(dense, 3)/256                91255286 ns     91181500 ns            8 items_per_second=188.344M/s
Shrink/(dense, 3)/4                     1531 ns         1530 ns       464554 items_per_second=141.144M/s
Shrink/(dense, 3)/16                   37736 ns        37711 ns        18464 items_per_second=154.65M/s
Shrink/(dense, 3)/64                 1708431 ns      1707039 ns          413 items_per_second=168.418M/s
Shrink/(dense, 3)/256               97709018 ns     97626571 ns            7 items_per_second=175.91M/s
ShrinkAndRebin/(dense, 3)/4             1473 ns         1472 ns       476605 items_per_second=146.767M/s
ShrinkAndRebin/(dense, 3)/16           37030 ns        37001 ns        18502 items_per_second=157.616M/s
ShrinkAndRebin/(dense, 3)/64         1657680 ns      1656336 ns          422 items_per_second=173.573M/s
ShrinkAndRebin/(dense, 3)/256       95240547 ns     95165750 ns            8 items_per_second=180.459M/s
Rebin/(unlimited, 1)/4                   112 ns          112 ns      6233692 items_per_second=53.5377M/s
Rebin/(unlimited, 1)/16                  173 ns          172 ns      4051770 items_per_second=104.361M/s
Rebin/(unlimited, 1)/64                  407 ns          407 ns      1725315 items_per_second=162.332M/s
Rebin/(unlimited, 1)/256                1302 ns         1301 ns       538835 items_per_second=198.35M/s
Shrink/(unlimited, 1)/4                  114 ns          113 ns      6113804 items_per_second=52.8792M/s
Shrink/(unlimited, 1)/16                 179 ns          179 ns      3908584 items_per_second=100.795M/s
Shrink/(unlimited, 1)/64                 431 ns          430 ns      1622669 items_per_second=153.416M/s
Shrink/(unlimited, 1)/256               1330 ns         1329 ns       518780 items_per_second=194.064M/s
ShrinkAndRebin/(unlimited, 1)/4          114 ns          114 ns      6161863 items_per_second=52.7872M/s
ShrinkAndRebin/(unlimited, 1)/16         178 ns          178 ns      3929758 items_per_second=101.291M/s
ShrinkAndRebin/(unlimited, 1)/64         418 ns          418 ns      1672489 items_per_second=158.027M/s
ShrinkAndRebin/(unlimited, 1)/256       1339 ns         1339 ns       533272 items_per_second=192.751M/s
Rebin/(unlimited, 2)/4                   301 ns          300 ns      2330335 items_per_second=119.879M/s
Rebin/(unlimited, 2)/16                 1953 ns         1951 ns       361350 items_per_second=166.036M/s
Rebin/(unlimited, 2)/64                22896 ns        22879 ns        30344 items_per_second=190.395M/s
Rebin/(unlimited, 2)/256              342861 ns       342590 ns         2047 items_per_second=194.297M/s
Shrink/(unlimited, 2)/4                  320 ns          319 ns      2213782 items_per_second=112.694M/s
Shrink/(unlimited, 2)/16                2119 ns         2118 ns       329354 items_per_second=152.989M/s
Shrink/(unlimited, 2)/64               24316 ns        24299 ns        28973 items_per_second=179.268M/s
Shrink/(unlimited, 2)/256             354869 ns       354579 ns         1988 items_per_second=187.727M/s
ShrinkAndRebin/(unlimited, 2)/4          307 ns          306 ns      2298201 items_per_second=117.48M/s
ShrinkAndRebin/(unlimited, 2)/16        2127 ns         2126 ns       331454 items_per_second=152.424M/s
ShrinkAndRebin/(unlimited, 2)/64       24567 ns        24551 ns        28298 items_per_second=177.429M/s
ShrinkAndRebin/(unlimited, 2)/256     354821 ns       354518 ns         1976 items_per_second=187.759M/s
Rebin/(unlimited, 3)/4                  1789 ns         1788 ns       388548 items_per_second=120.818M/s
Rebin/(unlimited, 3)/16                44916 ns        44883 ns        15635 items_per_second=129.938M/s
Rebin/(unlimited, 3)/64              2091193 ns      2089589 ns          336 items_per_second=137.585M/s
Rebin/(unlimited, 3)/256           118513701 ns    118414833 ns            6 items_per_second=145.028M/s
Shrink/(unlimited, 3)/4                 1817 ns         1816 ns       388098 items_per_second=118.947M/s
Shrink/(unlimited, 3)/16               45816 ns        45786 ns        15229 items_per_second=127.376M/s
Shrink/(unlimited, 3)/64             2149526 ns      2147726 ns          318 items_per_second=133.861M/s
Shrink/(unlimited, 3)/256          122330076 ns    122229000 ns            6 items_per_second=140.503M/s
ShrinkAndRebin/(unlimited, 3)/4         1786 ns         1785 ns       395244 items_per_second=121.009M/s
ShrinkAndRebin/(unlimited, 3)/16       46015 ns        45982 ns        15371 items_per_second=126.831M/s
ShrinkAndRebin/(unlimited, 3)/64     2124902 ns      2123209 ns          330 items_per_second=135.406M/s
ShrinkAndRebin/(unlimited, 3)/256  121024590 ns    120924333 ns            6 items_per_second=142.019M/s

@henryiii

henryiii commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Looking at the benchmarks, I think I can improve on this a little for very small histograms, will push a slightly better version if it does benchmark better in a few minutes.

Removes the extra per-axis pass before the cell loop; the values are
now set in the axes_transform lambda, which already visits every axis.

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

henryiii commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Slightly improved version committed.

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