← Back to list

Profiling Your Range Pipelines with C++20 std::source_location

Pinpoint bottlenecks in lazy pipelines using std::source_location and zero-macro instrumentation

Sagar in Towards Dev · 2026-04-20 14:25 · 3 claps · 5.4 min read
#software-development #programming #cpp #cpp20 #c-plus-plus-language
Open on Medium ↗
Wiki topics: GRW · Growth & Analytics 💻 · Programming

Profiling Your Range Pipelines with C++20 std::source_location

Pinpoint bottlenecks in lazy pipelines using std::source_location and zero-macro instrumentation

C++20 ranges give you beautifully composable pipelines — but they come with a trade-off: you lose visibility into what each stage actually costs. When performance regresses, the pipeline becomes a black box. Which adaptor is slow? How often is it called? The standard library won’t tell you, and profilers don’t always point back to the lines you care about.

In this post, we’ll fix that by instrumenting range pipelines directly — using **std::source_location** to tag each stage at the call site, and lightweight wrappers to measure exactly how much time each adaptor spends doing work. The result is a pipeline that not only reads cleanly, but tells you precisely where it hurts.

First, Let’s Talk About std::source_location

Before C++20, you had two options for capturing call-site info: the ancient **__FILE__/__LINE__ macros (ugly, error-prone in templates), or nothing. `std::source_location`** fixes this properly.

The key insight is that it captures the call site, not where the function is defined. That distinction matters a lot once you start passing it through wrappers.

#include <source_location>
#include <iostream>

void log_me(std::string_view msg,
            std::source_location loc = std::source_location::current()) {
    std::cout << loc.file_name() << ":"
              << loc.line()      << " ["
              << loc.function_name() << "] "
              << msg << "\n";
}

int main() {
    log_me("hello");   // prints YOUR line number, not log_me's
}

The trick is the default argument. **std::source_location::current()** is evaluated at the call site when used as a default parameter. If you call it inside the function body instead, you get the function's own location — which is almost never what you want.

Four fields you’ll actually use:

The Problem: Range Pipelines Are Opaque

You write something like this, ship it, and your pipeline starts getting slow on certain datasets:

auto result = data
    | std::views::filter(is_valid)
    | std::views::transform(normalize)
    | std::views::filter(above_threshold)
    | std::views::transform(expensive_computation);

Where’s the bottleneck? You have no idea. The pipeline is lazy — it evaluates on demand — and none of the standard adaptors have any instrumentation. You either reach for a profiler or start wrapping things by hand. Let’s do the latter, but make it automatic.

The Architecture

Each stage in the pipeline is wrapped with a thin adaptor **timed_view** that measures execution time and records it into a shared stats object. The key detail is that std::source_location is captured at the call site, so every measurement maps back to the exact line where the pipeline is defined—not somewhere inside <ranges>.

Let’s build it now.

Building the Instrumented Wrapper

The Stats Accumulator

Keep it simple and thread-unsafe for now — you can add a mutex if you need it:

#include <chrono>
#include <source_location>
#include <string_view>
#include <atomic>
#include <cstdio>

struct StepStats {
    std::source_location loc;
    std::string_view     label;

    // Using atomics so multiple threads draining the same range don't corrupt
    std::atomic<uint64_t> total_ns{0};
    std::atomic<uint64_t> call_count{0};

    void record(std::chrono::nanoseconds elapsed) noexcept {
        total_ns.fetch_add(
            static_cast<uint64_t>(elapsed.count()),
            std::memory_order_relaxed
        );
        call_count.fetch_add(1, std::memory_order_relaxed);
    }

    void report() const {
        auto calls = call_count.load(std::memory_order_relaxed);
        auto ns    = total_ns.load(std::memory_order_relaxed);
        std::fprintf(stderr,
            "[%s:%u] %-30s  total=%lluµs  calls=%llu  avg=%.1fµs\n",
            loc.file_name(), loc.line(),
            label.data(),
            (unsigned long long)(ns / 1000),
            (unsigned long long)(calls),
            calls ? (double)ns / calls / 1000.0 : 0.0
        );
    }
};

The Timed Transform Adaptor

This wraps any callable and times each invocation:

#include <ranges>
#include <functional>

// Wraps a callable F, recording timing into a StepStats reference on each call.
template<typename F>
struct TimedTransformFn {
    F          inner;
    StepStats& stats;  // non-owning reference — stats must outlive the view

    template<typename T>
    auto operator()(T&& val) const {
        auto t0 = std::chrono::steady_clock::now();

        // Forward to the real transform
        decltype(auto) result = std::invoke(inner, std::forward<T>(val));

        auto t1 = std::chrono::steady_clock::now();
        stats.record(t1 - t0);

        return result;
    }
};

// Factory — source_location captured here at the call site
template<typename F>
auto timed_transform(F&& fn,
                     StepStats& stats,
                     std::source_location loc = std::source_location::current()) {
    stats.loc = loc;   // stamp the location into the stats bucket
    return std::views::transform(
        TimedTransformFn<std::decay_t<F>>{ std::forward<F>(fn), stats }
    );
}

The Timed Filter Adaptor

Same idea, filter flavour:

template<typename Pred>
struct TimedFilterPred {
    Pred       inner;
    StepStats& stats;

    template<typename T>
    bool operator()(T&& val) const {
        auto t0 = std::chrono::steady_clock::now();
        bool result = std::invoke(inner, std::forward<T>(val));
        auto t1 = std::chrono::steady_clock::now();
        stats.record(t1 - t0);
        return result;
    }
};

template<typename Pred>
auto timed_filter(Pred&& pred,
                  StepStats& stats,
                  std::source_location loc = std::source_location::current()) {
    stats.loc = loc;
    return std::views::filter(
        TimedFilterPred<std::decay_t<Pred>>{ std::forward<Pred>(pred), stats }
    );
}

Lets’s Put It All Together now and test

#include <vector>
#include <numeric>
#include <iostream>
#include <algorithm>

int main() {
    std::vector<int> data(100'000);
    std::iota(data.begin(), data.end(), 0);

    // One StepStats per pipeline stage — lives as long as the pipeline runs
    StepStats s_filter1 { .label = "filter(is_valid)"         };
    StepStats s_xform1  { .label = "transform(normalize)"     };
    StepStats s_filter2 { .label = "filter(above_threshold)"  };
    StepStats s_xform2  { .label = "transform(expensive)"     };

    auto is_valid        = [](int x) { return x % 2 == 0; };
    auto normalize       = [](int x) { return x / 2; };
    auto above_threshold = [](int x) { return x > 1000; };
    auto expensive       = [](int x) {
        // Simulate work
        volatile int acc = 0;
        for (int i = 0; i < 50; ++i) acc += x * i;
        return acc;
    };

    // Pipeline — source_location is snapshotted on each of these lines
    auto pipeline = data
        | timed_filter   (is_valid,        s_filter1)   // line captured here
        | timed_transform(normalize,       s_xform1)    // and here
        | timed_filter   (above_threshold, s_filter2)   // and here
        | timed_transform(expensive,       s_xform2);   // and here

    // Drain it
    long long sum = 0;
    for (auto v : pipeline) sum += v;

    std::cout << "sum = " << sum << "\n\n";

    // Report — each line tells you exactly where in source the bottleneck is
    s_filter1.report();
    s_xform1.report();
    s_filter2.report();
    s_xform2.report();
}

Sample output:

sum = 2489510000

[main.cpp:38] filter(is_valid)            total=312µs   calls=100000  avg=0.003µs
[main.cpp:39] transform(normalize)        total=198µs   calls=50000   avg=0.004µs
[main.cpp:40] filter(above_threshold)     total=145µs   calls=50000   avg=0.003µs
[main.cpp:41] transform(expensive)        total=18420µs calls=23501   avg=0.783µs

Right there: **transform(expensive) at `main.cpp:41` costs 60x **more per call than anything else.

No flame graphs, no guesswork — just a direct mapping from cost to code.

Caution!

Lifetime. **StepStats** is held by reference inside the view. Don't let the stats bucket go out of scope before you finish iterating. Define them before the pipeline, same scope.

Lazy evaluation timing. The pipeline only runs when you iterate. Timing accumulates during the **for loop, not when you write `data | timed_filter(...)`**. This is correct — it's what you want.

**function_name() noise in templates. On MSVC you'll get decorated names. GCC/Clang give you the full template instantiation signature. For logging you probably want to pass an explicit `label** string anyway, as shown above, rather than relying onloc.function_name()`.

Release builds. **std::source_location compiles to zero-overhead constants — no runtime penalty for capturing location. The timing itself (`steady_clock::now()`**) is the cost, roughly 10–30ns per call depending on platform. For hot paths measured in nanoseconds, strip the instrumentation with a compile flag:

#ifdef PIPELINE_PROFILE
    auto pipeline = data | timed_filter(is_valid, s_filter1) | ...;
#else
    auto pipeline = data | std::views::filter(is_valid) | ...;
#endif

Final Thoughts

The goal isn’t just to measure — it’s to make performance visible at the level you actually write code. Once the pipeline stops being opaque, optimization stops being guesswork and starts becoming mechanical.

Key takeaways:

  • std::source_location as a default argument is the right pattern — it auto-captures call-site info without macros
  • Wrapping std::views::transform / std::views::filter with a timing predicate is cheap and composable
  • Stats live outside the view, so you can inspect them after the pipeline drains
  • The line numbers in output point directly at your pipeline definition, not at <ranges> internals

If you’re serious about passing technical interviews, try PracHub. It helped me structure my preparation with advanced mock sessions. [**Check it out here**] and start practicing. (Disclosure: This is an affiliate link).

Found this article helpful ? Clap 👏 and follow me for more C++ and system programming content.


메타데이터
post_id
38ad26547eb9
slug
profiling-cpp20-range-pipelines-source-location-38ad26547eb9
url
https://towardsdev.com/profiling-cpp20-range-pipelines-source-location-38ad26547eb9
canonical_url
https://towardsdev.com/profiling-cpp20-range-pipelines-source-location-38ad26547eb9
author_url
https://medium.com/@sagarmadala
status
ok
fetched_at
2026-06-23 03:48:11