deno 3차 작업 후기

5 min read

이번엔 그래도 난이도가 좀 있었다. 사실 성능을 포기하면 매우 단순한 일이 됐겠지만 성능을 포기할 생각이 있었으면 swc를 러스트로 짜지도 않았을 것이다.

제약사항

  • 번들러를 러스트에서 사용할 수 있어야 한다.
  • tree-shaking이 팔요하다

이는 이미 대략 구현된 상태였다.

  • swc의 성능이 낮아지면 안 됐다. 기존 spack은 병렬 처리를 하고 있었다.

  • ECMA 스펙의 기능들을 지원해야 한다.

cyclic import 같은 것들 말하는 것이다.

  • 번들러에 집어넣는 파일이 로컬 시스템에 존재하는 파일이 아닐 수도 있다.

swc는 파일 로딩을 번들러가 처리하고, deno는 모든 파일을 받아온 뒤 번들러를 호출한다. 참고로 deno 쪽에선 소스코드를 해시맵에 저장해서 넘겨주고 번들을 받을 수 있으면 좋겠다고 했다.

작업

swc_bundler 분리

이건 간단했다. 코드의 양이 기본적으로 많고 swc와 직접적으로 연결되어있는 부분이 많았기에 시간은 좀 걸렸지만 애초에 spack의 목표가 webpack 대체다보니 어느 정도 추상화가 되어있었다. 그냥 코드 옮기고 swc랑 분리했다.

추상화

swcdeno에서 쓸 수 있어야 하므로, 추상화가 필요했다. 제일 중요한 조건은 이것이였다.

  • 캐싱이 필요하다

같은 모듈을 여러 파일이 참조할 경우, 그 파일을 여러 번 파싱하고 의존성 분석해서 불러오고 그러는 건 너무 비효율적이다. util.js 같은 파일은 거의 모든 파일에서 사용될텐데, 매번 파싱하고 swc_ecma_transforms를 적용하는 건 말이 안 됐다.

또한, ES 스펙은 cycle import를 허용한다. 이 경우엔 효율성이 문제가 아니고 캐싱이 없으면 아예 구현이 불가능하다.

해결책은 파일 로드 과정을 분리하는 것이다. 파일 경로를 가져오는 작업 하나, 파일을 읽어서 파싱하는 작업 하나로. 이러면 첫번쨰에서 가져온 파일 경로를 이용해서 읽어온 파일을 캐싱할 수 있다.

pub trait Load {
    fn load(&self, file: &FileName) -> Result<(Lrc<SourceFile>, Module), Error>;
}

pub trait Resolve {
    fn resolve(&self, base: &FileName, module_specifier: &str) -> Result<FileName, Error>;
}

ResolveLoad를 분리하고 Resolve의 반환 값을 캐시 키로 사용하면 깔끔하게 해결된다.

선택적 병렬 처리

swc는 병렬 처리를 하고, deno는 하지 않는다. 따라서 병렬 처리 자체를 추상화해야했다. 처음에는 additive한 방식으로 가려고 했는데, 일이 너무 많아서 첫번쨰 때처럼 꼼수로 때우기로 했다.

기존에 병렬 처리를 위해 rayon을 쓰고 있었는데, rayon에 있는 trait을 가짜로 구현하고, 병렬 처리용 함수도 가짜로 구현해서 처리했다.


#[cfg(not(feature = "rayon"))]
pub(crate) fn join<A, B, RA, RB>(oper_a: A, oper_b: B) -> (RA, RB)
where
    A: FnOnce() -> RA,
    B: FnOnce() -> RB,
{
    (oper_a(), oper_b())
}

#[cfg(feature = "rayon")]
pub(crate) use rayon::iter::IntoParallelIterator;

/// Fake trait
#[cfg(not(feature = "rayon"))]
pub(crate) trait IntoParallelIterator: Sized + IntoIterator {
    fn into_par_iter(self) -> <Self as IntoIterator>::IntoIter {
        self.into_iter()
    }
}

#[cfg(not(feature = "rayon"))]
impl<T> IntoParallelIterator for T where T: IntoIterator {}

보면 가짜 join은 병렬 처리를 하는 대신 두 작업을 순서대로 처리하고, 가짜 into_par_iter은 그냥 Iterator 시스템을 이용해서 싱글 쓰레드에서 작업한다. 이건 그냥 아이디어만 떠올리면 되는 단순한 문제였고, 진짜 문제는 다른 곳에 있었다.

그 문제는 Send, Sync였다. 제너릭 파라미터가 저 trait을 구현하지 않을 가능성이 있기 때문에 러스트 컴파일러가 내 코드를 거부한 것이다. 어떻게 할까 잠깐 고민하다가 처음에 작업할 때 만들어놓은 가짜 Send, Sync가 떠올라서 그거 사용했다.

pub trait Load: swc_common::sync::Send + swc_common::sync::Sync {
    fn load(&self, file: &FileName) -> Result<(Lrc<SourceFile>, Module), Error>;
}

pub trait Resolve: swc_common::sync::Send + swc_common::sync::Sync {
    fn resolve(&self, base: &FileName, module_specifier: &str) -> Result<FileName, Error>;
}

trait 정의가 살짝 바뀌었다.

참고로 가짜 Send, Sync의 경우 모든 타입에 구현된 trait이다. 기능은 아무 것도 없다. trait 제약 조건에 들어간다는 점을 제외하면 쓸모가 아예 없다.

cyclic import 지원

파일 로딩

기존에 spack의 설계가 약간 잘못되어있었다. import, export를 분석한 뒤 모아서 바로 의존성을 처리했는데, 이렇게 하면 cyclic import를 지원할 때 문제가 생긴다. 당연히 스택 오버플로우가 발생했고, 처음엔 같은 파일 로드하는 걸 감지해서 스택 오버플로우를 막는 방식으로 하려고 했다.

근데 이러면 a -> b -> a에서 두번째 화살표가 날아가기 때문에 interior mutability가 필요해진다. 삽질을 하고 있을 때 좋은 생각이 떠올랐다.

a -> b -> a에서 첫 a를 로드할 때 분석까지만 한 뒤, 결과물을 저장하는 것이다. 그 다움에 b를 로드해서 분석하게 되면 이 문제가 깔끔하게 해결된다. 왜냐하면 이렇게 할 경우 두번째 화살표를 처리하는 시점에 a에 대한 정보가 이미 완전한 상태로 캐시에 존재하기 때문이다. 그러면 캐시를 수정하지 않아도 된다. Interior mutablilty가 필요 없어진 것이다.

hygiene 문제

Span hygiene는 러스트에 있는 macro hygiene에서 아이디어를 얻어서 내가 새로 만든 개념이다. 그래서 레퍼런스가 없다.

모듈별로 Mark를 하나씩 할당하고 각 모듈의 top-level id를 해당 마커로 마킹하는 방법으로 해결했다. 이러면

a1.js:

class A {}

a2.js:

class A {}

이 두 파일을 함수나 그런 걸로 감싸지 않고도 병합하는 괴랄한 짓이 가능해진다.

Reordering

a.js:

import { B } from "./b";
class A {
  b() {
    return new B();
  }
}

b.js:

import { A } from "./a";
class B extends A {}

이 경우를 지원하려면 A가 먼저 선언되어야 한다. 이는 import statement의 순서와 상관이 없어야 하기에 좀 복잡한 코드가 필요했다. 사실 그래프를 사용하면 단순하게 해결될 문제였지만, 성능을 위한다는 미명 하에 이상한 짓을 해서 어찌어찌 벡터와 visitor로 해결했다.

fn merge_respecting_order(mut entry: Vec<ModuleItem>, mut dep: Vec<ModuleItem>) -> Vec<ModuleItem> {
    let mut new = Vec::with_capacity(entry.len() + dep.len());

    loop {
        if entry.is_empty() {
            // 처리 끝
            break;
        }
        let item = entry.drain(..=0).next().unwrap();

        // 의존성 파일 처리 끝
        if dep.is_empty() {
            new.push(item);
            new.extend(entry);
            break;
        }

        // entry <- dep 의존성 검사
        if let Some(pos) = dependency_index(&item, &dep) {
            // drain은 성능을 위한 것이다. remove를 쓰면 벡터에 남은 값들을 shift하기 때문에 느리다.
            new.extend(dep.drain(..=pos));
            new.push(item);
            continue;
        }

        // dep <- entry[0] 의존성 검사
        if let Some(pos) = dependency_index(&dep[0], &[item.clone()]) {
            new.extend(entry.drain(..=pos));
            new.extend(dep.drain(..=0));
            continue;
        }

        // dep <- entry[1..] 의존성 검사
        if let Some(pos) = dependency_index(&dep[0], &entry) {
            new.extend(entry.drain(..=pos));
            new.extend(dep.drain(..=0));
            continue;
        }

        // 의존성 없는 경우 처리
        new.push(item);
    }

    // 남은 파일 마저 이어붙이기
    new.extend(dep);

    new
}

GC가 없고, 러스트에서의 Vec<T>는 한번 값을 빼온 뒤 앞에다 끼워넣는 건 매우 느리다. 그래서 if let을 두 번 사용했다.

spack 복구

.swcrc를 처리하고 트랜스파일링을 해야 하므로 위의 Load trait에서 swc를 처리하게 만들었다. 이건 딱히 설명할 것도 없을 정도로 단순한 작업이었다.

tree-shaking 버그 제거

dce(dead code elimination)에 여러가지 버그가 있었다. 근데 이미 dce의 체계는 잡힌 상태라서 버그 잡는게 어렵지는 않았다.

파서 버그 제거

파서가 이상했다. 분명히 파서 폴더에서 테스트 돌리면 작동하고 cargo의 예제 파일 써도 잘만 작동하는데 이상하게 번들러에서 쓰면 에러가 터졌다.

알고 보니 로깅 레벨이 trace인 경우에 터지는 코드가 존재했다. 이것 때문에 꽤나 삽질을 했다. 로그 레벨이 조건일 수도 있다는 생각을 못 해서...

끝나고...

이번 건 중간에 삽질을 해서 그런지 시간이 적지 않게 들었다. 그래도 어떻게 구현할지 고민이던 cyclic import를 구현해서 기분이 좋다.