Skip to content

latrec: land the stage-timing recorder, close #55 - #59

Merged
Thecave3 merged 10 commits into
mainfrom
feat/latency-tracing
Aug 27, 2026
Merged

latrec: land the stage-timing recorder, close #55#59
Thecave3 merged 10 commits into
mainfrom
feat/latency-tracing

Conversation

@Thecave3

Copy link
Copy Markdown
Collaborator

What

Lands latrec, libe3's stage-timing recorder, as the library's single
supported latency-measurement mechanism, and closes out the gaps identified
in #55 before merge.

latrec is a per-thread, lock-free, mmap-backed ring recorder: a stamp is one
clock_gettime(CLOCK_MONOTONIC) read plus four stores (32-byte records), no
syscall, no allocation, no lock. It replaces the compile-gated [LAT] log
markers in #46 (superseded — see the comment there): those 3 log points are a
strict subset of the 18 stages this branch already ships, anchor_ns is 0 at
every 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, OFF by default) — compiles the
    whole recorder in or out. Off: src/core/latrec.c isn't even built, and
    every latrec_tstamp()/latrec_tls_open_as() call site already
    instrumented throughout the library resolves to a true no-op inline stub —
    no dependency on latrec.c at all, confirmed with nm on the built
    library. On: propagated as a PUBLIC compile definition (same mechanism as
    LIBE3_ENABLE_ASN1/LIBE3_ENABLE_JSON), so a consumer linking against the
    CMake target inherits it automatically; a pkg-config consumer (OAI,
    flexric) passes a matching -DLIBE3_ENABLE_LATREC=ON explicitly, same as
    it already must for the encoder flags.
  • LATREC_DIR (env var) — the existing runtime gate, now only meaningful
    inside an ON build: which individual runs actually capture.

What's new here

  • Catalog: reserves three cross-repo blocks before any of that work
    starts — OC (jbpf-based CU/DU IQ source + its E3 controller), CB
    (L1 data-lake IQ source), PY (Python xApp/service-model framework) — plus
    two new blocks this PR itself stamps: EX (the shipped reference SM) and
    BD (a benchmark's own minimal dApp handler). 8 new libe3-core stamps:
    LATREC_C0_CONTEXT (reserved but never written before this — a ~1 Hz
    monitor 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 no
    stamp at all; only the setup handshake did).
  • tools/latrec.sh retired: 188 lines of shell for what was, at its
    core, 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: docs/latrec.md (new — the branch shipped none), wired into the
    Doxygen build (\page latrec_guide, verified locally with doxygen) and
    linked from the README.
  • bench_full_loop_latency rewritten to source its 8 phases from real
    latrec stamps instead of its own ad hoc steady_clock trace hook —
    it was measuring itself, not libE3's or the benchDApp's actual internals.
    Keeps the identical markdown table full-loop-bench.yml posts 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_load now measures achieved throughput with plain
    std::atomic counters 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)

  • CI overhead gate (tests/bench_latrec.cpp): 0.00 ns/stamp disabled,
    ~27.5 ns/stamp enabled — both comfortably inside the ≤10 ns / ≤400 ns
    ceiling.
  • End-to-end ablation (bench_latrec_load, mixed-load rate sweep): at the
    flood tier, achieved indication throughput was ~177k–204k msg/s with
    LIBE3_ENABLE_LATREC=OFF, ~168k–198k msg/s with it ON (traced and
    untraced) — 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.
  • Found in the process: the sweep's default ring sizing under-captures the
    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/PY blocks are reservations only — the OCUDU and
xDevSM work that will actually stamp them is downstream follow-up.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

CI report — bf88614 — ✅ all checks passed

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=5
    • dapp peer=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=5 sub=1, dApp#2 ind=6 sub=2, RAN saw 2 dApps
    • dapp1 peer=t12 ran=ran-shared 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]
    • dapp2 peer=t12 ran=ran-shared sub=2 indications=6 seq=[0..5] dropped=0 (0%) age_ms(avg=1 max=1 @seq=0) hist[<=1:6 2-5:0 6-10:0 >10:0]
  • 2 RANs - 1 dApp: from ran-a ind=5, from ran-b ind=5
    • dapp peer=t2a ran=ran-a 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]
    • dapp peer=t2b ran=ran-b 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]

zmq/tcp

  • 1 RAN - 1 dApp: indications=5
    • dapp peer=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=5 sub=1, dApp#2 ind=6 sub=2, RAN saw 2 dApps
    • dapp1 peer=default ran=ran-shared 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]
    • dapp2 peer=default ran=ran-shared sub=2 indications=6 seq=[0..5] dropped=0 (0%) age_ms(avg=0 max=0 @seq=0) hist[<=1:6 2-5:0 6-10:0 >10:0]
  • 2 RANs - 1 dApp: from ran-a ind=5, from ran-b ind=5
    • dapp peer=default ran=ran-a 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]
    • dapp peer=off100 ran=ran-b 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]
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 main

Head: 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 bf88614 finished.

@Thecave3
Thecave3 force-pushed the feat/latency-tracing branch 2 times, most recently from 9c79366 to 521d8bd Compare August 21, 2026 23:57
Ninjabippo1205 and others added 6 commits August 22, 2026 21:25
…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
Thecave3 force-pushed the feat/latency-tracing branch from 521d8bd to a995d57 Compare August 23, 2026 01:34
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.
@Thecave3
Thecave3 merged commit bf88614 into main Aug 27, 2026
25 checks passed
@Thecave3
Thecave3 deleted the feat/latency-tracing branch August 27, 2026 19:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants