❄️ JS 라이브러리 Rust로 재작성 후기

2024-03-31

Rust 를 새로 배우면서, “앞으로 Rust 를 어떻게 사용할까” 에 대해 고민을 해봤다.
Rust 가 CPP 를 대체한다면, Rust 를 잘 활용할 수 있는 분야는 어디일까?

다양한 예시들을 살펴보면서 내가 관심을 가지게 되었던건 그래픽, 애니메이션 영역이었다.

특히 WASM 을 통한 웹 환경에서의 그래픽 작업에 대해서 관심이 생겼다.

예전에 동아리 해커톤에서 진행했던 프로젝트.
HTML5 canvas 를 이용해서 눈꽃을 내려주는 프로젝트였다.

기존에는 JS 로 HTML5 canvas 를 조작했는데, 눈 송이 위치 계산을 Rust 로 하게 하면 성능이 좋아지지 않을까 하는 생각이 떠올랐다.

requestAnimationFrame 을 JS 에서 매번 호출하면서 반복되는 것 역시 Rust 가 WASM 으로 수행한다면 더 성능이 좋지 않을까 하는 생각이었다.

그래서 JS 코드를 전부 Rust-WASM 으로 재작성을 한 뒤 퍼포먼스를 비교해보기로 했다.



재작성 과정

JS 코드를 Rust 로 바꾸는 과정은 생각보다는 간단했다.
몇가지 함정만 제외하면…


함정1 : Crate 미지원

Rust 의 크레이트를 WASM 환경에서 사용하려면 코드가 달라지는 경우도 있었고,
심지어 아예 쓸 수 없는 패키지도 있었다.

예를 들어 랜덤 값을 뽑는데 사용하는 rand 크레이트는 WASM 전용 크레이트를 별도로 깔아줘야 했다.

심지어 tokio 같은 비동기 런타임은 WASM 에 대한 지원이 약해서 사용할 수 없는 수준이었다.

WASM 컴파일을 위해서 Rust 가 가진 장점을 많이 내려놓고 가야하는 느낌이었다.



함정2 : JS Function

또 JS function 을 가져다 쓰는 경우 코드가 지나치게 hacky 해지는 문제도 있었다.

Rust 만의 lifetime 개념을 JS Function 을 왔다갔다 하면서 사용하려면 이 역시 난이도가 급격히 상승한다.

내 Rust 실력이 부족한 이유도 있고, 익숙해지면 더 나아지긴 하겠지만..

Rust 를 막 배워서 바로 써먹어보려는 러린이 개발자들에게는 큰 진입장벽이 될 듯 싶다.



함정3 : 배포

아무래도 WASM 이 파일 형태가 css, js 처럼 익숙한 형태가 아니다보니 배포 과정에서도 어려움을 겪었다.

webpack 에서도 별도 설정을 이것저것 해줘야 하고, 빌드 결과로 나온 wasm 파일을 서빙하는데에도 고민해봐야 할 부분이 있다.

나 같은 경우 cdn 으로 one-line import 가 가능하게 하려했는데, wasm 파일을 다운로드 받는 방식을 어떻게 해야할지 막막했다.

자료도 잘 없다.. 결국 webpack 의 publicPath 를 cdn 주소로 설정해서 해결하긴 했다. 다만 이걸 cdn 이 아닌 npm install 로 사용하면 오버헤드가 얼마나 있을지..

Rust WASM 을 단순히 npm 에 배포하는건 아주아주 간단하긴 하다. wasm-pack 에서 간단하게 지원중.

그런데 간단한 배포로 원하는 결과를 가져올 수 없는 상황에서는 추상화가 많이 된 배포 환경이 답답하게 느껴질 수도 있다.



퍼포먼스 테스트 1

아무튼 역경을 해치며 코드를 작성한 후 테스트 시간.
육안으로는 성능 확인이 잘 안 돼서 개발자도구의 퍼포먼스 탭을 통해서 확인해보았다.

requestAnimationFrame 마다 10개의 눈송이를 생성하는 방식으로 두 라이브러리를 모두 테스트해봤다.

requestAnimationFrame 은 기본적으로 1초에 60번 호출되지만, 사용하는 모니터 주사율에 맞춰서 실행된다고 한다.

내가 사용중인 모니터는 주사율이 60Hz 여서, 1초에 600개의 눈송이를 생성한다고 볼 수 있다.

JS 초당 600 개 스폰

Rust-WASM 초당 600 개 스폰

결과는 놀랍게도 JS 의 압승. 육안으로 볼 때 큰 차이가 없긴 해도 퍼포먼스 도구로 확인해보면 2배정도 차이가 난다.



JS 의 퍼포먼스 상세

Rust-WASM 의 퍼포먼스 상세



퍼포먼스 상세를 보면 WASM 을 불러와서 실행하는데 오버헤드가 상당히 많은 걸로 보인다.



왜 그런가 외국 러스트 커뮤니티를 돌면서 찾아보니, js 와 wasm 사이에 상호작용이 많을 수록 오버헤드가 발생하기 쉽다고 한다.

pub fn animation_loop(&mut self) {
    let mut context = self.clone();
    let f = Rc::new(RefCell::new(None));
    let g = f.clone();
    *g.borrow_mut() = Some(Closure::new(move || {
        context.new_snow();
        context.animate();
        request_animation_frame(f.borrow().as_ref().unwrap());
    }));

    request_animation_frame(g.borrow().as_ref().unwrap());
}

requestAnimationFrame 을 대체할 수 있는 방법이 안 보여서 Rust 에서 가져와서 사용했었는데, 이게 문제인가 싶어서 몇가지 시도를 해봤다.



해결시도 1번 : rust loop 사용

처음 떠올린 해결책은 requestAnimationFrame 을 rust 의 loop 로 대체하기.

pub fn animation_loop_1(&mut self) {
    loop {
        self.new_snow();
        self.animate();
    }
}

그러나 무한 loop 가 실행을 blocking 하여 아예 첫 렌더링조차 되지 않았다.


런타임을 blocking 하지 않는 비동기 루프를 실행할 수 있는 방법이 있으면 좋겠다 싶었는데,

spawn_local 을 이용한 async loop 는 내 rust 실력이 부족해서 그런지 제대로 동작하지 않았다.



해결시도 2번 : requestAnimationFrame 을 rust 바깥으로 분리

마찬가지로 외국 러스트 커뮤니티에서 찾은 내용인데, js 에서 requestAnimationFrame 호출을 처리하고 rust 에서는 단일 render_frame 함수만 호출하는게 더 나을 수도 있다고 한다.

rustwasm 에서 만든 예시에도 requestAnimationFrame 은 js 에서 호출하고 rust 는 해당 루프에 들어가는 단일 메서드를 구현하는 식으로 되어있다.


그래서 requestAnimationFrame 을 분리해봤다.
class Snowy 는 rust 에서 작성된 struct 이다.
마찬가지로 rust 쪽에서 구현된 render_frame 을 requestAnimationFrame 내부에서 호출한다.


const snowy = new Snowy(config)
snowy.resize()
window.addEventListener('resize', () => {
    snowy.resize()
})
function animate() {
    snowy.render_frame()
    requestAnimationFrame(animate)
}
requestAnimationFrame(animate)


그러나 결과는 여전히 pure js 보다 느리다.


Rust-WASM 초당 600개 스폰

눈송이의 위치를 계산하는게 생각보다 무거운 작업이 아니라서 wasm 을 이용하는 오버헤드가 오히려 큰 것 같다.




퍼포먼스 테스트 2

더 계산할게 많은 상황에서 어떨까 해서 1초에 6,000개, 12,000개의 눈송이를 생성하도록 바꿔봤다. 이정도 되니 여유 없이 자원을 full 로 사용하는 듯 하다.

자원을 거의 다 쓰는건 비슷한데, 육안으로 봤을 때 rust-wasm 쪽 프레임 드랍이 확실히 더 크게 느껴졌다.

JS 초당 6,000개 스폰

Rust-WASM 초당 6,000개 스폰


JS 초당 12,000개 스폰

Rust-WASM 초당 12,000개 스폰

어떻게 봐도 현재 내가 구현할 수 있는 코드 안에서는 Rust 쪽의 퍼포먼스가 더 안 좋았다.




결론

계산이 복잡한 콘웨이의 game of life 의 경우, rust 가 더 나은 성능을 보일수도 있다고 한다. 다만 이 경우에도 규모를 크게 해야 더 나아지고, 작은 단위에서는 비슷하다고 한다.

이번에 테스트한 애니메이션처럼 단순한 계산을 하는 경우 JS-to-WASM 에서 오는 오버헤드가 더 큰 것으로 보인다. Rust-WASM 이 JS 보다 성능이 더 떨어지는 현상이 발생했다.

Rust WASM 진영의 canvas 애니메이션에 대한 활용은 아직은 좀 아쉬운 것 같다.
결국은 requestAnimationFrame 을 대체할 수 있는 애니메이션 구현 방법이 Rust WASM 쪽에 필요하다고 생각… 하나, 3년전에도 같은 논의가 있었는데 아직 새로운 소식이 없는걸 보면…
canvas animtaion 은 웬만하면 JS 쓰세요.

반면에 이미지 프로세싱 처럼, Blob 을 읽어서 처리하는 무거운 작업이라면 JS-to-WASM 오버헤드가 적어지고 WASM 이 더 나은성능을 보일까 싶은데 이것도 조만간 실험해서 포스팅을 해봐야겠다.

오늘 테스트에 사용했던 코드는 깃허브 레포에 배포해두었으니 혹시나 궁금하신 분이 계신다면 확인해보시길..



REF

https://rustwasm.github.io/book/ https://www.reddit.com/r/rust/comments/jwjbcd/canvas_animation_in_wasm_or_js/


👈 목록으로 돌아가기
😁 부족한 글 읽어주셔서 감사합니다