Skip to content

Commit 4ec6876

Browse files
committed
perf: a bit faster + restore NaN behavior if absent)
Signed-off-by: Tuan Anh Tran <me@tuananh.org>
1 parent 7ec610d commit 4ec6876

11 files changed

Lines changed: 326 additions & 101 deletions

File tree

benchmark/transform.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict'
22

33
const fs = require('fs')
4-
const { transform } = require('..')
4+
const { transform, destroy } = require('..')
55
const { XMLParser } = require('fast-xml-parser')
66
const xml2js = require('xml2js')
77
const xmljs = require('xml-js')
@@ -64,6 +64,7 @@ const template = {
6464
})
6565

6666
await run()
67+
await destroy()
6768
})().catch((err) => {
6869
console.error(err)
6970
process.exitCode = 1

dist/camaro.js

Lines changed: 32 additions & 32 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/camaro.wasm

-376 Bytes
Binary file not shown.

index.js

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,39 @@ if (NODE_MAJOR_VERSION < 12 || process.env.CAMARO_FORCE_SINGLE_THREAD === 'true'
88
pool = {
99
run(task, opts) {
1010
void opts
11-
return workerFn(task)
11+
return workerFn(task).then(parseCamaroJson)
1212
},
13+
destroy() {},
1314
}
1415
} else {
15-
const WorkerPool = require('piscina')
16-
const piscina = new WorkerPool({ filename: resolve(__dirname, 'worker.js') })
16+
const { LeanPool } = require('./lean-pool')
17+
const { parseCamaroJson } = require('./json-parse')
18+
const leanPool = new LeanPool(resolve(__dirname, 'pool-worker.js'))
1719
pool = {
1820
run(task, opts) {
19-
if (opts && opts.transferList && opts.transferList.length > 0) {
20-
return piscina.run(task, { transferList: opts.transferList })
21-
}
22-
return piscina.run(task)
21+
return leanPool.run(task, opts).then(parseCamaroJson)
22+
},
23+
destroy() {
24+
return leanPool.destroy()
2325
},
2426
}
2527
}
2628

2729
/** Wasm Embind Utf-16 string → std::string is slower than Utf-8 bytes + malloc; reuse the Utf-8 path. */
2830
const textEncoderUtf8 =
2931
typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
32+
let xmlUtf8Scratch = null
3033

3134
function utf8BytesFromJsString(xml) {
32-
return textEncoderUtf8
33-
? textEncoderUtf8.encode(xml)
34-
: Buffer.from(xml, 'utf8')
35-
}
36-
37-
function canTransferUnderlyingBuffer(view) {
38-
if (!(view instanceof Uint8Array) || view.byteLength === 0) return false
39-
return view.byteOffset === 0 && view.byteLength === view.buffer.byteLength
35+
if (!textEncoderUtf8) {
36+
return Buffer.from(xml, 'utf8')
37+
}
38+
const worstCase = xml.length * 3
39+
if (!xmlUtf8Scratch || xmlUtf8Scratch.length < worstCase) {
40+
xmlUtf8Scratch = new Uint8Array(Math.max(worstCase, 65536))
41+
}
42+
const { written } = textEncoderUtf8.encodeInto(xml, xmlUtf8Scratch)
43+
return xmlUtf8Scratch.subarray(0, written)
4044
}
4145

4246
/**
@@ -45,16 +49,10 @@ function canTransferUnderlyingBuffer(view) {
4549
*/
4650
function xmlPayloadForWorkerThread(xml) {
4751
if (typeof xml === 'string') {
48-
const u8 = utf8BytesFromJsString(xml)
49-
if (
50-
NODE_MAJOR_VERSION >= 12 &&
51-
process.env.CAMARO_FORCE_SINGLE_THREAD !== 'true' &&
52-
canTransferUnderlyingBuffer(u8)
53-
) {
54-
return { xmlWire: u8, poolOpts: { transferList: [u8.buffer] } }
55-
}
56-
return { xmlWire: u8 }
52+
// Reuse scratch + structured clone; transfer detaches and forces re-allocation.
53+
return { xmlWire: utf8BytesFromJsString(xml) }
5754
}
55+
// User-supplied binaries: structured clone only (transfer would detach caller's buffer).
5856
return { xmlWire: xml }
5957
}
6058

@@ -98,6 +96,17 @@ function isEmptyObject(obj) {
9896
return Object.entries(obj).length === 0 && obj.constructor === Object
9997
}
10098

99+
const templateStringCache = new WeakMap()
100+
101+
function templateString(template) {
102+
let cached = templateStringCache.get(template)
103+
if (cached === undefined) {
104+
cached = JSON.stringify(template)
105+
templateStringCache.set(template, cached)
106+
}
107+
return cached
108+
}
109+
101110
/**
102111
* convert xml to json base on the template object
103112
* @param {string|Buffer|Uint8Array|ArrayBuffer} xml xml as UTF-8 string or raw bytes
@@ -115,7 +124,7 @@ function transform(xml, template) {
115124
return dispatchPool(
116125
{
117126
fn: 'transform',
118-
args: [payload.xmlWire, JSON.stringify(template)],
127+
args: [payload.xmlWire, templateString(template)],
119128
},
120129
payload.poolOpts,
121130
)

json-parse.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use strict'
2+
3+
function parseCamaroJson(payload) {
4+
if (typeof payload === 'string') {
5+
const first = payload[0]
6+
if (first === '{' || first === '[') return JSON.parse(payload)
7+
return payload
8+
}
9+
if (payload && typeof payload === 'object' && typeof payload.json === 'string') {
10+
return JSON.parse(payload.json, (_, v) => (v === '__camaro_nan__' ? NaN : v))
11+
}
12+
return payload
13+
}
14+
15+
module.exports = { parseCamaroJson }

lean-pool.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
'use strict'
2+
3+
const { Worker } = require('worker_threads')
4+
5+
function idleTimeoutMs() {
6+
const raw = process.env.CAMARO_IDLE_TIMEOUT
7+
if (raw === 'Infinity') return Infinity
8+
if (raw !== undefined && raw !== '') {
9+
const n = Number(raw)
10+
if (Number.isFinite(n) && n >= 0) return n
11+
}
12+
// Match piscina default: shut down idle workers immediately so scripts can exit.
13+
return 0
14+
}
15+
16+
class LeanWorker {
17+
constructor(filename, idleTimeout) {
18+
this.filename = filename
19+
this.idleTimeout = idleTimeout
20+
this.worker = null
21+
this.seq = 0
22+
this.pending = new Map()
23+
this.idleTimer = null
24+
}
25+
26+
ensureWorker() {
27+
if (this.worker) return
28+
const worker = new Worker(this.filename)
29+
worker.unref()
30+
worker.on('message', ({ id, result, error }) => {
31+
const p = this.pending.get(id)
32+
this.pending.delete(id)
33+
if (!p) return
34+
if (error) p.reject(new Error(error))
35+
else p.resolve(result)
36+
if (this.pending.size === 0) this.scheduleIdleShutdown()
37+
})
38+
worker.on('error', (err) => {
39+
for (const p of this.pending.values()) p.reject(err)
40+
this.pending.clear()
41+
this.terminate()
42+
})
43+
worker.on('exit', () => {
44+
if (this.worker === worker) this.worker = null
45+
})
46+
this.worker = worker
47+
}
48+
49+
clearIdleTimer() {
50+
if (this.idleTimer !== null) {
51+
clearTimeout(this.idleTimer)
52+
this.idleTimer = null
53+
}
54+
}
55+
56+
scheduleIdleShutdown() {
57+
this.clearIdleTimer()
58+
if (this.idleTimeout === Infinity || !this.worker) return
59+
this.idleTimer = setTimeout(() => {
60+
this.idleTimer = null
61+
if (this.pending.size === 0) this.terminate()
62+
}, this.idleTimeout)
63+
this.idleTimer.unref()
64+
}
65+
66+
run(task, opts = {}) {
67+
this.ensureWorker()
68+
this.clearIdleTimer()
69+
const id = ++this.seq
70+
return new Promise((resolve, reject) => {
71+
this.pending.set(id, { resolve, reject })
72+
this.worker.postMessage({ id, task }, opts.transferList || [])
73+
})
74+
}
75+
76+
terminate() {
77+
this.clearIdleTimer()
78+
if (!this.worker) return Promise.resolve()
79+
const w = this.worker
80+
this.worker = null
81+
return w.terminate()
82+
}
83+
84+
destroy() {
85+
return this.terminate()
86+
}
87+
}
88+
89+
class LeanPool {
90+
constructor(filename, size) {
91+
const envSize = Number(process.env.CAMARO_POOL_SIZE || 0)
92+
const poolSize = size || (envSize > 0 ? envSize : 1)
93+
const idleTimeout = idleTimeoutMs()
94+
this.workers = Array.from(
95+
{ length: poolSize },
96+
() => new LeanWorker(filename, idleTimeout),
97+
)
98+
this.next = 0
99+
}
100+
101+
run(task, opts) {
102+
const worker = this.workers[this.next++ % this.workers.length]
103+
return worker.run(task, opts)
104+
}
105+
106+
destroy() {
107+
return Promise.all(this.workers.map((w) => w.destroy()))
108+
}
109+
}
110+
111+
module.exports = { LeanPool }

package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,5 @@
6969
"xml-js": "^1.6.11",
7070
"xml2js": "^0.6.0"
7171
},
72-
"dependencies": {
73-
"piscina": "^5.2.0"
74-
}
72+
"dependencies": {}
7573
}

pool-worker.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use strict'
2+
3+
const { parentPort } = require('worker_threads')
4+
const workerFn = require('./worker')
5+
6+
parentPort.on('message', async ({ id, task }) => {
7+
try {
8+
const result = await workerFn(task)
9+
parentPort.postMessage({ id, result })
10+
} catch (err) {
11+
parentPort.postMessage({
12+
id,
13+
error: err && err.message ? err.message : String(err),
14+
})
15+
}
16+
})

0 commit comments

Comments
 (0)