Skip to content

Commit

Permalink
feat: add async node binding and proper benchmark
Browse files Browse the repository at this point in the history
  • Loading branch information
phoenix-ru committed Nov 14, 2023
1 parent 753e451 commit 956849b
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 13 deletions.
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,33 @@ Please note that "correctness" of output will depend on the version of Vue, as V
## Is it fast?
Yes, it is incredibly fast. In fact, below are the parsing/compilation times benchmarked for a [test component](crates/fervid/benches/fixtures/input.vue).

```
@vue/compiler-sfc:
1 644 ops/s, ±0.78% | slowest, 97.2% slower
@fervid/napi sync:
6 240 ops/s, ±0.44% | 89.36% slower
@fervid/napi async (4 threads):
12 856 ops/s, ±1.53% | 78.08% slower
@fervid/napi async CPUS (23 threads):
58 650 ops/s, ±1.25% | fastest
```

<!--
| Action | Mean time |
|----------------------------|--------------|
| Parsing | 5.58µs |
| Code generation: CSR + DEV | 16.26µs |
| Code generation: CSR + DEV | 16.26µs | -->

> Note: results are for AMD Ryzen 9 5900HX running on Fedora 37 with kernel version 6.1.6
> Note: results are for AMD Ryzen 9 7900X running on Fedora 38 with kernel version 6.5.9
Micro-benchmarking has been done using Criterion, code for benchmarks can be found in `benches` directory.
<!-- Micro-benchmarking has been done using Criterion, code for benchmarks can be found in `benches` directory. -->
Benchmarking in Node.js has been done using [`benny`](https://github.com/caderek/benny), slightly modified to take `libuv` threads into consideration.
[Source code for a benchmark](crates/fervid_napi/benchmark/bench.ts).

Actual benchmarking is a TODO and has much lower priority compared to feature-completeness and usability in real-world scenarios, so **Pull Requests are welcome**.
Better benchmarking is a TODO and has a lower priority compared to feature-completeness and usability in real-world scenarios, so **Pull Requests are welcome**.

## Crates

Expand Down
92 changes: 86 additions & 6 deletions crates/fervid_napi/benchmark/bench.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import b from 'benny'

import { compileSync } from '../index'
import { compileTemplate } from '@vue/compiler-sfc'
import format from 'benny/lib/internal/format'
import type { CaseResultWithDiff, Summary } from 'benny/lib/internal/common-types'
import kleur from 'kleur'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { cpus } from 'node:os'
import { compileTemplate } from '@vue/compiler-sfc'

import { compileAsync, compileSync } from '../index'

// Increase libuv thread pool for a better async result
const CPUS = cpus().length - 1
process.env.UV_THREADPOOL_SIZE = CPUS.toString()

const input = readFileSync(join(__dirname, '../../fervid/benches/fixtures/input.vue'), {
encoding: 'utf-8',
Expand All @@ -21,15 +29,87 @@ async function run() {
})
}),

b.add('@fervid/napi', () => {
b.add('@fervid/napi sync', () => {
compileSync(input)
}),

b.cycle(),
b.complete(),
b.add('@fervid/napi async (4 threads)', () => {
return Promise.allSettled(Array.from({ length: 4 }, _ => compileAsync(input)))
}),

b.add(`@fervid/napi async CPUS (${CPUS} threads)`, () => {
return Promise.allSettled(Array.from({ length: CPUS }, _ => compileAsync(input)))
}),

// Custom cycle function to account for the async nature
// Copied from `benny` and adjusted
b.cycle((_, summary) => {
const allCompleted = summary.results.every((item) => item.samples > 0)
const fastestOps = format(summary.results[summary.fastest.index].ops)
const progress = Math.round(
(summary.results.filter((result) => result.samples !== 0).length / summary.results.length) * 100
)

const progressInfo = `Progress: ${progress}%`

// Compensate for async
if (progress === 100) {
for (const result of summary.results) {
const match = result.name.match(/\((\d+) threads\)/)
if (!match || !match[1] || isNaN(+match[1])) continue

result.ops *= +match[1]
}
}

// Re-map fastest/slowest
const fastest = summary.results.reduce((prev, next, index) => {
return next.ops > prev.ops ? { ops: next.ops, index, name: next.name } : prev
}, { ops: 0, index: 0, name: '' })
const slowest = summary.results.reduce((prev, next, index) => {
return next.ops < prev.ops ? { ops: next.ops, index, name: next.name } : prev
}, { ops: Infinity, index: 0, name: '' })
summary.fastest = fastest
summary.slowest = slowest
summary.results.forEach((result, index) => {
result.percentSlower = index === fastest.index
? 0
: Number(((1 - result.ops / fastest.ops) * 100).toFixed(2))
})

const output = summary.results.map((item, index) => {
const ops = format(item.ops)
const margin = item.margin.toFixed(2)

return item.samples
? kleur.cyan(`\n ${item.name}:\n`) + ` ${ops} ops/s, ±${margin}% ${
allCompleted
? getStatus(item, index, summary, ops, fastestOps)
: ''}`
: null;
})
.filter(item => item != null)
.join('\n')

return `${progressInfo}\n${output}`
}),

b.complete()
)
}

run().catch((e) => {
console.error(e)
})

function getStatus(item: CaseResultWithDiff, index: number, summary: Summary, ops: string, fastestOps: string) {
const isFastest = index === summary.fastest.index
const isSlowest = index === summary.slowest.index
const statusShift = fastestOps.length - ops.length + 2;
return (' '.repeat(statusShift) +
(isFastest
? kleur.green('| fastest')
: isSlowest
? kleur.red(`| slowest, ${item.percentSlower}% slower`)
: kleur.yellow(`| ${item.percentSlower}% slower`)));
}
5 changes: 5 additions & 0 deletions crates/fervid_napi/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ export interface CompileSyncOptions {
isProd: boolean
}
export function compileSync(source: string, options?: CompileSyncOptions | undefined | null): string
export function compileAsync(
source: string,
options?: CompileSyncOptions | undefined | null,
signal?: AbortSignal | undefined | null,
): Promise<unknown>
3 changes: 2 additions & 1 deletion crates/fervid_napi/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}

const { compileSync } = nativeBinding
const { compileSync, compileAsync } = nativeBinding

module.exports.compileSync = compileSync
module.exports.compileAsync = compileAsync
1 change: 1 addition & 0 deletions crates/fervid_napi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "^5.0.1",
"husky": "^8.0.3",
"kleur": "^4.1.5",
"lint-staged": "^14.0.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.0.3",
Expand Down
3 changes: 3 additions & 0 deletions crates/fervid_napi/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 35 additions & 2 deletions crates/fervid_napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,43 @@ use fervid::compile_sync_naive;

#[napi(object)]
pub struct CompileSyncOptions {
pub is_prod: bool
pub is_prod: bool,
}

#[napi]
pub fn compile_sync(source: String, options: Option<CompileSyncOptions>) -> Result<String> {
compile_sync_naive(&source, options.map_or(false, |v| v.is_prod)).map_err(|e| Error::from_reason(e))
compile_sync_naive(&source, options.map_or(false, |v| v.is_prod))
.map_err(|e| Error::from_reason(e))
}

#[napi]
pub fn compile_async(
source: String,
options: Option<CompileSyncOptions>,
signal: Option<AbortSignal>,
) -> napi::Result<AsyncTask<CompileTask>> {
let task = CompileTask {
input: source,
options: options.unwrap_or_else(|| CompileSyncOptions { is_prod: false }),
};
Ok(AsyncTask::with_optional_signal(task, signal))
}

pub struct CompileTask {
input: String,
options: CompileSyncOptions,
}

#[napi]
impl Task for CompileTask {
type JsValue = String;
type Output = String;

fn compute(&mut self) -> napi::Result<Self::Output> {
compile_sync_naive(&self.input, self.options.is_prod).map_err(|e| Error::from_reason(e))
}

fn resolve(&mut self, _env: Env, result: Self::Output) -> napi::Result<Self::JsValue> {
Ok(result)
}
}

0 comments on commit 956849b

Please sign in to comment.