latrec: land the stage-timing recorder, close #55 - #59
Merged
Conversation
Thecave3
force-pushed
the
feat/latency-tracing
branch
from
August 19, 2026 19:56
48434f2 to
5b4f8a3
Compare
This was referenced Aug 19, 2026
Thecave3
force-pushed
the
feat/latency-tracing
branch
4 times, most recently
from
August 21, 2026 20:45
b68f1ab to
e5b4e55
Compare
Contributor
CI report —
|
| Workflow | Result | Time | Run |
|---|---|---|---|
| Commit policy | ✅ success | 8m09s | #88 |
| E2E dApp Integration | ✅ success | 2m07s | #103 |
| E2E Topologies (multi-dApp / multi-RAN) | ✅ success | 2m53s | #101 |
| Full-loop Latency Benchmark | ✅ success | 1m04s | #102 |
| latrec portability | ✅ success | 1m00s | #14 |
| Unit Tests | ✅ success | 5m08s | #129 |
| MPMC Queue Benchmark | ⏭️ not triggered (paths filter) | — | — |
E2E Topologies (multi-dApp / multi-RAN)
zmq/ipc
- ✅ 1 RAN - 1 dApp: indications=
5dapppeer=t11 ran=ran-solo sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=1 max=1 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
- ✅ 1 RAN - 2 dApps: dApp#1 ind=
5sub=1, dApp#2 ind=6sub=2, RAN saw2dApps - ✅ 2 RANs - 1 dApp: from ran-a ind=
5, from ran-b ind=5
zmq/tcp
- ✅ 1 RAN - 1 dApp: indications=
5dapppeer=default ran=ran-solo sub=1 indications=5 seq=[0..4] dropped=0 (0%) age_ms(avg=0 max=0 @seq=0) hist[<=1:5 2-5:0 6-10:0 >10:0]
- ✅ 1 RAN - 2 dApps: dApp#1 ind=
5sub=1, dApp#2 ind=6sub=2, RAN saw2dApps - ✅ 2 RANs - 1 dApp: from ran-a ind=
5, from ran-b ind=5
E2E dApp Integration
✅ posix/ipc
- dApp exit:
0 - Indications received:
7
✅ posix/tcp
- dApp exit:
0 - Indications received:
7
✅ zmq/ipc
- dApp exit:
0 - Indications received:
7
✅ zmq/tcp
- dApp exit:
0 - Indications received:
7
Full-loop Latency Benchmark
Full-loop latency
Full-loop latency benchmark (N=1084 after 50 warmup)
All values in microseconds (μs). Link: zmq, transport: ipc, encoding: ASN.1 APER.
| # | Description | Tags | mean | p50 | p99 | max |
|---|---|---|---|---|---|---|
| 1 | Collect indication data | RECORD_BEGIN to ENCODE_E3SM_BEGIN |
0.14 | 0.12 | 0.33 | 0.47 |
| 2 | Create & encode indication | ENCODE_E3SM_BEGIN to ENCODE_E3SM_DONE |
1.13 | 1.07 | 3.35 | 3.93 |
| 3 | Encode E3AP (indication) | EMIT_ENTER to ENQUEUE, then DEQUEUE to ENCODE_E3AP_DONE |
3.08 | 3.17 | 5.61 | 11.89 |
| 4 | Queuing (indication) | ENQUEUE to DEQUEUE |
6.20 | 7.49 | 16.01 | 24.66 |
| 5 | Delivery (indication) | ENCODE_E3AP_DONE to SEND_DONE |
0.33 | 0.28 | 1.34 | 9.45 |
| 6 | E3 wire (RAN -> dApp) | SEND_DONE to RECV |
53.41 | 53.65 | 65.75 | 94.16 |
| 7 | Decode E3AP (indication) | RECV to DECODE_E3AP_DONE |
1.78 | 1.53 | 3.02 | 16.11 |
| 8 | libe3 dispatch (indication) | DECODE_E3AP_DONE to DELIVER_BEGIN |
0.13 | 0.13 | 0.20 | 0.27 |
| 9 | Decode indication | DELIVER_BEGIN to DECODE_E3SM_DONE |
0.67 | 0.61 | 0.94 | 1.24 |
| 10 | Process data | DECODE_E3SM_DONE to ENCODE_E3SM_BEGIN |
0.04 | 0.04 | 0.05 | 0.13 |
| 11 | Create & encode control | ENCODE_E3SM_BEGIN to ENCODE_E3SM_DONE |
0.37 | 0.36 | 0.58 | 0.77 |
| 12 | Encode E3AP (control) | EMIT_ENTER to ENQUEUE, then DEQUEUE to ENCODE_E3AP_DONE |
4.06 | 4.00 | 5.38 | 7.00 |
| 13 | Queuing (control) | ENQUEUE to DEQUEUE |
17.30 | 16.80 | 23.40 | 29.50 |
| 14 | Delivery (control) | ENCODE_E3AP_DONE to SEND_DONE |
5.39 | 5.20 | 8.66 | 29.96 |
| 15 | E3 wire (dApp -> RAN) | SEND_DONE to RECV |
53.76 | 54.31 | 66.06 | 78.20 |
| 16 | Decode E3AP (control) | RECV to DECODE_E3AP_DONE |
2.84 | 2.72 | 5.98 | 9.16 |
| 17 | libe3 dispatch (control) | DECODE_E3AP_DONE to DECODE_E3SM_BEGIN |
0.31 | 0.30 | 0.47 | 1.97 |
| 18 | Decode & handle control | DECODE_E3SM_BEGIN to DECODE_E3SM_DONE |
0.44 | 0.40 | 0.71 | 14.45 |
| Total | Total round-trip | 151.91 | 153.84 | 179.19 | 215.91 |
ubuntu-latest, Release build, ZMQ + IPC, ASN.1 APER.
These numbers are measured inside a GitHub Actions container and should be treated as an upper bound on E3AP's and the library's own latency, not a representative deployment measurement.
Ready to merge (fast-forward only)
A maintainer can land the reviewed commits with:
git fetch origin
git checkout main && git merge --ff-only bf886149b8ffe1e5812b1560eccf93e9c63baad7 && git push origin mainHead: bf886149b8ffe1e5812b1560eccf93e9c63baad7 (branch feat/latency-tracing). If --ff-only fails as non-fast-forward, the branch must be rebased on the latest main.
One comment per PR, rewritten in place once every workflow for
bf88614finished.
Thecave3
force-pushed
the
feat/latency-tracing
branch
2 times, most recently
from
August 21, 2026 23:57
9c79366 to
521d8bd
Compare
…oling latrec records one timestamped stage per event into a lock-free, mmap-backed ring owned by the writing thread: a stamp is one CLOCK_MONOTONIC read and four stores, with no syscall, allocation, formatting or lock. Records are 32 bytes (seq:48 | cpu:8 | stage:8, t_ns, aux, aux2). CLOCK_MONOTONIC shares an origin across processes, so RAN, dApp and xApp stamps join without calibration. The catalog names one identifier per *operation* on the E3AP / E3SM / E2SM-DAPP path, not one per (operation, component) pair: an indication is encoded by whichever Service Model produced it, and E3AP framing is the same call whether the RAN or the dApp made it, so they share an identifier. Which component wrote a record comes from its ring name, and which message it carried from the aux payloads. Gated once, at compile time, by LIBE3_ENABLE_LATREC (off by default). A normal build carries none of this -- not even a disabled-ring branch -- because serving a real-time protocol, not measuring itself, is the library's job. With the flag on, tracing runs; there is no second per-run opt-in, and latrec_open_in()/latrec_set_output_dir()/LATREC_DEFAULT_DIR choose only where the rings are written. Ships the reader and CSV converter alongside, deliberately in the same commit: tests/test_latrec_reader.py round-trips a ring through tools/latrec_reader.py and asserts its stage table matches the catalog in latrec.h exactly, so the header and the tooling cannot be separated without the drift check failing on the commit in between. tests/bench_latrec.cpp holds the stamp cost to a ceiling: 400 ns enabled, and 10 ns on a thread with no ring, which is the path a build with the flag on but a component not tracing actually takes. Signed-off-by: Filippo Olimpieri <olimpieri.f@northeastern.edu> Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Andrea Lacava <a.lacava@northeastern.edu>
Stamps the operations libe3 itself performs, on both legs and in both roles: the emit entry where a producer's thread crosses into the library, the outbound queue and its drain, the E3AP encode, the connector send, and inbound receive, decode and delivery. seq is Pdu::enqueue_seq outbound, allocated at the emit entry so that record and the rest of the leg share one key, and allocated at receive inbound. Two joins are worth naming. Outbound, a producer publishes its own trace seq with latrec_ctx_set() and the emit entry carries it in aux, which is what ties a Service Model's stages to this leg. Inbound, libe3 publishes the delivery seq the same way before invoking the application's handler, so a dApp's own stages key to the DELIVER_BEGIN/DELIVER_DONE pair around them rather than being paired positionally on the strength of sharing a thread. The outbound join had a gap: a Service Model's own indications and acks reach libe3 through set_outbound_emitter (ServiceModel::emit_outbound), and that path called queue_outbound directly without stamping EMIT_ENTER first -- queue_outbound only allocates enqueue_seq for a Pdu that arrives with none, and stamps ENQUEUE, never EMIT_ENTER. Every other emit path (E3Agent:: send_indication, e3_service_model_emit_indication) does stamp it, which is what carries a producer's published context into the outbound leg -- so a Service Model using the library's own documented emitter API got no join at all, including the shipped example, which is meant to be the reference for exactly this. Fixed by stamping EMIT_ENTER in the emitter lambda itself, mirroring the other two call sites. The RAN side of the same gap sat in handle_control_action: it dispatches an inbound control to the registered SM without publishing the inbound leg's seq, so whatever the SM emits in response (typically an ack) had nothing to read either -- fixed by publishing it there, the same bridge handle_indication and handle_xapp_control_action already publish on the dApp role. A message that goes nowhere is recorded rather than absent: DROP carries a reason, and shutdown-time rejections are distinguished from real capacity overflow. E3Interface::stop() shuts the queues before a registered Service Model's own producer thread is joined -- that happens later, via SmRegistry::clear() -- so pushes in that window fail with NOT_INITIALIZED and would otherwise be indistinguishable from a full ring. The connectors carry the matching distinction on send. A queue handoff is timestamped before the push, not after: the consumer may already be blocked on pop(), so it can drain the item and stamp its own completion before the producer reaches its stamp, putting the recorded queue entry after the recorded queue exit. Co-authored-by: Filippo Olimpieri <olimpieri.f@northeastern.edu> Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Andrea Lacava <a.lacava@northeastern.edu>
The Python binding does not receive events one at a time: libe3's callbacks push onto a lock-free ring and the binding drains it in batches with the GIL released. That seam is where a Python dApp's latency actually accumulates, and it was invisible -- the C++ side ended at delivery and the Python side began at the callback, with the queue between them unmeasured. Stamps the push and the poll, with the poll carrying its position in the batch and the batch size in aux, so a slow drain shows up as a widening spread across one batch rather than as uniformly slower events. A push that the ring rejects is stamped as a drop with its own reason. Timestamped before the push for the same reason as libe3's report queue: the binding's poll thread can drain an event and stamp it polled before the producing thread reaches its own stamp. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Andrea Lacava <a.lacava@northeastern.edu>
The example Service Model carried its own TracePhase/set_trace_hook mechanism: a callback the benchmark installed to receive five phase timestamps. That made the shipped example measurable only by code that knew about the hook, and measured it with a different clock and a different join than everything else on the path. Replaced with the ordinary catalog operations, so the example is instrumented the way a real Service Model would be and its stages join libe3's outbound leg through latrec_ctx_set() like any other producer's. This is also what makes the example a worked reference rather than a special case: if the shared operation catalog could not express what the SM does without a bespoke hook, the catalog would be wrong. Removing that hook is an API removal, so it lands together with every consumer of it -- the integration test and the full-loop benchmark both call set_trace_hook today, and splitting the removal from their updates would leave a commit in between that does not compile. The benchmark is the substantive half of that update. It reported an eight-phase breakdown built from steady_clock reads in the benchmark itself plus the SM's hook, so it measured the benchmark and the hook rather than the library: every phase boundary sat outside libe3, and time inside the encode, the queue and the connector was attributed to whichever phase happened to span it. It is now reconstructed from the capture, joining the SM's operations, the dApp handler's and libe3's own leg on the keys the catalog already provides, so each phase is bounded by the operation it claims to measure. The reported table keeps its shape, since CI posts it on every PR. bench_latrec_load gains counters independent of latrec, so achieved throughput is still measurable with the recorder compiled out -- otherwise the traced-versus-untraced comparison would have no untraced arm, one side's numbers being derived from the rings themselves. The new integration tests assert on a real two-role session across every transport and encoding combination: that each leg's operations appear in the order the catalog claims, that no stage is skipped or stamped twice for one key, that the emit entry carries the producer's context so the cross-thread join resolves, and that every enqueued PDU is accounted for as either sent or dropped. The drop test asserts the reason space is exhaustive rather than that drops are absent -- teardown legitimately produces them, and a drop with no reason, or one outside the known set, is the actual failure. emit_entry_carries_the_producers_context originally checked only the records the test's own driver thread produces via E3Agent::send_indication. That is one of two producers reaching this stage in the run -- the registered SimpleServiceModel's own FixedRate worker reaches it too, through emit_outbound -- and checking only the driver's is exactly how the gap the previous commit fixes went unstamped, and would have gone right on being unstamped: nothing failed, because the SM's records never appeared in this ring at all before that commit. It now checks both, distinguished by the driver's context being drawn from a sentinel far above any real seq. The SM-side check can't just require non-zero, either: the SM's own counter is zero-based, so its first indication has a legitimately zero context, no different in the data from a broken join defaulting to zero. It cross- references against the seqs the SM's own RECORD_BEGIN and libe3's own RECV actually used instead, which zero included, only a real join can produce. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Andrea Lacava <a.lacava@northeastern.edu>
Three documents, wired into the Doxygen build and linked from the README. docs/path-a-e3-loop.md and docs/path-b-e2-e3-loop.md define the loops the stage identifiers name: the E3-only loop between a RAN and a dApp, and the E2-E3 loop between a dApp and an xApp, box by box, with the aggregates built on them. The catalog cites these box numbers directly, so without them a reader has the identifiers but not what they bound. docs/latrec.md is the operational half: the two-level model of what is compiled in and where rings are written, the clock model and what it does and does not let you join, ring naming and sizing, the capture-to-CSV workflow, and what a downstream repo has to match to make its own stamps do anything. It also records the limit of the attribution rule. "Which component wrote a record comes from its ring name" holds for a thread that component owns and fails for a synchronous callback, where the stamp lands in the ring of the thread that ran it rather than of the code that wrote it -- so an application handler invoked inline by libe3 files under libe3. Nothing is lost and the join is unaffected, but summing that CSV as library overhead overstates the library, and the same application over a different backend attributes differently. A possible fix is recorded alongside it rather than done here, since it needs new public configuration. Corrected in the same commit rather than a separate one, since it is the same document: the pkg-config section still said a consumer must pass -DLIBE3_ENABLE_LATREC=ON explicitly because pkg-config does not propagate these defines. That was true when written; it is not on this branch, whose libe3.pc now builds Cflags from the target's own compile definitions the same way the CMake path already did -- confirmed empirically against both a LIBE3_ENABLE_LATREC=ON and an OFF install. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Andrea Lacava <a.lacava@northeastern.edu>
…bility The recorder is off by default, so without passing LIBE3_ENABLE_LATREC=ON the jobs that exercise it would build the stubs and pass vacuously. Sets it on the integration and full-loop-benchmark configures. Adds a portability job, because the ring format is the part of this that is hardest to fix later: the layout is mirrored by writers that do not link libe3, and readers must parse a file written by any of them. It compiles the header under strict C and C++ dialects with conversion warnings as errors, cross-compiles for aarch64, and checks the generated assembly for the release store that makes a record self-consistent and for the absence of a syscall on the stamp path -- properties that hold silently on x86-64 and would otherwise break unnoticed on another target. VERSION goes to 0.1.1. latrec does add public surface -- an installed latrec.h and a LIBE3_ENABLE_LATREC option that flows through the exported target and the .pc Cflags -- but it is off by default and changes nothing for a consumer that does not opt in, so it goes out as a patch over 0.1.0 rather than claiming a feature release. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Andrea Lacava <a.lacava@northeastern.edu>
Thecave3
force-pushed
the
feat/latency-tracing
branch
from
August 23, 2026 01:34
521d8bd to
a995d57
Compare
spear-dApp is going to record its own application-level stages through libe3py rather than hand-roll a second latrec writer in Python, and swig/libe3.i had no way to: only latrec_set_output_dir_py() was bound, which places libe3's rings but cannot record a stage into one. Binds latrec_tls_open_as, latrec_seq_next, latrec_tstamp, latrec_ctx_set, latrec_ctx and, alongside latrec_tnow, latrec_tstamp_at -- the pair a Python caller needs for the same reason libe3's own queue handoffs need it, to timestamp before an operation whose outcome is only known afterwards rather than after. Every one of these is a documented no-op with LIBE3_ENABLE_LATREC off, so a Python caller never branches on the build, matching every other consumer of this header. The stage catalog and the aux2 reason enums are parsed straight out of latrec.h via %include, curated down to just the constants with %ignore on everything else in the file (the ring/header structs, the low-level explicit-ring API, the always-on latrec_stamp(_at), the raw __thread pointer): SWIG auto-generates the Python constants from the same source of truth the header already is for tools/latrec_reader.py, rather than a third hand-copied table. That table already drifted once in this repo's own history (the per-commit bisect check caught tools/latrec_reader.py's mirror lagging a renumbered identifier); a hand-written stage table in a downstream package would carry the identical risk with no check at all. SWIG's own preprocessor pass needed one accommodation: latrec.h refuses to compile without CLOCK_MONOTONIC declared, and SWIG never walks glibc's real <time.h>/<bits/time.h> chain the way the actual compiler does afterwards on the generated wrapper, so the header's own #error fired during the parse pass. A placeholder -DCLOCK_MONOTONIC=1 in SWIG_FLAGS satisfies the check without SWIG needing to resolve the real one -- its numeric value is never evaluated by SWIG, only its definedness. Measured cost through the binding: ~0.6 us/stamp (latrec_tstamp_py alone, seq pre-fetched), against ~3.0 us/stamp for a hand-rolled pure-Python ring writer measured separately in this effort -- roughly 5x, which is the comparison that justifies routing a Python consumer through this rather than reimplementing the writer. Recorded in docs/latrec.md alongside the rest of the binding's documentation. Added tests/test_swig_latrec_stamp.py: no RAN peer needed, unlike test_swig_latrec.py's session-ring coverage, so it runs in the same lightweight always-on tier as test_swig_smoke.py rather than needing the integration harness's opt-in. Stamps through the new binding, reads the result back with tools/latrec_reader.py, and checks the round-tripped values -- including that latrec_tstamp_at_py's explicit timestamp survives rather than being silently replaced by a fresh clock read. tests/test_swig_latrec.py, not registered in CTest and apparently never run since the catalog rework, turned out to be the drift this commit's own message warns about: it hard-coded LQ0_QUEUED/LQ1_POLLED/L9_DROP at their pre-rework values (0x3C/0x3D/0x39), which the real code has not stamped since the "operations not components" collapse, so every run silently took the "no events produced" branch regardless of whether a real RAN peer was driving real traffic through it. Fixed to read libe3py's own constants and to read rings through tools/latrec_reader.py instead of a second hand-rolled struct.unpack of the format -- confirmed against a real run: 1652 events completed queue -> poll, all four checks that could previously never fire now pass for real. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Andrea Lacava <a.lacava@northeastern.edu>
queue_dapp_control_action never stamped EMIT_ENTER, unlike the mirrored indication-emit path in E3Agent::send_indication and register_sm's own outbound_emitter -- docs/path-a-e3-loop.md's A13 entry and latrec.h's own "the same five outbound... stamps each time" comment already assumed this stamp existed on both legs; the code just never added it. Bring it in line: same aux=latrec_ctx()/aux2=ran_function_id pattern as the indication side, giving the control leg a real, key-based bridge to the caller's business seq instead of no bridge at all.
The benchmark collapsed 18 docs boxes (A1-A18) into 8 rows, mainly because it never read libE3's own already-existing EMIT_ENTER/ENQUEUE/DEQUEUE/ ENCODE_E3AP_DONE/SEND_DONE/RECV/DECODE_E3AP_DONE stamps -- avoided previously because those looked keyed by libE3's internal message_id, which wraps within this benchmark's iteration count. Verified against the actual call sites: they're keyed by latrec_seq_next(), a separate, non-wrapping, process-wide counter, so every leg-instance groups safely with no positional pairing needed for the outbound side at all -- and, now that EMIT_ENTER exists on both legs, EMIT_ENTER.aux bridges each outbound quintuple straight to its business seq via a plain key lookup. Only the two inbound pairs (no business-seq bridge on receipt) still need positional pairing, same technique already used for DELIVER_BEGIN. Expands the table from 8 rows to 18 (+ Total), replaces the single Phase label column with three (row #, Description, Tags), and adds two "libe3 dispatch" rows for gaps that are already instrumented but have no A-number in the docs. Total round-trip's span is unchanged (still A1->A18-equivalent). docs/path-a-e3-loop.md gains matching "--" rows for those two dispatch gaps, mirroring its existing E3-wire row convention.
… disclaimer Render the benchmark's unit string as the µs HTML entity in the posted PR comment (GitHub renders it; a raw non-ASCII µ risks locale/encoding issues in the shell pipeline). Applied only at report-assembly time, via sed on the captured stdout, so the benchmark's own terminal output stays plain "us" for local/manual runs. Also add a disclaimer after the machine-info line: these numbers are measured inside a GitHub Actions container and are an upper bound on E3AP's and the library's own latency, not a representative deployment measurement.
17 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Lands
latrec, libe3's stage-timing recorder, as the library's singlesupported latency-measurement mechanism, and closes out the gaps identified
in #55 before merge.
latrecis a per-thread, lock-free, mmap-backed ring recorder: a stamp is oneclock_gettime(CLOCK_MONOTONIC)read plus four stores (32-byte records), nosyscall, no allocation, no lock. It replaces the compile-gated
[LAT]logmarkers in #46 (superseded — see the comment there): those 3 log points are a
strict subset of the 18 stages this branch already ships,
anchor_nsis 0 atevery call site, the formatting logger call perturbs the sub-millisecond path
it measures by orders of magnitude more than a latrec stamp, and it has no
tests.
Design: two gates, not one
libE3's job is serving a real-time protocol, not measuring itself, so the
mechanism must compile to nothing in a normal build:
LIBE3_ENABLE_LATREC(CMake option,OFFby default) — compiles thewhole recorder in or out. Off:
src/core/latrec.cisn't even built, andevery
latrec_tstamp()/latrec_tls_open_as()call site alreadyinstrumented throughout the library resolves to a true no-op inline stub —
no dependency on
latrec.cat all, confirmed withnmon the builtlibrary. On: propagated as a
PUBLICcompile definition (same mechanism asLIBE3_ENABLE_ASN1/LIBE3_ENABLE_JSON), so a consumer linking against theCMake target inherits it automatically; a pkg-config consumer (OAI,
flexric) passes a matching
-DLIBE3_ENABLE_LATREC=ONexplicitly, same asit already must for the encoder flags.
LATREC_DIR(env var) — the existing runtime gate, now only meaningfulinside an
ONbuild: which individual runs actually capture.What's new here
starts —
OC(jbpf-based CU/DU IQ source + its E3 controller),CB(L1 data-lake IQ source),
PY(Python xApp/service-model framework) — plustwo new blocks this PR itself stamps:
EX(the shipped reference SM) andBD(a benchmark's own minimal dApp handler). 8 new libe3-core stamps:LATREC_C0_CONTEXT(reserved but never written before this — a ~1 Hzmonitor thread, compiled only under the flag, samples involuntary context
switches and CPU frequency),
LF0_FILTER_PASSED/LF1_CALLBACK_DONE(closes the C++ dApp-side gap between the inbound filter and the
application callback — previously only the Python/SWIG seam was covered),
LS2_SUB_RECV/LS3_SUB_SENT(the subscription request/response leg had nostamp at all; only the setup handshake did).
tools/latrec.shretired: 188 lines of shell for what was, at itscore, one env var — and now that capture also needs an explicit build
flag, "on/off" isn't a single toggle a wrapper usefully owns either. Its
one behavior worth keeping (wait for a capture to go quiet) moves to
tools/latrec2csv.py --watch [--quiet-secs] [--timeout].docs/latrec.md(new — the branch shipped none), wired into theDoxygen build (
\page latrec_guide, verified locally withdoxygen) andlinked from the README.
bench_full_loop_latencyrewritten to source its 8 phases from reallatrec stamps instead of its own ad hoc
steady_clocktrace hook —it was measuring itself, not libE3's or the benchDApp's actual internals.
Keeps the identical markdown table
full-loop-bench.ymlposts on every PR;that CI guard is unchanged in contract, just now backed by the real
instrumentation. Verified locally across several runs: phase magnitudes
are stable and the same order of magnitude/shape as the branch's previous
baseline.
bench_latrec_loadnow measures achieved throughput with plainstd::atomiccounters in the receiving handlers, independent of latrec,alongside its existing latrec-derived tables — this is what makes the
ablation below possible, and it no longer force-sets
LATREC_DIR.Data (see #55 for the full write-up)
tests/bench_latrec.cpp): 0.00 ns/stamp disabled,~27.5 ns/stamp enabled — both comfortably inside the ≤10 ns / ≤400 ns
ceiling.
bench_latrec_load, mixed-load rate sweep): at theflood tier, achieved indication throughput was ~177k–204k msg/s with
LIBE3_ENABLE_LATREC=OFF, ~168k–198k msg/s with itON(traced anduntraced) — run-to-run variance in this environment (~±15%) exceeds any
difference attributable to latrec itself, consistent with the
microbenchmark's ns-scale per-stamp cost against encode/IPC costs an order
of magnitude larger.
flood tier specifically (multiple rings wrapped, retaining as little as
~1.5% of records at ~180k msg/s over 4s) — a real finding about sizing
guidance for high-rate captures, not a correctness bug; noted for
follow-up.
Not in this PR
The
OC/CB/PYblocks are reservations only — the OCUDU andxDevSM work that will actually stamp them is downstream follow-up.