
지난 네 편은 전부 "무엇이 안 됐고 왜 안 됐는가"의 기록이었다. 블록 디자인을 처음 그리면서 헤맨 이야기, 그리고 세 편에 걸친 에러 트러블슈팅.
이번 편은 고친 것들이 모여서 결국 무엇이 되었는지만 다룬다. 자랑할 숫자와 자랑 못 할 숫자를 같이 놓는다. 마지막에는 이 프로젝트가 하지 못한 것도 정리한다.
| 항목 | 내용 |
|---|---|
| 대상 | N=43 anti-alias FIR + ↓2 데시메이터 IP (Zynq-7020) |
| 기간 | 2026-03 ~ 2026-08 (6개월, 1인 수행) |
| 결과 | 실보드 SNR 74.9dB PASS / Fmax 116→146MHz / 8192샘플 85.0µs / 보드 실측 2.21W |
| 이 글에서 다루는 것 | 위 숫자들이 어디까지 믿을 만한가 |
100 MS/s 16-bit 입력을 안티에일리어싱 필터링과 함께 50 MS/s로 2:1 데시메이션하는 N=43 FIR IP다. 타겟은 Zynq-7020(Zybo Z7-20)이고, 데이터 경로는 이렇다. PC의 Python이 UART로 명령을 내리면 PS의 bare-metal C가 AXI DMA MM2S 채널로 입력 샘플을 PL의 FIR에 공급하고, 처리된 출력은 S2MM 채널로 DDR에 회수된 뒤 UART로 PC에 전달된다. PC는 이를 FFT로 시각화하는 동시에 Python 고정소수점 골든모델과 대조해 PASS/FAIL을 자동 판정한다.
PC(Python)
↓
UART
↓
PS bare-metal C
↓
AXI DMA MM2S
↓
PL FIR/decimator
↓
AXI DMA S2MM
↓
DDR
↓
UART
↓
PC FFT plot / 자동 판정
설계 사양은 이렇다.
| 항목 | 값 |
|---|---|
| 입력 / 출력 샘플레이트 | 100 MS/s → 50 MS/s (M=2) |
| 탭 수 / 설계 | N=43, Kaiser window lowpass (β ≈ 5.653) |
| 통과대역 / 저지대역 | fp 15 MHz / fs 25 MHz, 감쇠 ≥ 60 dB (worst-case 판정) |
| 구조 | Transposed form, 계수 Q1.15 / 내부 Q2.30, 1 sample/cycle |
| 파이프라인 | v1 3-stage → v2 4-stage |
| 인터페이스 | AXI-Stream (tvalid/tready/tlast) |
43은 처음부터 정한 숫자가 아니었다. Kaiser 차수 추정식에 이 사양(fp 15 MHz, fs 25 MHz, As 60 dB)을 넣으면 39탭이 나온다. 초기 작업 가정은 거기에 양자화 오차 여유를 +2 준 41탭이었다. 둘 다 결국 탈락했다.
갈린 건 탭 수가 아니라 판정 기준을 어디에 두느냐였다. "저지대역 감쇠 60 dB"라고 쓸 때, 이걸 25 MHz 한 지점에서 재는 것과 f ≥ 25 MHz 전 구간의 worst-case로 재는 것은 다른 시험이다. 후자로 바꾸는 순간 순위가 뒤집혔다.
| N | 25 MHz 감쇠 | 저지대역 worst-case | worst 발생 위치 | 판정 |
|---|---|---|---|---|
| 39 | 71.27 dB | 59.17 dB | 25.463 MHz | fail |
| 41 | 60.38 dB | 59.62 dB | 25.154 MHz | fail |
| 43 | 60.25 dB | 60.25 dB | 25.000 MHz | pass |
41탭이 함정이었다. 25 MHz 한 점만 보면 60.38 dB라 통과처럼 보인다. 그런데 조금 위인 25.154 MHz에서 59.62 dB까지 올라온다. 스펙에서 0.38 dB 모자란다. 사양서에 "25 MHz에서 60 dB"라고 적어놓고 그 한 점만 확인했다면 41로 확정하고 넘어갔을 것이다. 39탭은 더 직관적이지 않다. 25 MHz에서의 감쇠가 71.27 dB로 셋 중 제일 깊다. 그런데도 미달이다. 저지대역 안쪽 25.463 MHz에서 59.17 dB로 솟기 때문이다. Kaiser 윈도 설계의 저지대역은 평평한 벽이 아니라 리플이 있는 능선이라, 경계에서 깊다고 전 구간이 깊은 게 아니다.
43탭이 통과한 이유도 표에 그대로 있다. 25 MHz 감쇠(60.25 dB)와 저지대역 worst-case(60.25 dB)가 같은 값이고, 발생 위치가 정확히 25.000 MHz다. 저지대역 안쪽에 경계보다 나쁜 봉우리가 없다는 뜻 — 즉 필터가 설계 의도대로 저지대역 시작점을 최악점으로 갖는다. 41/39는 최악점이 경계 바깥으로 밀려나 있었다.
마지막으로 이 판정은 멀티톤 입력을 넣어서 한 게 아니라 계수의 주파수 응답 |H(f)|로 했다. 입력을 넣어 재면 "이 입력에서 alias가 얼마나 남았나"라는 시스템 질문의 답이 나오지, "필터가 저지대역에서 60 dB를 주는가"라는 필터 자체의 질문에는 답이 안 된다. 입력 스펙트럼을 바꾸면 결과가 따라 바뀌니까. Q1.15로 양자화한 계수로도 다시 확인해서 61.06 dB로 여전히 통과하는 걸 보고 43으로 확정했다(즉 이건 고정소수점 포맷 문제가 아니라 탭 수 문제였다).
추정식은 시작점이지 판정 기준이 아니다. 그리고 판정은 한 점이 아니라 구간에서 해야 한다.
아무리 빠르고 전력 적게 먹더라도 틀린 필터라면 아무 가치가 없다.
검증 방식은 보드가 뱉은 실제 출력을 Python 고정소수점 골든모델과 샘플 단위로 비교하는 것이다. 시뮬레이션 결과가 아니라 SD 부팅한 실보드에서 UART로 관측한 숫자다.
| 지표 | 시나리오 1-1 (5/20/30 MHz) | 시나리오 1-2 (7/15/25/45 MHz) |
|---|---|---|
| 판정 | PASS | PASS |
| SNR | 74.863 dB | 72.216 dB |
| Max error | 6 LSB | 7 LSB |
| Mean error | 0.008 LSB | 0.002 LSB |
| RMSE | 1.403 LSB | 1.805 LSB |
| Correlation | 1.000000 | 1.000000 |
| 보드 포화 발생 | 0회 | 0회 |
톤별로 살펴보는 건 1-2만 한다. 1-1은 입력 30 MHz가 접히는 자리(20 MHz)에 입력 20 MHz 톤이 이미 있어서 두 성분이 같은 출력 위치에 겹친다. 그 자리의 피크가 어느 쪽 몫인지 분리할 수 없으니, 판정 스크립트도 두 톤을 PASS가 아니라 INFO로 리포트한다. 1-2는 7/15/25/45 MHz가 출력에서 7/15/25/5 MHz로 전부 다른 위치에 떨어져서 톤별 감쇠를 단독으로 읽을 수 있다.
| 톤 | 대역 | 보드 감쇠 | 골든 감쇠 | 보드−골든 | 판정 |
|---|---|---|---|---|---|
| 7 MHz | 통과대역 | −6.03 dB | −6.03 dB | −0.00 dB | PASS |
| 15 MHz | 통과대역 경계 | −6.03 dB | −6.03 dB | 0.00 dB | PASS |
| 25 MHz | 전이대역 | −60.33 dB | −60.25 dB | −0.08 dB | INFO |
| 45 MHz | 저지대역 (→ 5 MHz alias) | −64.51 dB | −64.57 dB | 0.06 dB | PASS |
통과대역 −6.03 dB는 감쇠가 아니다. 측정 스크립트의 정규화 때문에 나오는 값이다. sw/fir_decimator_metrics.py는 np.abs(np.fft.rfft(sig))로 정규화하지 않은 FFT 크기를 쓰고, 출력(4096샘플)의 dB도 입력(8192샘플)에서 잡은 같은 기준값(input_ref)으로 나눈다. 샘플 수가 절반이면 크기도 절반이 되니 −6.02 dB가 상수로 깔린다. 즉 저 숫자는 "통과대역이 평탄하게 통과했다"는 뜻이다. 7 MHz와 15 MHz가 소수점 둘째 자리까지 같은 값인 게 그 증거다.
25 MHz −60.33 dB가 사양 그 자체다. "저지대역 시작 25 MHz에서 60 dB 이상 감쇠"라고 스펙에 써놓은 그 지점이 −60.33 dB로 나왔다.
여기서 방금 말한 −6.02 dB 오프셋이 왜 안 보이는지 짚어둘 필요가 있다. 25 MHz는 출력 샘플레이트 50 MS/s의 Nyquist 지점이다. rfft에서 Nyquist bin은 켤레 짝이 없어 크기가 일반 bin의 정확히 2배(+6.02 dB)로 잡히고, 이게 샘플 수 절반에서 오는 −6.02 dB와 상쇄된다. 그래서 이 한 지점에서만 표의 숫자가 필터의 실제 감쇠와 바로 같아진다. 계수 응답을 직접 계산하면 |H(25 MHz)| = 60.249 dB인데 골든이 −60.25 dB인 것이 그 결과다. 전이대역 경계라 하드 PASS 기준이 아닌 INFO로 리포트하지만, 사양을 아슬아슬하게 넘긴 게 아니라 설계한 대로 정확히 그 자리에 떨어졌다.
45 MHz가 이 프로젝트의 존재 이유다. 50 MS/s로 데시메이션하면 45 MHz는 5 MHz로 접혀서(alias) 통과대역 한복판에 오염을 만든다. 안티에일리어싱 필터가 하는 일이 바로 이걸 막는 것이고, 출력 스펙트럼의 5 MHz 자리에 아무것도 없다.
숫자를 정확히 읽으면 이렇다. 계수 응답상 이 필터의 45 MHz 감쇠는 77.2 dB다. 그런데 표의 실측은 64.5 dB다. 12 dB 넘게 벌어진 건 필터가 거기까지만 눌러서가 아니라, 그 아래가 이 측정계의 바닥이기 때문이다. 8192 샘플 유한 레코드의 FFT로 재는 이상 −77 dB짜리 성분은 바닥에 묻힌다.
증거는 같은 표 안에 있다. 골든모델이 −64.57 dB로 0.06 dB 차이밖에 안 난다. 고정소수점을 빼고 float64로만 같은 경로를 돌려도 같은 자리에 떨어진다. 즉 이 바닥은 보드가 만든 것도 양자화가 만든 것도 아니라 측정 방법이 만든 것이다. 이 글의 판정에는 "적어도 64.5 dB 이상 억제됐다"로 충분했고, 더 내려가 보려면 측정 방식 자체를 바꿔야 한다.
그리고 보드−골든 차이가 전 톤에서 0.1 dB 이내다. 실리콘이 Python이 계산한 것과 같은 답을 냈다는 뜻이다.
여기서부터가 이번 편의 본론이다.
"100 MHz에서 타이밍 통과"는 사실 정보가 별로 없는 문장이다. 100 MHz가 한계인지, 200 MHz도 되는데 100으로 돌린 건지 알 수가 없으니까. 그래서 타이밍이 무너지는 지점을 찾기로 했다.
주파수 스윕을 할 때 중요한 부분은, PS7의 하드 PLL은 정수 분주만 된다는 것이다. 즉 117 MHz로 스윕하면 가장 가까운 값으로 스냅되어 버린다. 그러면 Fmax를 1 MHz 해상도로 잴 수가 없다. 그래서 Clocking Wizard(MMCM)로 PL 전체 클럭을 만들어 요청 = 실제가 되도록 하고, 1 MHz씩 올리면서 WNS(Worst Negative Slack)가 음수로 떨어지는 첫 지점을 찾았다.
이건 뒤늦게 고친 것이기도 하다. 처음 스윕은 PS7 클럭으로 했고 그때 나온 Fmax는 115 MHz였다. 라벨과 실제 주파수가 어긋나 있었다는 걸 방법론 재검증에서 발견하고 clk_wiz로 다시 잰 값이 아래 표다.
| 실제 주파수 | WNS | 판정 |
|---|---|---|
| 110 MHz | +0.381 ns | pass |
| 115 MHz | +0.231 ns | pass |
| 116 MHz | +0.016 ns | pass (마지막) |
| 117 MHz | −0.071 ns | fail |
| 118 MHz | −0.044 ns | fail |
| 119 MHz | −0.205 ns | fail |
| 120 MHz | −0.098 ns | fail |
v1 Fmax = 116 MHz.
한계 주파수를 찾았으면 다음은 어느 경로가 그 한계를 만드는지가 중요하다. 정밀 스윕 이전에 120 MHz 빌드에서 이미 이 경로를 열어 본 적이 있었다.
Source: u_fir_n43/z_reg[1][2] (FIR 딜레이 레지스터)
Destination: u_fir_n43/round_reg_reg[45] (라운딩 레지스터)
Data Path Delay: 8.664 ns (요구 8.000 ns, 0.664 ns 초과)
Logic : 6.057 ns (69.9%) — CARRY4 19개 + LUT 4개, 총 23 로직 레벨
Routing: 2.607 ns (30.1%)
한 가지 밝혀둘 게 있다. 이건 clk_wiz 정밀 스윕 이전, 120 MHz 빌드의 리포트다. 정밀 스윕에서 117~120 MHz FAIL의 worst는 같은 데이터패스의 다른 시작점(prod_reg_reg[0][2]_replica → round_reg_reg)으로 잡혔다. 시작점 이름은 배치마다 바뀌지만 도착점과 원인은 같다 — 누산과 라운딩이 한 사이클에 겹친 그 경로다.
원인은 내 코드에 있었다. v1의 Stage 2는 누산과 라운딩을 한 사이클 안에서 같이 하고 있었다.
z[0] <= prod_reg[0] + z[1];
// non-blocking이라 z[0]을 바로 못 읽어서, 같은 식을 한 번 더 계산
round_reg <= round_q2_30_to_q1_15(prod_reg[0] + z[1]);
round_q2_30_to_q1_15() 내부는 abs() → +ROUND_BIAS → 부호 재적용의 3단 연쇄 48-bit 연산이다. 여기에 앞의 누산 덧셈까지 붙으면 한 클럭에 48-bit 덧셈이 최대 3회 직렬로 들어간다. 이게 CARRY4 19개짜리 캐리 체인으로 합성되어 8.664 ns를 만든 것이다.
핵심은 저 주석이다. non-blocking 할당이라 방금 만든 z[0]을 같은 사이클에서 읽을 수 없으니, 읽는 대신 다시 계산했다. 그 "다시 계산"이 그대로 크리티컬 패스가 됐다.
고친 건 간단하다. 라운딩을 다음 사이클로 미뤄서, 이미 레지스터에 들어간 z[0]을 읽어서 쓰도록 했다. 재계산이 사라지니 누산 덧셈 1회분이 라운딩 경로에서 통째로 빠진다.
| v1 (3-stage) | v2 (4-stage) |
|---|---|
| Stage 1: 곱셈 → prod_reg | Stage 1: 곱셈 → prod_reg (동일) |
| Stage 2: 누산 + 라운딩 동시 | Stage 2: 누산만 |
| Stage 3: 포화 → 출력 | Stage 3: round(z[0]) — 레지스터 값 참조 |
| Stage 4: 포화 → 출력 |
코어 latency는 3 → 4 사이클로 늘어난다(수락된 입력 기준, RTL 헤더 주석). 4편에서 "v1=4 / v2=5 사이클"이라고 쓴 것과 숫자가 다른데, 그건 래퍼에서 본 값이라 세는 구간이 하나 더 길다. 같은 설계의 다른 기준점이다.
어쨌든 늘어난 건 사실인데, 아래 모듈(decimator, AXI-Stream 래퍼)이 전부 valid 핸드셰이크 기반이라 고정 latency를 가정하는 곳이 없다. 그래서 RTL 수정은 이 파일 하나로 끝났다.
| 실제 주파수 | WNS | 판정 |
|---|---|---|
| 120 MHz | +0.655 ns | pass |
| 130 MHz | +0.563 ns | pass |
| 140 MHz | +0.069 ns | pass |
| 145 MHz | +0.129 ns | pass |
| 146 MHz | +0.022 ns | pass (마지막) |
| 147 MHz | −0.102 ns | fail |
| 148 MHz | −0.021 ns | fail |
| 150 MHz | −0.012 ns | fail |
v2 Fmax = 146 MHz. v1 대비 +30 MHz, +26%. 자원은 사실상 동급이다 — DSP48 16개 동일, 코어 기준 LUT 1,792 / FF 2,113으로 두 구조가 같고, 늘어난 건 파이프 FF 몇 개다. 주파수 26%를 자원 증가 없이 구조 하나로 확보했다는 게 이 절의 핵심이다.
(1) WNS는 주파수에 대해 단조롭게 줄지 않는다. 위 v2 표에서도 알 수 있다. 140 MHz의 WNS(+0.069)가 더 빠른 145 MHz(+0.129)보다 작다. FAIL 구간도 마찬가지여서 148 MHz(−0.021)가 147 MHz(−0.102)보다 낫다. v1 표에서도 118(−0.044)이 117(−0.071)보다 좋다.
제약이 빡빡해질수록 Vivado가 배치를 다르게 최적화하기 때문이다. 즉 WNS는 절대 주파수의 함수가 아니라 매 실행의 place&route 결과다. 그래서 "몇 MHz가 한계인가"는 한 점만 보고 판단하면 안 되고, 구간으로 봐야 한다.
(2) 그래서 배포는 Fmax로 안 한다. 146 MHz는 WNS +0.022 ns로 정적 마진이 거의 0이다. 온도·전압 변동에 간헐적으로 에러가 날 위험이 있다. 따라서 실보드 배포는 145 MHz로 정했다. 속도 손실은 0.7 MHz(0.7%)인데 타이밍 마진은 약 6배다. v1도 같은 이유로 116이 아니라 115 MHz를 배포본으로 쓴다.
v2의 147 MHz FAIL 경계에서 worst path를 열어 봤더니 이렇게 나왔다.
Source: axi_dma_0/.../I_S2MM_REALIGNER/.../sig_max_first_increment_reg[1]
Destination: axi_dma_0/.../I_S2MM_REALIGNER/.../sig_btt_eq_0_reg
Xilinx AXI DMA IP 내부(S2MM realigner의 byte-count 로직)다. 정확히 말하면 경계 부근에서는 이 DMA 경로와 AXIS 래퍼 경로가 비슷하게 임계여서, 배치에 따라 둘 중 하나가 worst가 된다. 래퍼는 남의 IP가 아니라 내 코드다. 그러니 "이제 남의 IP 탓"이라고 쓰면 틀리고, 정확하게 정리하자면 — 어느 쪽도 FIR 코어의 곱셈·누산·라운딩 데이터패스가 아니다.
이게 v3를 만들지 않은 이유다. round를 더 쪼개도 Fmax는 더 안 올라간다. "더 최적화할 수 있는데 안 했다"가 아니라 "더 해도 소용없다는 걸 데이터로 확인하고 멈췄다"이다. 어디서 멈춰야 하는지 아는 것도 설계의 일부라는 걸 여기서 배웠다.
"FPGA로 만들었다"는 것만으로는 아무 의미가 없다. "노트북에서 numpy로 돌리면 되는 거 아닌가"라는 질문에 숫자로 답할 수 있어야 한다.
같은 8192 샘플, 같은 43-tap FIR + 2:1 decimation을 두 곳에서 돌렸다.

| 비교 대상 | 처리 시간 |
|---|---|
| CPU (i5-1340P, Windows, numpy) | 221.0 µs |
| CPU (Ryzen 7 9700X, Ubuntu, BLAS 최적화) | 58.6 µs |
| FPGA (v2 @ 145 MHz) | 85.0 µs |
| 이론 한계 (8192 ÷ 2 ÷ 100 MHz) | 81.92 µs |
두 CPU 값은 머신·OS·BLAS가 전부 다르다. Windows와 Ubuntu의 차이가 아니라 서로 다른 컴퓨터의 차이다. UART 전송은 관측 경로라 양쪽 모두에서 제외했다. 115200 baud로 16,384바이트를 올려보내면 약 1.4초가 걸리는데, 이걸 넣으면 연산 비교가 아니라 시리얼 속도 측정이 된다. CPU는 time.perf_counter()로 연산 구간만, FPGA는 bare-metal C의 XTime_GetTime()으로 MM2S kick부터 S2MM IDLE까지 DMA 전송 구간만 잰다. FPGA 85.0 µs는 추정이 아니라 보드가 정수 µs로 UART에 올려보낸 실측값이다.
노트북 CPU 대비 약 2.6배, 이론 한계 대비 오차 3.8%. 그런데 이 표에서 정작 눈에 띄는 건 두 번째 줄이다. Ryzen CPU가 FPGA보다 빠르다.
그러니 이 절의 질문은 "FPGA가 CPU보다 빠른가"가 될 수 없다. 답이 경우에 따라 다르다로 이미 나와 있으니까. 물어야 할 건 다른 것이다.
같은 i5-1340P 환경에서 스크립트를 수십 번 돌렸는데, 판독값을 따로 적어둔 건 그중 여섯 번뿐이다.
181.2 / 223 / 208.8 / 174.4 / 174.8 / 175.9 (µs)
기록하지 않은 실행까지 포함하면 대략 170 µs에서 300 µs 사이였다. 위 여섯 개는 그 구간에서 기록된 표본이지 최솟값도 최댓값도 아니다.
그럼 표의 221.0 µs는 어디서 온 값인가. 가장 흔한 값이라서 고른 게 아니다. 최종 비교 차트를 생성한 측정 라운드에서 나온 값이고, 그 차트가 이미 산출물로 확정돼 있어서 뒤늦게 요동을 관찰한 뒤에도 숫자를 갈아엎는 대신 확정값은 그대로 두고 요동 범위를 병기하는 쪽을 택했다.
같은 기간 FPGA는 전 세션 85 µs 고정이었다.
평균이 조금 빠른 게 아니라, 최악값이 평균값과 같다.
실시간 신호처리에서 의미 있는 건 평균 지연이 아니라 지연의 상한이 보장되는가다. 170~300 µs 사이 어딘가에서 끝나는 시스템과, 항상 85 µs에 끝나는 시스템은 평균이 비슷하더라도 다른 물건이다. FPGA의 본질적 이점은 절대 속도가 아니라 실행 환경과 무관한 결정론적 처리 시간이고, 이걸 데이터로 보인 게 이 절의 성과다.
Vivado는 전력을 추정해 준다. v2@145 MHz 빌드 기준 on-chip total 1.705 W. 그런데 이건 툴이 계산한 값이다. 실제로 재보면 얼마가 나올까.
OWON HDS242를 이용하여 직접 측정하였다.
보드를 개조하여 전력을 측정하는건 보드가 망가질 위험이 크다고 생각하여 5V 배럴잭 입력 경로를 DC잭 피그테일로 끊고 그 절단점에 전류계를 직렬로 넣었다. GND는 전 과정에서 직결 유지시켰다.
[전류] 어댑터 ─[암]─ +5V ─▶ 적색[10A잭] ─ HDS242(DC A) ─ [COM]흑색 ─▶ +5V ─[수]─ 보드
GND ──────────────────── 직결 ──────────────────── GND
● 매듭 = 측정점
[전압] 어댑터 ─[암]─ +5V ──●(직결)●── +5V ─[수]─ 보드
GND ──────────────────── ●(직결)●───────────────── GND

보드 전체 입력만 잴 수 있으니, 상태의 차분으로 변수를 분리했다.
| 상태 | 조건 | I (중앙값) | V | P |
|---|---|---|---|---|
| S0 | SD 미삽입 — PL 미구성 baseline | 0.340 A | 5.047 V | 1.72 W |
| S1 | SD boot 완료 — PL 구성 + 145 MHz, FIR idle | 0.443 A | 4.988 V | 2.21 W |
| S2 | mode 1-1 반복 실행 중 | 0.435 A | 5.008 V | 2.18 W |
(각 상태 5회 판독의 중앙값. 전원 인가 직후 ~0.30 A 과도 전류가 흐르므로 안정화 후 판독)
(1) S1 − S0 = +0.49 W. PL 비트스트림 구성 + 145 MHz MMCM 클럭 + bare-metal 앱 + DDR 활성이 전부 더해진 몫이다. PL 단독이 아니라는 건 측정 방식을 정할 때부터 알고 있던 한계다.
(2) S2 ≈ S1. FIR을 돌려도 전력이 안 늘었다.
처음엔 측정 실수인 줄 알았는데, 계산해 보면 당연한 결과다. 8192 샘플 처리가 85 µs에 끝난다. 데모는 1~2초 주기로 돈다. duty가 0.01% 미만이다. 멀티미터는 판독 주기 동안 평균을 내서 표시하니 원리적으로 잡힐 수가 없다.
그래서 이건 실패한 측정이 아니라 결과 그 자체다. 연산 구간이 평균 전력에 흔적을 남기지 못할 만큼 짧다는 것이 처리 효율의 전력 측면 증거다.
(3) 흥미로운 숫자들
| 상태 | USB 미연결 | USB 연결 | 차이 |
|---|---|---|---|
| S0 | 0.293 A | 0.340 A | +47 mA |
| S1 | 0.398 A | 0.443 A | +45 mA |
USB-UART(FTDI 브리지)를 PC에 꽂으면 0.22 W가 추가로 든다. FIR 연산이 평균 전력에 남긴 몫(측정 불가)보다 훨씬 크다.
연산 경로보다 관측 경로가 전력을 더 쓴다.
UART를 처음부터 "데이터 경로가 아니라 관측 경로"로 분리해 설계했는데, 그 구분이 전력에서도 그대로 나타난 셈이다. 덤으로, USB 유무 두 조건에서 S1−S0 델타가 0.105 A / 0.103 A로 재현돼서 차분 측정 방법론 자체도 검증됐다.
이걸 그냥 "오차"로 넘기면 측정한 의미가 없기에 분석해봤다.
| 단계 | 값 | 근거 |
|---|---|---|
| 보드 실측 S1 (USB 포함) | 2.21 W | 실측 |
| − USB-UART(FTDI) 몫 | −0.22 W | 실측으로 분리 |
| = S1 (USB 제외 환산) | 1.99 W | USB 미연결 실측 0.398 A와 일치 |
| ÷ 레귤레이터 효율 85~90% (가정) | 1.69 ~ 1.79 W | 5V → 코어/IO 레일 변환 손실 |
| Vivado on-chip 추정 | 1.705 W | 위 구간 안 — 정합 |
USB 몫은 가정이 아니라 실측으로 뺐고, 남은 차이는 레귤레이터 손실과 추정 범위 밖 주변부(DDR3 칩, 이더넷 PHY 등)로 설명된다. 등식이 아니라 정합성 점검이고, 레귤레이터 효율 85~90%는 가정값이다. 레일별 부하 배분을 알 수 없어서 더 정밀하게는 못 잡았다.
routed DCP에서 계층 스코프로 뽑으면 이렇다.
| 스코프 | LUT | FF | DSP48 | 전력 |
|---|---|---|---|---|
u_fir_n43_v2 코어 단독 | 1,792 | 2,113 | 16 | 0.015 W |
| AXIS IP 전체 (래퍼+decimator+코어) | 1,948 | 2,286 | 16 | 0.017 W |
| 전체 비트스트림 | 4,556 | — | 16 | 1.705 W |
계층 분해로 보면 on-chip 총 전력 중 PS7이 1.407 W로 지배적이고, PL 전체 dynamic이 ~0.16 W, 그중 내가 만든 코어 몫이 0.015 W다. 칩 자원으로는 LUT 3.4% / FF 2.0% / DSP 7.3%.
(전력 계층 분해는 report_power를 다시 돌린 값이라 총합이 1.711 W로, 스윕 표의 1.705 W와 기본 설정 차이만큼 미세하게 다르다.)
즉 "이 FIR이 1.7 W를 먹는다"는 틀린 문장이다. 1.7 W는 Zynq 칩 한 장이 켜져 있는 값이고, 내 필터는 그 위에서 15 mW 정도를 쓴다.
여기가 이번 과정에서 제일 흥미로웠던 부분이다.
+26% → 0% — 최적화는 아키텍처가 아니라 타겟에 속한다
v1 → v2 파이프라인 분할로 FPGA Fmax를 26% 올렸다. 그러면 이건 좋은 설계일까? 같은 RTL을 다른 타겟에 올려도 26% 이득이 날까?
학교 서버의 Oasys-RTL로 동일한 v1/v2 소스를 250nm 표준셀에 합성해서 20000 ps부터 6000 ps까지 같은 제약으로 페어 스윕했다.
| FPGA (28nm, CARRY4) | ASIC (250nm 표준셀 합성) | |
|---|---|---|
| v1 Fmax | 116 MHz | ≥ 166.7 MHz |
| v2 Fmax | 146 MHz | ≥ 166.7 MHz |
| v1 ↔ v2 격차 | +26% | 동률 (전 구간 slack 차 ≤ period의 6%, 순위 요동) |
둘 다 166.7 MHz를 통과한다. 26%가 사라졌다. 그것도 250nm이, 28nm FPGA의 146 MHz를 넘겨서.
같은 경로를 두 타겟이 어떻게 처리하는지 열어 봤다. v1의 그 문제의 경로(z_reg[1] → round_reg, 누산+라운딩 병합)다.
| 타겟 | 이 경로의 지연 | 결과 |
|---|---|---|
| FPGA CARRY4 (고정 캐리 체인) | 8.664 ns | 타이밍 fail → v2 분할로 해결 |
| ASIC 표준셀 (carry-save 재구조화) | 5.719 ns | 166.7 MHz에서도 pass — 분할 불필요 |
FPGA의 CARRY4는 하드와이어된 물리 구조다. 덧셈 2회가 병합된 식을 넣으면 그 순서 그대로 캐리 체인을 타야 한다. 반면 표준셀 합성기는 multi-operand 덧셈을 carry-save 구조로 재구조화해서 병합해 버린다. 그러니 v1도 v2와 같은 한계까지 조여진다.
내가 손으로 한 최적화를, ASIC 합성기는 알아서 했다.
이 결과를 처음 봤을 때는 "그럼 v2 만든 게 헛수고였나" 싶었는데, 아래와 같이 결론을 정리했다.
최적화는 아키텍처에 속하지 않는다. 타겟에 속한다.
v2 분할은 FPGA CARRY4라는 특정 물리 구조를 정확히 겨냥한 타겟 특화 최적화다. FPGA에서는 26%의 가치가 있고, ASIC에서는 0의 가치가 있다. 둘 다 참이다.
그리고 이건 v2를 버릴 이유가 아니라 v2를 쓸 이유다. 내 타겟은 Zynq니까. v1은 폐기하지 않고 "타겟 특화 최적화의 비교 사례"로 남겨뒀다 — 이 절이 존재할 수 있는 게 v1을 지운 덕이 아니라 남긴 덕이다.
처음 20000 ps(느슨한 제약)에서는 v1 ≈ v2로 보였고, 15000 ps에서는 갑자기 v2의 slack이 5배 좋아 보였다(+4680.8 ps vs +946.6 ps). 12000 ps 이하로 조이니 다시 수렴했다.
이유는 이렇다. 제약이 느슨하면 합성기가 면적 최소인 ripple 매핑을 고르기 때문에 탭 누산 경로(depth 85)가 지배적이고, v2가 분할한 경로는 아예 병목이 아니다. 중간 구간에서는 합성기가 탭 가산기를 재구조화하면서(depth 85→50) v1의 병합 경로가 병목으로 부상하는데, 아직 그 경로까지는 덜 조인 상태라 차이가 크게 보인다. 끝까지 조이면 합성기가 그 경로까지 재구조화해서 결국 붙는다.
합성 결과 한 점만 보고 결론 내리면 안 된다는 걸 여기서 확실히 배웠다. Fmax 스윕을 해야 하는 이유와 정확히 같은 이유다.
| 항목 | FPGA v2 코어 @145MHz | ASIC v1 @166.7MHz | ASIC v2 @166.7MHz |
|---|---|---|---|
| 로직 | LUT 1,792 / FF 2,113 / DSP 16 | 28,615 cells | 27,506 cells |
| 면적 | 자원 점유율 LUT 3.4% / DSP 7.3% | 1.42 mm² | 1.41 mm² |
| 전력 | 0.015 W | 1.331 W | 1.342 W |
이 표는 절대 비교용이 아니다. FPGA 0.015 W는 Vivado vectorless 추정이고 ASIC 1.33 W는 Oasys 기본 toggle-rate 가정 기반 250nm 추정이다. 산정 방식이 다르다. DSP48 하드블록과 표준셀 곱셈기라 "로직 규모"의 단위도 대응이 안 된다. FPGA는 기성 칩 자원을 점유하는 방식이라 mm² 자체가 정의되지 않는다.
여기서 유효한 건 같은 타겟 안에서의 v1 ↔ v2 상대 비교뿐이고(ASIC 내 Δ≈1%), 그게 이 절의 목적이었다.
| 항목 | 결과 |
|---|---|
| 실보드 기능 검증 | 4096 샘플 vs 골든 — SNR 74.9 dB, max error 6 LSB, correlation 1.000000, PASS |
| 저지대역 억제 | 25 MHz −60.3 dB (사양 60 dB), 45 MHz alias −64.5 dB |
| 처리 시간 | 8192 샘플 85.0 µs (이론 81.92 µs 대비 3.8%), CPU 221.0 µs 대비 2.6배 |
| 시간 결정성 | FPGA 전 세션 85 µs 고정 vs CPU 170~300 µs 요동 |
| Fmax | v1 116 MHz → v2 146 MHz (+26%, 자원 증가 없음), 배포 145 MHz (마진 6배) |
| 자원 (코어 단독) | LUT 1,792 / FF 2,113 / DSP48 16 / BRAM 0 |
| 전력 | 보드 5V 실측 2.21 W (코어 몫 0.015 W), Vivado 추정 1.705 W와 정합 |
| ASIC 교차 검증 | 250nm 합성 시 v1 ≈ v2 동률 (≥166.7 MHz) — 26%는 FPGA 종속 |
| 검증 자산 | pytest 163 케이스 + RTL TB 스위트 + 보드 자동 판정, 개발 로그 50편 |
Fmax를 오실로스코프로 정밀 측정하지 못했다. 116 / 146 MHz는 Vivado 정적 타이밍 분석(STA)에서 WNS ≥ 0인 최대 주파수다. 보드에서는 v1@115, v2@145로 기능 동작만 확인했다. "실제 실리콘이 몇 MHz까지 버티는가"는 별개의 실험이고, 범위 밖으로 두었다.
ASIC P&R을 완주하지 못했다. Nitro placer가 내부 assertion(SDA101: grCapFrac <= 1)으로 죽었다. 칩을 1.4 mm(util 70%)에서 1.6 mm(util 53%)로 넓혀 재배치해도 같은 지점에서 재현됐다. 판정 근거 네 가지(assertion 성격, 파라미터 무관 재현, GEMM 대조군, 회피 수단 소진)를 확인한 뒤 툴 내부 버그로 결론 내고 중단했다.
다행히 v1/v2 비교 결론은 합성 결과로 완결되고, P&R이 더해줄 건 "post-route에서도 유지된다" 한 줄이었다. 절차와 스크립트는 재개 가능하게 보존해 뒀다.
PL 단독 전력을 분리하지 못했다. Zybo Z7-20은 코어 레일에 전류 측정 헤더가 없어서 rail 직접 측정은 트레이스 컷이 필요하다. 대여 보드라 배제했고, 차분 측정으로 "PL 구성 + 클럭 + 앱 + DDR = 0.49 W"까지만 비파괴로 분리했다.
전체 설계 상한은 이제 FIR 코어가 아니다. 147 MHz worst path가 AXI DMA IP 내부거나 AXIS 래퍼다. FIR 코어 데이터패스를 더 손봐도 시스템 Fmax는 안 올라간다. 더 올리려면 DSP48 캐스케이드 기반 systolic 재설계 같은 v3급 구조 변경에 더해 DMA 경로와 래퍼까지 같이 봐야 한다.
검증은 directed 방식이었다. 내가 상정한 시나리오를 골든모델과 비교하는 구조라, 4편의 TLAST 데드락은 시뮬레이션이 아니라 코드 리뷰에서 발견됐다. 입력과 백프레셔의 조합을 사람이 일일이 상상하는 방식의 한계다. 다음 단계는 constrained-random + coverage와 SVA 어서션인데, 이번 범위에서는 못 했다.
| 구분 | 내용 |
|---|---|
| RTL | v1(3-stage)·v2(4-stage) FIR 코어, ↓2 데시메이터, AXI-Stream 래퍼 — Verilog/SystemVerilog 16개 파일 |
| 검증 | 테스트벤치 20개(골든 대조 · skid 스트레스 · 버퍼 깊이 · 버블 스윕 회귀), pytest 163 케이스 |
| 모델·도구 | Python 고정소수점 골든모델, Kaiser 계수 설계·저지대역 스펙 판정, PC 데모/FFT/자동 판정 도구 — 42개 파일 |
| 빌드 재현 | Vivado 블록 디자인·클럭 스윕, Vitis BOOT 재생성, ASIC 합성·P&R 스크립트 — Tcl 27개 |
| 배포 이미지 | release/v1_115mhz/BOOT.bin, release/v2_145mhz/BOOT.bin |
| 기록 | 개발 로그 50편, 근거 리포트 4종(Fmax 스윕 · ASIC 대비 · 전력 실측 · 보드 검증), 그리고 이 연재 5편 |
1. 테스트가 버그를 잡는다는 것부터 증명하라.
AXI-Stream 데드락을 고칠 때, 새 테스트벤치를 수정 전 RTL에 먼저 돌렸다. 예측한 임계값에서 정확히 일치하는 실패가 재현되는 걸 확인하고 나서 고쳤다. 이 순서를 지키지 않으면 "고쳤더니 통과했다"가 "원래 통과하는 테스트였다"와 구분되지 않는다.
2. 근본 원인까지 안 가면 같은 버그가 다시 온다.
모든 DMA 전송이 멈춘 원인은 16,384 바이트가 DMA 기본 length field(14-bit)의 한계인 16,383을 정확히 1바이트 초과한 것이었다. 여기까지 안 가면 "샘플 수 좀 줄여 보니 되네"에서 멈췄을 거다. 그러면 나중에 다시 만난다.
3. 한 점이 아니라 구간을 봐야 한다.
"100 MHz 통과"는 정보가 없고, WNS는 주파수에 대해 단조롭지 않으며, ASIC 합성 결과는 제약 구간에 따라 결론이 뒤집혀 보인다. 스윕이 필요한 이유가 전부 같다.
4. 어디서 멈춰야 하는지 아는 것도 설계다.
v3를 안 만들었다. Nitro P&R을 중단했다. 오실로스코프 Fmax 측정을 범위에서 뺐다. 셋 다 "못 했다"가 아니라 "해도 결론이 안 바뀐다는 걸 확인하고 멈췄다"이다. 그리고 그 판단 근거를 문서로 남겨서, 판단이 틀렸을 때 남이 재개할 수 있게 했다.
5. 측정 방식의 한계를 먼저 적어두면, 예상 밖 결과가 결과가 된다.
S2 ≈ S1을 측정 전에 예측하고 적어두지 않았다면 "측정 실패"로 처리했을 거다. 적어뒀기 때문에 "duty 0.01%의 증거"가 됐다.
6. 자랑 못 할 숫자를 같이 적어야 자랑할 숫자가 산다.
2.6배 옆에 "데스크톱 CPU는 58.6 µs로 더 빠르다"를 적었다. 그러고 나니 진짜 주장(결정론적 처리 시간)이 오히려 또렷해졌다. 유리한 숫자만 남긴 결산은 읽는 사람이 나머지를 의심하게 만든다.
3월에 시작할 때는 "FIR 하나 만들면 되겠지"였습니다. 8월에 남은 건 RTL 몇백 줄과, 그것보다 훨씬 두꺼운 왜 그렇게 만들었는지에 대한 기록 50편입니다.
가장 크게 바뀐 생각은 이겁니다. 하드웨어 설계에서 어려운 건 동작하게 만드는 게 아니라 동작한다는 걸 증명하는 것이더군요. 4편까지의 노력이 전부 거기에 쓰였고, 이번 편의 숫자는 전부 그것의 결과물입니다.
읽어주셔서 감사합니다. 코드·로그·재현 스크립트는 전부 공개해 뒀습니다.