Skip to content

[QEC] Hardware CI for the realtime decoding server (NVQLink lab: DGX Spark + GB200) - #787

Closed
cketcham2333 wants to merge 11 commits into
NVIDIA:mainfrom
cketcham2333:decoding-server-hw-ci
Closed

[QEC] Hardware CI for the realtime decoding server (NVQLink lab: DGX Spark + GB200)#787
cketcham2333 wants to merge 11 commits into
NVIDIA:mainfrom
cketcham2333:decoding-server-hw-ci

Conversation

@cketcham2333

Copy link
Copy Markdown
Collaborator

What

A self-contained hardware CI runner (docker/decoding-server/hw_ci/) that
exercises the realtime decoding server on real RDMA/FPGA hardware. Given a
commit, it clones it, builds a layer-cached dev image
(dev.Dockerfile on top of the pin-matched cudaqx-dev base), compiles
cudaq-realtime + cudaqx + the demo binaries inside a privileged container,
and runs the test lanes. Every lane ends in the summary table as PASS,
FAIL, or a named SKIP, so lost coverage is always visible.

Lane coverage

  • All four decoders (pymatching, multi_error_lut, nv-qldpc, trt) over
    udp, cpu_roce (two-process), and the FPGA syndrome source
  • FPGA device-graph GPU dispatch (incl. 64 KiB-host-page ring geometry)
  • hsb-fpga-server on both wires (cpu_roce + gpu_roce), the
    gpu-roce-qldpc-bridge cross-check, and the GB200-class GPU ctests
    unreachable in normal CI (qldpc-graph, mixed-dispatch)
  • ising-prepare: downloads the gated HF model and rebuilds the trt
    bundle on-GPU each run (--hf-token-prompt / HF_TOKEN / staged-bundle
    fallback for tokenless machines)

SoftRoCE on DOCA-OFED hosts (GB200)

Two gaps blocked --roce-pair rxe (the default) on hosts whose ib_core
comes from DOCA/MLNX-OFED DKMS, and both are handled automatically:

  • the distro rdma_rxe.ko cannot bind OFED's ib_core (symbol CRC
    mismatch) and OFED dropped the rxe driver, so the image stages a
    3-line-patched upstream v6.17 rxe source (hw_ci/rxe-ofed/); when
    modprobe fails, the runner builds it against the host's ofa_kernel
    tree (mounted RO) and insmods it from the privileged container
  • Mellanox ibverbs-providers ships no rxe userspace provider and its
    provider ABI differs from Ubuntu's, so the image builds librxe from
    the matching Mellanox rdma-core source release (sha256-pinned)

Hardware-in-the-loop CI for the decoding server on machines with real
ConnectX NICs, an FPGA syndrome source, and CC >= 9.0 GPUs -- none of
which GitHub CI has (its A100/L4 runners make every device_graph /
CUDA-graph path structurally unreachable).

New: docker/decoding-server/hw_ci/run_hw_ci.sh
  Host-side runner: takes a cudaqx commit, clones it, builds the dev
  image locally (layer-cached, never pushed), fail-fasts if the image's
  baked CUDA-Q mismatches the commit's .cudaq_version pin (--build-base
  builds the base locally for pre-merge pin bumps), then runs 23 lanes
  sequentially with a PASS/FAIL/SKIP(reason) summary (exit-77 = SKIP;
  --strict makes skips fatal).  Lanes: the realtime_decoding_demo
  matrix (4 decoders x udp / two-process cpu_roce / FPGA cpu_roce host
  dispatch / FPGA device_graph), the two-process cpu_roce ctests, the
  hsb_fpga decoding-server script over cpu_roce and gpu_roce, the
  CC>=9.0-only ctests (qldpc-graph, mixed-dispatch, FP8 ONNX), and the
  gpu_roce QLDPC bridge as a below-the-server cross-check.  The Ising
  TRT bundle is rebuilt from the gated HF model on every run
  (ising-prepare lane; HF_TOKEN passed only to that lane's exec).
  Proprietary artifacts (nv-qldpc plugin, cudevice archive) come from a
  read-only bind mount; missing pieces surface as named SKIPs.  CUDA
  arch is auto-detected (GB200=100, Spark GB10=121).  Two supported
  configurations: DGX Spark with the loopback cable (--no-fpga
  --roce-pair DEV0,DEV1; the single cable is FPGA XOR loopback) and
  GB200 with the FPGA cabled (--roce-pair rxe --fpga-device DEV).

New: docker/decoding-server/dev.Dockerfile
  Build+run environment on ghcr.io/nvidia/cudaqx-dev: RDMA userspace,
  DOCA 3.3.0 gpunetio-dev, Holoscan SDK, TensorRT, cuStabilizer, the
  Ising exporter's Python env (CPU torch) + hf CLI, and a prebuilt
  holoscan-sensor-bridge 2.6.0-EA2 -- the build_qec.sh test-time
  installs moved into cached image layers.  cudaq-realtime (HSB=ON)
  and cudaqx itself are built per-commit by hw_ci/container_build.sh,
  not baked in.  Known gap documented in the README: the base's
  Mellanox ibverbs-providers has no rxe provider, so SoftRoCE lanes
  currently SKIP.

Demo (docs/sphinx/examples/qec/realtime_decoding_demo):
  - run_realtime_decoding.sh: new --page-size option (default 384).
    The fixed 384-byte geometry made the device_graph mode refuse to
    run on 16K/64K-page kernels -- i.e. on stock GB200 -64k images,
    the advertised platform; the fail-fast now computes and suggests a
    compliant value instead of dead-ending.
  - Fix playback invocation: --hololink was silently ignored by
    hsb_fpga_syndrome_playback after the #754 rename (it only parses
    --hsb-ip), so every FPGA run of the example exited with usage.

Signed-off-by: Chuck Ketcham <cketcham@nvidia.com>
…tion

Host-side validation of the hardware CI runner on a DGX Spark (cu13.0
dev image), run in BOTH cable configurations -- ConnectX loopback pair
and FPGA -- with proprietary artifacts and an HF token staged, surfaced
issues in the runner, the dev image, the container build, and several
never-before-executed test paths (cpu_roce two-process, gpu_roce/
device_graph GPU dispatch, ising/trt). Final state: loopback config
13 passed / 0 failed / 9 skipped (named); FPGA config 14 passed /
7 skipped (named) / 1 known FAIL (see below).

Runner (docker/decoding-server/hw_ci/run_hw_ci.sh):
- Resolve ibdev -> netdev via host sysfs during pair setup. The old
  `[ -n ] && [ -n ]` guard never tripped under set -e (short-circuited
  && failures are exempt), so setup drove `ip` with empty netdev names.
- Re-assert the RoCE pair addresses and wait for the IPv4-mapped RoCE
  GIDs before every cpu_roce lane: NetworkManager-managed ports drop
  statically added addresses on NM's DHCP retry timer, which killed
  lanes minutes after a successful setup ("no IPv4-mapped RoCEv2 GID
  found"). README documents the permanent nmcli fix.
- Make --repo authoritative on every run (remote set-url before fetch);
  a stale origin otherwise pinned all runs to the first-ever --repo.
- ctest lanes run with --timeout 900 so a wedged test is bounded well
  below ctest's 1500 s per-test default.
- --hf-token-prompt reads the Hugging Face token from the terminal
  (hidden input, never written to disk) for runs from shared/public
  accounts, and tokenless runs fall back to a pre-built Ising bundle
  staged at <artifacts-dir>/ising-bundle when present -- the trt lanes
  then still run, and ising-prepare SKIPs with a reason noting the HF
  download/export path was not exercised.

Dev image (docker/decoding-server/dev.Dockerfile):
- Install mlnx-ofed-kernel-utils (userspace only, from the DOCA repo):
  it provides ibdev2netdev, which four in-tree --setup-network helpers
  shell out to. Modern mlnx-tools no longer ships the tool, and the
  cudaqx-dev base image never had it.
- Install patchelf: libs/qec's add_target_libs_to_wheel patches the
  rpath of staged external decoder plugins at configure time and fails
  (with an empty error) when patchelf is absent.
- Switch torch to the cu130 CUDA build: the pinned Ising-Decoding
  exporter is GPU-only (its local_run.sh preflights
  torch.cuda.is_available()), so the previous CPU-torch choice failed
  the ising-prepare lane before inference. cu130 matches the image's
  toolkit and runs on GB200 (sm_100) and Spark GB10 (sm_121).

- Build holoscan-sensor-bridge with DOCA_SEND_BLUE_FLAME=0: in-tree mlx5
  drivers (e.g. the Spark's -nvidia kernel, no OFED) reject BlueFlame UAR
  allocation, killing every gpu_roce lane at transceiver start
  ("Failed to create UAR: DOCA Driver call failure"); NONCACHE doorbells
  are functionally equivalent for the CI lanes. Verified with a minimal
  doca_uar_create reproducer: BLUEFLAME fails and NONCACHE succeeds on
  both rdma-core 2601 and 2510, so this is kernel-driver behavior, not
  userspace version skew.

Runner: the container now runs with --ulimit memlock=-1:-1 (matching the
long-lived dev container; --privileged does not raise ulimits).

Container build (docker/decoding-server/hw_ci/container_build.sh):
- Hand cmake a writable copy of the nv-qldpc plugin: /artifacts is
  mounted read-only and the external-decoder install patches the
  plugin's rpath in place. The decoder-plugins symlink then serves the
  patched copy.

Tests (first cpu_roce execution of the two-process suites):
- DecodingServerTwoProcess.TwoProcessHostDispatchDualDecoders: add the
  udp-only skip guard its sibling tests already have. Shared-wire demux
  cannot work on rendezvous transports: a multi-decoder server opens one
  ring per decoder and blocks in each ring's connect(), while this
  caller dials a single endpoint -- the test hung to the ctest timeout.
- surface_code-1-cqr-two-process-test.sh: named SKIP (exit 77) for
  NUM_LOGICAL > 1 off udp (device-scoped endpoint args exist only for
  udp), and TERM -> KILL escalation in server shutdown: decoding_server
  ignores SIGTERM while blocked in a rendezvous accept(), which
  otherwise wedges the script's `wait` until the ctest timeout. That
  server-side SIGTERM gap is a real cpu_roce robustness bug worth a
  separate fix in the bridge/server bring-up.
- Register SKIP_RETURN_CODE 77 and TIMEOUT 300 for both two-process app
  tests.

Validated end-to-end on the Spark, both configurations:
- Loopback: all four decoders (pymatching, multi_error_lut,
  nv-qldpc-decoder, trt_decoder) PASS over udp and cpu_roce with full
  dispatch/residual-error criteria; the two-process ctest lanes PASS
  with the multi-decoder cases skipping by name.
- FPGA: all four decoders PASS on the FPGA source (SIF playback,
  BRAM-verified), device_graph GPU dispatch PASSes (first run on GB10),
  and hsb-fpga-server PASSes on both cpu_roce and gpu_roce.  One known
  FAIL remains: extra/gpu-roce-qldpc-bridge trips its ILA-capture
  verification (a pre-arm reading of 194/500 samples suggests stale ILA
  counter state between lanes) while the product paths it cross-checks
  PASS at the server level -- tracked separately.
- ising-prepare PASSes (gated download + on-GPU export, 8 s); the trt
  lanes PASS both from a fresh bundle and from a staged tokenless
  bundle; the qldpc-graph and mixed-dispatch ctests register and PASS
  with the proprietary artifacts staged.

Signed-off-by: Chuck Ketcham <cketcham@nvidia.com>
 bridge-lane pacing

Three follow-ups from continued Spark validation:

- Drop the holoscan-sensor-bridge DOCA_SEND_BLUE_FLAME=0 build tweak.
  The gpu_roce UAR failures that motivated it were environmental, not a
  platform property: pointed at the FPGA-cabled port, the stock
  BlueFlame build passes every FPGA lane end-to-end.
- README: the Spark FPGA invocation must name the port actually cabled
  to the FPGA (roceP2p1s0f0 on the current lab Spark).  A
  wrong-but-linked --fpga-device still passes the HSB control plane and
  BRAM verification but fails with "ILA: captured 0 of N expected
  samples".
- Pass --spacing 100 to the gpu-roce-qldpc-bridge lane: at the playback
  tool's default 10 us inter-shot spacing the ILA verification
  deterministically undercounts (194/500 on the Spark, reproducible
  across doorbell modes, ports, and reboots); at 100 us -- the same
  pacing the demo lanes use -- it captures 500/500 with all 100 shots
  verified and corrections matched.
- Clarify the runner's build-phase messages: "Container build complete"
  read as if the docker image had just finished building; the messages
  now distinguish the layer-cached image build from the per-commit
  source build (cudaq-realtime + cudaqx + demo) that runs inside the
  container.

With these, the FPGA configuration certifies clean on the Spark:
every lane PASSes or SKIPs with a named reason, zero failures.

Signed-off-by: Chuck Ketcham <cketcham@nvidia.com>
…age, OFED-compat rdma_rxe fallback

  On hosts whose ib_core comes from DOCA/MLNX-OFED DKMS (GB200 #2,
  6.17.0-1008-nvidia-64k + DOCA-OFED 25.10), --roce-pair rxe had two
  blockers:

  - the distro rdma_rxe.ko cannot load against the OFED ib_core
    ("disagrees about version of symbol ib_*"), and OFED dropped the
    rxe driver from its source tree.  Stage a patched upstream v6.17
    rxe driver in the image (hw_ci/rxe-ofed/: three compat deltas +
    an iser-style external-module makefile); in rxe mode the runner
    mounts /lib/modules + /usr/src read-only, builds it against the
    host's ofa_kernel tree inside the privileged container, and
    insmods it -- only when the distro modprobe fails.

  - Mellanox ibverbs-providers ships only the mlx5 provider, and its
    provider ABI (rdmav59) differs from Ubuntu's rdma-core.  Build
    librxe from the same Mellanox rdma-core source release (DOCA
    SOURCES bundle, sha256-pinned; the rxe provider is shipped there
    but if(0)-disabled in CMakeLists.txt) into the image.

  Validated on GB200: module loads against DOCA-OFED 25.10; provider
  opens rxe devices in-container.

Signed-off-by: Chuck Ketcham <cketcham@nvidia.com>
  - dev.Dockerfile: do not apt-install cmake for the rxe provider
    build.  noble's cmake 3.28 shadows the base image's cmake 4.x at
    /bin/cmake, and the in-container source build (cudaq-realtime)
    requires >= 4.0; rdma-core needs only >= 3.18.1, which the base
    cmake satisfies.

  - run_hw_ci.sh: bind-mount /dev/infiniband instead of --device.
    --device snapshots char devices at container creation, so the
    uverbs node of the rxe device that setup_roce_pair creates AFTER
    container start never appeared inside, and ibv_devinfo reported
    "IB device 'hwci_rxe0' wasn't found".  A bind mount is live; the
    privileged container loses no device-cgroup allowance.

Signed-off-by: Chuck Ketcham <cketcham@nvidia.com>
…p dead lane and bring-up note

  GB200 validation finished at dd9ae05: 21 passed / 0 failed across
  udp, cpu_roce-over-SoftRoCE, and FPGA (mlx5_4), with ai-decoder-fp8
  the only (permanent) skip.  Follow-ups from that bring-up:

  - --roce-pair defaults to rxe (SoftRoCE self-loop); the old
    omitted-means-skip behavior moves to an explicit --roce-pair none
  - --fpga-device defaults to mlx5_4 (the GB200 lab wiring)
  - drop --repo: the commit under test is always cloned from the repo
    this script lives in, which also makes local unpushed commits
    directly testable
  - drop the ai-decoder-fp8 lane: the ctest is never registered until
    CUDAQX_QEC_ENABLE_REALTIME_PIPELINE returns upstream, so the
    permanent named skip carries no signal; re-add the lane when the
    pipeline lands
  - delete the GB200 bring-up handoff note, per its own instructions
    now that the machine is validated

Signed-off-by: Chuck Ketcham <cketcham@nvidia.com>
Signed-off-by: Chuck Ketcham <cketcham@nvidia.com>
Signed-off-by: Chuck Ketcham <cketcham@nvidia.com>
@cketcham2333
cketcham2333 marked this pull request as ready for review August 11, 2026 23:44
@melody-ren

Copy link
Copy Markdown
Collaborator

Thanks Chuck! Tried this out on GB200 and it works.
But I think we have a concurrent user hazard. The container's name is derived from the commit SHA. If two or more people happen to be testing the same commit on the same machine, wouldn't they collide? One of them might remove the other's container. Also, two people running the same script at the same time will also be competing for the same hardware resources. I think we need to add some guard around this and only allow one CI run at a time, or maybe loudly write this in the Readme - "one user at a time".

…eir model

  Two users running the CI on one machine collided: the container name
  was derived from the commit SHA alone (docker rm -f would kill the
  other's run), and the FPGA, SoftRoCE objects (hwci-dummy0/hwci_rxe0),
  and RoCE pair addressing are host-global regardless of SHA.

  - take a host-wide flock on /tmp/cudaqx-hw-ci.lock for every real run
    (--list is exempt); a second invocation fails fast naming the active
    run's user/pid/sha, or queues behind it with --lock-wait
  - the kernel releases the flock when the holder dies, so crashes can
    never leave a stale lock; for a live-but-wedged holder (including an
    orphaned child that inherited the fd) --force-unlock kills whatever
    fuser says is actually holding the lock, with a sudo hint when the
    holder belongs to another user
  - container name gains $USER (hwci-$USER-$SHORT_SHA) so a
    --keep-container debugging container survives someone else later
    testing the same SHA
  - rename the trt lanes to trt_decoder(ising): the plugin is the generic
    TensorRT decoder, but the engine under test is the exported Ising
    model, and the summary table should say so
  - README: one-run-at-a-time + recovery documented; lane list clarified

Signed-off-by: Chuck Ketcham <cketcham@nvidia.com>
@cketcham2333
cketcham2333 enabled auto-merge (squash) August 13, 2026 22:38
@@ -0,0 +1,72 @@
# SPDX-License-Identifier: GPL-2.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot check GPL-code into this repo.

@cketcham2333
cketcham2333 disabled auto-merge August 13, 2026 22:41
@cketcham2333
cketcham2333 deleted the decoding-server-hw-ci branch August 13, 2026 23:48
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.

3 participants