Skip to content

Commit 525dd4b

Browse files
committed
perf(notecard): Collapse execute() prologue to non-template core
execute<RequestT>() is instantiated once per request type, so every branch in its body was code-generated and coverage-counted per type. Move the type-independent prologue — begin/end timing and the transport/alloc/backend path selection — into non-template begin_execute()/end_execute() members, so those branches (and the inlined debug_timing null-checks) are emitted once rather than per instantiation. Only the type-dependent execute_streaming/ execute_tree calls remain in the template. Behaviour is unchanged (still one TransactionBegin + one TransactionEnd per call; identical error strings; request-id bump only on the buffered path). Host suite + full HIL sweep (serial + i2c on MPCB 1.9) pass; AVR unaffected (StaticNotecard is a separate, already-collapsed path). Raises raw branch coverage 89.6% -> 92.1%; MIN_BRANCH_COV restored 89 -> 92. ci.sh documents why 93 is not stably reachable (the raw metric counts inlined-per-instantiation branch copies; see DESIGN notes).
1 parent 83c6e13 commit 525dd4b

2 files changed

Lines changed: 98 additions & 47 deletions

File tree

ci.sh

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -488,17 +488,31 @@ MIN_FUNC_COV=90
488488
# Line (96.3%) and function (97.7%) — the logic-level signals — stay well above
489489
# their floors.
490490
#
491-
# EXPLORATION (design session pending — see DESIGN-branch-coverage-floor.md):
492-
# reaching 93 again is NOT a small fix. The gap from ~89.6 to 93 is ~430 branch
493-
# records; the known collapse refactors (body_handler_factory type-erasure ~18,
494-
# struct_sink make_body_handler switch ~18) recover far less. The session should
495-
# decide between: (a) an instantiation-stable branch metric (de-inflate BRDA to
496-
# source branches, mirroring the function check's per-file FNF/FNH), (b) broader
497-
# template-collapse refactors, (c) a library-wide pass of targeted error-path
498-
# branch tests, or (d) accepting a lower floor as the honest reality for a
499-
# heavily-templated header lib and documenting why. Until then, 89 holds the
500-
# line at the measured value so the gate stays meaningful, not green-by-default.
501-
MIN_BRANCH_COV=89
491+
# RESOLVED (2026-06, see DESIGN-branch-coverage-floor.md): the investigation
492+
# concluded that the raw BRDA metric is fundamentally instantiation-noisy on this
493+
# header-template library — it counts (instantiation x branch) tuples, so the
494+
# number moves unpredictably as GCC regenerates instantiations. Proven three ways:
495+
# collapsing retry_transaction LOWERED it (-2.7, type-erasure adds uncovered
496+
# per-instantiation trampolines); adding generated per-op retry tests LOWERED it
497+
# (-0.6) EVEN with retries correctly enabled — retry_transaction is inlined into
498+
# each execute<T>, so extra test call sites multiply the inlined branch copies
499+
# (notecard.hpp L339) faster than the tests cover them (retry.hpp itself hit
500+
# 94%, but the inlined copies dominate); and adding the lock tests lowered it
501+
# earlier (-1.4). The de-inflated source-level metric (~90%) is the only stable
502+
# measure.
503+
#
504+
# What worked: collapsing Notecard::execute()'s type-independent prologue
505+
# (timing + transport/alloc/backend selection) into the non-template
506+
# begin_execute()/end_execute() — those branches are now emitted and measured
507+
# ONCE instead of per request-type, lifting raw branch 89.6% -> 92.1%.
508+
#
509+
# Floor set to 92 on the raw metric (the post-collapse value). Reproducible for
510+
# the current tree; the documented risk is that FUTURE test/code additions can
511+
# shift instantiations and dip it below 92 (the metric's noise, not a real
512+
# regression). If that becomes a recurring annoyance, switch to the
513+
# instantiation-stable de-inflated metric (gcov --json fold; prototype validated
514+
# at ~90%) and floor there — it is immune to the pathology.
515+
MIN_BRANCH_COV=92
502516

503517
check_coverage_thresholds() {
504518
local lcov_file="$1"

src/note/notecard.hpp

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -319,45 +319,38 @@ class Notecard {
319319
ApiResult<typename RequestT::Response> execute(const RequestT& req) {
320320
return run_operation([&]() -> ApiResult<typename RequestT::Response> {
321321
using Rsp = typename RequestT::Response;
322-
323-
debug_timing(debug_, TimingEvent::TransactionBegin, RequestT::notecard_request);
324-
325-
if (!transport_) {
326-
debug_timing(debug_, TimingEvent::TransactionEnd, RequestT::notecard_request);
327-
return Unexpected(make_error(Error::NotReady, NOTE_ERR("no transport configured")));
328-
}
329-
330-
// Streaming path: SAX-stream the response straight into the
331-
// typed Rsp::Sink, no intermediate tree. Parallel to
332-
// execute_tree below — the two are siblings, picked at this
333-
// call site by (a) whether the response type has a sink and
334-
// (b) whether an allocator is configured.
335-
if constexpr (std::is_void_v<Rsp> || detail::has_sink<Rsp>::value) {
336-
if (alloc_.has_value()) {
337-
auto result = execute_streaming(req);
338-
debug_timing(debug_, TimingEvent::TransactionEnd, RequestT::notecard_request);
339-
return result;
322+
constexpr bool can_stream =
323+
std::is_void_v<Rsp> || detail::has_sink<Rsp>::value;
324+
325+
// Type-independent prologue (timing + path selection) — see
326+
// begin_execute(). Only the type-dependent calls below stay here.
327+
ExecDecision d = begin_execute(can_stream, RequestT::notecard_request);
328+
329+
ApiResult<Rsp> result = [&]() -> ApiResult<Rsp> {
330+
// Streaming path: SAX-stream the response straight into the
331+
// typed Rsp::Sink, no intermediate tree.
332+
if constexpr (can_stream) {
333+
if (d.path == ExecPath::Streaming) {
334+
return execute_streaming(req);
335+
}
340336
}
341-
}
342-
343-
// Buffered fallback: requires a JsonBackend + buffered transport.
337+
// Buffered fallback: JsonBackend + buffered transport, retried.
344338
#if !NOTE_NO_JSON_TREE
345-
if (backend_) {
346-
const uint32_t req_id = request_ids_enabled_ ? next_request_id_++ : 0;
347-
auto attempt = [&]() -> ApiResult<Rsp> {
348-
return execute_tree(req, req_id);
349-
};
350-
auto reset = [&]() { transport_->reset(); };
351-
352-
auto result = retry_transaction<ApiResult<Rsp>>(
353-
hal(), timing_, RequestT::safety, retry_policy_,
354-
attempt, reset);
355-
debug_timing(debug_, TimingEvent::TransactionEnd, RequestT::notecard_request);
356-
return result;
357-
}
339+
if (d.path == ExecPath::Buffered) {
340+
auto attempt = [&]() -> ApiResult<Rsp> {
341+
return execute_tree(req, d.req_id);
342+
};
343+
auto reset = [&]() { transport_->reset(); };
344+
return retry_transaction<ApiResult<Rsp>>(
345+
hal(), timing_, RequestT::safety, retry_policy_,
346+
attempt, reset);
347+
}
358348
#endif
359-
debug_timing(debug_, TimingEvent::TransactionEnd, RequestT::notecard_request);
360-
return Unexpected(make_error(Error::NotReady, NOTE_ERR("no backend or streaming transport configured")));
349+
return *d.error;
350+
}();
351+
352+
end_execute(RequestT::notecard_request);
353+
return result;
361354
});
362355
}
363356

@@ -940,6 +933,50 @@ class Notecard {
940933
return request_ids_enabled_ ? next_request_id_++ : 0;
941934
}
942935

936+
// ── Type-independent execute() core ─────────────────────────────────────
937+
// execute<RequestT>() is instantiated once per request type, so any branch
938+
// in its body is counted (and code-generated) once per type. The config
939+
// checks below (transport/streaming/buffered selection, the debug-timing
940+
// null checks) are identical across every RequestT, so they live here in
941+
// non-template members: emitted and measured once, not per instantiation.
942+
// Only the genuinely type-dependent calls (execute_streaming/execute_tree)
943+
// stay in the template. See DESIGN-branch-coverage-floor.md.
944+
enum class ExecPath : uint8_t { Streaming, Buffered, Error };
945+
struct ExecDecision {
946+
ExecPath path = ExecPath::Error;
947+
uint32_t req_id = 0;
948+
std::optional<Unexpected> error; // set iff path == Error
949+
};
950+
951+
// Prologue: emit begin-timing, validate config, choose the dispatch path.
952+
// `can_stream` is the compile-time streamability of the response type.
953+
ExecDecision begin_execute(bool can_stream, string_view req_name) {
954+
debug_timing(debug_, TimingEvent::TransactionBegin, req_name);
955+
ExecDecision d;
956+
if (!transport_) {
957+
d.error = make_error(Error::NotReady, NOTE_ERR("no transport configured"));
958+
return d;
959+
}
960+
if (can_stream && alloc_.has_value()) {
961+
d.path = ExecPath::Streaming;
962+
return d;
963+
}
964+
#if !NOTE_NO_JSON_TREE
965+
if (backend_) {
966+
d.path = ExecPath::Buffered;
967+
d.req_id = next_request_id_or_zero();
968+
return d;
969+
}
970+
#endif
971+
d.error = make_error(Error::NotReady, NOTE_ERR("no backend or streaming transport configured"));
972+
return d;
973+
}
974+
975+
// Epilogue: emit end-timing. Called once per execute(), regardless of path.
976+
void end_execute(string_view req_name) {
977+
debug_timing(debug_, TimingEvent::TransactionEnd, req_name);
978+
}
979+
943980
/// Allocator-backed durable copy of a Notecard error message — used
944981
/// by the Api singleton thunk path to give `NcErrorCapture::view()`
945982
/// a pointer that outlives the caller-stack-allocated NcErrorCapture

0 commit comments

Comments
 (0)