AboutWritten 지나간 발자국 발자국
황현석 | 2003. 06. 23.에 작성되었습니다.

황현석 일지

Judge Blog 구축 일지 01 - MEMFS 채택 그리고 폐기 본문

Judge Blog 구축 일지 01 - MEMFS 채택 그리고 폐기

회고.

안녕하세요. 최근에는 알고리즘 블로그 글을 조금 더 전문적으로 포스팅하고 싶다는 생각이 들었습니다. 단순히 풀이를 적고 끝내는 것이 아니라, 글 안에서 바로 코드를 실행하고 결과를 확인할 수 있으면 좋겠다고 생각했습니다. 그래서 Judge 기능이 달린 Blog를 만들자는 간단한 프로젝트를 진행하고 있었습니다. 프로젝트 명은 HWJ입니다.

 

HWJ는 Hybrid WASM Judge의 줄임말입니다. 지금 만들고 있는 것은 기존 온라인 저지처럼 서버가 모든 테스트를 실행하는 구조가 아니라, 사용자의 브라우저 안에서 Web Worker와 WebAssembly를 이용해 테스트 생성, 정답 실행, 제출 코드 실행, 출력 비교를 위임하는 구조를 만들고 있습니다.

 

사용자의 어뷰징은 관심이 없습니다. 물론 실제 서비스라면 언젠가는 반드시 다뤄야 하는 문제이지만, 지금 단계의 타겟층은 정말 배움에 뜻이 있는 사람으로 잡았습니다. 그래서 대회 플랫폼처럼 강한 격리와 검증을 먼저 만들기보다는, GitHub와 같은 overview 형태의 UI를 간략하게 제공하고, 글을 읽는 사람이 직접 코드를 돌려보는 경험을 우선으로 잡았습니다.

 

프로젝트는 구체적으로 브라우저를 작은 채점 클러스터처럼 쓰는 방향에 가깝습니다. 출제자는 generator, validator, checker, solution을 작성하고, 사용자가 문제를 풀면 브라우저 worker들이 여러 테스트를 나눠 실행합니다. 서버는 C++ 코드를 WASM으로 컴파일하거나 artifact를 관리하는 역할만 맡고, 실제 반복 실행 비용은 가능한 한 클라이언트로 넘기는 구조를 목표로 하고 있습니다.

 

사용자의 브라우저에 테스트케이스를 네트워크로 다 넘기면, 너무 손해라고 생각했습니다. 문제마다 수많은 입력을 서버에서 만들고, 다시 네트워크로 내려보내고, 제출 결과를 다시 서버로 올리는 구조라면 결국 기존 온라인 저지와 크게 다르지 않기 때문입니다. 그래서 테스트 생성과 실행 자체를 브라우저 안으로 최대한 밀어 넣는 방향을 생각했습니다.

 

프로젝트를 진행하기 이전에, 간단하게 emcc를 이용해 WASM을 만들고, $O(1)$ 입력에 대해 테스팅을 맞췄습니다. 굉장히 빨랐고, 이 구조를 채택하여 구현해야겠다고 생각했습니다. C++ 코드는 emcc로 컴파일해 Emscripten artifact를 만들고, worker에서는 Emscripten이 생성한 JS glue와 .wasm을 로드한 뒤 Module.callMain()을 호출했습니다. 입력과 출력은 Emscripten의 MEMFS를 사용했습니다.

 

처음에는 이 선택이 꽤 자연스럽다고 생각했습니다. 사용자의 C++ 코드 안에 freopen("in.txt", "r", stdin)freopen("out.txt", "w", stdout)를 주입하고, worker가 실행 전에 FS.writeFile("in.txt", input)을 호출한 뒤 실행이 끝나면 FS.readFile("out.txt")로 출력을 읽으면 됩니다. 기존 BOJ식 cin, cout, scanf, printf 코드를 거의 그대로 살릴 수 있었습니다.

 

MEMFS라는 이름도 이 판단에 영향을 줬습니다. 실제 디스크를 쓰는 파일 시스템이 아니라 메모리 기반 가상 파일 시스템이므로, 입력 파일을 쓰고 출력 파일을 읽더라도 충분히 빠를 것이라고 생각했습니다. 브라우저 안에서 사용자의 코드를 고치지 않고 표준 입출력을 흉내 낼 수 있다면, 이 정도 추상화 비용은 감수할 만하다고 봤습니다.

 

처음에는 C++을 WebAssembly로 컴파일해 브라우저에서 실행하면 JavaScript보다 빠를 가능성이 높다고 생각한 것도 있습니다. 알고리즘 문제 풀이에서 C++은 보통 가장 빠른 선택지이고, WASM도 네이티브에 가까운 실행 성능을 기대하게 만드는 기술이기 때문입니다.

 

하지만 실제로 여러 테스트를 돌려보니 특정 케이스에서는 C++ WASM이 JavaScript보다 느리게 나왔습니다. 처음에는 WebAssembly 자체가 느린 것인지, 메모리 증가가 병목인지, 아니면 worker의 wasm 할당문제인지, 컴파일 옵션을 잘못 잡은 것인지부터 의심했습니다.

 

처음에는 구현을 실수했다고 생각했습니다. worker pool에서 잘못 WASM을 호출하거나, artifact를 매번 비효율적으로 잡거나, 실행 시간을 재는 위치가 잘못돼서 생기는 문제라고 생각했습니다. 하지만 여러 시도 끝에도 실행시간에 극적인 차이를 주지 못했습니다.

 

그러다가 Java, Python, JS가 더욱더 빠르게 실행시간을 가지는 것을 보고, 정말 미쳐버릴 것 같았습니다. C++을 WASM으로 구웠는데도 JavaScript보다 느리게 나오는 순간이 있었고, 이건 단순히 언어 성능으로는 설명이 되지 않았습니다.

그래서 의심을 I/O쪽으로 잡았고, 그 판단은 꽤 맞는 방향이었습니다. 저는 WASM이 무조건 더 빠른 건 맞다는 전제를 가지고 있었기 때문에, 병목을 가지는 곳이 있다면 그럼 I/O라고 생각을 했습니다. 정확히 말하면 Java, Python, JS가 C++보다 본질적으로 빠르다기보다는, 현재 HWJ에서 돌아가는 런타임 경로가 C++의 freopen, libc FILE*, iostream, MEMFS 경로보다 훨씬 얇았습니다. 쉽게 말하면 C++만 표준 입출력을 살리기 위해 여러 계층을 더 통과하고 있었습니다.

 

C++은 하지만 여러 계층을 통과해야 했었고, 이때부터 WASM을 조금 더 공부하게 되었습니다. 단순히 C++을 WASM으로 컴파일하면 빠르다는 생각만으로는 부족했고, 브라우저 WASM에서 메모리, 런타임, 입출력이 실제로 어떤 경로를 타는지 봐야 했습니다.

 

먼저 C++과 JS의 최적화 방식부터 다시 봤습니다. C++은 emcc가 LLVM을 통해 컴파일하는 시점에 대부분의 최적화가 끝납니다. 반면 JS는 코드가 실행되는 도중에 V8 같은 엔진이 실제 데이터 흐름을 보면서 JIT 최적화를 수행합니다. 그래서 단순히 "C++이니까 무조건 빠르다"라고 보기에는 브라우저 안의 실행 조건이 생각보다 복잡했습니다.

 

처음에는 wasm_gc 같은 것도 의심했습니다. Java, Python, JS가 더 자연스럽게 도는 것처럼 보이니, 브라우저 런타임 쪽에서 관리형 언어가 더 유리한 길을 타는 건가 싶었습니다. 하지만 조금 더 따져보니 그렇게 단순한 문제는 아니었습니다. Python은 Pyodide 같은 WASM 런타임 위에서 돌고, Java도 별도 런타임을 거치며, JS는 애초에 브라우저 엔진의 네이티브 실행 경로를 탑니다. 결국 중요한 것은 GC 하나가 아니라 각 언어 runner가 입력과 출력을 어떤 경로로 주고받는지였습니다.

 

메모리 접근 방식도 다시 보게 되었습니다. 네이티브 x86 환경에서는 정렬되지 않은 메모리 접근을 CPU가 어느 정도 알아서 처리해 줍니다. 약간의 비용이 있을 수는 있지만, 보통 알고리즘 코드를 짤 때 그 부분을 깊게 의식하지는 않습니다. 그런데 WASM에서는 메모리 접근이 linear memory 위에서 일어나고, 브라우저 엔진은 명령에 담긴 정렬 정보를 바탕으로 접근을 최적화합니다. 정렬되지 않은 접근이 항상 실패한다는 뜻은 아니지만, C++ 네이티브에서 당연하게 기대하던 메모리 감각을 그대로 가져오면 성능을 잘못 판단할 수 있겠다고 느꼈습니다.

 

그래서 WebAssembly.Memory.grow()도 의심했습니다. Wasm에서 메모리가 부족해지면 브라우저에 메모리 페이지를 더 달라고 요청해야 하고, 이 과정에서 기존 ArrayBuffer의 view가 바뀌거나 메모리 재매핑이 일어날 수 있습니다. 실행 중 이런 일이 반복되면 순수 연산보다 메모리 관리 비용이 더 눈에 띌 수 있다고 생각했습니다.

 

또 하나는 JS와 WASM 사이의 버퍼 공유였습니다. 메모리를 처음부터 충분히 크게 잡아두고, C++ 쪽에 입력 버퍼와 출력 버퍼를 만들어둔 뒤, JS가 포인터와 길이를 받아 Module.HEAPU8.subarray(pointer, pointer + size) 형태로 직접 view를 잡으면 불필요한 복사를 줄일 수 있습니다. 엄밀히 말하면 JS 입력을 WASM memory에 넣는 복사 자체가 완전히 사라지는 것은 아니지만, 적어도 파일 시스템 계층을 거치는 것보다 훨씬 얇은 경로를 만들 수 있습니다.

 

메모리 제한을 기준으로 INITIAL_MEMORY를 키워보고, ALLOW_MEMORY_GROWTH=0도 실험해봤지만 결과는 크게 달라지지 않았습니다. 이 과정에서 적어도 이번 문제의 1차 병목은 C++의 순수 계산 속도나 WebAssembly.Memory.grow()가 아닐 수 있다는 쪽으로 생각이 옮겨갔습니다.

 

다시 보니 현재 C++ 실행 경로는 사용자가 작성한 표준 입출력을 그대로 살리기 위해 Emscripten의 libc, iostream, freopen, MEMFS 계층을 통과하고 있었습니다. 반면 JavaScript runner는 같은 worker 안에서 문자열이나 Uint8Array를 직접 다룹니다. 둘 다 입력과 출력을 전달하지만, 실제로 통과하는 계층의 두께는 달랐습니다.

 

이 글은 그 차이를 확인하면서, Emscripten 안에서 입출력 경로를 얇게 만드는 방법과 WASI로 별도 C++ runner를 두는 방향을 정리한 기록입니다.

 


 

문제의 핵심: 산술보다 두꺼운 입출력 경로

 

현재 구조를 단순화하면 C++ 실행 경로는 다음과 같습니다.

 
1

JS input에서 시작

FS.writeFile("in.txt")freopen("in.txt", "r", stdin)libc FILE*iostream / scanfuser C++ code

2

user C++ output 반환

cout / printflibc / iostreamfreopen("out.txt", "w", stdout)MEMFSFS.readFile("out.txt")JS output

 

이 방식은 사용자 코드 호환성 면에서는 좋습니다. BOJ에 제출하던 코드를 거의 그대로 브라우저에서 실행할 수 있기 때문입니다. 하지만 성능 관점에서는 결코 얇은 경로가 아닙니다. 실제 디스크를 쓰는 것은 아니고 MEMFS도 메모리 기반이지만, 그래도 Emscripten이 제공하는 POSIX 파일 시스템과 JS glue를 계속 통과합니다.

 

특히 출력이 많은 문제에서 cout <<를 반복하면 작은 write가 계속 발생합니다. C++ 코드 안에서 문자열에 모아 한 번에 출력했을 때 실행 시간이 줄어드는 현상은, 병목이 알고리즘보다 stdout 경로에 있을 수 있다는 신호였습니다.


첫 번째 후보: streambuf로 MEMFS 우회하기

 

가장 현실적인 다음 실험은 Emscripten을 유지하되, freopen("in.txt/out.txt") 주입을 끄고 C++ wrapper에서 std::cin.rdbuf()std::cout.rdbuf()를 직접 교체하는 방식입니다.

 

즉, JS가 입력을 WASM linear memory에 한 번 복사하고, C++의 cin은 그 메모리 버퍼를 직접 읽도록 만듭니다. 출력도 cout이 MEMFS 파일에 쓰는 대신, 미리 잡아둔 output buffer에 바로 쓰게 됩니다.

 
1

JS inputHEAPU8.set(inputBytes, inputPtr)로 WASM memory에 복사합니다.

 
2

init_fast_io(inputLen)로 입력 범위를 알려주고, cin.rdbuf(customInputBuf)를 붙입니다.

 
3

user C++ code는 기존처럼 cin을 읽지만, 실제로는 memory buffer를 직접 읽습니다.

4

cout.rdbuf(customOutputBuf)가 output buffer에 쓰고, JS는 그 byte를 바로 읽습니다.

 

이 방식은 엄밀히 말하면 완전한 zero-copy는 아닙니다. JS의 Uint8Array를 WASM memory로 옮기는 HEAPU8.set() 한 번은 필요합니다. 다만 FS.writeFile, freopen, libc FILE*, MEMFS를 통과하는 것보다 훨씬 얇습니다.

class FastInputBuf : public std::streambuf {
public:
  FastInputBuf(char* base, size_t length) {
    setg(base, base, base + length);
  }
};

class FastOutputBuf : public std::streambuf {
public:
  FastOutputBuf(char* base, size_t length) {
    setp(base, base + length);
  }
};

이 실험에서 C++ 실행 시간이 줄어든다면 원인은 꽤 좁혀집니다. C++ WASM 산술 자체보다 Emscripten의 MEMFS/POSIX I/O glue가 현재 구조에서 더 큰 비용을 만들고 있었다고 볼 수 있기 때문입니다.


streambuf 방식의 한계

 

다만 이 방식도 만능은 아닙니다. std::cinstd::cout을 사용하는 solution에는 잘 맞지만, C의 scanf, printf, read, write, fread, fwrite까지 모두 잡지는 못합니다.

 

또한 output buffer가 꽉 찼을 때의 정책도 필요합니다. 조용히 잘리면 사용자는 Wrong Answer를 받게 되지만, 실제로는 Output Limit Exceeded나 Runtime Error에 가까운 상황입니다. 따라서 runner가 output cap을 명확히 관리하고, overflow를 verdict로 바꿔야 합니다.

 

매 실행마다 new FastInputBuf 같은 heap allocation을 만들면 실험 결과가 흐려질 수도 있습니다. 가능하면 wrapper 안에서 재사용 가능한 object를 두고, 실행마다 buffer 범위만 다시 설정하는 편이 낫다고 판단했습니다.


두 번째 후보: cpp-wasi

 

더 낮은 계층을 건드리는 후보는 Emscripten을 거치지 않고 WASI로 C++을 컴파일하는 방식입니다. 기존에는 emcc로 C++을 컴파일했다면, WASI 방식에서는 보통 WASI SDK의 clang++ --target=wasm32-wasi를 사용합니다.

clang++ --target=wasm32-wasi main.cpp -O2 -o main.wasm

 

Emscripten은 module.wasm과 함께 JS glue, FS, runtime을 제공합니다. 반면 WASI는 보통 순수 .wasm을 만들고, 실행 환경이 fd_read, fd_write, proc_exit 같은 import를 제공해야 합니다.

 
1

READ

C++ cin / scanfwasi-libcfd_read importHWJ JS hostinput Uint8Array / WASM memory

2

WRITE

C++ cout / printfwasi-libcfd_write importHWJ JS hostoutput byte chunks

중요한 점은 WASI가 MEMFS를 더 빠르게 읽는 기술이 아니라는 것입니다. 오히려 MEMFS 자체를 제거하고, stdin/stdout을 얇은 fd 기반 import로 직접 연결하는 방식에 가깝습니다.


두 컴파일러가 바라보는 실행 환경

 

둘 다 C++을 WebAssembly로 만들 수 있지만, 애초에 바라보는 목적지는 다릅니다. Emscripten은 브라우저라는 제약 많은 환경에 기존 C++ 프로그램을 최대한 그대로 이식하는 도구에 가깝고, WASI-SDK는 Wasm을 위한 표준 시스템 인터페이스에 맞춰 모듈을 만드는 도구에 가깝습니다.

 

그래서 Emscripten은 브라우저와의 결합이 강합니다. SDL2, OpenGL/WebGL, canvas, input event 같은 웹 쪽 기능과 붙는 프로젝트라면 Emscripten이 제공하는 glue가 큰 장점이 됩니다. 기존 C++ 코드를 브라우저 앱처럼 보이게 만들기 위해 필요한 것들을 많이 대신 처리해주기 때문입니다.

 

반대로 WASI-SDK는 그런 브라우저 편의 기능을 자동으로 제공하지 않습니다. 대신 fd_read, fd_write 같은 표준화된 시스템 인터페이스에 맞춰 훨씬 단순한 실행 모델을 만듭니다. 브라우저에서 쓰려면 JS host를 직접 만들어야 하지만, stdin/stdout 중심의 judge runner에서는 그 단순함이 오히려 장점이 될 수 있습니다.

Emscripten

glue/runtime

C++ project.wasm + JS glue/runtime + FS/MEMFS

목표: 기존 C/C++ 프로그램을 브라우저에서 최대한 그대로 실행

WASI-SDK

host imports

C++ project.wasm

목표: WASI import를 제공하는 host 위에서 실행


wasm32-wasi의 32는 느리다는 뜻이 아니다

 

wasm32-wasi라는 이름에서 32가 붙어 있어서 32비트 CPU처럼 느려지는 것인지 헷갈릴 수 있습니다. 하지만 여기서 32는 WASM linear memory의 주소가 32비트라는 뜻입니다.

 

브라우저에서 일반적으로 사용하는 WebAssembly도 대부분 wasm32입니다. Emscripten 역시 기본적으로 wasm32 계열입니다. 따라서 WASI로 간다고 해서 32비트라서 느려지는 구조는 아닙니다.

 

순수 계산 성능은 Emscripten WASM과 WASI WASM이 크게 다르지 않을 수도 있습니다. 둘 다 결국 브라우저의 WebAssembly 엔진 위에서 실행됩니다. 차이가 나는 부분은 주로 stdin/stdout, 작은 write 반복, 파일 시스템 에뮬레이션, JS glue입니다.


generator 병목도 줄어들 수 있다

 

이 구조는 solution뿐 아니라 generator에도 의미가 있습니다. 현재 generator가 C++ WASM으로 실행되고 stdout으로 큰 테스트 입력을 만든다면, 그 출력 역시 MEMFS의 out.txt를 거쳐 다시 JS로 읽힙니다.

 

WASI에서는 generator의 stdout을 fd_write에서 바로 byte chunk로 받을 수 있습니다. 그러면 out.txt를 만들고, 다시 읽고, 다음 solution의 in.txt로 쓰는 과정이 줄어듭니다.

 
1

generator stdout이 발생합니다.

 
2

fd_write에서 바로 JS byte chunks로 받습니다.

3

그 byte를 다시 solution stdin으로 넘깁니다.

이렇게 되면 solution과 generator처럼 stdin/stdout 중심으로 움직이는 C++ artifact는 WASI의 효과를 볼 가능성이 있습니다. 다만 checker는 조금 다릅니다. testlib checker는 보통 input.txt, output.txt, answer.txt 같은 파일을 직접 열기 때문에, WASI에서 파일 API나 preopen directory를 별도로 설계해야 합니다.


cpp-wasi를 실제로 붙이며 밟은 삽질

 

계획 단계에서는 WASI로 바꾸면 Emscripten MEMFS를 걷어내고 fd_read, fd_write만 얇게 구현하면 될 것처럼 보였습니다. 실제 구현에서는 wasm_artifacts.targetwasi-wasm32를 추가하고, 기존 module.wasm 옆에 module.wasi.wasm을 저장한 뒤, worker 쪽에 WASI host를 붙이는 방식으로 시작했습니다.

 

이 과정에서 확인한 첫 번째 삽질은 링크 옵션이었습니다. 처음에는 메모리를 export하려고 -Wl,--export=memory를 넣었는데, wasm-ldsymbol exported via --export not found: memory를 냈습니다. WASI SDK에서 memory 자체를 export하려면 -Wl,--export-memory를 써야 했습니다.

잘못 넣은 옵션 -Wl,--export=memory
실제로 필요했던 옵션 -Wl,--export-memory

 

두 번째 삽질은 C++ 런타임과 exception ABI였습니다. 단순한 알고리즘 코드라도 iostream, vector, STL을 쓰면 내부적으로 std::logic_error나 exception 관련 심볼이 엮일 수 있습니다. 그래서 처음에는 __cxa_allocate_exception, __cxa_throw 같은 undefined symbol이 터졌습니다.

 

그 다음에는 -fwasm-exceptions와 WASI SDK의 eh 라이브러리를 붙여서 링크를 통과시켰습니다. 컨테이너 안에서는 컴파일이 됐지만, 브라우저 제출에서는 module uses a mix of legacy and new exception handling instructionsWebAssembly.compile() 단계에서 죽었습니다. 여기서 얻은 결론은 명확합니다. WASI는 링크 성공만 보면 안 되고, 실제 브라우저 WebAssembly validator가 받아들이는 instruction set인지까지 봐야 합니다.

컨테이너

링크 성공

브라우저

WebAssembly.compile 실패

원인 방향

C++ exception ABI / wasm exception instruction 조합이 현재 브라우저 실행 경로와 맞지 않음

 

처음에는 이 문제를 피하려고 -fno-exceptionsnoeh libc++ 조합을 시도했습니다. 단순 solution은 이 방식으로 지나가는 것처럼 보였습니다. 하지만 곧바로 틀렸다는 것이 드러났습니다. 유저 C++ 코드는 당연히 try/catch를 쓸 수 있어야 하고, Polygon 계열 generator/checker가 자주 쓰는 testlib.h도 내부에서 try, throw를 사용합니다.

 

-fno-exceptions를 켜면 testlib.h 포함 source는 컴파일 단계에서 바로 실패합니다. 실제로 cannot use 'try' with exceptions disabled, cannot use 'throw' with exceptions disabled가 나왔습니다. 따라서 judge runner의 기본 C++ ABI에서 exception을 끄는 것은 선택지가 아닙니다.

clang++ main.cpp \
  --target=wasm32-wasip1 \
  --sysroot=/opt/wasi-sdk/share/wasi-sysroot \
  -std=c++20 \
  -O2 \
  -fexceptions \
  -I /app/src/lib/headers \
  -L/opt/wasi-sdk/share/wasi-sysroot/lib/wasm32-wasip1/eh \
  -lc++ -lc++abi -lunwind \
  -Wl,--initial-memory=... \
  -Wl,--max-memory=... \
  -Wl,--export-memory \
  -o module.wasi.wasm

 

최종적으로 통과한 조합은 -fexceptions, WASI SDK의 wasm32-wasip1/eh 라이브러리, 그리고 -lc++ -lc++abi -lunwind였습니다. 여기서 중요한 함정은 -fwasm-exceptions를 넣지 않는 것입니다. -fwasm-exceptions를 넣으면 링크는 되지만 Chromium에서 다시 module uses a mix of legacy and new exception handling instructions가 재현되었습니다.

-fno-exceptions + noeh

compile fail

testlib.htry/throw 때문에 컴파일 실패

-fwasm-exceptions + eh

browser fail

링크 성공, Chromium WebAssembly.compile 실패

-fexceptions + eh + -lunwind

pass

testlib 포함 generator 컴파일 성공, Chromium WebAssembly.compile 성공

 

또 하나의 착시가 있었습니다. 서버 컨테이너의 Node에서 WebAssembly.compile()을 돌리면 문제 artifact가 통과하는데, 실제 Chromium에서는 실패했습니다. 그래서 WASI artifact 검증은 Node만 믿으면 안 됩니다. 브라우저에서 fetch로 실제 artifact URL을 가져온 뒤 WebAssembly.compile(bytes)를 직접 해보는 smoke test가 필요합니다.

 

마지막 삽질은 stale artifact였습니다. 예전 -fwasm-exceptions 조합으로 만들어진 module.wasi.wasm이 DB에서는 ready로 남아 있었고, 파일도 그대로 있었기 때문에 새 컴파일러 옵션을 넣어도 기존 artifact가 재사용되었습니다. 이 문제는 compiler version에 ABI signature를 붙이고, 저장된 compiler_version이 현재 compiler signature와 다르면 ready라도 재컴파일하도록 막았습니다.

cpp-wasi ABI signature와 재컴파일 조건

현재 cpp-wasi ABI signature: hwj-wasi:wasip1:eh-fexceptions:v1

  • source.need_compile == true
  • artifact 없음
  • artifact.status != ready
  • artifact 파일 없음
  • artifact.compiler_version != 현재 compiler signature

 

또 하나 중요한 점은 target 이름입니다. 최근 WASI SDK에서는 wasm32-wasi보다 wasm32-wasip1가 현재 명시적인 target입니다. 예전 이름으로도 어느 정도 돌아갈 수는 있지만, 경고와 라이브러리 경로 혼선을 줄이려면 compile target과 sysroot library path를 wasm32-wasip1 기준으로 맞추는 편이 낫습니다.

 

운영 쪽 삽질도 있었습니다. production container는 소스가 bind mount된 개발 컨테이너가 아니라 이미지 빌드 결과로 뜹니다. 따라서 WASI runner나 compiler adapter를 고쳤으면 docker compose -f docker-compose.prod.yaml up -d --build로 이미지를 다시 구워야 합니다. 파일만 고치고 기존 prod 컨테이너를 보고 있으면 아무 변화가 없어 보입니다.

 

현재까지의 정리는 이렇습니다. WASI는 MEMFS를 빠르게 쓰는 방법이 아니라 MEMFS 경로를 없애는 방법입니다. 다만 C++ 표준 라이브러리, exception ABI, 브라우저 WebAssembly validator, checker의 파일 API까지 같이 맞아야 실제 judge engine으로 안정화됩니다. generator와 solution처럼 stdin/stdout만 중심인 artifact부터 붙이는 것이 가장 현실적인 순서입니다.


결론

 

이번 과정을 통해 적어도 이번 케이스에서는 C++ WASM 산술 자체보다, BOJ식 표준 입출력을 브라우저 안에서 재현하는 계층이 더 큰 병목일 수 있다는 것을 알게 되었습니다. C++의 계산은 빠를 수 있지만, 입력을 넣고 출력을 꺼내는 통로가 두꺼우면 전체 실행 시간은 쉽게 밀립니다.

 

단기적으로는 std::streambuf를 이용해 cin/cout을 WASM memory buffer에 직접 붙이는 실험이 가장 현실적입니다. 이 방식은 현재 Emscripten 기반 구조를 크게 흔들지 않으면서도 MEMFS 병목을 우회할 수 있습니다.

 

장기적으로는 cpp-wasi가 더 자연스러운 후보로 보입니다. WASI는 MEMFS를 최적화하는 것이 아니라 제거합니다. fd_read, fd_write를 HWJ worker가 직접 구현하면, C++의 stdin/stdout을 훨씬 얇은 경로로 연결할 수 있습니다.

 

결국 HWJ의 C++ runner는 두 방향으로 실험해볼 가치가 있습니다. 하나는 Emscripten 안에서 최대한 얇게 만드는 방향이고, 다른 하나는 WASI로 별도 실행 엔진을 두는 방향입니다. 지금 필요한 것은 어느 쪽이 이론적으로 더 멋진가가 아니라, 같은 generator와 solution을 놓고 실제 worker 안에서 callMainMs, stdoutReadMs, caseTotalMs가 어떻게 달라지는지 확인하는 것입니다.

 

이후 실제 구현에서는 우선 cpp-wasi를 별도 C++ 실행 target으로 붙였습니다. 기존 Emscripten artifact와 구분하기 위해 artifact target을 나누고, worker에는 cpp-wasi runtime을 추가했습니다. WASI host는 브라우저 worker 안에서 fd_read, fd_write, path_open, fd_seek 같은 import를 직접 제공하도록 만들었습니다. 즉, C++ solution과 generator는 가능한 한 MEMFS 파일을 통하지 않고 byte stream에 가깝게 stdin/stdout을 주고받는 방향으로 정리했습니다.

 

메모리 옵션에 대해서도 결론이 조금 바뀌었습니다. 처음에는 문제 메모리 제한만큼 INITIAL_MEMORY를 크게 잡고 ALLOW_MEMORY_GROWTH=0처럼 고정하면 빨라질 것이라고 생각했습니다. 하지만 실제로는 worker 여러 개가 동시에 WASM memory를 잡는 구조라서, 큰 초기 메모리는 브라우저 메모리 압박과 GC pause를 키울 수 있었습니다. 그래서 초기 메모리는 linker 기본값에 맡기고, --max-memory만 문제 메모리 제한으로 거는 쪽으로 바꿨습니다. 메모리 제한은 지키되, 시작부터 모든 worker가 큰 memory를 들고 출발하지 않게 한 것입니다.

 

또 하나 크게 정리한 부분은 checker입니다. 처음에는 checker도 전부 WASM으로 실행하는 것이 자연스럽다고 생각했습니다. 하지만 표준 checker까지 매 케이스마다 WASM으로 실행하면, 실제 비교보다 input.txt, output.txt, answer.txt를 준비하고 testlib checker를 띄우는 비용이 더 커졌습니다. 어떤 케이스에서는 checker만 약 20ms 이상 잡아먹는 것을 확인했습니다.

 

그래서 표준 checker는 JS fast path로 빼고, custom checker만 WASM으로 실행하도록 정리했습니다. 출제자가 직접 작성하거나 파일로 올린 checker는 의미를 알 수 없으므로 반드시 custom WASM checker를 사용합니다. 반대로 UI에서 선택한 기본 checker는 marker를 보고 JS에서 바로 비교합니다. 현재는 wcmp.cpp는 token compare, ncmp.cpp는 줄 단위 오른쪽 공백 trim 비교, yesno.cpp는 대소문자 무시 token 비교, rcmp4.cpprcmp9.cpp는 각각 1e-4, 1e-9 기준 실수 비교로 처리합니다.

 

표준 checker

JS fast path
  • wcmp.cpp → JS token byte compare
  • ncmp.cpp → JS line right-trim byte compare
  • yesno.cpp → JS case-insensitive token compare
  • rcmp4.cpp → JS real compare, 1e-4
  • rcmp9.cpp → JS real compare, 1e-9

custom checker

WASM checker
  • 직접 작성 / 파일 업로드 / 템플릿 수정
  • checker artifact 준비
  • WASM checker 실행

 

이 결론은 꽤 중요했습니다. HWJ에서 빠르게 만들어야 하는 것은 모든 것을 WASM으로 돌리는 구조가 아니라, WASM으로 돌려야 하는 것과 JS에서 바로 끝내야 하는 것을 구분하는 구조였습니다. generator와 solution처럼 사용자 코드 자체는 WASM으로 실행해야 하지만, 의미가 고정된 표준 checker는 JS byte stream 비교가 훨씬 얇습니다. 반대로 custom checker는 문제별 의미가 들어 있으므로 느리더라도 WASM 실행을 유지해야 합니다.

 

마지막으로 측정값도 다시 해석해야 했습니다. UI에 보이는 worst time이 항상 사용자가 느끼는 전체 wall time은 아니었습니다. solution의 순수 실행 시간은 낮게 나오더라도, generator 실행, main solution 실행, stdout materialize, checker, worker restart, artifact cache miss, 브라우저 GC가 합쳐진 caseTotalMs는 훨씬 크게 보일 수 있습니다. 따라서 앞으로는 callMainMs 하나만 보지 않고 stdoutReadMs, checkerDurationMs, caseTotalMs, worker pause를 같이 봐야 합니다.

 

현재 결론은 이렇습니다. C++ WASM이 JS보다 느리게 보였던 핵심 원인은 C++ 산술 성능이 아니라, 브라우저 judge runner 안에서 C++만 더 두꺼운 I/O와 checker 경로를 타고 있었기 때문입니다. 그래서 HWJ는 C++ solution/generator에는 WASI 기반의 얇은 stdin/stdout 경로를 붙이고, 표준 checker는 JS fast path로 빼며, custom checker만 WASM으로 남기는 방향으로 정리했습니다. 이 방향이 지금까지의 삽질에서 얻은 가장 실용적인 결론입니다.

 

'개발 일지' 카테고리의 다른 글

Judge Blog 구축 일지 02 - JavaScript의 메모리 문제  (0) 2026.06.21