RAN CN 테스트베드 구축/Sionna RT-OAI (AI-RAN)

Sionna-RK에서 UE별 위치 기반 CIR을 반영하기 위한 gNB 패치 과정

gksyb4235 2026. 7. 2. 14:00

Sionna-rk 공식 이미지의 한계점


NVIDIA Sionna Research Kit(sionna-rk)는 Sionna RT 레이트레이싱 엔진과 OAI gNB를 ZMQ로 연결하여, 
실시간으로 계산된 CIR(Channel Impulse Response)을 gNB의 RF 시뮬레이터에 주입하는 구조다. 

문제는, 이 CIR 주입 경로가 "전역 단일 채널"로 설계되어 있다는 점이다.

 

기존 cir_zmq 플러그인의 상태 구조는 다음과 같은 전역 구조의 형태였다.

 

여기에 ZMQ REP 소켓도 'tcp://*5555" 하나뿐이고, Json 프로토콜에 UE를 구분하는 필드 자체가 존재하지 않는 상황.

CUDA 채널 에뮬레이터(`chn_emu.c`)는 이 하나의 CIR을 `ru->common.txdata[0]`에 in-place로 컨볼루션하고, 
rfsimulator가 그 동일한 버퍼를 모든 UE 소켓에 전송한다.

 

결과적으로, Sionna가 UE별로 다른 경로손실(예: UE1 = 78dB, UE3 = 88dB)을 계산하더라도 gNB에는 대표 1개만 들어간다. 

모든 UE가 동일한 페이딩을 겪는 상황이었던 것인데, MCS/PRB/throughput이 UE별로 달랐던 건 MAC 스케줄러가 트래픽량 기반으로 분배했기 때문이지, 무선 채널이 달라서가 아니었다.

 

 

Sionna-rk 공식 Plugin의 패치 과정


핵심 설계: 구조체 슬롯 배열과 단일 소켓 멀티플렉싱


이 패치의 핵심 아이디어는 ZMQ 소켓을 여러 개 만드는 것이 아니라,
기존 단일 소켓 위에서 JSON 메시지의 `ue_id` 필드로 라우팅하는 것이다.

 

이를 위해 우선 cir_slot_t 구조체를 도입하여 기존 flat 배열 대신, UE별로 독립된 CIR 저장 공간을 갖는 구조체 배열로 전환했다.

 

 

소켓 하나에 mutex로 직렬화


소켓을 UE 수만큼 열면 포트 관리와 클라이언트 구현이 복잡해진다.

대신 소켓 1개 + pthread mutex 1개로 모든 slot 접근을 직렬화하여 ZMQ 수신 스레드가 Json을 파싱하면,
'ue_id 존재 여부에 따라 쓰기 대상이 결정된다.

다만 이 부분의 경우 추후 UE 수만큼의 포트를 여는 패치도 진행해 성능을 비교해볼 예정이다.

  • `ue_id` 없음 → `broadcast_slot`에 쓰기 (레거시 호환)
  • `ue_id: 2` → `slots[2]`에 쓰기, `explicitly_set = 1` 마킹

읽기 시의 resolution rule도 단순하게 구현하였는데, `cir_zmq_read_ue(ue_id)`가 호출되면, 해당 슬롯이 `explicitly_set`이면 그 슬롯을, 아니면 `broadcast_slot`을 fallback으로 반환한다.

이로써 아직 개별 CIR을 받지 못한 UE는 자동으로 전역 CIR을 사용한다.'

 

 

CUDA 에뮬레이터: 공유 버퍼에서 per-소켓 버퍼로


 

기존 `chn_emu_compute()`는 `ru->common.txdata[0]`라는 gNB 전역 DL 버퍼에 직접 컨볼루션을 적용했다. 

per-UE 모드에서는 이 경로를 건너뛰고, 
rfsimulator가 각 UE 소켓에 write할 때 독립 스크래치 버퍼에 per-UE CIR을 적용해야 한다.

 

이를 위해 새 함수 `chn_emu_compute_buffer()`를 추가했다.

 

`RU_t`나 `NR_DL_FRAME_PARMS` 없이도 동작하도록 프레임 기하 정보를 직접 인자로 받는 구조다. 

내부적으로는 기존 CUDA 커널(`chn_emu_cuda_compute`)을 그대로 호출하므로, GPU 측 코드 재작성은 최소화했다.

 

 

 

OAI gNB 패치 과정: tx_rf 게이팅 + rfsimulator per-소켓 Write


tx_rf 게이팅 (`executables/nr-ru.c` — `tx_rf()`)


OAI의 `tx_rf()` 함수에서 채널 에뮬레이션 호출 조건에 게이팅을 추가했다.

 

`per_ue_enabled()`가 true이면 공유 버퍼 경로는 skip된다.

 

 

rfsimulator per-소켓 Write 루프 (`radio/rfsimulator/simulator.c` — `rfsimulator_write_internal()`)


실제 per-UE 컨볼루션은 여기서 일어나는데, rfsimulator가 각 UE 소켓에 IQ 샘플을 write하는 루프에서, 소켓마다 독립된 스크래치 버퍼에 해당 UE의 CIR을 적용한 뒤 전송한다.

const int per_ue_enabled = channel_emulator_per_ue_enabled();
const int max_ue_count = channel_emulator_get_max_ue_count();

for (int i = 0; i < MAX_FD_RFSIMU; i++) {
    if (b->conn_sock >= 0) {
        if (per_ue_enabled) {
            const int ue_id = socket_index_to_ue_id(i, max_ue_count);
            const void *cir = cir_zmq_interface.read_ue(ue_id);
            sample_t faded[nsamps];
            memcpy(faded, samplesVoid[0], ...);  // 클린 원본 복사
            chn_emu_interface.compute_buffer(faded, sps, spf, sfs, sos, 0, cir);
            fullwrite(b->conn_sock, faded, ...); // faded 결과를 이 UE에게만 전송
        } else {
            fullwrite(b->conn_sock, samplesVoid[0], ...); // 레거시: 공유 버퍼 직접 전송
        }
    }
}

 

  • 핵심 설계 포인트:
    `samplesVoid`(원본 DL IQ)는 read-only — 한 UE의 fading이 다른 UE의 입력을 오염시키지 않는다.
  • `socket_index_to_ue_id(i, max_ue_count)`는 `i % max_ue_count`로 결정적 매핑.
    UE가 rfsimulator에 접속한 순서(소켓 인덱스 0, 1, 2…)가 곧 ue_id가 된다.
  • `cir_zmq_interface.read_ue(ue_id)`가 해당 UE의 per-UE CIR 슬롯(explicitly_set이면)이나 broadcast 폴백을 반환.

즉, 채널 에뮬레이션의 적용 지점이 "gNB RF 출력 직전(전역 1회)" → "각 UE 소켓 write 직전(소켓별 독립)"으로 이동한 것이다.

 

기존 Sionna Python 클라이언트가 `ue_id` 없이 `cir` 메시지를 보내면 그대로 동작한다(broadcast_slot에 기록). 

새 Client는 `ue_id` 필드를 추가하기만 하면 per-UE 주입이 가능하다:

```json
{"msg_type": "cir", "ue_id": 0, "sigma_scaling": 1.0, "sigma_max": 1.0,
 "norms": [...], "taps": [...], "tap_indices": [...]}
```


`config_res`에 `max_ue_count`가 추가되어, 클라이언트가 gNB가 몇 개의 UE 슬롯을 지원하는지 확인할 수 있다.

범위 밖의 `ue_id`는 에러 응답과 함께 거부되며, 어떤 슬롯도 변경되지 않는다(validate-then-copy 패턴).

 

 

 

Python에서 UE별로 다른 CIR 전송 (zmq_cir_channel.py)


gNB 플러그인 쪽은 준비가 됐으니, Python에서 UE별로 다른 CIR을 보내야 한다. 

`MultiUEZMQChannel` 클래스가 이를 담당한다.

 

 

```python
class MultiUEZMQChannel:
    def send_cirs(self, cir_dict_map, ue_id_map, target_snr_db):
        if self.per_ue_enabled:
            return self.send_per_ue_cirs(cir_dict_map, ue_id_map, target_snr_db)
        return self.send_average_cir(cir_dict_map, target_snr_db)  # legacy 폴백
```
  • `per_ue_enabled` = `gnb_config.max_ue_count > 1`로 판단 (config_res에서 조회)
  • per-UE 가능하면 `send_per_ue_cirs()`가 각 UE의 CIR을 `ue_id` 필드 포함하여 개별 전송
  • legacy gNB면 `send_average_cir()`로 가장 강한 UE의 CIR 하나만 브로드캐스트

CIR taps 정규화도 이 단계에서 수행한다. (`_pack_cir_zmq()`)

```python
if norm > 1e-10:
    taps_ri = taps_ri / norm  # 에너지 보존: 채널 형태만 전달
```


Sionna RT의 절대 경로손실(norm ~ 10⁻⁵)을 그대로 보내면 CUDA 컨볼루션 출력이 0에 수렴하여 UE가 죽는다. 

정규화 후에는 채널 형태(다중경로 구조)만 반영되고, SNR 제어는 `sigma_scaling`으로 분리한다.

 

 

 

최종 패치된 이미지 빌드


이후 최종적으로 패치된 이미지를 빌드한다.

cd /home/user/sionna-oai-flexric-smo/sionna-rk

# 빌드 (ext/ 안에 패치된 OAI 소스가 있어야 함)
bash scripts/build-oai-images.sh --tag per-ue ext/openairinterface5g

 

이후 docker image를 확인하면 다음과 같다.

 

Docker의 image 계층은 다음과 같은 멀티스테이지 빌드 구조를 가진다.

nvcr.io/nvidia/cuda:13.0.1-devel-ubuntu24.04  (NVIDIA CUDA 베이스)
    ↓
ran-base-cuda         ← 빌드 의존성 설치 (gcc, libzmq, cjson, tensorrt, UHD 등)
    ↓
ran-build-cuda        ← OAI 소스코드 + 플러그인 복사 → 전체 빌드 (gNB, nrUE, FlexRIC)
    ↓
oai-gnb-cuda          ← 최종 런타임 이미지 (빌드 결과물만 복사, 빌드 도구 제거)

 

이미지 역할 Dockerfile
ran-base-cuda 빌드 환경 준비 단계. CUDA 이미지 위에 빌드 도구(gcc, cmake, bison, flex), 
라이브러리(libzmq, libcjson, tensorrt), UHD 드라이버를 설치. 
build_oai -I로 OAI 빌드 의존성 스크립트 실행.
Dockerfile.base.ubuntu.cuda
ran-build-cuda 실제 컴파일 단계. ran-base-cuda 위에서 ext/openairinterface5g/(패치된 OAI 
소스) + plugins/(cir_zmq, chn_emu CUDA)를 복사하고 build_oai로 전체 빌드. 
FlexRIC도 여기서 빌드.
Dockerfile.build.ubuntu.cuda
oai-gnb-cuda 최종 런타임 이미지. ran-base-cuda의 런타임 라이브러리 + ran-build-cuda에서
빌드된 바이너리(nr-softmodem, librfsimulator.so, libcir_zmq.so, libchn_emu.so)만 복사. 빌드 도구는 포함하지 않아 이미지 크기를 줄임.
Dockerfile.gNB.ubuntu.cuda

 

이 과정을 통해서 최종적으로 패치된 oai-gnb-cuda:per-ue 이미지가 생성된다.

 

따라서 해당 이미지를 활용한 전체 데이트 흐름은 다음과 같다.

① Sionna RT PathSolver (UE별 레이트레이싱)
   _compute_all_ue_cirs() → {ue1: cir1, ue2: cir2, ue3: cir3}
        ↓
② _pack_cir_zmq() — taps 정규화 + ue_id 태깅
   MultiUEZMQChannel.send_per_ue_cirs()
        ↓ ZMQ REQ (JSON, tcp://192.168.70.140:5555)
③ cir_zmq.c — handle_cir_message()
   slots[ue_id]에 저장 (mutex 보호)
        ↓
④ rfsimulator/simulator.c — rfsimulator_write_internal()
   per-socket loop:
     ue_id = socket_index_to_ue_id(i, max_ue_count)
     cir = cir_zmq_interface.read_ue(ue_id)
     memcpy(faded, samplesVoid, ...)   ← 원본 복사
     chn_emu_interface.compute_buffer(faded, ..., cir)  ← CUDA 컨볼루션
     fullwrite(socket, faded, ...)     ← 이 UE에게만 전송
        ↓
⑤ UE (rfsim TCP) — 자기만의 faded IQ 수신
   → DL 디코딩 → MCS/BLER 반영
        ↓
⑥ FlexRIC MAC SM → KPICollector (ZMQ PUB, 포트 5557)