Profiling Your Range Pipelines with C++20 std::source_location
Pinpoint bottlenecks in lazy pipelines using std::source_location and zero-macro instrumentation
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_locationas a default argument is the right pattern — it auto-captures call-site info without macros- Wrapping
std::views::transform/std::views::filterwith 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