Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions .github/workflows/build-and-measure.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# Build the controller both ways, and measure what recording a slot costs.
#
# The matrix leg is the one thing that has to vary: libe3's stage recorder is a
# build-time decision (-DLIBE3_ENABLE_LATREC), carried to us as a PUBLIC compile
# definition, so "traced" and "untraced" are two different binaries rather than
# two run modes. Building both is what stops the untraced path from rotting
# unnoticed - and the untraced leg is the one that proves the stamps really do
# compile away to nothing.
name: build and measure

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
build:
name: build (latrec=${{ matrix.latrec }})
runs-on: ubuntu-latest
# Cold, this is asn1c-from-source + libe3 + jbpf and its third-party tree +
# the controller. Comfortably inside an hour; nowhere near the 6h default.
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
latrec: [on, off]

steps:
- uses: actions/checkout@v4
with:
# build.sh fetches the submodules itself and then runs jbpf's
# init_and_patch_submodules.sh over them. Letting the checkout action
# do it first would hand that patch script a tree it did not lay out.
submodules: false

# The libe3 pin decides which asn1c revision is wanted, so it belongs in
# the cache key. hashFiles cannot read a gitlink, and the submodule is not
# checked out yet, so read the pinned SHA straight out of the tree object.
- name: Read the libe3 pin
id: pin
run: echo "sha=$(git rev-parse HEAD:libe3)" >> "$GITHUB_OUTPUT"

# asn1c is built from the mouse07410 fork, which is minutes of autotools.
# No restore-keys: a stale asn1c is worse than no cache, because it would
# generate against a different grammar than the pinned libe3 expects.
- name: Cache asn1c
id: cache-asn1c
uses: actions/cache@v4
with:
path: /opt/asn1c
key: asn1c-${{ runner.os }}-${{ steps.pin.outputs.sha }}

- name: Cache ccache
uses: actions/cache@v4
with:
path: ~/.ccache
key: ccache-${{ runner.os }}-latrec${{ matrix.latrec }}-${{ github.sha }}
restore-keys: |
ccache-${{ runner.os }}-latrec${{ matrix.latrec }}-
ccache-${{ runner.os }}-

- name: Install dependencies and build
env:
# -march=native would tune to whatever CPU this runner happens to be,
# which breaks both ccache reuse across heterogeneous runners and any
# comparison of the measurements below between runs.
E3C_CMAKE_ARGS: -DUSE_NATIVE=OFF
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ccache
export PATH="/usr/lib/ccache:${PATH}"
if [ "${{ matrix.latrec }}" = "on" ]; then
./build.sh --install-deps --latrec
else
./build.sh --install-deps
fi

- name: Record the machine the numbers came from
run: |
{
echo "## Environment (latrec=${{ matrix.latrec }})"
echo
echo '```'
grep -m1 'model name' /proc/cpuinfo || true
echo "nproc: $(nproc)"
echo "/dev/shm: $(df -h /dev/shm | tail -1)"
echo "libe3 pin: $(git -C libe3 rev-parse --short HEAD) ($(cat libe3/VERSION))"
echo '```'
} > report.md

# The claim being tested: with the recorder off, nothing links against the
# recorder runtime. A timing test cannot show that - only the symbol
# table can.
#
# The check is on global and undefined symbols specifically. libe3 only
# compiles src/core/latrec.c when the recorder is on, so a traced binary
# carries `D latrec_tls` plus `T latrec_seq_next` / `latrec_tls_open_as`
# / `latrec_set_output_dir`; an untraced one carries none of them. What
# an untraced binary can still carry is lowercase `t` entries for the
# inline no-op stubs latrec.h substitutes - local copies the optimiser
# did not bother to discard. Those are empty functions, not the
# recorder, and grepping for them would assert something that is not
# true and would fail for the wrong reason.
- name: Assert nothing links the recorder
if: matrix.latrec == 'off'
run: |
status=0
for binary in out/bin/e3_controller out/bin/bench_stage_recording; do
# Uppercase type letter = global or undefined, i.e. a real link.
if nm -C "$binary" | grep -E ' [A-Z] ' | grep -i latrec; then
echo "::error::$binary links latrec symbols in an untraced build"
status=1
else
echo "ok: $binary links no latrec symbols"
fi
if nm -C "$binary" | grep -qw latrec_tls; then
echo "::error::$binary carries the latrec ring registry"
status=1
fi
done
[ "$status" -eq 0 ] || exit 1
{
echo
echo '## Not linked'
echo
echo 'Neither binary links any latrec symbol, and neither carries the ring'
echo 'registry. Only latrec.h'"'"'s inline no-op stubs remain, as local symbols.'
} >> report.md

- name: Measure what recording a slot costs
run: |
mkdir -p rings
{
echo
./out/bin/bench_stage_recording \
--latrec-dir "$PWD/rings" \
--csv-path "$PWD/bench_csv_arm.log"
} >> report.md 2>&1
# The bench fails if any stamp had to be clamped: that would mean its
# synthetic slot times are not ascending, so its latrec arm would be
# exercising the clamp path rather than the one production takes.

# Converting the capture with libe3's own tool is what checks the parts of
# the contract that live outside this repository: that the ring role maps
# to the component we intend, that the stage ids form a complete source
# leg, and that the emit-tail hop column materialises. A capture that
# wrapped is not a valid measurement, so fail on it.
- name: Convert and validate the capture
if: matrix.latrec == 'on'
run: |
python3 -m pip install --quiet numpy
TOOLS=/usr/local/share/libe3/tools
python3 "$TOOLS/latrec2csv.py" rings | tee convert.log
{
echo
echo '## Capture'
echo
echo '```'
cat convert.log
echo '```'
} >> report.md

# The role must land in ocudu.csv, not other.csv: latrec2csv maps the
# role prefix through its own table, so a rename here silently
# reattributes every record to an unknown component.
test -f rings/csv/ocudu.csv \
|| { echo "::error::records were not attributed to the ocudu component"; exit 1; }

# The emit tail replaces the removed CSV's emit_ns, and only exists
# because (ENCODE_E3SM_DONE -> WAIT_ENTER) is a declared extra hop.
head -2 rings/csv/ocudu.csv | grep -q 'ENCODE_E3SM_DONE__WAIT_ENTER_us' \
|| { echo "::error::the emit-tail hop column is missing"; exit 1; }

python3 - <<'PY'
import csv, sys
with open('rings/csv/rings.csv') as fh:
rows = list(csv.DictReader(fh))
if not rows:
sys.exit('::error::no rings were written')
for r in rows:
# A wrapped ring has lost records off the front, so any span
# computed across the wrap is wrong. Better to fail than to
# publish a number from a truncated capture.
if r['wrapped'] != '0' or int(r['lost_records'] or 0):
sys.exit(f"::error::{r['ring']} wrapped ({r['lost_records']} lost) "
"- shorten the run or raise LATREC_ENTRIES_LOG2")
print(f"ok: {r['ring']} {r['rec_count']} records, no wrap, "
f"clock {r['clock_ns_per_call']} ns/call")
PY

- uses: actions/upload-artifact@v4
with:
name: report-latrec-${{ matrix.latrec }}
path: |
report.md
rings/csv/
if-no-files-found: warn
145 changes: 145 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,57 @@ file(MAKE_DIRECTORY ${OUTPUT_DIR}/lib)
# Disable tests and examples — we only need the libraries
set(BUILD_TESTING OFF CACHE BOOL "Disable jbpf tests" FORCE)

# Stop jbpf from re-initialising its own submodules.
#
# jbpf's CMakeLists.txt runs init_and_patch_submodules.sh when INITIALIZE_SUBMODULES
# is ON, which is its default. That script `rm -rf`s and re-clones jbpf/3p/*, so on
# any tree where git cannot resolve the submodules — a tarball, an `oc cp`/rsync
# copy into a container, a git worktree — it DESTROYS the working 3p checkout and
# then fails:
#
# fatal: not a git repository
# cp: cannot create regular file '3p/ebpf-verifier/': Not a directory
#
# and the tree is left with ebpf-verifier and ubpf gone, so a plain re-configure
# cannot recover. build.sh already populates the submodules itself
# (jbpf/init_and_patch_submodules.sh), so there is nothing to gain from letting
# jbpf redo it. ocudu forces the same value for the same reason
# (ocudu CMakeLists.txt, the ENABLE_JBPF block).
#
# FORCE because it is a cached option in jbpf; without FORCE a stale ON from an
# earlier configure would survive.
set(INITIALIZE_SUBMODULES OFF CACHE BOOL "Let jbpf init its own submodules" FORCE)

# Fail early and legibly if the submodules are genuinely absent, rather than
# letting the first missing header surface hundreds of lines into the build.
#
# A per-module sentinel FILE, one that module actually ships.
#
# Two traps to avoid here, both hit while testing this:
# - `ck` is autotools (configure/Makefile.in), so CMakeLists.txt is the wrong
# sentinel for it and gives a false alarm.
# - `file(GLOB dir/*)` is NOT a usable emptiness test: unlike a shell glob,
# CMake's matches dotfiles, so a de-populated submodule that still has
# .git/.github looks fully populated.
set(_jbpf_3p_checks
"ubpf/CMakeLists.txt"
"ebpf-verifier/CMakeLists.txt"
"mimalloc/CMakeLists.txt"
"ck/configure")
foreach(_jbpf_3p_file IN LISTS _jbpf_3p_checks)
if(NOT EXISTS "${CMAKE_SOURCE_DIR}/jbpf/3p/${_jbpf_3p_file}")
message(FATAL_ERROR
"jbpf/3p/${_jbpf_3p_file} is missing, so jbpf's third-party submodules are "
"not populated. jbpf's own auto-init is disabled here (see above, it would "
"destroy them), so populate them yourself:\n"
" git submodule update --init --recursive # then\n"
" ./jbpf/init_and_patch_submodules.sh\n"
"or, for a copied tree with no git metadata, copy jbpf/ wholesale from a "
"working checkout.")
endif()
endforeach()
unset(_jbpf_3p_checks)

# Override add_test and set_tests_properties so jbpf subdir doesn't register tests
macro(add_test)
endmacro()
Expand All @@ -41,6 +92,21 @@ add_subdirectory(jbpf)
unset(add_test)
unset(set_tests_properties)

# ---- Codelet verifier (offline) ----
# The gNB does not verify codelets at load time: jbpf's path is
# ubpf_load_elf_ex + ubpf_compile, and ubpf's JIT emits no memory bounds
# checks. This binary, run from codelets/Makefile at build time, is therefore
# the only memory-safety gate these codelets ever pass through.
#
# It needs ocudu's janus headers, which define the hook context structs — the
# verifier derives its ctx descriptors from them with offsetof() so the
# contract has a single source of truth. Point OCUDU_DIR elsewhere if your
# checkout is not a sibling of this one.
set(OCUDU_DIR "${CMAKE_SOURCE_DIR}/../ocudu-e3"
CACHE PATH "Path to the ocudu-e3 checkout (hook context contract)")
set(OCUDU_JANUS_INC "${OCUDU_DIR}/include/ocudu/janus")
add_subdirectory(codelets/verifier)

# ---- ASN.1 (E3 Service Model) ----
add_subdirectory(src/e3sm/asn)

Expand All @@ -55,6 +121,27 @@ FetchContent_Declare(
set(EXPECTED_BUILD_TESTS OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(tl_expected)

# ---- yaml-cpp (E3Controller configuration) ----
# The controller is configured by a single YAML file rather than 20 CLI options.
# YAML specifically (not JSON, which would be free via libe3's nlohmann) because
# ocudu's own configuration is YAML, including the `jbpf:` section this file must
# agree with — keeping both sides in the same language makes them diffable and
# reviewable together, which is the point when the invariant is "these must match".
#
# FetchContent rather than a system package, matching the tl::expected pattern
# above, so the build stays self-contained.
FetchContent_Declare(
yaml_cpp
GIT_REPOSITORY https://github.com/jbeder/yaml-cpp.git
GIT_TAG 0.8.0
GIT_SHALLOW TRUE
)
set(YAML_CPP_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(YAML_CPP_BUILD_TOOLS OFF CACHE BOOL "" FORCE)
set(YAML_CPP_BUILD_CONTRIB OFF CACHE BOOL "" FORCE)
set(YAML_CPP_INSTALL OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(yaml_cpp)

# ---- libe3 (E3 agent library) ----
# libe3 is the `libe3/` git submodule (https://github.com/wineslab/libe3.git,
# pinned to tag 0.0.4). build.sh builds it with BOTH encoders and installs it to
Expand All @@ -66,6 +153,36 @@ FetchContent_MakeAvailable(tl_expected)
# cmake --build libe3/build -j && sudo cmake --install libe3/build
find_package(libe3 REQUIRED)

# ---- libe3 staleness stamp ----
#
# libe3::libe3 is STATIC IMPORTED (see /usr/local/lib/cmake/libe3/libe3Targets.cmake),
# so this binary carries a SNAPSHOT of liblibe3.a taken at link time. Reinstalling
# libe3 afterwards changes nothing until the controller is relinked, and there is
# no runtime symptom: a changed SLEEP_DURATION or E3AP encoder is simply absent.
# Record what we linked against so the binary can say so on startup and warn when
# /usr/local has moved on. Costs one stat() at boot.
get_filename_component(LIBE3_PREFIX "${libe3_DIR}/../../.." ABSOLUTE)
set(LIBE3_STAMP_LIB "${LIBE3_PREFIX}/lib/liblibe3.a")
set(LIBE3_STAMP_VERSION "${libe3_VERSION}")
set(LIBE3_STAMP_BUILDTYPE "${CMAKE_BUILD_TYPE}")

# Bake the queue's poll period: it is the value most often changed by hand and
# the one whose staleness is least visible (it only shows up as a shifted
# outbound queue-wait distribution).
set(LIBE3_STAMP_SLEEP_US "unknown")
if(EXISTS "${LIBE3_PREFIX}/include/libe3/lockfree_queue.hpp")
file(STRINGS "${LIBE3_PREFIX}/include/libe3/lockfree_queue.hpp"
_libe3_sleep_line REGEX "SLEEP_DURATION[ \t]*=")
string(REGEX MATCH "microseconds\\(([0-9]+)\\)" _m "${_libe3_sleep_line}")
if(CMAKE_MATCH_1)
set(LIBE3_STAMP_SLEEP_US "${CMAKE_MATCH_1}")
endif()
endif()
message(STATUS "libe3: ${LIBE3_STAMP_VERSION} from ${LIBE3_PREFIX} "
"(static, SLEEP_DURATION=${LIBE3_STAMP_SLEEP_US}us)")
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/cmake/libe3_stamp.h.in"
"${CMAKE_BINARY_DIR}/generated/libe3_stamp.h" @ONLY)

# ---- E3Controller executable ----
add_executable(e3_controller
src/e3_controller.cpp
Expand All @@ -79,13 +196,16 @@ add_executable(e3_controller
# shared data plane
src/e3sm/iq_pipeline.cpp
src/e3sm/slot_iq_pipeline.cpp
# configuration (YAML)
src/e3_config.cpp
# writers + decompression
src/e3sm/utils/e3sm_shm_writer.cpp
src/e3sm/utils/bfp_decompress.cpp
)

# Include paths: our own headers + jbpf headers
target_include_directories(e3_controller PRIVATE
${CMAKE_BINARY_DIR}/generated # libe3_stamp.h
${CMAKE_SOURCE_DIR}/include
${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/jbpf/src/io
Expand All @@ -102,6 +222,7 @@ target_link_libraries(e3_controller PRIVATE
jbpf::lcm_ipc_lib
e3sm_asn
libe3::libe3
yaml-cpp
pthread
rt
dl
Expand All @@ -111,3 +232,27 @@ target_link_libraries(e3_controller PRIVATE
set_target_properties(e3_controller PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${OUTPUT_DIR}/bin"
)

# ---- Recording-cost bench ----
# Prices the stage recorder that replaced the per-slot stats CSV. Deliberately
# minimal: it links the same trace header the slot handler uses and nothing
# else -- no jbpf, no Service Model, no shared-memory writer, no encoders. That
# is enough to measure the recording mechanism, and it keeps the target
# buildable (and CI-runnable) with no RAN attached.
#
# There is no "real slot work" arm to link for: under the default
# shm.writer: gnb the gNB converts and writes the row itself, so the controller
# moves no slot data at all. See bench/bench_stage_recording.cpp.
option(E3C_BUILD_BENCH "Build the stage-recording cost bench" ON)

if(E3C_BUILD_BENCH)
add_executable(bench_stage_recording bench/bench_stage_recording.cpp)
target_include_directories(bench_stage_recording PRIVATE
${CMAKE_SOURCE_DIR}/include
${CMAKE_SOURCE_DIR}/src
)
target_link_libraries(bench_stage_recording PRIVATE libe3::libe3 pthread rt)
set_target_properties(bench_stage_recording PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${OUTPUT_DIR}/bin"
)
endif()
Loading
Loading