gnu.or.kr
차세대 네트워크

0.1 + 0.2 - 0.3으로 나누면 벌어지는 일: 5G 시뮬레이터의 NaN

5G 수신기 시뮬레이션의 느린 경로를 다시 구현해 11배 빠르게 만들고 원본과 결과를 비교하던 중, 원본 쪽에서 NaN을 발견했습니다. 0이어야 할 값에 남은 1e-21 크기의 허수가 0 검사를 통과해 나눗셈으로 폭주한 것이었고, 정밀도를 올린 쪽에서만 터졌습니다. 통신 전공이 아니어도 따라올 수 있도록 용어를 풀어 가며 추적 과정을 정리했습니다.

심근우 (Gnu Shim)수정 2026년 11월 2일SionnaTensorFlowLMMSEIEEE 754cuBLASNsight수치 안정성

왜 NaN이 났나 — JavaScript에서 0.1 + 0.2 - 0.3이 0이 아닌 것과 뿌리가 같은 문제입니다. 세 가지가 겹쳤습니다.

  • 0이어야 할 값이 0이 아니었습니다: 수학적으로 0이어야 할 허수부에 1e-21 크기의 부동소수점 잔여물이 남아 있었습니다.
  • 그 값으로 나눴습니다: 코드는 “분모가 정확히 0이면 나누지 않는다”고 막아 두었는데, 1e-21은 0이 아니라 통과했고 몫이 1e21 규모로 커졌습니다.
  • float32 범위를 넘었습니다: 그 몫을 제곱하면 Infinity가 되고, 여기에 0을 곱해 NaN이 됩니다.

TL;DR — 같은 식에 공분산 행렬(뒤에서 설명합니다)만 바꿔 넣은 결과입니다.

공분산 0이어야 할 허수부 결과
complex64로 누적 5.0e-15 ~ 6.3e-11 비유한값 없음
complex128로 누적 후 complex64로 저장 1.25e-25 ~ 1.71e-21 TB마다 LLR 40개 NaN
위 공분산을 (R+Rᴴ)/2로 대칭화 0 비유한값 없음
대칭화 후 대각에 1e-10j 추가 1e-10 비유한값 없음
단위 공분산 대각에 1e-22j 추가 1e-22 비유한값 발생

정밀도를 올려 잔여물을 줄인 쪽에서만 터졌습니다. 저장 경계에서 행렬을 대칭화해 막았고, 재구현한 경로는 3,808 µs/TB에서 346 µs/TB로 11.0배 빨라졌습니다. 측정 환경은 Sionna 0.18·TF 2.17·RTX PRO 6000 Blackwell이며, 식의 재현은 공식 Sionna 1.2.2 CPU 컨테이너에서도 확인했습니다.

들어가며

5G 기지국 수신기를 소프트웨어로 흉내 내는 시뮬레이션을 돌리고 있었습니다. 도구는 NVIDIA가 Apache 2.0으로 공개한 Sionna입니다. 무선 통신의 신호 처리 과정, 곧 비트를 전파에 실어 보내고 받은 신호에서 다시 비트를 찾아내는 과정을 코드로 시뮬레이션하는 Python 라이브러리입니다. 안테나나 기지국 장비 없이 GPU로 계산할 수 있고, 신경망 수신기와 고전 수신기를 같은 틀에서 비교할 수 있습니다. 이번에 쓴 0.18과 1.x는 TensorFlow 기반이고, 2.x부터는 PyTorch 기반입니다.

이 Sionna로 수신기 여러 개를 여러 전파 환경에서 평가하는 실험을 돌렸습니다. 조건을 바꿔 가며 반복해야 하는데, 기존 구조로는 한 번에 약 8.5시간이 걸릴 것으로 나왔습니다.

그래서 평가 시간부터 줄이기로 했고, 가장 느린 부분은 아예 다시 구현하기로 마음먹었습니다. 다시 짠 코드가 원본과 같은 결과를 내는지 비교했는데, 세 번째 검증에서 256건이 전부 어긋났습니다. 새 코드를 의심해야 할 상황이었는데, 저장해 둔 값을 보니 NaN은 원본 쪽에 있었습니다.

이 글은 그 NaN을 추적한 기록입니다. 저도 통신을 전공하지 않았으니, 필요한 용어는 그때그때 풀어서 쓰겠습니다.

먼저, 시뮬레이터 안에서 무슨 일이 일어나는가

수신기는 망가진 전파에서 원래 비트를 복원합니다. 이 글의 NaN은 그중 “전파가 얼마나 망가졌는지 추정하는” 단계에서 생겼습니다.

휴대폰이 기지국으로 데이터를 보내면 전파는 건물에 반사되면서 세기와 위상이 달라집니다. 수신기는 이 왜곡을 되돌려야 하고, 흐름은 대략 다음과 같습니다.

받은 신호 → 채널 추정 → 검출 → LLR → LDPC 복호 → CRC 확인 → TB 성공/실패
            └ 이번 문제가 생긴 곳
  • TB(Transport Block): 한 번에 보내는 데이터 묶음입니다. 네트워크의 패킷과 비슷한 단위로 생각하면 됩니다. 이 글의 시간 수치는 TB 한 건을 처리하는 시간입니다.
  • 채널 추정: 전파가 어떻게 망가졌는지 알아내는 단계입니다. 송신 쪽이 미리 약속한 값(파일럿)을 군데군데 함께 보내면, 수신 쪽은 그 값이 어떻게 변해서 왔는지 보고 왜곡을 잽니다.
  • LLR: 비트마다 수신기가 내는 “확신 점수”입니다. Sionna 기준으로 양수면 1, 음수면 0에 가깝고, 절댓값이 클수록 확신이 큽니다. 곧바로 0과 1로 자르지 않고 점수로 넘기는 이유는 다음 단계가 이 확신 정도를 쓰기 때문입니다.
  • LDPC: 오류 정정 부호입니다. 보낼 때 여분의 비트를 붙여 두고, 받은 쪽은 비트들이 지켜야 할 규칙을 반복해서 맞춰 보며 틀린 비트를 고칩니다. 칸 몇 개가 지워져도 규칙으로 답을 찾아내는 스도쿠와 비슷합니다.
  • CRC: 복원한 데이터가 맞는지 보는 체크섬입니다.

받은 신호는 표처럼 생각할 수 있습니다. 이 실험의 표는 가로가 주파수 288칸(부반송파), 세로가 시간 13칸(OFDM 심볼)입니다. 칸 하나를 RE라고 부르고, 16QAM이라는 방식에서는 칸 하나에 4비트가 실립니다. 시간 13칸 중 3칸에만 파일럿이 있고, 나머지 10칸은 파일럿이 없습니다. 이 “파일럿 없는 10칸”이 뒤에서 문제의 무대가 됩니다.

파일럿은 일부 칸에만 있으니 나머지 칸의 왜곡은 메워야 합니다. Sionna의 LMMSEInterpolator가 이 일을 합니다. LMMSE는 평균 제곱 오차가 가장 작아지도록 값을 선형으로 조합하는 추정 방식이고, Interpolator는 빈칸을 메운다는 뜻입니다. 전국 날씨 관측소 몇 곳의 값으로 전국 기온 지도를 채우는 것과 비슷한데, “어느 지역끼리 기온이 같이 움직이는지”를 미리 알면 훨씬 잘 채울 수 있습니다. 이 사전 지식이 공분산 행렬입니다. 288×288 표로, i행 j열의 값은 “i번 칸과 j번 칸의 왜곡이 얼마나 같이 움직이는가”입니다.

평가 시간의 81%가 이 보간 코드였습니다

병렬 처리가 많을 것 같아 GPU로 옮겼는데, 이 경로의 완료시간 중 GPU 커널이 도는 시간은 13%뿐이었습니다.

수만 개의 TB에 같은 행렬 연산을 반복하는 일이니 병렬 처리가 많을 것이라고 봤고, GPU로 옮기면 한 번에 해결될 것이라고 생각했습니다. 그래서 TB를 묶어서 처리하는 배치를 키우고, 코드를 한 줄씩 실행하는 eager 방식을 미리 묶어 최적화하는 그래프 방식으로 바꾼 뒤 다시 쟀습니다.

경로 조치 전 (µs/TB) 조치 후 (µs/TB)
신경망 추론 16,320 (eager) 166.3 (그래프, 배치 1024)
LDPC 복호 6,501 (배치 8) 214.7 (배치 1024)
LMMSE 추정·검출 8,683 (배치 8) 3,803.5 (배치 256)

신경망과 LDPC는 크게 줄었지만 LMMSE는 평가 시간 합의 약 81%로 남았고, 배치 256에서 GPU 메모리를 이미 11.09 GB 쓰고 있어 더 키우기도 어려웠습니다.

GPU가 얼마나 일하는지 보려고 NVIDIA의 프로파일러 Nsight Systems를 썼습니다. CPU와 GPU 작업, 메모리 복사를 시간 축 위에 보여 주는 도구입니다. 3,803.5 µs 중 GPU 커널이 도는 시간은 약 497 µs였습니다. 나머지 시간을 정확히 나눠 재지는 않았습니다. CPU 샘플링을 끄고 추적했기 때문에 CPU 계산, 복사, 대기가 섞여 있습니다. 다만 원인 후보는 두 군데에서 확인했습니다.

  • CPU 왕복: 필터를 구하는 tf.linalg.lstsq(fast=False)는 TF에 CPU 커널만 있어서 CPU로 내려갔다가 돌아옵니다.
  • 복사: 슬롯마다 변하지 않는 288×288 공분산을 배치 전체로 복제하면서 배치당 약 2.9 GB가 복사됐습니다.

GPU로 옮겼다고 GPU가 계속 일하는 것은 아니었습니다. 그래서 이 부분을 다시 구현하기로 했습니다.

다시 구현하되, 결과가 하나라도 다르면 멈추게 했습니다

수학적으로 같은 계산을 다른 방식으로 하니, 원본과 복호 결과가 같은지 단계별로 확인했습니다.

방법은 두 가지를 놓고 봤습니다. lstsq를 그대로 두고 중복만 없애면 원본과 가장 비슷하지만 CPU 왕복이 남습니다. 공분산을 미리 고유분해해 두면 슬롯마다 GPU에서 나눗셈과 행렬곱만 하면 되는 대신 계산 경로가 달라집니다. 속도를 우선해 후자를 택했고, 대신 위험은 검증으로 막기로 했습니다.

단계 조건 비교 건수 원본과 결과가 다른 건수
1 표본에서 만든 공분산 7,680 0
2 시험 채널에 맞춘 공분산 3,072 0
3 UMi 공분산 256 256

첫 두 단계에서 추정 구간은 3,808 µs/TB에서 346 µs/TB로, 피크 메모리는 11.09 GB에서 330 MB로 줄었습니다.

세 번째 조건의 UMi는 Urban Micro의 줄임말로, 3GPP 표준이 정의한 전파 환경 중 하나입니다. 도심 가로등 높이의 소형 기지국 환경을 통계로 만든 모델입니다.

세 번째 검증에서 256건이 전부 어긋났습니다

정답 기준을 따로 만들어 비교하니 새 구현은 맞았고, NaN은 원본에만 있었습니다.

payload가 다른 TB가 156건, CRC가 다른 TB가 100건으로 합쳐서 256건 전부였습니다. 저장한 LLR을 열어 보니 새 코드의 값은 모두 멀쩡한 숫자인데, 원본 쪽에 NaN(Not a Number, 계산이 잘못돼 숫자가 아니게 된 값)이 섞여 있었습니다.

NaN이 있다고 새 구현이 맞다고 볼 수는 없습니다. 두 코드만 비교해서는 누가 틀렸는지 가릴 수 없으니, 같은 계산을 numpy complex128로 옮긴 세 번째 코드를 정답 기준으로 만들었습니다. 먼저 정상 조건에서 원본과 맞는지 확인한 뒤 셋을 비교했습니다. 새 구현은 정답 기준과 결과가 모두 같았고(LLR 최대 차이 0.001633), 원본은 TB마다 정확히 40개의 LLR이 NaN이었습니다.

NaN은 최종 출력에 보이지 않았습니다

파일럿 없는 칸에서 생긴 NaN이 한 곳으로 모였다가 사라졌고, 다음 단계에서 다시 나타났습니다.

LMMSEInterpolator의 출력에는 NaN이 하나도 없어서, 출력에 isfinite를 걸어 두었어도 지나갔을 것입니다. 값을 단계별로 기록해 따라가 보면 경로는 다음과 같습니다.

주파수 단계  파일럿 없는 시간 10칸 × 주파수 288칸 → err_var 2,880개 NaN
곱셈 마스크  NaN × 0 = NaN, 지워지지 않음
시간 단계    _add_err_var_indices 기본값 [0,0,0,0,0]
             → 2,880개가 0번 주파수 칸의 행렬 한 곳으로 합쳐짐
lstsq        이 칸에 0을 반환 → 0번 주파수 칸의 추정값이 전부 0
추정기 출력  err_var는 유한한 값으로 다시 계산 → 전부 유한
검출기       0번 주파수 칸의 데이터 10칸 × 4비트 → LLR 40개 NaN

여기서 err_var는 “이 추정값을 얼마나 믿을 수 있는가”를 나타내는 오차 분산입니다. TB당 40개라는 숫자는 데이터 칸 10개에 칸당 4비트가 실린 값입니다.

왜 하필 여기서 복소수 문제가 터졌나

보통은 1e-21이 1.6 옆에서 그냥 사라집니다. 이 코드에서는 큰 값끼리 빼서 잔여물만 남기고, 그것으로 나눕니다.

무선 신호는 세기와 위상을 함께 나타내려고 복소수로 계산합니다. 그래서 공분산 표의 값도 복소수입니다. 다만 이 표에는 규칙이 있습니다. 표를 대각선으로 접었을 때 마주보는 두 칸은 실수부가 같고 허수부 부호만 반대입니다. 이런 행렬을 Hermitian이라고 부릅니다. 대각선 위의 칸은 자기 자신과 마주보므로 허수부가 0이어야 합니다. 뜻으로 봐도 대각선 값은 “그 칸과 그 칸이 같이 움직이는 정도”, 곧 그 칸의 평균 전력이니 실수여야 맞습니다.

그런데 컴퓨터로 계산하면 이 허수부가 정확히 0이 되지 않을 수 있습니다. 대부분의 코드에서는 문제가 안 됩니다. JS로 보면 이렇습니다.

1.6 + 1e-21 === 1.6        // true, 잔여물은 큰 값 옆에서 사라집니다
0.1 + 0.2 - 0.3            // 5.551115123125783e-17, 큰 값끼리 빼면 잔여물만 남습니다
(0.1 + 0.2 - 0.3) === 0    // false, 0 검사를 통과합니다
1 / (0.1 + 0.2 - 0.3)      // 18014398509481984, 나누면 잔여물이 주인공이 됩니다

이 코드가 정확히 이 순서를 밟습니다. Sionna 1.2.2 기준 1141~1151행이고 0.18에도 같은 식이 있습니다.

err_var_c = tf.complex(err_var, self._rzero)
h_var = tf.linalg.diag_part(cov_mat)
s = tf.math.divide_no_nan(2.*h_var, h_hat_var + h_var - err_var_c)
...
err_var = s*(s-1.)*h_hat_var + (1.-s)*h_var + s*err_var_c

파일럿 없는 칸에서는 h_hat_var가 0이고 err_var는 대각 값의 실수부입니다. 분모는 결국 “대각 값 - 대각 값의 실수부”입니다. 대각이 실수면 분모가 정확히 0이어서 divide_no_nan이 s를 0으로 만들어 줍니다. 설계대로라면 이 칸은 안전하게 지나갑니다.

하지만 코드를 보면 err_var는 앞에서 실수로 바꿨는데 h_var는 복소수 그대로입니다. 대각에 허수 1e-21이 남아 있으면 분모는 1e-21j가 되고, divide_no_nan은 정확히 0일 때만 막으니 그대로 나눕니다. s는 1e21 규모가 되고, s*(s-1)은 float32의 최댓값 약 3.4e38을 넘어 Infinity가 됩니다. 이 계산은 complex64로 도는데, complex64는 실수부와 허수부가 각각 float32인 타입입니다. 여기에 0인 h_hat_var를 곱하는 순간 NaN입니다. 실제 사례에서 s의 최대 절대값은 2.57e25였습니다. 나눗셈, 제곱, 0 곱하기가 한 줄에 모여 있어서 다른 코드라면 조용히 사라졌을 잔여물이 살아남았습니다.

허수부를 일부러 고려하지 않았을 가능성도 생각해 봤지만 코드만으로 의도를 단정할 수는 없었습니다. 공분산은 수학적으로 Hermitian이어야 하니 입력이 그 규칙을 지킨다고 전제했을 수 있습니다. 그래서 라이브러리 결함이라기보다, 규칙에서 아주 조금 벗어난 값과 이 계산이 만나 생긴 강건성 문제로 판단했습니다.

공분산 값이 극단적이어서 생긴 문제일 가능성도 확인했습니다. 파일럿 없는 칸의 필터는 공분산과 상관없이 0이고, 가장 단순한 단위 공분산에 1e-22j만 더해도 재현되므로 이 가설은 접었습니다.

정밀도를 올리자 잔여물이 위험 대역으로 들어갔습니다

잔여물이 크면 안전하고, 아주 작으면 터지고, 정확히 0이면 다시 안전합니다.

이 공분산은 원래 complex64로 누적했는데, 공분산을 만드는 누적 단계만 complex128로 올리고 추정 계산은 complex64로 두었습니다. 정밀도를 올린 데에는 이유가 있었습니다. 코드에 양의 정부호 단언을 걸어 두었는데 여기에 걸렸습니다. 분산은 음수가 될 수 없으니 공분산 행렬도 어느 방향으로 봐도 분산이 양수여야 하고, 이것을 확인하는 검사입니다. “몸무게가 음수면 멈춘다”는 assert와 비슷합니다. 고유값이 약 -1.6e-5로 나와서 멈췄고, 반올림 오차로 보고 누적만 complex128로 올렸습니다.

그런데 잔여물은 줄었는데 그 쪽에서만 NaN이 났습니다. 복소 연산을 float32로 옮겨 잔여물 ε만 바꿔 봤습니다(대각 값 1.604).

대각 허수부 ε |s| err_var
1e-5 3.2e5 유한
1e-18 3.2e18 유한
1e-19 3.2e19 NaN
1e-25 3.2e25 NaN
0 0 유한

ε이 작을수록 s가 커지므로 경계는 약 1.08e-19·대각 값입니다. 표의 “유한”이 맞는 값이라는 뜻은 아닙니다. ε이 0이 아니면서 경계보다 크면 err_var는 대각 값이 아니라 그 음수가 되어 0으로 잘립니다. 다만 이 값은 시간 단계 전에 마스크로 지워지므로, 코드상 결과에는 영향이 없는 것으로 봅니다.

실제 공분산을 재 보면 complex64 누적의 잔여물은 5.0e-15 ~ 6.3e-11이고, complex128 누적 후 저장한 것은 1.25e-25 ~ 1.71e-21이었습니다. 정밀도를 올린 조치가 잔여물을 위험 구간으로 옮겨 놓은 것입니다. 추정 계산까지 complex128(실수부와 허수부가 각각 double, 최댓값 약 1.8e308)로 돌리면 범위가 넓어 같은 입력이 유한하고, ε이 1e-160까지 내려가야 같은 현상이 나왔습니다. 그러니 float32의 범위가 실제 잔여물 크기와 겹친 것이 문제였습니다.

그렇다고 complex64가 안전하다고 볼 수는 없습니다. s 최대값 약 6.5e14와 float32 경계 약 1.8e19 사이는 약 4.5자릿수로, 이 입력에서 터지지 않았다는 뜻이지 여유가 보장된다는 뜻은 아닙니다. 양의 정부호 단언도 나중에 보니 지나치게 엄격했고, 수치 허용 오차를 두는 검사로 충분했습니다.

잔여물은 곱셈 구현에 따라 달랐습니다

잔여물은 CPU냐 GPU냐로 갈리지 않았습니다. 원인 후보는 FMA와 누산 순서이고, 모델로 패턴 일부를 재현했지만 추정입니다.

수식상 대각의 허수부는 항마다 aᵢ·aᵣ - aᵣ·aᵢ이니 0입니다. 그런데 컴퓨터는 숫자를 정해진 자릿수까지만 저장하므로 계산 중에 반올림이 일어나고, 그 반올림이 어떻게 끼어드는지는 곱셈 구현에 달려 있습니다. CPU로 돌렸다면 어땠을지 x86 TF 2.17에서 합성 입력(288×13, 배치 30)으로 쟀습니다. GPU는 3,334회, CPU는 200회 평균입니다.

입력 GPU 대각 허수부 GPU NaN 원소 CPU 대각 허수부
complex64 8.4e-14 ~ 3.7e-11 0/288 0
complex64를 complex128로 캐스트 7.1e-23 ~ 3.6e-20 288/288 0
complex128 6.1e-23 ~ 1.3e-19 285/288 측정 안 함

측정 자체에서 먼저 알 수 있는 것은 CPU냐 GPU냐로 갈리지 않는다는 점입니다. 같은 aarch64 CPU에서 numpy einsum은 0이었지만 BLAS 경로는 0이 아니었고, 같은 GPU의 cuBLAS에서도 Zgemm은 0이 아니었지만 Zherk는 0이었습니다. 행렬 곱셈은 보통 BLAS라는 표준 라이브러리 규격의 구현이 맡습니다. 같은 인터페이스를 CPU에서는 OpenBLAS, NVIDIA GPU에서는 cuBLAS가 구현하는 식인데, 데이터베이스 드라이버처럼 규격은 같아도 내부 계산 순서는 다릅니다. TF의 CPU 경로는 별도의 자체 커널을 쓰는 것으로 추정합니다.

여기에 FMA(융합 곱셈-덧셈, Fused Multiply-Add) 가 얽혔을 것으로 봅니다. a×b+c를 한 명령으로 계산하고 마지막에 한 번만 반올림하는 CPU·GPU 명령입니다. 대체로 더 빠르고 정확하지만, “같은 곱 둘을 빼면 0”을 보장하지 않습니다. 유효숫자 세 자리만 쓰는 컴퓨터를 가정해 보겠습니다.

1.23 × 4.56 = 5.6088, 반올림하면 5.61
곱 둘을 각각 반올림한 뒤 뺌:  5.61 - 5.61   = 0
한쪽만 FMA로 정확히 계산:     5.6088 - 5.61 = -0.0012 ← 잔여물
대각 허수부 = Σ (aᵢ·aᵣ − aᵣ·aᵢ)수식으로는 항마다 0인데, 구현에 따라 남기도 하고 0이기도 합니다.계산 순서 (재현 모델)실측 잔여물c64캐스트c128곱 둘을 각각 반올림한 뒤 뺌같은 곱이 같게 반올림돼 정확히 상쇄TF CPU, numpy einsum000한쪽 곱만 FMA로 정확히 계산곱의 반올림 오차가 그대로 남음numpy BLAS 경로남음0남음한 누산기에 더하고 빼기누산 중 반올림이 남음cuBLAS GEMM, TF GPU남음일부남음대각을 실수로 계산허수부를 계산하지 않음cuBLAS HERK-0-캐스트가 2행에서 0인 이유: 24비트 값 둘의 곱은 double에정확히 담겨 곱의 반올림 오차가 생기지 않습니다.
칩의 값은 커널별 실측이고, 왼쪽의 계산 순서는 그 패턴을 재현한 모델입니다. 실측은 aarch64의 TF 2.21 CPU, numpy, GB10 cuBLAS와 x86의 TF 2.17 CPU·GPU입니다. 닫힌 커널의 내부를 확인한 것은 아닙니다.

이 설명이 어디까지 맞는지 보려고, C로 곱셈 순서만 바꿔 288×13 합성 입력 2,000개에 돌려 봤습니다.

계산 순서 complex64 캐스트 complex128 비슷한 실측
곱 둘 반올림 후 뺌, 또는 누산기 분리 후 뺌 0 0 0 TF CPU, numpy einsum
한쪽 곱만 FMA 최대 5.8e-7 0 최대 1.0e-15 numpy BLAS 5.2e-7, 0, 9.2e-16
한 누산기에 전부 더한 뒤 뺌 최대 3.3e-6 35.4%, 최대 4.4e-15 최대 7.4e-15 cuBLAS GEMM 6.1e-7, 9.5%, 2.7e-15

numpy BLAS 경로는 패턴과 크기가 모두 비슷합니다. 캐스트 입력이 0인 것은 float32 값 둘의 곱이 double에 정확히 담겨 반올림 오차가 없기 때문입니다. cuBLAS는 “일부만 0이 아니다”라는 패턴은 같지만 비율과 크기가 달라서, 내부가 정확히 이 순서라고 볼 수는 없습니다. Hermitian 전용 함수인 Zherk는 캐스트 입력에서 정확히 0이었습니다.

그래서 “GPU 문제인가”는 둘로 나눠서 봐야 했습니다. 잔여물을 폭주시키는 식은 플랫폼과 무관해서 공식 Sionna 1.2.2 CPU 컨테이너에서도 같은 입력으로 재현됐습니다. 반면 그 입력을 만드는 잔여물은 커널에 달려 있어서, 공분산을 TF CPU로 계산했다면 이번 일은 일어나지 않았을 가능성이 있습니다.

공분산을 저장할 때 대칭화해 막았습니다

(R+Rᴴ)/2만으로 비유한값이 사라졌고, 결과를 보기 전에 적어 둔 예측 네 가지도 모두 맞았습니다.

행렬과 그 켤레 전치를 평균하면 Hermitian 규칙이 강제로 맞춰지고, 대각의 허수부가 정확히 0이 됩니다. 실제 사례에서 대각 이외의 값은 하나도 바뀌지 않았습니다. 대칭화한 공분산으로 기준 조건 4개를 256 TB씩 원본과 새 구현에 넣어 결과가 같은 것을 확인한 뒤 전체 평가를 끝까지 돌렸습니다.

원인을 제대로 이해했는지 보려고 예측을 먼저 적었습니다. complex64 누적, 대칭화 후 1e-10j 추가, 전체 complex128 계산은 비유한값이 없고, 단위 공분산에 1e-22j를 더하면 생긴다는 예측이었고 네 가지 모두 그대로 나왔습니다.

Sionna 코드는 고치지 않았습니다. h_var에 tf.math.real을 거는 것이 자연스러운 수정으로 보이지만 이 수정 자체는 검증하지 않았습니다.

컨트리뷰션을 생각했는데, 그 클래스는 이미 없었습니다

최신 Sionna에서 LMMSEInterpolator가 제거됐습니다. 후속 코드의 1e-12 가드는 NaN을 막지만 다른 조건에서 추정값을 0으로 만들었습니다.

원인과 재현 코드까지 갖추니 “오, 잘하면 이거 컨트리뷰션할 수 있는 거 아니야?“라는 생각이 들었습니다. 그런데 최신 버전을 확인해 보니 Sionna 2.1.0에서 이 클래스 자체가 제거돼 있었습니다. 고칠 대상이 사라진 셈이라 이슈는 등록하지 않았습니다.

대신 후속 LMMSEChannelEstimator의 내부 코드를 봤습니다. 대각은 여전히 복소수이지만 분모를 denom.abs() > 1e-12로 막고 있습니다. JS로 치면 === 0 대신 허용 오차로 비교하는 것인데, 이것이 제대로 동작하는지 공식 배포본 2.1.0과 2.2.0을 CPU 컨테이너에 설치해 돌려 봤습니다. 두 버전의 결과는 같았습니다.

대각 허수부 ε 비유한값 추정값 최대 차이
1e-25 ~ 1e-13 없음 0
1e-12 없음 약 2e-6
6e-11 없음 약 4e-6
1e-8 없음 약 1e-5

이번 NaN은 실제로 막혔습니다. 그런데 허용 오차가 절대값이라는 점이 걸렸습니다. 입력 전체의 크기만 줄이면 이론상 결과도 비례해서 줄어야 하는데, 공분산·수신 신호·잡음을 일관되게 줄이자 전력 1e-13배부터 경고도 NaN도 없이 추정값 전체가 0이 됐습니다.

전력 배율 비유한값 이론값 대비 상대 오차 추정값이 0인 비율
1 ~ 1e-12 없음 3.6e-11 이하 0%
1e-13 ~ 1e-16 없음 1.0 100%

실제로 닿는 값인지는 Sionna 2.2.0의 UMa(옥상 기지국 도심 환경)와 UMi 채널을 거리에 따른 감쇠를 그대로 둔 채 생성해 확인했습니다. 시나리오별 1,000드롭, 단일 안테나, 3.5 GHz입니다. 감쇠가 -130 dB 이하인 위치는 UMa 54.2%, UMi 17.9%로 드물지 않았습니다. 다만 가드에 걸리는지는 공분산에 전력을 어떻게 넣느냐로 갈렸습니다.

공분산의 전력 표현 -125 dB 이하 -125 ~ -120 dB -120 dB 초과
위치별 전력 반영 100% 43~46% 0%
시나리오 평균 전력 하나 0% 0% 0%
전력 1 그대로 0% 0% 0%

즉 감쇠가 큰 것만으로는 걸리지 않고, 정규화하지 않은 단말별 표본으로 공분산을 만들어 작은 전력이 공분산까지 들어갔을 때만 걸립니다. 입력이나 공분산을 정규화해 쓰는 일반적인 경우에는 닿을 일이 드물 것으로 봅니다. 분모를 대각 값에 대한 상대값으로 비교하면 두 경우를 함께 막을 수 있어 보이지만, 이 수정은 식으로만 계산했고 Sionna에서 돌려 보지는 않았습니다.

여담으로, 1e-12라는 숫자가 눈에 익어서 찾아봤습니다. Reddit의 질문을 보고 흥미가 생겼는데, 유선 Ethernet 쪽에서는 비트 오류율(BER) 목표로 자주 보이는 값입니다. IEEE 802.3 Ethernet 작업 문서에 “BER 1e-12 이하” 목표가 나오고, 10 Gbps 링크라면 약 5분이면 오류 없이 이 수준을 시험으로 확인할 수 있는 값이기도 합니다. 다만 왜 하필 이 값이 골라졌는지 설명하는 출처는 찾지 못했고, SONET 쪽은 1e-10을 쓴다는 자료도 있습니다. FEC를 쓰는 최근 Ethernet에서는 프레임 손실률로 바꾸자는 논의도 있었습니다. 이 값은 오류 정정 뒤의 유선 링크 기준이라 5G 무선의 PHY 수치로 옮겨 쓸 수는 없습니다. 5G는 전송 블록 오류율 0.1처럼 다른 기준을 씁니다. Sionna의 1e-12도 이와 무관한 분모 허용 오차이니, 숫자만 같은 셈입니다.

마치며

  • 결과 비교 검증이 원본의 문제까지 드러냈습니다. 속도만 쟀다면 만나지 못했을 NaN이고, 두 구현만 비교했다면 어느 쪽 문제인지 가르지 못했을 것입니다.
  • GPU로 옮겼다고 GPU가 계속 일하는 것은 아니었습니다. 이 경로의 완료시간 중 GPU 커널이 도는 시간은 13%였고, 프로파일러로 보기 전에는 알 수 없었습니다.
  • isfinite는 최종 출력만 봐서는 부족했습니다. 오차 분산 같은 중간값을 봐야 했습니다.
  • === 0은 정확히 0일 때만 듣고, 절대값 허용 오차는 값의 크기에 묶입니다. 2.x의 1e-12는 NaN을 막았지만 전력이 1e-13배인 입력의 추정값을 전부 0으로 만들었습니다. 실수여야 할 값은 타입이 바뀌는 경계에서 강제하는 편이 안전합니다.
  • 정밀도를 높이는 것이 항상 안전 쪽은 아니었습니다. 0.18부터 1.2.x까지의 LMMSEInterpolator에 직접 만든 공분산을 넘긴다면 대칭화를 검토할 만합니다.

남은 문제도 있습니다. cuBLAS 내부의 계산 순서는 확인하지 못했습니다. 1e-12 가드 문제는 단일 섹터 1,000드롭의 UMa·UMi 채널에서 확인했고, 실제 프로젝트에서 위치별 전력을 공분산에 넣는 경우가 얼마나 되는지는 모릅니다. 상대 기준 수정과 tf.math.real 수정도 Sionna에서 검증하지 않았습니다. 실제 채널 공분산의 잔여물은 GPU에서 저장한 값만 쟀고 다른 커널로는 재지 않았으며, 이 NaN이 복호 성공률에 얼마나 영향을 주는지도 따로 재지 않았습니다.

데이터와 범위

결론은 2026년 11월에 측정한 환경과 입력에 한정됩니다. 잔여물의 크기는 커널과 입력에 따라 달라질 수 있습니다.

곱셈 순서 모델은 실제 커널을 재현한 코드가 아니며, 패턴이 어디까지 비슷한지 보기 위한 것입니다. x86 측정은 TF 2.17과 RTX PRO 6000 Blackwell, aarch64 커널 비교는 TF 2.21과 NVIDIA GB10의 cuBLAS입니다. 공식 Sionna 1.2.2 재현은 CPU 전용 컨테이너(TF 2.19.1)에서 저장한 입력으로 했습니다. Sionna 2.1.0과 2.2.0 확인은 aarch64 CPU 전용 컨테이너(torch 2.15.0)에서 합성 입력으로, 시스템 레벨 채널 확인은 2.2.0으로 했습니다. 커널 비교는 합성 입력입니다.

자료는 다음과 같습니다.

참고

  1. Sionna 1.2.2 channel_estimation.py
  2. Sionna 2.1.0 channel_estimation.py, 릴리스 노트
  3. Sionna 2.2.0 channel_estimation.py