From one segmented range to two

In the first installment of this series we revisited Matt Austern's Segmented Iterators and Hierarchical Algorithms paper and measured what a modern compiler can do when a single-range, single-pass algorithm (find, fill, count, for_each, ...) is decomposed into per-segment flat loops. The results were encouraging: once the per-element block-boundary check disappears from the inner loop, auto-vectorisers wake up and speedups of 3×-6× (with 17× corner cases) appear on small trivially-comparable types.

This second installment moves one step up in difficulty: algorithms that read from one range and write to another one, such as copy, copy_n, copy_if, remove_copy, remove_copy_if, swap_ranges and transform. Now there are two ranges in play and each of them may be segmented, may be flat, or both at the same time.

The good news is that the two ranges are independent: the input walks forward, the output walks forward, and neither influences the shape of the other. The bad news is that the output side is not symmetrical to the input side, and this asymmetry is where all the interesting problems (and all the interesting performance results) of this article come from.

Challenges with the output range

Iterating the output to obtain segment bounds

Austern's original scheme decomposes a range [first, last) into its segments: you need both endpoints (first + last) to discover the (potentially recursive) segment boundaries. His paper's worked example is hierarchical_fill, and its shape is the template that every single-range hierarchical algorithm follows:

hierarchical_fill(first, last, value):
    sfirst = segment(first)            // segment containing the first element
    slast  = segment(last)             // segment containing the one-past-last

    if sfirst == slast: // whole range lives in one segment
        fill_dispatch(local(first), local(last), value)
    else:
        fill_dispatch(local(first), end(sfirst), value) //first segment: partial

        for seg = sfirst + 1 while seg != slast, ++seg: // middle segments: full
            fill_dispatch(begin(seg), end(seg), value)

        fill_dispatch(begin(slast), local(last), value) // last segment: partial

The three fill_dispatch calls need a local_iterator range; if that local_iterator is itself segmented, fill_dispatch calls hierarchical_fill again until the local iterator is flat, at which point it calls the classic STL-like fill. However, the STL output interface provides only a single output iterator (std::copy(first, last, result)) with no result_end argument and thus no segment(result_end) to compute and follow Austern's decomposition. result is assumed unbounded.

State of the art for segmented outputs

This is less well charted than the input segmentation side, but there are some interesting implementations. Some examples:

This article will try to outline a scheme that is simultaneously generic and recursive, and that supports independent segmentation on both sides, together with the problems encountered and the benchmark results.

Handling the missing bound for output iterators

The proposal is a two-phase approach that handles the unbounded output iterator and discovers (recursively) its segment boundaries.

Two-phase segmented output

So let's try to write some pseudo-code implementing this idea for output iterators. For simplicity, let's ignore any input range segmentation:

unbounded_copy(first, last, result):        // result is unbounded
    result_seg   = segment(result)          // Obtain result's current segment
    local_result = local(result)            // Obtain result's local position

    while first != last:        // Unbounded output loop
       local_result_end = end(result_seg)   // Obtain the missing local bound

       (first, local_result) =  // [local_result, local_result_end) is *bounded*
          bounded_copy(first, last, local_result, local_result_end)

       if first != last:        // if destination segment full
          ++result_seg          // only outermost may advance
          local_result = begin(result_seg)

    return compose(result_seg, local_result)

bounded_copy(first, last, dst, dst_end):    // both ends known
    if dst is segmented:
       // Austern first/middle/last calling bounded_copy recursively
    else:
       // leaf: process until input or destination exhausted
       while first != last and dst != dst_end:
          *dst++ = *first++
       return (first, dst)

Writing hierarchical_copy_if supporting input + output segmented iterators

Before explaining the difficulties and the code, it is worth recalling what we are starting from. This is the canonical implementation of std::copy_if, essentially unchanged since the original HP STL:

template <class InputIt, class OutputIt, class Pred>
OutputIt copy_if(InputIt first, InputIt last, OutputIt result, Pred pred)
{
   for (; first != last; ++first)
      if(pred(*first)) {
         *result = *first;
         ++result;
      }
   return result;
}

It's incredibly simple code, with no helper functions and no traits.

Main steps to write hierarchical_copy_if in classic C++

In order to write a hierarchical_copy_if that is generic and recursive, and that supports independent segmentation on both sides, we should:

Note 1: Effect of bounded destinations

In the classic STL interface the loop of the copy-family algorithms is bounded only by the input. That's an advantage for the code generator, even if the output iterator is complex (e.g. deque::iterator), as the innermost loop has exactly one termination condition:

for(; first != last; ++first) {
   *dst_first = *first;
   ++dst_first;
}

The produced code is efficient, and if the branch predictor does a good job for the deque::iterator internal segment logic, the CPU can process that loop quite efficiently. But once the destination is segmented, the write loop must stop at whichever comes first: input exhausted or destination segment full:

for(; first != last; ++first) {
   if(dst_first == dst_last)   //destination segment full?
      break;
   *dst_first = *first;
   ++dst_first;
}

This implies two loop-carried exit conditions instead of one, and this shape is not something that auto-vectorisers like; we will see the effect clearly in the benchmarks, especially when additional conditions (like predicates) are handled inside the leaf loop.

Note 2: Predicate call contracts

The second complication is related to correctness. For the conditionally-writing algorithms (copy_if, remove_copy, remove_copy_if) the standard mandates exactly last - first applications of the predicate. A hierarchical implementation, once both the input and output ranges have been flattened, splits the work into many leaf calls, and a leaf call may stop in the middle of the input because its destination segment filled up.

The obvious way to write the bounded leaf is to test the destination just before writing:

for(; first != last; ++first) {
   if(pred(*first)) {
      if(dst_first == dst_last)   //destination segment full?
         break;                   //Predicate called but output not produced
      *dst_first = *first;
      ++dst_first;
   }
}

But this approach is correct only at a price, as the loop can stop between testing an element and writing it. If the upper layer advances to the next destination segment and calls the leaf again, it resumes on that same element and pred is applied to it twice, breaking the guarantee. To avoid it, the leaf would have to report a third piece of state ("a write is owed on *first") and every enclosing level would need a special case to settle that write before resuming and calling the predicate again.

Another alternative is to reorder the test: check the destination once on entry, and then again only after a successful write:

if(dst_first == dst_last)         //no room at all: nothing tested yet
   return {first, dst_first};

for(; first != last; ++first) {
   if(pred(*first)) {
      *dst_first = *first;
      ++dst_first;
      if(dst_first == dst_last) { //destination segment full?
         ++first;                 //consume the element just written
         break;
      }
   }
}
return {first, dst_first};

Now the leaf never stops in the gap. On return, first either equals last or points at an element the predicate has never seen, so the caller simply calls again with a fresh destination segment. In any case, the predicate guarantee implies extra per-segment tests that can hurt performance, as those checks might subtly affect inlining, instruction cache usage and branch prediction.

hierarchical_copy_if, a possible implementation

Now, let's take a deep breath. Implementing all the steps and notes outlined before is no longer simple:

// (1) LEAF — both iterators are flat. Stops on input exhausted or
// destination full, and reports both final positions. To properly
// implement the predicate call guarantee, on return 'first' is always
// at an element pred has never seen, so the caller can resume with a
// fresh segment and no extra state.
template <class InIt, class DstIt, class Pred>
pair<InIt, DstIt> copy_if_bounded
   ( InIt first, InIt last, DstIt dst, DstIt dst_end
   , Pred pred, non_segmented_iterator_tag)
{
   if (dst == dst_end)                 // no room: nothing tested
      return {first, dst};

   for (; first != last; ++first) {
      if (pred(*first)) {              // exactly once per element, ever
         *dst = *first;
         ++dst;
         if (dst == dst_end) {
            ++first;                   // consume the element just written
            break;
         }
      }
   }
   return {first, dst};
}

// (2) BOUNDED SEGMENTED DESTINATION — used at every level below the outermost.
// Both destination endpoints are known, so Austern's decomposition is applied.
template <class InIt, class SegDst, class Pred>
pair<InIt, SegDst> copy_if_bounded
   ( InIt first, InIt last, SegDst dst_first, SegDst dst_last
   , Pred pred, segmented_iterator_tag)
{
   using out_tr    = segmented_iterator_traits<SegDst>;
   using local_it  = typename out_tr::local_iterator;
   using seg_it    = typename out_tr::segment_iterator;
   using local_tag = typename segmented_iterator_traits
                        <local_it>::is_segmented_iterator;

   seg_it       sfirst = out_tr::segment(dst_first);
   const seg_it slast  = out_tr::segment(dst_last);

   if (sfirst == slast) {   //whole output range in one segment
      auto r = copy_if_bounded 
         ( first, last, out_tr::local(dst_first), out_tr::local(dst_last)
         , pred, local_tag());
      return {r.first, out_tr::compose(sfirst, r.second)};
   }

   auto r = copy_if_bounded // first: [local(dst_first), end(sfirst))
      ( first, last, out_tr::local(dst_first), out_tr::end(sfirst)
      , pred, local_tag());
   first = r.first;
   if (first == last)
      return {first, out_tr::compose(sfirst, r.second)};

   for (++sfirst; sfirst != slast; ++sfirst) {// middle: [begin(seg), end(seg))
      r = copy_if_bounded
         ( first, last, out_tr::begin(sfirst), out_tr::end(sfirst)
         , pred, local_tag());
      first = r.first;
      if (first == last)
         return {first, out_tr::compose(sfirst, r.second)};
   }

   r = copy_if_bounded  // last: [begin(slast), local(dst_last))
      ( first, last, out_tr::begin(sfirst), out_tr::local(dst_last)
      , pred, local_tag());
   return {r.first, out_tr::compose(sfirst, r.second)};
}

// (3) UNBOUNDED DESTINATION, SEGMENTED — the outermost segmentation
// level for destination
template <class InIt, class SegOut, class Pred>
SegOut copy_if_dst_dispatch(InIt first, InIt last, SegOut result, Pred pred,
    segmented_iterator_tag)
{
   using out_tr    = segmented_iterator_traits<SegOut>;
   using local_it  = typename out_tr::local_iterator;
   using seg_it    = typename out_tr::segment_iterator;
   using local_tag = typename segmented_iterator_traits<local_it>
       ::is_segmented_iterator;

   if (first == last)
      return result;

   seg_it   seg   = out_tr::segment(result);
   local_it local = out_tr::local(result);

   for (;;) { // Convert unbounded iterator into a bounded local iterator range.
      auto r = copy_if_bounded(first, last, local, out_tr::end(seg), pred,
          local_tag());
      first = r.first;
      if (first == last)
         return out_tr::compose(seg, r.second);
      ++seg;        // go to the next segment
      local = out_tr::begin(seg);
   }
}

// (3b) the classic STL case: no output bound, so no destination test at all.
template <class InIt, class OutIt, class Pred>
OutIt copy_if_dst_dispatch(InIt first, InIt last, OutIt result, Pred pred,
    non_segmented_iterator_tag)
{
   for (; first != last; ++first) {
      if (pred(*first)) {
         *result = *first;
         ++result;
      }
   }
   return result;
}

// (4) SOURCE SIDE — Austern first/middle/last over the input.
template <class SegIn, class OutIt, class Pred>
OutIt copy_if_src_dispatch(SegIn first, SegIn last, OutIt result, Pred pred,
    segmented_iterator_tag)
{
   using tr    = segmented_iterator_traits<SegIn>;
   using local_it  = typename tr::local_iterator;
   using seg_it    = typename tr::segment_iterator;
   using local_tag = typename segmented_iterator_traits<local_it>
       ::is_segmented_iterator;

   seg_it       sfirst = tr::segment(first);
   const seg_it slast  = tr::segment(last);

   if (sfirst == slast)
      return copy_if_src_dispatch(tr::local(first), tr::local(last), result,
          pred, local_tag());

   result = copy_if_src_dispatch(tr::local(first), tr::end(sfirst), result,
       pred, local_tag());
   for (++sfirst; sfirst != slast; ++sfirst)
      result = copy_if_src_dispatch(tr::begin(sfirst), tr::end(sfirst), result,
          pred, local_tag());
   return copy_if_src_dispatch(tr::begin(sfirst), tr::local(last), result, pred,
        local_tag());
}

// (4b) Source flat: hand over to the destination side.
template <class InIt, class OutIt, class Pred>
OutIt copy_if_src_dispatch(InIt first, InIt last, OutIt result, Pred pred,
    non_segmented_iterator_tag)
{
   using out_traits = segmented_iterator_traits<OutIt>;
   return copy_if_dst_dispatch
      (first, last, result, pred, typename out_traits::is_segmented_iterator());
}

// (5) MAIN ALGORITHM — dispatch on input segmentation; destination is
// handled later.
template <class InIt, class OutIt, class Pred>
OutIt hierarchical_copy_if(InIt first, InIt last, OutIt result, Pred pred)
{
   using in_tr = segmented_iterator_traits<InIt>;
   return copy_if_src_dispatch
      (first, last, result, pred, typename in_tr::is_segmented_iterator());
}

How Boost.Container is experimenting with output segmentation

Boost.Container's experimental headers under boost/container/experimental/ turn the design sketched above into a working prototype library. The goal is not a drop-in replacement for <algorithm> yet — it is a testbed for a traits-driven, recursively segmented algorithm suite that can be measured against std:: on the same containers and iteratively refined. The main design points:

Extensible traits. segmented_iterator_traits exposes Austern's protocol — segment / local / begin / end / compose — as a public customization point. User iterators can opt in by specializing the traits.

Recursive on both sides. Every dispatch peels one level and asks whether the local_iterator is itself segmented. The output iterator segmentation is handled using the two-phase output approach explained earlier.

Independent segmentation, not lockstep. Source and destination are walked by separate outer loops. A source segment may spill across several destination segments, and vice versa.

Predicate-once contracts. Conditionally writing algorithms (segmented_copy_if, segmented_remove_copy_if) honour the classic STL rule of exactly last - first predicate applications.

So in the next chapter we'll use that experimental Boost code to benchmark segmented input-output algorithms and compare them with the Standard library.

Benchmarking segmented input-output algorithms

The invariants of the benchmarks are essentially the same as in the first article:

Since these are two-range algorithms, each of them is measured in three shape variants that describe which of the two ranges is segmented:

ShapeInput rangeOutput range
1Ssegmented (deque)flat (vector)
2Sflat (vector)segmented (deque)
1+2Ssegmented (deque)segmented (deque)

The seven algorithms and their hit/miss variants (30 sub-benchmarks in total, each in the three shapes above):

AlgorithmHit caseMiss case
copyCopies all N elements.
copy_nCopies the first N elements through the _n overload.
copy_ifPredicate is_odd(x) — true for half the elements; half the writes happen.Predicate x < 0 — never true; full scan, zero writes.
remove_copyValue = N/2 — present exactly once; all but one element written.Value = -1 — never present; all elements written.
remove_copy_ifPredicate x < N/4 or x > 3N/4 — true for half the elements; half the writes happen.Predicate x < 0 — never true; all elements written.
swap_rangesSwaps all N elements between the two ranges.
transformApplies x + 1 to every element, writing the result.

Three element types are used this time:

Note: all three types have user-provided copy operations, so they are not trivially copyable. Neither the standard library nor Boost can collapse any of these copies into memmove; every measurement below reflects genuine loop code generation.

As in the first article, each algorithm is executed in three modes, and for every algorithm and every value type the benchmark prints three columns:

ColumnWhat it isolatesMeaning of ratio > 1.0
nsg/segSame Boost implementation, same iterator object, with segmentation advertised or notBoost segmented algorithm path is X times faster than the Boost non-segmented path
std/segHow the segmented version compares to the platform's stock implementationBoost segmented path is X times faster than the std:: algorithm
std/nsgHow the platform's stock implementation compares to the same flat loop without the segmentation tagBoost non-segmented path is X times faster than the std:: algorithm, this ratio should be near 1.0

Tested compilers / standard libraries:

First benchmark: Segmented input-output algorithms

The first configuration is the direct generalisation of the first article: the input range is decomposed into segments exactly as before, the output range is decomposed with the same (potentially recursive) technique, and the leaves are bounded write loops carrying the two exit conditions shown earlier, plus the bookkeeping needed to honour the predicate-call contract.

Geomean per compiler, split by shape:

Shape 1S — 1S (segmented input, flat output)

T = MyInt:

Compilernsg/segstd/segstd/nsg
GCC 161.551.611.04
Clang 223.072.970.97
MSVC 20265.125.151.00

T = MyFatInt<4>:

Compilernsg/segstd/segstd/nsg
GCC 160.960.971.01
Clang 221.381.230.90
MSVC 20262.822.800.99

T = MyFatInt<8>:

Compilernsg/segstd/segstd/nsg
GCC 161.031.031.00
Clang 221.061.030.96
MSVC 20261.811.750.97

Per-algorithm for MyInt, shape 1S (geomean of the three compilers; per-compiler breakdowns are in the Annex):

Algorithmnsg/segstd/segstd/nsg
copy4.564.701.03
copy_if(hit)1.901.880.99
copy_if(miss)2.942.240.76
copy_n2.922.840.97
remove_copy(hit)2.322.741.18
remove_copy(miss)2.282.851.25
remove_copy_if(hit)1.881.850.98
remove_copy_if(miss)2.312.311.00
swap_ranges4.644.470.96
transform5.325.140.97
geomean2.902.911.00
First benchmark: std/seg per algorithm, T = MyInt, shape = 1S
First benchmark: std/seg per algorithm, T = MyFatInt<4>, shape = 1S
First benchmark: std/seg per algorithm, T = MyFatInt<8>, shape = 1S

Shape 2S — 2S (flat input, segmented output)

T = MyInt:

Compilernsg/segstd/segstd/nsg
GCC 161.051.020.98
Clang 220.770.831.08
MSVC 20261.861.881.01

T = MyFatInt<4>:

Compilernsg/segstd/segstd/nsg
GCC 161.041.041.00
Clang 221.031.041.01
MSVC 20261.421.350.96

T = MyFatInt<8>:

Compilernsg/segstd/segstd/nsg
GCC 161.021.021.00
Clang 221.011.021.00
MSVC 20261.231.190.97

Per-algorithm for MyInt, shape 2S (geomean of the three compilers; per-compiler breakdowns are in the Annex):

Algorithmnsg/segstd/segstd/nsg
copy1.631.641.00
copy_if(hit)0.930.920.99
copy_if(miss)0.770.680.87
copy_n1.431.591.12
remove_copy(hit)0.960.951.00
remove_copy(miss)1.021.010.99
remove_copy_if(hit)0.971.001.03
remove_copy_if(miss)1.041.281.23
swap_ranges1.491.601.07
transform1.561.470.94
geomean1.141.171.02
First benchmark: std/seg per algorithm, T = MyInt, shape = 2S
First benchmark: std/seg per algorithm, T = MyFatInt<4>, shape = 2S
First benchmark: std/seg per algorithm, T = MyFatInt<8>, shape = 2S

Shape 1+2S — 1+2S (segmented input and output)

T = MyInt:

Compilernsg/segstd/segstd/nsg
GCC 161.361.361.00
Clang 221.972.041.03
MSVC 20263.763.640.97

T = MyFatInt<4>:

Compilernsg/segstd/segstd/nsg
GCC 161.301.361.04
Clang 221.271.250.99
MSVC 20262.441.940.79

T = MyFatInt<8>:

Compilernsg/segstd/segstd/nsg
GCC 161.081.091.01
Clang 221.161.150.99
MSVC 20261.751.250.71

Per-algorithm for MyInt, shape 1+2S (geomean of the three compilers; per-compiler breakdowns are in the Annex):

Algorithmnsg/segstd/segstd/nsg
copy3.543.541.00
copy_if(hit)2.091.990.95
copy_if(miss)2.072.010.97
copy_n3.423.691.08
remove_copy(hit)1.541.520.98
remove_copy(miss)1.931.890.98
remove_copy_if(hit)1.431.461.02
remove_copy_if(miss)1.901.880.99
swap_ranges3.203.301.03
transform1.621.631.00
geomean2.162.161.00
First benchmark: std/seg per algorithm, T = MyInt, shape = 1+2S
First benchmark: std/seg per algorithm, T = MyFatInt<4>, shape = 1+2S
First benchmark: std/seg per algorithm, T = MyFatInt<8>, shape = 1+2S

Reading the first benchmark

The distribution is bimodal, and the split follows the shape column, not the algorithm:

Why does segmenting only the output hurt so much, when segmenting only the input helps so much? Looking at the generated assembly, we can identify three causes:

  1. The bounded write loop does not vectorise. With the destination bound live inside the loop there are two loop-carried exit conditions, and both GCC and Clang give up on SIMD for the plain copy(2S) leaf. MSVC is the exception: its vectoriser handles some of the two-exit loops, which is one reason its 2S column degrades less.
  2. Conditional writes plus a destination bound is the worst combination. In copy_if / remove_copy_if the store address advances data-dependently (only on predicate hits), which already rules out straightforward vectorisation — the standard's "exactly N predicate applications" contract also forbids speculative execution alternatives, and adding the destination-full test puts a second unpredictable branch in the loop.
  3. Per-segment bookkeeping is not negligible. With the needed machinery (compose/decompose, input-output iterator returns...), some compilers might not inline some calls, the calling convention might use addresses instead of registers, and inlining decisions can be altered.

The element-type columns also give us important information:

Second benchmark: taking advantage of random-access leaves

The optimization

There is an observation that can help the 2S cases (destination-only segmentation):

For the unconditional algorithms (copy, copy_n, swap_ranges, transform, and remove_copy(miss)-like flows), instead of testing the destination on every element, the leaf can compute the number of elements to process once and can fall back to the single-exit loop:

//Random-access source and destination: precompute the trip count,
//then run the single-exit loop
for( difference_type n = min(last - first, dst_last - dst_first)
   ; n ; ++first, ++dst_first) {
   --n;
   *dst_first = *first;
}

One subtraction and one comparison can replace N per-element destination tests, and — more importantly — the inner loop recovers a single-exit shape that can help auto-vectorisers.

For the conditionally-writing algorithms (copy_if, remove_copy...) the trip count cannot be precomputed (the number of writes is predicate-dependent), but the check can still be amortised: as long as the destination has at least B slots free, a block of B source elements can be processed with no destination test at all, because even if every predicate hits, all the writes fit in the destination. Moreover, if B is a power of two, it will probably help the optimizer select better instructions:

//Random-access source and destination: if both have room to process
//B elements in the worst case, then the inner loop is greatly simplified
difference_type n = last - first;
while(n >= B && (dst_last - dst_first) >= B) {
   n -= B;
   for(difference_type chunk = B; chunk; --chunk, ++first) {
      if(pred(*first)) {
         *dst_first = *first;
         ++dst_first;
      }
   }
}
//tail: per-element destination checks...

The destination test now runs once per B elements (e.g. 16 in the Boost implementation) instead of once per element, the predicate is still applied exactly once per element, and the loop body might be light enough for the compiler to unroll.

Benchmark results

Same benchmark, same machine, same compilers, with these random-access leaves enabled. We will split the results by shape again:

Shape 1S — 1S (segmented input, flat output)

T = MyInt:

Compilernsg/segstd/segstd/nsg
GCC 161.921.870.97
Clang 223.012.950.98
MSVC 20265.295.271.00

T = MyFatInt<4>:

Compilernsg/segstd/segstd/nsg
GCC 160.991.051.06
Clang 221.411.180.84
MSVC 20262.652.640.99

T = MyFatInt<8>:

Compilernsg/segstd/segstd/nsg
GCC 161.011.021.01
Clang 221.091.040.95
MSVC 20261.791.740.97
Second benchmark: std/seg per algorithm, T = MyInt, shape = 1S
Second benchmark: std/seg per algorithm, T = MyFatInt<4>, shape = 1S
Second benchmark: std/seg per algorithm, T = MyFatInt<8>, shape = 1S

Shape 2S — 2S (flat input, segmented output)

T = MyInt:

Compilernsg/segstd/segstd/nsg
GCC 162.782.811.01
Clang 222.562.611.02
MSVC 20262.592.591.00

T = MyFatInt<4>:

Compilernsg/segstd/segstd/nsg
GCC 161.301.321.02
Clang 221.241.301.05
MSVC 20261.551.470.95

T = MyFatInt<8>:

Compilernsg/segstd/segstd/nsg
GCC 161.031.031.00
Clang 221.031.031.00
MSVC 20261.191.140.96
Second benchmark: std/seg per algorithm, T = MyInt, shape = 2S
Second benchmark: std/seg per algorithm, T = MyFatInt<4>, shape = 2S
Second benchmark: std/seg per algorithm, T = MyFatInt<8>, shape = 2S

Shape 1+2S — 1+2S (segmented input and output)

T = MyInt:

Compilernsg/segstd/segstd/nsg
GCC 163.243.241.00
Clang 224.023.890.97
MSVC 20266.676.450.97

T = MyFatInt<4>:

Compilernsg/segstd/segstd/nsg
GCC 161.531.581.03
Clang 221.561.621.04
MSVC 20262.592.060.79

T = MyFatInt<8>:

Compilernsg/segstd/segstd/nsg
GCC 161.091.091.00
Clang 221.161.161.00
MSVC 20261.811.280.71
Second benchmark: std/seg per algorithm, T = MyInt, shape = 1+2S
Second benchmark: std/seg per algorithm, T = MyFatInt<4>, shape = 1+2S
Second benchmark: std/seg per algorithm, T = MyFatInt<8>, shape = 1+2S

Reading the random-access iterator optimized benchmark

Good news: the MyInt geomean is greatly improved, and the previously identified weak points improve:

On the fat types the improvements are real but bounded again by the memory bandwidth. Once the algorithm saturates the bus, executing fewer instructions barely improves times.

Final conclusions

Let's see the geomean of std/seg (Boost segmented algorithm is X times faster than std) across all 30 sub-benchmarks for each compiler, comparing the forward-iterator leaves of the first benchmark with the random-access leaves of the second, one chart per value type:

Geomean std/seg per compiler, T = MyInt
Geomean std/seg per compiler, T = MyFatInt<4>
Geomean std/seg per compiler, T = MyFatInt<8>

Some findings about segmented input-output algorithms that are worth carrying forward from this analysis:

  1. Austern's decomposition can be extended to output ranges, but it needs special handling. A bounded, two-exit-condition write loop defeats today's auto-vectorisers, and predicate-dependent writes can make the algorithm slower than a flat loop.
  2. Random-access optimizations are important. When the localmost iterators on both sides are random-access, precomputing the trip count for unconditional algorithms, and block-amortising the destination check for conditional ones, yields a compiler-friendly inner loop.
  3. The standard's predicate-call contract has a measurable impact. Exactly-N predicate applications forbid speculative or re-scanning implementations, forcing leaves to report input-consumption and output-position precisely and keeping the conditionally-writing algorithms less optimizable. A hypothetical relaxed contract would open the door to better-performing algorithms.
  4. Segmented gains are not universal: for some types and algorithms, memory bandwidth is a hard limit that can't be surpassed. Segmented algorithms shine where memory pressure is low and the work is instruction-bound.

In the first article we ended by noting that Austern's abstraction ages into hardware improvements. This second article adds a nuance: the abstraction is also restricted by interface decisions. The STL's unbounded output iterators are a good simplification, but we might need novel improvements to Austern's pattern to fully exploit segmented algorithm opportunities.

Achieved speedups are very encouraging and Austern's proposal seems a promising research line to improve good old C++ algorithms. Let's hope C++ algorithm experts can find time to improve and incorporate these ideas into a future C++ standard.


Annex: Per-compiler results

Each compiler section shows one chart per value type: 30 sub-benchmarks on the X axis and paired bars for the first and second benchmark configurations (std/seg ratio). The Y axis is not shared between charts — the goal is to see each compiler's internal ranking and the first-vs-second contrast. The tables list every sub-benchmark for T = MyInt (B1 = first benchmark, B2 = second benchmark).

GCC 16

GCC 16 per-algorithm results, T = MyInt
GCC 16 per-algorithm results, T = MyFatInt<4>
GCC 16 per-algorithm results, T = MyFatInt<8>

GCC is the compiler that gains the most relative ground from the random-access leaves on MyInt: the geomean doubles (1.31× → 2.57×). Without them, GCC only keeps the 1S store-heavy loops (transform(1S) 3.55×, swap_ranges(1S) 3.49×) and everything touching a segmented destination sits at 0.68×-1.4×. With them, the whole copy/copy_n/swap_ranges/transform family lands in the 3.2×-7.1× band (copy_n(1+2S) 7.06×, transform(1+2S) 6.82×). The conditional algorithms settle at 1.8×-3.1× — GCC does not if-convert the blocked copy_if body, so the predicate branch stays, but the amortised destination check still pays. On MyFatInt<4> the geomean moves 1.11× → 1.30×; on MyFatInt<8> nothing moves — pure bandwidth.

Algorithmnsg/seg B1std/seg B1std/nsg B1nsg/seg B2std/seg B2std/nsg B2
copy(1S)2.582.781.083.323.170.95
copy(2S)1.111.111.004.334.341.00
copy(1+2S)1.111.261.145.595.320.95
copy_if(1S hit)1.001.011.011.151.080.94
copy_if(2S hit)1.201.160.972.602.741.06
copy_if(1+2S hit)2.021.750.873.013.061.02
copy_if(1S miss)1.121.100.990.890.921.03
copy_if(2S miss)0.780.680.871.681.811.08
copy_if(1+2S miss)1.551.390.892.202.050.93
copy_n(1S)0.920.880.954.514.320.96
copy_n(2S)0.940.850.914.464.140.93
copy_n(1+2S)1.381.671.216.307.061.12
remove_copy(1S hit)1.221.511.241.721.310.76
remove_copy(2S hit)1.401.360.972.242.180.97
remove_copy(1+2S hit)1.041.061.022.032.241.10
remove_copy(1S miss)1.191.531.291.471.240.84
remove_copy(2S miss)1.191.170.981.811.851.02
remove_copy(1+2S miss)1.521.490.981.981.940.98
remove_copy_if(1S hit)1.010.980.960.971.141.18
remove_copy_if(2S hit)1.111.131.011.931.991.03
remove_copy_if(1+2S hit)1.331.260.952.152.040.95
remove_copy_if(1S miss)1.531.531.001.281.521.19
remove_copy_if(2S miss)1.211.351.122.072.010.97
remove_copy_if(1+2S miss)1.321.341.022.172.100.97
swap_ranges(1S)3.473.491.013.463.971.15
swap_ranges(2S)0.750.751.003.963.991.01
swap_ranges(1+2S)1.161.070.924.284.250.99
transform(1S)3.983.550.894.043.400.84
transform(2S)0.950.920.975.175.281.02
transform(1+2S)1.361.501.106.846.821.00
geomean1.301.311.012.592.570.99

Clang 22

Clang 22 per-algorithm results, T = MyInt
Clang 22 per-algorithm results, T = MyFatInt<4>
Clang 22 per-algorithm results, T = MyFatInt<8>

Clang shows the starkest before/after contrast, because its vectoriser is both the most eager and the most easily blocked. In the first benchmark it produces the best 1S numbers of the matrix (transform(1S) 7.17×, copy(1S) 5.85×) and the worst 2S numbers (copy(2S) 0.67×, remove_copy(2S miss) 0.69×, transform(2S) 0.70×) — the bounded write loop completely disables its SIMD engine. With the random-access leaves the same rows read 4.42×, 1.53× and 5.76×, and transform posts 5.8×-7.4× across all three shapes. An interesting oddity: copy_if(1S miss) shows std/nsg ≈ 0.5, meaning libstdc++'s std::copy_if is twice as fast as the plain flat loop on a miss-heavy scan — libstdc++ hand-hoists the write out of the branch in a way Clang rewards; the segmented path matches that trick only partially. On MyFatInt<4> the geomean moves 1.17× → 1.35×, on MyFatInt<8> 1.06× → 1.07×: the bandwidth ceiling again.

Algorithmnsg/seg B1std/seg B1std/nsg B1nsg/seg B2std/seg B2std/nsg B2
copy(1S)5.775.851.015.945.921.00
copy(2S)0.670.671.004.424.421.00
copy(1+2S)6.856.580.966.996.991.00
copy_if(1S hit)1.671.590.961.651.741.05
copy_if(2S hit)0.800.800.991.281.230.96
copy_if(1+2S hit)1.491.491.002.772.740.99
copy_if(1S miss)5.292.380.454.582.450.53
copy_if(2S miss)0.870.690.791.882.011.07
copy_if(1+2S miss)2.002.051.035.825.700.98
copy_n(1S)5.365.160.965.455.210.96
copy_n(2S)0.590.911.554.997.791.56
copy_n(1+2S)6.266.521.047.788.151.05
remove_copy(1S hit)1.732.311.341.642.321.41
remove_copy(2S hit)0.830.790.951.461.400.96
remove_copy(1+2S hit)1.041.010.972.122.141.01
remove_copy(1S miss)1.532.321.521.532.301.51
remove_copy(2S miss)0.700.690.981.561.530.98
remove_copy(1+2S miss)0.990.950.972.062.071.01
remove_copy_if(1S hit)1.831.820.991.781.560.88
remove_copy_if(2S hit)0.780.811.041.441.451.01
remove_copy_if(1+2S hit)1.051.231.172.662.470.93
remove_copy_if(1S miss)1.591.540.971.591.550.98
remove_copy_if(2S miss)0.791.331.682.391.820.76
remove_copy_if(1+2S miss)1.181.130.962.192.170.99
swap_ranges(1S)4.934.320.885.014.330.87
swap_ranges(2S)0.901.111.224.375.381.23
swap_ranges(1+2S)6.227.991.286.725.000.74
transform(1S)7.077.171.017.016.890.98
transform(2S)0.800.700.876.615.760.87
transform(1+2S)0.880.881.007.437.441.00
geomean1.671.711.033.143.110.99

MSVC 2026 (toolset v14.51)

MSVC 2026 per-algorithm results, T = MyInt
MSVC 2026 per-algorithm results, T = MyFatInt<4>
MSVC 2026 per-algorithm results, T = MyFatInt<8>

MSVC starts from the highest first-benchmark baseline (3.28× geomean on MyInt) for two reasons: its v14.51 vectoriser survives some of the bounded write loops that stop GCC and Clang cold, and its scalar flat loops are comparatively slow, which inflates every X/seg ratio. Still, the random-access leaves lift it to 4.45×, and the conditional algorithms respond more dramatically here than anywhere else: copy_if(1+2S hit) jumps from 3.02× to 9.84× and copy_if(1+2S miss) from 2.87× to 7.10× — the v14.51 back-end if-converts the blocked predicate body into branchless code once the destination check leaves the loop. The lingering weak spot is the flat-source 2S family (remove_copy(2S), copy_if(2S) around 1.0×-1.7×), where the Microsoft STL's own loops are already reasonable and the deque write side dominates. MSVC is also the only compiler that keeps meaningful gains on MyFatInt<8> (1.38× and 1.36× geomean, essentially unchanged between benchmarks): its scalar baseline is far enough from the bandwidth ceiling that per-block iteration still shows.

Algorithmnsg/seg B1std/seg B1std/nsg B1nsg/seg B2std/seg B2std/nsg B2
copy(1S)6.376.381.005.945.941.00
copy(2S)5.875.881.006.496.491.00
copy(1+2S)5.835.370.926.826.250.92
copy_if(1S hit)4.114.131.004.764.771.00
copy_if(2S hit)0.840.841.011.331.331.00
copy_if(1+2S hit)3.023.021.009.839.841.00
copy_if(1S miss)4.304.311.004.344.351.00
copy_if(2S miss)0.680.660.970.941.011.08
copy_if(1+2S miss)2.872.871.007.107.101.00
copy_n(1S)5.045.041.005.895.891.00
copy_n(2S)5.225.190.995.965.941.00
copy_n(1+2S)4.634.631.006.106.111.00
remove_copy(1S hit)5.925.921.005.445.441.00
remove_copy(2S hit)0.760.811.071.721.680.98
remove_copy(1+2S hit)3.403.270.966.556.541.00
remove_copy(1S miss)6.506.511.006.506.501.00
remove_copy(2S miss)1.271.291.011.501.460.97
remove_copy(1+2S miss)4.774.771.006.756.751.00
remove_copy_if(1S hit)3.573.571.003.993.820.96
remove_copy_if(2S hit)1.051.101.052.312.361.02
remove_copy_if(1+2S hit)2.081.990.956.776.250.92
remove_copy_if(1S miss)5.095.221.035.195.191.00
remove_copy_if(2S miss)1.181.160.981.321.300.98
remove_copy_if(1+2S miss)4.414.411.005.875.871.00
swap_ranges(1S)5.855.931.015.915.921.00
swap_ranges(2S)4.894.891.005.895.891.00
swap_ranges(1+2S)4.564.190.925.735.260.92
transform(1S)5.345.341.005.485.471.00
transform(2S)4.974.981.005.955.951.00
transform(1+2S)3.583.290.925.985.510.92
geomean3.303.280.994.504.450.99