Binaryen으로 Wasm 컴파일 및 최적화

Binaryen은 C++로 작성된 WebAssembly용 컴파일러 및 도구 모음 인프라 라이브러리로, WebAssembly에 대한 컴파일을 직관적이고 빠르며 효과적이도록 만드는 것을 목표로 합니다. 이 게시물에서는 ExampleScript라는 합성 장난감 언어의 예를 사용하여 Binaryen.js API를 사용하여 자바스크립트에서 WebAssembly 모듈을 작성하는 방법을 알아봅니다. 모듈 생성, 모듈에 함수 추가, 모듈에서 함수 내보내기의 기본사항을 다룹니다. 실제 프로그래밍 언어를 WebAssembly로 컴파일하는 전반적인 메커니즘에 관한 지식을 얻을 수 있습니다. 또한 Binaryen.js와 명령줄에서 wasm-opt을 사용하여 Wasm 모듈을 최적화하는 방법을 알아봅니다.

Binaryen의 배경

Binaryen은 단일 헤더에 직관적인 C API를 사용하며 JavaScript에서 사용할 수도 있습니다. WebAssembly 형식의 입력을 허용하지만, 이 방식을 선호하는 컴파일러의 경우 일반적인 제어 흐름 그래프도 허용합니다.

중간 표현 (IR)은 컴파일러 또는 가상 머신이 소스 코드를 나타내기 위해 내부적으로 사용하는 데이터 구조 또는 코드입니다. Binaryen의 내부 IR은 압축 데이터 구조를 사용하며 사용 가능한 모든 CPU 코어를 사용하여 완전 병렬 코드 생성 및 최적화를 위해 설계되었습니다. Binaryen의 IR은 WebAssembly의 하위 집합이므로 WebAssembly로 컴파일됩니다.

Binaryen의 옵티마이저에는 코드 크기와 속도를 개선할 수 있는 패스가 많이 있습니다. 이러한 최적화의 목표는 Binaryen을 그 자체로도 컴파일러 백엔드로 사용할 수 있을 만큼 강력하게 만드는 것입니다. 여기에는 범용 컴파일러에서 지원하지 않을 수 있는 WebAssembly 관련 최적화가 포함되어 있으며 이는 Wasm 축소로 생각할 수 있습니다.

AssemblyScript(예: Binaryen) 사용자

Binaryen은 여러 프로젝트에서 사용되고 있습니다(예: Binaryen을 사용하여 TypeScript와 유사한 언어에서 WebAssembly로 직접 컴파일하는 AssemblyScript). AssemblyScript 플레이그라운드의 예를 사용해 보세요.

AssemblyScript 입력:

export function add(a: i32, b: i32): i32 {
  return a + b;
}

Binaryen에서 생성된 텍스트 형식의 해당 WebAssembly 코드:

(module
 (type $0 (func (param i32 i32) (result i32)))
 (memory $0 0)
 (export "add" (func $module/add))
 (export "memory" (memory $0))
 (func $module/add (param $0 i32) (param $1 i32) (result i32)
  local.get $0
  local.get $1
  i32.add
 )
)

이전 예를 기반으로 생성된 WebAssembly 코드를 보여주는 AssemblyScript 플레이그라운드

Binaryen 도구 모음

Binaryen 도구 모음은 JavaScript 개발자와 명령줄 사용자 모두에게 여러 가지 유용한 도구를 제공합니다. 다음은 이러한 도구 중 일부입니다. 포함된 도구의 전체 목록은 프로젝트의 README 파일에서 확인할 수 있습니다.

  • binaryen.js: Wasm 모듈 생성 및 최적화를 위한 Binaryen 메서드를 노출하는 독립형 자바스크립트 라이브러리입니다. 빌드의 경우 npm에서 binaryen.js를 참조하거나 GitHub 또는 unpkg에서 직접 다운로드하세요.
  • wasm-opt: WebAssembly를 로드하고 Binaryen IR 패스를 실행하는 명령줄 도구입니다.
  • wasm-aswasm-dis: WebAssembly를 조합하고 분해하는 명령줄 도구입니다.
  • wasm-ctor-eval: 컴파일 시간에 함수 (또는 함수의 일부)를 실행할 수 있는 명령줄 도구입니다.
  • wasm-metadce: 모듈 사용 방식에 따라 유연한 방식으로 Wasm 파일의 일부를 삭제하는 명령줄 도구입니다.
  • wasm-merge: 여러 Wasm 파일을 단일 파일로 병합하여 상응하는 가져오기를 내보내기에 연결하는 명령줄 도구입니다. JavaScript용 번들러와 비슷하지만 Wasm용 번들러와 같습니다.

WebAssembly로 컴파일

한 언어를 다른 언어로 컴파일하는 작업은 일반적으로 여러 단계를 거치며, 가장 중요한 단계는 다음 목록에 나와 있습니다.

  • 어휘 분석: 소스 코드를 토큰으로 나눕니다.
  • 구문 분석: 추상 구문 트리를 만듭니다.
  • 시맨틱 분석: 오류를 확인하고 언어 규칙을 적용합니다.
  • 중간 코드 생성: 좀 더 추상적인 표현을 만듭니다.
  • 코드 생성: 도착어로 번역합니다.
  • 타겟별 코드 최적화: 타겟에 맞게 최적화합니다.

Unix 환경에서 컴파일에 자주 사용되는 도구는 lexyacc입니다.

  • lex(어휘 분석기 생성기): lex는 어휘 분석기(어휘 분석기 또는 스캐너라고도 함)를 생성하는 도구입니다. 일련의 정규 표현식 및 상응하는 작업을 입력으로 사용하고 입력 소스 코드의 패턴을 인식하는 어휘 분석기용 코드를 생성합니다.
  • yacc (Yet Another Compiler Compiler): yacc는 구문 분석을 위한 파서를 생성하는 도구입니다. 이는 프로그래밍 언어의 공식 문법 설명을 입력으로 사용하여 파서용 코드를 생성합니다. 파서는 일반적으로 소스 코드의 계층 구조를 나타내는 추상 구문 트리(AST)를 생성합니다.

실제 사례

이 게시물의 범위를 고려할 때 전체 프로그래밍 언어를 다룰 수는 없습니다. 편의상 구체적인 예를 통해 일반 연산을 표현함으로써 작동하는 매우 제한적이고 쓸모없는 합성 프로그래밍 언어인 ExampleScript를 고려해 보세요.

  • add() 함수를 작성하려면 모든 덧셈의 예를 코딩합니다(예: 2 + 3).
  • multiply() 함수를 작성하려면 예를 들어 6 * 12를 작성합니다.

사전 경고에 따르면 전혀 쓸모가 없지만 어휘 분석기가 단일 정규 표현식(/\d+\s*[\+\-\*\/]\s*\d+\s*/)이 될 만큼 간단합니다.

다음으로 파서가 있어야 합니다. 실제로 매우 간소화된 추상 구문 트리 버전은 이름이 지정된 캡처 그룹(/(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/)과 함께 정규 표현식을 사용하여 만들 수 있습니다.

ExampleScript 명령어는 한 줄에 하나씩 실행되므로 파서가 줄바꿈 문자로 분할하여 줄 단위로 코드를 처리할 수 있습니다. 이것으로 앞의 글머리기호 목록에서 처음 세 단계인 어휘 분석, 구문 분석, 시맨틱 분석만 확인하면 됩니다. 이러한 단계의 코드는 다음 목록에 있습니다.

export default class Parser {
  parse(input) {
    input = input.split(/\n/);
    if (!input.every((line) => /\d+\s*[\+\-\*\/]\s*\d+\s*/gm.test(line))) {
      throw new Error('Parse error');
    }

    return input.map((line) => {
      const { groups } =
        /(?<first_operand>\d+)\s*(?<operator>[\+\-\*\/])\s*(?<second_operand>\d+)/gm.exec(
          line,
        );
      return {
        firstOperand: Number(groups.first_operand),
        operator: groups.operator,
        secondOperand: Number(groups.second_operand),
      };
    });
  }
}

중간 코드 생성

이제 ExampleScript 프로그램을 추상 구문 트리로 표현할 수 있으므로(상당히 간소화되었지만) 다음 단계는 추상적인 중간 표현을 만드는 것입니다. 첫 번째 단계는 Binaryen에서 새 모듈을 생성하는 것입니다.

const module = new binaryen.Module();

추상 구문 트리의 각 행에는 firstOperand, operator, secondOperand로 구성된 3가지가 포함됩니다. ExampleScript에서 가능한 네 가지 연산자(+, -, *, /) 각각에 대해 Binaryen의 Module#addFunction() 메서드를 사용하여 모듈에 새 함수를 추가해야 합니다. Module#addFunction() 메서드의 매개변수는 다음과 같습니다.

  • name: string로, 함수 이름을 나타냅니다.
  • functionType: Signature로, 함수의 서명을 나타냅니다.
  • varTypes: Type[]이며 지정된 순서대로 추가 로컬을 나타냅니다.
  • body: Expression이며 함수의 콘텐츠입니다.

풀어야 할 추가 세부정보가 있으며 Binaryen 문서가 공간을 탐색하는 데 도움이 될 수 있지만 결국 ExampleScript의 + 연산자의 경우 사용 가능한 여러 정수 작업 중 하나로 Module#i32.add() 메서드가 있게 됩니다. 덧셈을 하려면 두 개의 피연산자(첫 번째와 두 번째 합계)가 필요합니다. 함수가 실제로 호출 가능하려면 Module#addFunctionExport()를 사용하여 내보내야 합니다.

module.addFunction(
  'add', // name: string
  binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
  binaryen.i32, // results: Type
  [binaryen.i32], // vars: Type[]
  //  body: ExpressionRef
  module.block(null, [
    module.local.set(
      2,
      module.i32.add(
        module.local.get(0, binaryen.i32),
        module.local.get(1, binaryen.i32),
      ),
    ),
    module.return(module.local.get(2, binaryen.i32)),
  ]),
);
module.addFunctionExport('add', 'add');

추상 구문 트리를 처리한 후 모듈에는 4개의 메서드가 포함됩니다. 세 가지 메서드는 정수로 작동합니다. 즉, Module#i32.add()를 기반으로 하는 add(), Module#i32.sub()를 기반으로 하는 subtract(), Module#i32.mul()를 기반으로 하는 multiply(), Module#f64.div()를 기반으로 하는 이상점 divide()입니다. ExampleScript도 부동 소수점 결과와 함께 작동하기 때문입니다.

for (const line of parsed) {
      const { firstOperand, operator, secondOperand } = line;

      if (operator === '+') {
        module.addFunction(
          'add', // name: string
          binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
          binaryen.i32, // results: Type
          [binaryen.i32], // vars: Type[]
          //  body: ExpressionRef
          module.block(null, [
            module.local.set(
              2,
              module.i32.add(
                module.local.get(0, binaryen.i32),
                module.local.get(1, binaryen.i32)
              )
            ),
            module.return(module.local.get(2, binaryen.i32)),
          ])
        );
        module.addFunctionExport('add', 'add');
      } else if (operator === '-') {
        module.subtractFunction(
          // Skipped for brevity.
        )
      } else if (operator === '*') {
          // Skipped for brevity.
      }
      // And so on for all other operators, namely `-`, `*`, and `/`.

실제 코드베이스를 다룰 경우 간혹 호출되지 않는 데드 코드가 있을 수 있습니다. 실행 중인 ExampleScript 컴파일의 Wasm에서 데드 코드를 인위적으로 삽입하려면 (나중 단계에서 최적화되고 제거될 예정임) 내보내지 않은 함수를 추가하면 됩니다.

// This function is added, but not exported,
// so it's effectively dead code.
module.addFunction(
  'deadcode', // name: string
  binaryen.createType([binaryen.i32, binaryen.i32]), // params: Type
  binaryen.i32, // results: Type
  [binaryen.i32], // vars: Type[]
  //  body: ExpressionRef
  module.block(null, [
    module.local.set(
      2,
      module.i32.div_u(
        module.local.get(0, binaryen.i32),
        module.local.get(1, binaryen.i32),
      ),
    ),
    module.return(module.local.get(2, binaryen.i32)),
  ]),
);

이제 컴파일러가 거의 준비되었습니다. 반드시 필요한 것은 아니지만 Module#validate() 메서드를 사용하여 모듈을 검증하는 것이 좋습니다.

if (!module.validate()) {
  throw new Error('Validation error');
}

결과 Wasm 코드 얻기

결과 Wasm 코드를 얻기 위해 Binaryen에는 S-표현식.wat 파일로 텍스트 표현을 사람이 읽을 수 있는 형식으로 가져오고 바이너리 표현을 브라우저에서 직접 실행할 수 있는 .wasm 파일로 가져오는 두 가지 메서드가 있습니다. 바이너리 코드는 브라우저에서 직접 실행할 수 있습니다. 제대로 작동하는지 확인하려면 내보내기를 로깅하면 됩니다.

const textData = module.emitText();
console.log(textData);

const wasmData = module.emitBinary();
const compiled = new WebAssembly.Module(wasmData);
const instance = new WebAssembly.Instance(compiled, {});
console.log('Wasm exports:\n', instance.exports);

네 가지 작업이 모두 포함된 ExampleScript 프로그램의 전체 텍스트 표현은 다음과 같습니다. 데드 코드가 여전히 존재하지만 WebAssembly.Module.exports()의 스크린샷에 따라 노출되지는 않습니다.

(module
 (type $0 (func (param i32 i32) (result i32)))
 (type $1 (func (param f64 f64) (result f64)))
 (export "add" (func $add))
 (export "subtract" (func $subtract))
 (export "multiply" (func $multiply))
 (export "divide" (func $divide))
 (func $add (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.add
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $subtract (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.sub
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $multiply (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.mul
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $divide (param $0 f64) (param $1 f64) (result f64)
  (local $2 f64)
  (local.set $2
   (f64.div
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
 (func $deadcode (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local.set $2
   (i32.div_u
    (local.get $0)
    (local.get $1)
   )
  )
  (return
   (local.get $2)
  )
 )
)

더하기, 나누기, 곱하기, 빼기 (노출되지 않은 데드 코드는 아님)의 네 가지 함수를 보여주는 WebAssembly 모듈 내보내기의 DevTools 콘솔 스크린샷

WebAssembly 최적화

Binaryen은 Wasm 코드를 최적화하는 두 가지 방법을 제공합니다. 하나는 Binaryen.js 자체에, 하나는 명령줄용입니다. 전자는 기본적으로 표준 최적화 규칙 세트를 적용하고 최적화 및 축소 수준을 설정할 수 있도록 합니다. 후자는 기본적으로 규칙을 사용하지 않지만 전체 맞춤설정이 가능하므로 충분한 실험을 통해 코드를 기반으로 최적의 결과를 얻을 수 있도록 설정을 조정할 수 있습니다.

Binaryen.js로 최적화

Binaryen으로 Wasm 모듈을 최적화하는 가장 간단한 방법은 Binaryen.js의 Module#optimize() 메서드를 직접 호출하고 선택적으로 최적화 및 축소 수준을 설정하는 것입니다.

// Assume the `wast` variable contains a Wasm program.
const module = binaryen.parseText(wast);
binaryen.setOptimizeLevel(2);
binaryen.setShrinkLevel(1);
// This corresponds to the `-Os` setting.
module.optimize();

이렇게 하면 이전에 인위적으로 도입된 불량 코드가 삭제되므로 ExampleScript 장난감 예의 Wasm 버전 텍스트 표현에는 더 이상 이 코드가 포함되지 않습니다. 최적화 단계인 SimplifyLocals(기타 로컬 관련 최적화) 및 Vacuum(분명히 불필요한 코드 삭제)에서 local.set/get 쌍이 삭제되고, RemoveUnusedBrs에서 return(필요하지 않은 위치의 중단 삭제)에 의해 삭제되는 방식을 참고하세요.

 (module
 (type $0 (func (param i32 i32) (result i32)))
 (type $1 (func (param f64 f64) (result f64)))
 (export "add" (func $add))
 (export "subtract" (func $subtract))
 (export "multiply" (func $multiply))
 (export "divide" (func $divide))
 (func $add (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
  (i32.add
   (local.get $0)
   (local.get $1)
  )
 )
 (func $subtract (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
  (i32.sub
   (local.get $0)
   (local.get $1)
  )
 )
 (func $multiply (; has Stack IR ;) (param $0 i32) (param $1 i32) (result i32)
  (i32.mul
   (local.get $0)
   (local.get $1)
  )
 )
 (func $divide (; has Stack IR ;) (param $0 f64) (param $1 f64) (result f64)
  (f64.div
   (local.get $0)
   (local.get $1)
  )
 )
)

최적화 패스는 많으며, Module#optimize()는 특정 최적화 및 축소 수준의 기본 세트를 사용합니다. 전체 맞춤설정을 위해서는 명령줄 도구 wasm-opt을(를) 사용해야 합니다.

wasm-opt 명령줄 도구를 사용한 최적화

사용할 패스의 전체 맞춤설정을 위해 Binaryen에는 wasm-opt 명령줄 도구가 포함되어 있습니다. 사용 가능한 최적화 옵션의 전체 목록을 보려면 도구의 도움말 메시지를 확인하세요. wasm-opt 도구는 아마도 가장 인기 있는 도구이며 Emscripten, J2CL, Kotlin/Wasm, dart2wasm, Wasm-pack 등 Wasm 코드를 최적화하기 위해 여러 컴파일러 도구 모음에서 사용됩니다.

wasm-opt --help

패스에 대한 이해를 돕기 위해 전문 지식이 없어도 이해할 수 있는 패스가 다음과 같이 발췌되어 있습니다.

  • CodeFolding: 두 개의 if 부문 끝에 공유된 명령이 있는 경우와 같이 코드를 병합하여 중복 코드를 방지합니다.
  • DeadArgumentElimination: 함수가 항상 동일한 상수로 호출되는 경우 시간 최적화 패스를 연결하여 함수의 인수를 삭제합니다.
  • MinifyImportsAndExports: "a", "b"로 축소합니다.
  • DeadCodeElimination: 데드 코드를 삭제합니다.

다양한 플래그 중 어느 것이 더 중요하고 먼저 시도해 볼 가치가 있는지 식별하는 데 도움이 되는 여러 팁이 포함된 최적화 설명서가 있습니다. 예를 들어 wasm-opt를 반복적으로 실행하면 입력이 더 축소되는 경우가 있습니다. 이러한 경우 --converge 플래그를 사용하여 실행하면 추가 최적화가 발생하지 않고 고정 지점에 도달할 때까지 반복이 유지됩니다.

데모

이 게시물에서 소개한 개념을 실제로 사용해 보려면 삽입된 데모를 사용해 보고 생각나는 ExampleScript를 입력해 보세요. 또한 데모의 소스 코드도 확인하세요.

결론

Binaryen은 언어를 WebAssembly에 컴파일하고 결과 코드를 최적화하는 강력한 도구를 제공합니다. JavaScript 라이브러리와 명령줄 도구로 유연성과 사용 편의성을 높일 수 있습니다 이 게시물에서는 Binaryen의 효과와 최대 최적화 가능성을 강조한 Wasm 컴파일의 핵심 원칙을 시연했습니다. Binaryen의 최적화를 맞춤설정하는 옵션에는 대부분 Wasm의 내부 기능에 관한 심도 있는 지식이 필요하지만, 보통 기본 설정이 이미 잘 작동합니다. 이제 Binaryen으로 컴파일하고 최적화하시기 바랍니다.

감사의 말

이 게시물을 리뷰한 사람은 알론 자카이, 토마스 라이블리, 레이첼 앤드류입니다.