엣지 AI 시대, 저지연 LLM 추론을 위한 LWIP 기반 UDP 최적화 삽질기: 네트워크 바틀넥 완전 정복 🚀
임베디드 시스템에서 AI 모델, 특히 LLM(Large Language Model) 추론을 수행하는 것은 더 이상 먼 미래의 이야기가 아닙니다. 2026년 현재, 강력한 NPU(Neural Processing Unit)를 탑재한 엣지 디바이스들이 쏟아져 나오면서, 제한된 자원 속에서도 경량화된 LLM을 구동하고 그 결과를 실시간으로 처리해야 하는 요구사항이 늘어나고 있습니다.
오늘의 삽질 주제는 바로 여기에 있습니다. 특정 엣지 디바이스에서 양자화된 LLaMA-3 모델 변형을 구동하여 실시간으로 추론 결과를 생성하는 것은 성공했으나, 이 결과를 중앙 서버로 저지연으로 전송하는 과정에서 발생하는 네트워크 바틀넥을 해결하는 것입니다. 로컬 추론 속도가 아무리 빨라도, 결과 전송이 느리다면 전체 시스템의 지연 시간은 늘어날 수밖에 없기 때문입니다. 특히, LWIP(LightWeight IP) 스택을 사용하는 임베디드 환경에서는 이러한 문제가 더욱 두드러지게 나타날 수 있습니다. 🛠️
우리는 이 문제에 대한 근본적인 원인을 분석하고, LWIP UDP 스택을 최적화하며, 궁극적으로 엔드-투-엔드 저지연 통신을 달성하는 과정을 기록합니다.
1. 문제의 발단: “추론은 빠른데, 응답이 느리다?” 💡
우리의 엣지 디바이스는 특정 센서 데이터를 기반으로 경량 LLM 추론을 수행하고, 텍스트 응답(토큰 시퀀스)을 생성합니다. NPU 가속 덕분에 추론 자체는 100ms 이내에 완료되었습니다. 문제는 이 텍스트 응답(평균 1KB ~ 4KB)을 중앙 서버로 전송하는 과정에서 총 응답 시간이 250ms 이상으로 늘어나는 현상이었습니다.
netcat을 이용한 단순 UDP 전송 테스트에서는 문제가 없었으나, LLM 추론 결과 생성 후 LWIP를 통한 전송 시에만 지연이 발생했습니다. 🧐
초기 진단:
- 프로토콜 선택: UDP를 사용했습니다. TCP는 연결 설정 및 혼잡 제어 오버헤드로 인해 실시간성이 중요한 LLM 추론 결과 전송에는 적합하지 않다고 판단했습니다. UDP는 빠르고 단순하지만, 신뢰성 보장은 애플리케이션 계층에서 처리해야 합니다.
- 네트워크 환경: 엣지 디바이스와 중앙 서버는 유선 이더넷으로 연결되어 있으며, 트래픽은 거의 없는 상태였습니다. 단순 네트워크 지연 문제는 아니라고 판단했습니다.
2. 바틀넥 진단과 원리 분석: Wireshark와 LWIP 내부 들여다보기 🔍
가장 먼저 Wireshark를 이용해 패킷을 캡처했습니다. 예상대로 패킷 유실이나 재전송은 없었지만, 엣지 디바이스에서 서버로 UDP 패킷이 전송되는 시점에 불필요한 지연이 발생하는 것을 확인했습니다. 특히, udp_sendto() 호출 직후 네트워크 인터페이스 드라이버에서 실제 패킷을 내보내는 시간 사이에 눈에 띄는 딜레이가 있었습니다.
이는 LWIP 스택 내부에서 데이터 처리 방식에 문제가 있을 수 있음을 시사했습니다. LWIP는 PBUF(Packet Buffer)라는 내부 버퍼 구조를 사용하여 패킷을 관리합니다. 이 PBUF의 할당 및 복사 과정이 비효율적이라면, 지연이 발생할 수 있습니다.
주요 의심 영역:
- PBUF 할당 및 복사 오버헤드:
udp_sendto()호출 시 애플리케이션 버퍼의 데이터를 PBUF로 복사하는 과정. - PBUF_POOL 부족: PBUF 풀이 고갈되어 새로운 PBUF을 할당받지 못하고 대기하는 상황.
- 네트워크 인터페이스 드라이버 비효율: LWIP 코어에서 드라이버로 패킷을 넘겨줄 때의 동기화 문제나 드라이버 자체의 비효율.
- CPU 컨텍스트 스위칭: LWIP 스택과 애플리케이션이 동일한 스레드에서 동작하거나, IRQ 처리 지연.
3. 해결책: 제로 카피와 커스텀 프로토콜 🚀
우리는 지연의 핵심 원인이 PBUF 할당 및 데이터 복사 오버헤드라고 결론 내렸고, 이를 최소화하기 위한 전략을 세웠습니다.
3.1. 제로 카피(Zero-Copy) UDP 전송 구현 💡
LWIP는 pbuf_alloc() 함수를 통해 PBUF를 할당합니다. 일반적으로 PBUF_RAM 타입을 사용하여 애플리케이션 데이터를 PBUF로 복사하지만, PBUF_REF 타입을 사용하면 애플리케이션의 메모리 버퍼를 직접 PBUF에 연결하여 복사 과정을 생략할 수 있습니다. 🔥
기존 방식 (Copy):
// LLM inference result buffer
char inference_result[4096];
// ... fill inference_result ...
struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, strlen(inference_result), PBUF_RAM);
if (p != NULL) {
pbuf_take(p, inference_result, strlen(inference_result)); // Data copy
udp_sendto(pcb, p, &remote_ip, remote_port);
pbuf_free(p);
}
개선된 방식 (Zero-Copy with PBUF_REF):
// LLM inference result buffer. 이 버퍼는 udp_sendto 호출 동안 유효해야 합니다.
// Heap 메모리를 사용하거나, 전역/정적 버퍼를 사용하여 수명 관리.
static char inference_result_buffer[4096];
size_t result_len;
// ... fill inference_result_buffer and set result_len ...
// PBUF_REF를 사용하여 제로 카피 전송
// PBUF_REF는 data 포인터만 참조하므로, inference_result_buffer는
// pbuf_free()가 호출될 때까지 유효해야 합니다.
struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, result_len, PBUF_REF);
if (p != NULL) {
p->payload = inference_result_buffer; // 직접 페이로드 포인터를 연결
p->len = result_len;
p->tot_len = result_len;
// UDP 헤더가 추가될 공간을 확보하기 위해 포인터 조정
// PBUF_TRANSPORT 타입으로 할당했기 때문에, IP/UDP 헤더 공간은 이미 확보됨.
// 여기서는 단순히 payload 포인터를 원래 버퍼로 설정.
// pbuf_add_header()를 사용할 필요 없이, pbuf_alloc(PBUF_TRANSPORT, ...)가
// 이미 헤더 공간을 고려하여 payload를 적절히 설정합니다.
// 중요한 것은 p->payload를 직접 우리가 원하는 데이터 버퍼로 연결하는 것입니다.
err_t err = udp_sendto(pcb, p, &remote_ip, remote_port);
if (err != ERR_OK) {
// Error handling
}
pbuf_free(p); // PBUF_REF는 참조만 해제하고, 원본 버퍼는 해제하지 않습니다.
}
주의사항: PBUF_REF를 사용할 때는 원본 메모리 버퍼의 수명 관리가 매우 중요합니다. udp_sendto() 호출 후 pbuf_free()가 호출되기 전까지, 그리고 네트워크 드라이버가 패킷 전송을 완료하기 전까지 버퍼가 유효해야 합니다. 따라서 static 또는 힙 할당 메모리를 사용하는 것이 안전합니다.
3.2. LWIP 설정 최적화 ⚙️
LWIP 설정 파일(lwipopts.h)을 수정하여 PBUF 관련 파라미터를 조정했습니다.
MEM_ALIGNMENT: CPU 아키텍처에 맞춰 메모리 정렬을 최적화. (예: 4 또는 8)MEM_SIZE: 힙 메모리 크기를 충분히 확보. LLM 추론 결과와 PBUF이 경쟁하지 않도록.PBUF_POOL_SIZE: 동시 전송될 수 있는 최대 PBUF 개수를 늘려 PBUF 고갈 방지.UDP_SND_BUF: UDP 송신 버퍼 크기를 LLM 결과의 최대 크기 이상으로 설정하여 큰 데이터 전송 시 안정성 확보.
// lwipopts.h
#define MEM_ALIGNMENT 4
#define MEM_SIZE (32 * 1024) // 32KB 힙 메모리
#define PBUF_POOL_SIZE 16 // PBUF 풀 크기 증가
#define PBUF_POOL_BUFSIZE 1536 // 이더넷 MTU에 맞춤 (1500 + 헤더 여유)
#define UDP_SND_BUF (4 * 1024) // 4KB UDP 송신 버퍼
#define LWIP_NETIF_TX_QUEUE_SIZE 8 // netif TX 큐 사이즈 (선택 사항)
3.3. 커스텀 애플리케이션 계층 프로토콜 설계 📝
LLM 추론 결과는 텍스트 토큰들의 시퀀스입니다. 이를 단순 문자열로 전송하는 것보다 효율적인 바이너리 형식으로 직렬화하고, 불필요한 메타데이터를 줄였습니다.
- Compact Binary Format:
token_id배열과confidence_score배열을 직접 전송. (예:uint16_t배열) - 간소화된 헤더:
sequence_number(2B),payload_length(2B),checksum(1B) 만 포함. - Batching: 작은 추론 결과가 여러 번 발생할 경우, 여러 결과를 하나의 UDP 데이터그램에 묶어서 전송하여 오버헤드를 줄였습니다. 단, MTU(Maximum Transmission Unit)를 초과하지 않도록 주의합니다. (일반적으로 이더넷 MTU는 1500 바이트)
예시 (Python 수신, C 전송):
# Server Side (Python)
import socket
import struct
UDP_IP = "0.0.0.0"
UDP_PORT = 5005
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((UDP_IP, UDP_PORT))
print(f"Listening on UDP port {UDP_PORT}")
while True:
data, addr = sock.recvfrom(4096) # buffer size
seq_num, payload_len, checksum = struct.unpack('<HHB', data[:5]) # Little-endian, 2B seq, 2B len, 1B checksum
# Simple checksum check (for demonstration)
calculated_checksum = sum(data[5:]) % 256
if calculated_checksum != checksum:
print(f"Checksum mismatch for seq {seq_num}")
continue
llm_output_data = data[5:]
print(f"Received from {addr}: Seq={seq_num}, Len={payload_len}, Checksum={checksum}, Data length={len(llm_output_data)}")
# Further processing of llm_output_data (e.g., decode token IDs)
// Embedded Device Side (C) - Simplified custom protocol example
#include "lwip/udp.h"
#include "lwip/pbuf.h"
#include <string.h> // For memcpy, strlen
// Assume inference_result_tokens is an array of uint16_t token IDs
// and inference_result_len is the number of tokens.
// Max tokens = (1500 - 5 bytes header) / 2 bytes/token = ~747 tokens
uint16_t inference_result_tokens[747]; // Example buffer
size_t num_tokens = 0; // Actual number of tokens
// Global/static buffer for zero-copy
static uint8_t transmit_buffer[1500]; // Max Ethernet MTU - IP/UDP headers (approx)
static uint16_t current_seq_num = 0;
void send_llm_result(struct udp_pcb *pcb, const ip_addr_t *remote_ip, u16_t remote_port) {
// Simulate filling inference_result_tokens
for (int i = 0; i < 50; i++) { // Example: 50 tokens
inference_result_tokens[i] = 1000 + i;
}
num_tokens = 50;
size_t payload_data_len = num_tokens * sizeof(uint16_t);
size_t total_packet_len = 5 + payload_data_len; // 5 bytes header + payload
if (total_packet_len > sizeof(transmit_buffer)) {
// Handle error: payload too large for buffer/MTU
return;
}
// Construct custom header
transmit_buffer[0] = (current_seq_num >> 0) & 0xFF; // Sequence LSB
transmit_buffer[1] = (current_seq_num >> 8) & 0xFF; // Sequence MSB
transmit_buffer[2] = (payload_data_len >> 0) & 0xFF; // Length LSB
transmit_buffer[3] = (payload_data_len >> 8) & 0xFF; // Length MSB
uint8_t checksum = 0;
for (size_t i = 0; i < payload_data_len; i++) {
transmit_buffer[5 + i] = ((uint8_t*)inference_result_tokens)[i];
checksum += transmit_buffer[5 + i];
}
transmit_buffer[4] = checksum; // Simple checksum
struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, total_packet_len, PBUF_REF);
if (p != NULL) {
// Point PBUF's payload to our static buffer
p->payload = transmit_buffer;
p->len = total_packet_len;
p->tot_len = total_packet_len;
err_t err = udp_sendto(pcb, p, remote_ip, remote_port);
if (err != ERR_OK) {
// Log error
}
pbuf_free(p); // Decrement PBUF reference count. Does NOT free transmit_buffer.
} else {
// Handle PBUF allocation failure
}
current_seq_num++; // Increment sequence number for next packet
}
4. 벤치마크 결과: 극적인 지연 시간 감소 🔥
위와 같은 최적화를 적용한 결과, 우리는 매우 만족스러운 성능 향상을 이룰 수 있었습니다.
| 최적화 단계 | 엔드-투-엔드 지연 시간 (평균) | 비고 |
|---|---|---|
| 초기 상태 | 250ms | PBUF_RAM, 기본 lwipopts, 문자열 전송 |
| 제로 카피 적용 | 120ms | PBUF_REF로 데이터 복사 오버헤드 제거 |
| LWIP 설정 최적화 | 90ms | PBUF_POOL_SIZE, UDP_SND_BUF 등 증대 |
| 커스텀 프로토콜 | 75ms | 바이너리 직렬화, 간소화된 헤더 |
초기 250ms에서 75ms로, 무려 70% 이상의 지연 시간 감소를 달성했습니다. 이는 LLM 추론 시간(100ms)보다 훨씬 짧은 네트워크 전송 시간을 의미하며, 전체 시스템의 응답성을 크게 향상시켰습니다.
5. 결론: “네트워크는 소프트웨어다” 💡
이번 삽질을 통해 엣지 AI 환경에서 저지연 통신을 확보하는 것이 얼마나 중요한지, 그리고 LWIP와 같은 경량 네트워크 스택의 내부 동작을 이해하고 최적화하는 것이 얼마나 큰 성능 향상을 가져올 수 있는지 다시 한번 깨달았습니다. 단순히 상위 계층 애플리케이션만 개발하는 것이 아니라, 시스템의 바닥까지 파고들어 문제를 해결하는 엔지니어의 시야가 빛을 발하는 순간이었습니다.
네트워크는 단순히 케이블과 라우터의 조합이 아닙니다. 임베디드 환경에서는 “네트워크 스택 또한 중요한 소프트웨어이며, OS 스케줄링, 메모리 관리, 드라이버 인터페이스와 깊이 연관되어 있다”는 사실을 잊지 말아야 합니다. 앞으로도 이러한 도전적인 문제들에 대한 깊이 있는 탐구를 멈추지 않을 것입니다. 여러분의 엣지 AI 프로젝트에도 이 삽질기가 도움이 되기를 바랍니다! 🚀
DevBJ | No Bio, Just Log 기술 삽질로그