jspurefix 5.8.5 → 5.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +4 -0
- package/BACKPORT_PLAN.md +84 -0
- package/DEMO_PORT_PLAN.md +10 -0
- package/README.md +3 -0
- package/WORKLOG.md +119 -0
- package/dist/benchmark/bench-report.d.ts +5 -0
- package/dist/benchmark/bench-report.js +91 -0
- package/dist/benchmark/bench-report.js.map +1 -0
- package/dist/benchmark/bench-runner.d.ts +12 -0
- package/dist/benchmark/bench-runner.js +193 -0
- package/dist/benchmark/bench-runner.js.map +1 -0
- package/dist/benchmark/bench-stats.d.ts +10 -0
- package/dist/benchmark/bench-stats.js +56 -0
- package/dist/benchmark/bench-stats.js.map +1 -0
- package/dist/benchmark/bench-types.d.ts +74 -0
- package/dist/benchmark/bench-types.js +3 -0
- package/dist/benchmark/bench-types.js.map +1 -0
- package/dist/benchmark/gc-probe.d.ts +18 -0
- package/dist/benchmark/gc-probe.js +103 -0
- package/dist/benchmark/gc-probe.js.map +1 -0
- package/dist/benchmark/index.d.ts +6 -0
- package/dist/benchmark/index.js +23 -0
- package/dist/benchmark/index.js.map +1 -0
- package/dist/benchmark/parse-bench.d.ts +28 -0
- package/dist/benchmark/parse-bench.js +85 -0
- package/dist/benchmark/parse-bench.js.map +1 -0
- package/dist/buffer/ascii/ascii-parser.d.ts +1 -0
- package/dist/buffer/ascii/ascii-parser.js +4 -1
- package/dist/buffer/ascii/ascii-parser.js.map +1 -1
- package/dist/buffer/ascii/ascii-segment-parser.js +3 -2
- package/dist/buffer/ascii/ascii-segment-parser.js.map +1 -1
- package/dist/buffer/ascii/tag-index.js.map +1 -1
- package/dist/buffer/msg-view.d.ts +1 -0
- package/dist/buffer/msg-view.js +25 -19
- package/dist/buffer/msg-view.js.map +1 -1
- package/dist/config/js-fix-config.d.ts +4 -0
- package/dist/config/js-fix-config.js.map +1 -1
- package/dist/dictionary/parser/quickfix/dictionary-validator.d.ts +1 -1
- package/dist/dictionary/parser/quickfix/dictionary-validator.js.map +1 -1
- package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.d.ts +1 -1
- package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.js +1 -2
- package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.js.map +1 -1
- package/dist/dictionary/parser/quickfix/validation-error.d.ts +2 -2
- package/dist/dictionary/parser/quickfix/validation-error.js.map +1 -1
- package/dist/jsfix-bench.d.ts +1 -0
- package/dist/jsfix-bench.js +213 -0
- package/dist/jsfix-bench.js.map +1 -0
- package/dist/runtime/session-launcher.js +1 -1
- package/dist/runtime/session-launcher.js.map +1 -1
- package/dist/runtime/session-scope.d.ts +3 -0
- package/dist/runtime/session-scope.js +47 -0
- package/dist/runtime/session-scope.js.map +1 -0
- package/dist/sample/http/oms/app.js +3 -3
- package/dist/sample/http/oms/app.js.map +1 -1
- package/dist/sample/tcp/qf-md/app.js +3 -3
- package/dist/sample/tcp/qf-md/app.js.map +1 -1
- package/dist/sample/tcp/skeleton/app.js +1 -1
- package/dist/sample/tcp/skeleton/app.js.map +1 -1
- package/dist/sample/tcp/trade-capture/app-launcher.js +3 -3
- package/dist/sample/tcp/trade-capture/app-launcher.js.map +1 -1
- package/dist/store/file-session-store.js +1 -1
- package/dist/store/file-session-store.js.map +1 -1
- package/dist/store/file-session-stream-provider.d.ts +1 -0
- package/dist/store/file-session-stream-provider.js +13 -13
- package/dist/store/file-session-stream-provider.js.map +1 -1
- package/dist/store/fix-session-store-factory.d.ts +1 -1
- package/dist/store/fix-session-store-factory.js.map +1 -1
- package/dist/store/fix-session-store.d.ts +11 -11
- package/dist/store/fix-session-store.js.map +1 -1
- package/dist/store/memory-session-store.js.map +1 -1
- package/dist/store/session-stream-provider.d.ts +13 -13
- package/dist/store/session-stream-provider.js.map +1 -1
- package/dist/transport/ascii/ascii-session.d.ts +17 -3
- package/dist/transport/ascii/ascii-session.js +121 -8
- package/dist/transport/ascii/ascii-session.js.map +1 -1
- package/dist/transport/duplex/fix-duplex.d.ts +1 -0
- package/dist/transport/duplex/fix-duplex.js +3 -0
- package/dist/transport/duplex/fix-duplex.js.map +1 -1
- package/dist/transport/duplex/tcp-duplex.d.ts +1 -0
- package/dist/transport/duplex/tcp-duplex.js +5 -0
- package/dist/transport/duplex/tcp-duplex.js.map +1 -1
- package/dist/transport/factory/msg-transport.d.ts +2 -0
- package/dist/transport/factory/msg-transport.js +17 -2
- package/dist/transport/factory/msg-transport.js.map +1 -1
- package/dist/transport/session/a-session-msg-factory.d.ts +1 -0
- package/dist/transport/session/a-session-msg-factory.js +4 -0
- package/dist/transport/session/a-session-msg-factory.js.map +1 -1
- package/dist/transport/session/clone-session-description.d.ts +6 -0
- package/dist/transport/session/clone-session-description.js +11 -0
- package/dist/transport/session/clone-session-description.js.map +1 -0
- package/dist/transport/session/fix-clock.d.ts +1 -1
- package/dist/transport/session/fix-clock.js.map +1 -1
- package/dist/transport/session/fix-session.d.ts +5 -1
- package/dist/transport/session/fix-session.js +93 -24
- package/dist/transport/session/fix-session.js.map +1 -1
- package/dist/transport/session/index.d.ts +2 -0
- package/dist/transport/session/index.js +2 -0
- package/dist/transport/session/index.js.map +1 -1
- package/dist/transport/session/resend-request-manager.d.ts +2 -2
- package/dist/transport/session/resend-request-manager.js.map +1 -1
- package/dist/transport/session/session-msg-factory.d.ts +1 -0
- package/dist/transport/session/session-msg-factory.js.map +1 -1
- package/dist/transport/session/session-registry.d.ts +23 -0
- package/dist/transport/session/session-registry.js +59 -0
- package/dist/transport/session/session-registry.js.map +1 -0
- package/dist/transport/session/session-sequence-coordinator.d.ts +1 -1
- package/dist/transport/session/session-sequence-coordinator.js.map +1 -1
- package/dist/transport/session/session-sequence-store.d.ts +3 -3
- package/dist/transport/session/session-sequence-store.js.map +1 -1
- package/dist/transport/tcp/tcp-acceptor-listener.d.ts +3 -0
- package/dist/transport/tcp/tcp-acceptor-listener.js +26 -4
- package/dist/transport/tcp/tcp-acceptor-listener.js.map +1 -1
- package/dist/transport/tcp/tcp-acceptor.d.ts +5 -0
- package/dist/transport/tcp/tcp-acceptor.js +74 -20
- package/dist/transport/tcp/tcp-acceptor.js.map +1 -1
- package/dist/transport/tcp/tcp-initiator.js +15 -5
- package/dist/transport/tcp/tcp-initiator.js.map +1 -1
- package/dist/transport/tcp/tcp-transport-description.d.ts +1 -0
- package/dist/transport/tcp/tcp-transport-description.js.map +1 -1
- package/dist/transport/tcp/tls-diagnostics.d.ts +5 -0
- package/dist/transport/tcp/tls-diagnostics.js +64 -0
- package/dist/transport/tcp/tls-diagnostics.js.map +1 -0
- package/dist/transport/tcp/tls-options-factory.d.ts +1 -0
- package/dist/transport/tcp/tls-options-factory.js +24 -2
- package/dist/transport/tcp/tls-options-factory.js.map +1 -1
- package/docs/acceptor.md +207 -0
- package/docs/benchmarking.md +117 -0
- package/eslint.config.mjs +15 -1
- package/jsfix.test_client.txt +62 -75
- package/jsfix.test_server.txt +72 -72
- package/package.json +19 -10
- package/script/pack-local.js +68 -0
- package/src/benchmark/bench-report.ts +101 -0
- package/src/benchmark/bench-runner.ts +196 -0
- package/src/benchmark/bench-stats.ts +86 -0
- package/src/benchmark/bench-types.ts +110 -0
- package/src/benchmark/gc-probe.ts +145 -0
- package/src/benchmark/index.ts +6 -0
- package/src/benchmark/parse-bench.ts +144 -0
- package/src/buffer/ascii/ascii-parser.ts +16 -1
- package/src/buffer/ascii/ascii-segment-parser.ts +9 -6
- package/src/buffer/ascii/tag-index.ts +3 -3
- package/src/buffer/msg-view.ts +35 -18
- package/src/config/js-fix-config.ts +8 -0
- package/src/dictionary/parser/quickfix/dictionary-validator.ts +1 -1
- package/src/dictionary/parser/quickfix/quick-fix-graph-parser.ts +2 -3
- package/src/dictionary/parser/quickfix/validation-error.ts +1 -1
- package/src/jsfix-bench.ts +204 -0
- package/src/runtime/session-launcher.ts +5 -1
- package/src/runtime/session-scope.ts +84 -0
- package/src/sample/http/oms/app.ts +3 -3
- package/src/sample/tcp/qf-md/app.ts +3 -3
- package/src/sample/tcp/skeleton/app.ts +1 -1
- package/src/sample/tcp/trade-capture/app-launcher.ts +3 -3
- package/src/store/file-session-store.ts +2 -2
- package/src/store/file-session-stream-provider.ts +17 -11
- package/src/store/fix-session-store-factory.ts +1 -1
- package/src/store/fix-session-store.ts +11 -11
- package/src/store/memory-session-store.ts +1 -1
- package/src/store/session-stream-provider.ts +13 -13
- package/src/transport/ascii/ascii-session.ts +186 -11
- package/src/transport/duplex/fix-duplex.ts +9 -0
- package/src/transport/duplex/tcp-duplex.ts +10 -0
- package/src/transport/factory/msg-transport.ts +20 -0
- package/src/transport/session/a-session-msg-factory.ts +18 -0
- package/src/transport/session/clone-session-description.ts +41 -0
- package/src/transport/session/fix-clock.ts +1 -1
- package/src/transport/session/fix-session.ts +136 -25
- package/src/transport/session/index.ts +2 -0
- package/src/transport/session/resend-request-manager.ts +4 -4
- package/src/transport/session/session-msg-factory.ts +13 -0
- package/src/transport/session/session-registry.ts +112 -0
- package/src/transport/session/session-sequence-coordinator.ts +1 -1
- package/src/transport/session/session-sequence-store.ts +3 -3
- package/src/transport/tcp/tcp-acceptor-listener.ts +43 -5
- package/src/transport/tcp/tcp-acceptor.ts +92 -18
- package/src/transport/tcp/tcp-initiator.ts +26 -5
- package/src/transport/tcp/tcp-transport-description.ts +7 -0
- package/src/transport/tcp/tls-diagnostics.ts +76 -0
- package/src/transport/tcp/tls-options-factory.ts +34 -2
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { compare } from './bench-stats'
|
|
2
|
+
import { IBenchResult, IBenchSuiteResult } from './bench-types'
|
|
3
|
+
|
|
4
|
+
const bytesPerKb = 1024
|
|
5
|
+
const bytesPerMb = bytesPerKb * 1024
|
|
6
|
+
const nanosPerMicro = 1e3
|
|
7
|
+
|
|
8
|
+
function micros (nanos: number): string {
|
|
9
|
+
return (nanos / nanosPerMicro).toFixed(3)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mb (bytes: number): string {
|
|
13
|
+
return (bytes / bytesPerMb).toFixed(2)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function thousands (n: number): string {
|
|
17
|
+
return Math.round(n).toLocaleString('en-US')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function signed (n: number, digits: number = 2): string {
|
|
21
|
+
return `${n >= 0 ? '+' : ''}${n.toFixed(digits)}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatEnvironment (suite: IBenchSuiteResult): string {
|
|
25
|
+
const env = suite.environment
|
|
26
|
+
const lines = [
|
|
27
|
+
`${suite.label} ${suite.capturedAt}`,
|
|
28
|
+
`node ${env.node} v8 ${env.v8} ${env.platform} ${env.arch}`,
|
|
29
|
+
`${env.cpu} x${env.cpuCount}`
|
|
30
|
+
]
|
|
31
|
+
if (!env.forcedGcAvailable) {
|
|
32
|
+
lines.push('WARNING: no --expose-gc, rounds are not isolated and retained heap is indicative only')
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatResult (result: IBenchResult): string {
|
|
38
|
+
const t = result.timing
|
|
39
|
+
const m = result.memory
|
|
40
|
+
const noise = t.coefficientOfVariation * 100
|
|
41
|
+
const lines = [
|
|
42
|
+
`${result.name}`,
|
|
43
|
+
result.description ? ` ${result.description}` : '',
|
|
44
|
+
` rounds ${t.samples} x ${result.options.operationsPerRound} msgs` +
|
|
45
|
+
` (warmup ${result.options.warmupOperations})`,
|
|
46
|
+
` per msg median ${micros(t.median)}us min ${micros(t.min)}us` +
|
|
47
|
+
` p95 ${micros(t.p95)}us max ${micros(t.max)}us`,
|
|
48
|
+
` throughput ${thousands(result.operationsPerSecond)} msg/sec`,
|
|
49
|
+
` noise ${noise.toFixed(2)}% of median (a change smaller than this means nothing)`,
|
|
50
|
+
` gc pass ${m.operations} msgs uninterrupted: ${m.gc.minor} minor ${m.gc.major} major ` +
|
|
51
|
+
`${m.gc.totalPauseMs.toFixed(1)}ms paused longest ${m.gc.longestPauseMs.toFixed(2)}ms`,
|
|
52
|
+
` gc rate ${m.minorGcPerMillionOperations.toFixed(1)} minor/M msgs ` +
|
|
53
|
+
`${m.majorGcPerMillionOperations.toFixed(1)} major/M msgs ` +
|
|
54
|
+
`${m.gcPauseMsPerMillionOperations.toFixed(0)}ms paused/M msgs`,
|
|
55
|
+
` retained ${mb(m.retainedHeapBytes)}MB ` +
|
|
56
|
+
`${m.retainedBytesPerOperation.toFixed(1)} bytes/msg heap at end of pass ${mb(m.heapAtEndOfPassBytes)}MB`
|
|
57
|
+
]
|
|
58
|
+
return lines.filter(l => l.length > 0).join('\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatSuite (suite: IBenchSuiteResult): string {
|
|
62
|
+
const blocks = suite.results.map(formatResult)
|
|
63
|
+
return [formatEnvironment(suite), '', ...blocks].join('\n\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compare a suite against a stored baseline, matching results by name.
|
|
68
|
+
*
|
|
69
|
+
* A difference is only called out as real when it exceeds the run to run
|
|
70
|
+
* spread both suites displayed. Everything else is reported as noise, because
|
|
71
|
+
* that is what it is.
|
|
72
|
+
*/
|
|
73
|
+
export function formatComparison (baseline: IBenchSuiteResult, candidate: IBenchSuiteResult): string {
|
|
74
|
+
const lines: string[] = [`baseline ${baseline.label} (${baseline.capturedAt})`, '']
|
|
75
|
+
if (baseline.environment.cpu !== candidate.environment.cpu ||
|
|
76
|
+
baseline.environment.node !== candidate.environment.node) {
|
|
77
|
+
lines.push('WARNING: baseline was taken on a different machine or node build, timings are not comparable')
|
|
78
|
+
lines.push('')
|
|
79
|
+
}
|
|
80
|
+
for (const result of candidate.results) {
|
|
81
|
+
const before = baseline.results.find(r => r.name === result.name)
|
|
82
|
+
if (!before) {
|
|
83
|
+
lines.push(`${result.name} no baseline`)
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
const timing = compare(before.timing, result.timing)
|
|
87
|
+
const verdict = timing.significant
|
|
88
|
+
? (timing.deltaPercent < 0 ? 'FASTER' : 'SLOWER')
|
|
89
|
+
: 'noise'
|
|
90
|
+
lines.push(`${result.name}`)
|
|
91
|
+
lines.push(` per msg ${micros(before.timing.median)}us -> ${micros(result.timing.median)}us` +
|
|
92
|
+
` ${signed(timing.deltaPercent)}% ${verdict}` +
|
|
93
|
+
` (noise floor +/-${timing.noiseThresholdPercent.toFixed(2)}%)`)
|
|
94
|
+
const gcBefore = before.memory.minorGcPerMillionOperations
|
|
95
|
+
const gcAfter = result.memory.minorGcPerMillionOperations
|
|
96
|
+
const gcDelta = gcBefore === 0 ? 0 : ((gcAfter - gcBefore) / gcBefore) * 100
|
|
97
|
+
lines.push(` minor gc ${gcBefore.toFixed(1)} -> ${gcAfter.toFixed(1)} per M msgs ${signed(gcDelta)}%`)
|
|
98
|
+
lines.push('')
|
|
99
|
+
}
|
|
100
|
+
return lines.join('\n')
|
|
101
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as os from 'os'
|
|
2
|
+
import { GcProbe } from './gc-probe'
|
|
3
|
+
import { summarise } from './bench-stats'
|
|
4
|
+
import {
|
|
5
|
+
IBenchCase,
|
|
6
|
+
IBenchOptions,
|
|
7
|
+
IBenchResult,
|
|
8
|
+
IBenchSuiteResult,
|
|
9
|
+
IEnvironment,
|
|
10
|
+
IMemoryResult,
|
|
11
|
+
IRoundResult
|
|
12
|
+
} from './bench-types'
|
|
13
|
+
|
|
14
|
+
const nanosPerSecond = 1e9
|
|
15
|
+
const operationsPerMillion = 1e6
|
|
16
|
+
|
|
17
|
+
export const defaultBenchOptions: IBenchOptions = {
|
|
18
|
+
// v8 needs to see a function run a few thousand times before it stops
|
|
19
|
+
// interpreting it, so an unwarmed first round measures the wrong code
|
|
20
|
+
warmupOperations: 20000,
|
|
21
|
+
rounds: 10,
|
|
22
|
+
operationsPerRound: 20000,
|
|
23
|
+
gcBetweenRounds: true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function environment (): IEnvironment {
|
|
27
|
+
const cpus = os.cpus()
|
|
28
|
+
return {
|
|
29
|
+
node: process.version,
|
|
30
|
+
v8: process.versions.v8,
|
|
31
|
+
platform: `${os.type()} ${os.release()}`,
|
|
32
|
+
arch: process.arch,
|
|
33
|
+
cpu: cpus.length > 0 ? cpus[0].model.trim() : 'unknown',
|
|
34
|
+
cpuCount: cpus.length,
|
|
35
|
+
forcedGcAvailable: GcProbe.forcedGcAvailable()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** hand the event loop one turn, so queued observer callbacks can run */
|
|
40
|
+
async function tick (): Promise<void> {
|
|
41
|
+
await new Promise<void>(resolve => { setImmediate(resolve) })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Yield until the probe stops seeing new entries. Node dispatches a gc entry on
|
|
46
|
+
* the turn after the collection, and a burst takes several turns to come
|
|
47
|
+
* through, so a single yield loses most of them. Bounded so a pathological case
|
|
48
|
+
* cannot spin here.
|
|
49
|
+
*/
|
|
50
|
+
async function drain (probe: GcProbe, maxTurns: number = 50): Promise<void> {
|
|
51
|
+
let quietTurns = 0
|
|
52
|
+
for (let turn = 0; turn < maxTurns && quietTurns < 3; ++turn) {
|
|
53
|
+
const before = probe.seen()
|
|
54
|
+
await tick()
|
|
55
|
+
quietTurns = probe.seen() === before ? quietTurns + 1 : 0
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Drives a case through a warmup, a series of timed rounds and a separate
|
|
61
|
+
* diagnostics pass.
|
|
62
|
+
*
|
|
63
|
+
* One round is one sample. A single long run cannot tell you whether a number
|
|
64
|
+
* is repeatable, and a difference smaller than the spread between rounds is not
|
|
65
|
+
* a difference at all.
|
|
66
|
+
*
|
|
67
|
+
* Timing and memory are deliberately not measured together. Isolating rounds
|
|
68
|
+
* means forcing a collection between them, and a forced collection is itself a
|
|
69
|
+
* gc event - counting those would report the harness rather than the work. So
|
|
70
|
+
* the timed rounds collect between them and report no gc figures, and a final
|
|
71
|
+
* pass runs uninterrupted with the probe attached and reports no timings.
|
|
72
|
+
*/
|
|
73
|
+
export class BenchRunner {
|
|
74
|
+
constructor (private readonly options: IBenchOptions = defaultBenchOptions) {
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public async run (benchCase: IBenchCase): Promise<IBenchResult> {
|
|
78
|
+
const options = this.options
|
|
79
|
+
benchCase.setup?.()
|
|
80
|
+
try {
|
|
81
|
+
this.warmup(benchCase)
|
|
82
|
+
const rounds: IRoundResult[] = []
|
|
83
|
+
for (let round = 0; round < options.rounds; ++round) {
|
|
84
|
+
if (options.gcBetweenRounds) {
|
|
85
|
+
// a collection provoked by the previous round must not be charged
|
|
86
|
+
// to the next one
|
|
87
|
+
GcProbe.forceGc()
|
|
88
|
+
}
|
|
89
|
+
rounds.push(this.measureRound(benchCase, round))
|
|
90
|
+
}
|
|
91
|
+
const timing = summarise(rounds.map(r => r.nanosPerOperation))
|
|
92
|
+
return {
|
|
93
|
+
name: benchCase.name,
|
|
94
|
+
description: benchCase.description,
|
|
95
|
+
options,
|
|
96
|
+
rounds,
|
|
97
|
+
timing,
|
|
98
|
+
operationsPerSecond: timing.median === 0 ? 0 : nanosPerSecond / timing.median,
|
|
99
|
+
memory: await this.measureMemory(benchCase)
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
benchCase.teardown?.()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public async runAll (label: string, cases: IBenchCase[]): Promise<IBenchSuiteResult> {
|
|
107
|
+
const results: IBenchResult[] = []
|
|
108
|
+
for (const benchCase of cases) {
|
|
109
|
+
results.push(await this.run(benchCase))
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
label,
|
|
113
|
+
// stamped from the clock rather than passed in, a suite is a record of
|
|
114
|
+
// when it was taken
|
|
115
|
+
capturedAt: new Date().toISOString(),
|
|
116
|
+
environment: environment(),
|
|
117
|
+
results
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One uninterrupted pass with the gc probe attached.
|
|
123
|
+
*
|
|
124
|
+
* Nothing is forced while it runs, so every collection counted is one the
|
|
125
|
+
* work itself provoked. The heap readings either side are taken after a
|
|
126
|
+
* forced collection, making them what the pass retained rather than what it
|
|
127
|
+
* churned - churn is what the collection counts are for.
|
|
128
|
+
*/
|
|
129
|
+
private async measureMemory (benchCase: IBenchCase): Promise<IMemoryResult> {
|
|
130
|
+
// settle, then let any delivery from that settling drain before the probe
|
|
131
|
+
// exists - entries are dispatched a turn late and would otherwise land in
|
|
132
|
+
// the middle of the measured pass
|
|
133
|
+
GcProbe.forceGc()
|
|
134
|
+
await tick()
|
|
135
|
+
|
|
136
|
+
const heapBefore = process.memoryUsage().heapUsed
|
|
137
|
+
const probe = new GcProbe()
|
|
138
|
+
probe.start()
|
|
139
|
+
const operations = benchCase.run(this.options.operationsPerRound)
|
|
140
|
+
const heapAtEndOfPassBytes = process.memoryUsage().heapUsed
|
|
141
|
+
await drain(probe)
|
|
142
|
+
const gc = probe.stop()
|
|
143
|
+
|
|
144
|
+
GcProbe.forceGc()
|
|
145
|
+
const heapAfter = process.memoryUsage().heapUsed
|
|
146
|
+
|
|
147
|
+
const retainedHeapBytes = heapAfter - heapBefore
|
|
148
|
+
const perMillion = operations === 0 ? 0 : operationsPerMillion / operations
|
|
149
|
+
return {
|
|
150
|
+
operations,
|
|
151
|
+
retainedHeapBytes,
|
|
152
|
+
retainedBytesPerOperation: operations === 0 ? 0 : retainedHeapBytes / operations,
|
|
153
|
+
heapAtEndOfPassBytes,
|
|
154
|
+
gc,
|
|
155
|
+
minorGcPerMillionOperations: gc.minor * perMillion,
|
|
156
|
+
majorGcPerMillionOperations: gc.major * perMillion,
|
|
157
|
+
gcPauseMsPerMillionOperations: gc.totalPauseMs * perMillion,
|
|
158
|
+
forcedGcAvailable: GcProbe.forcedGcAvailable()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private warmup (benchCase: IBenchCase): void {
|
|
163
|
+
const target = this.options.warmupOperations
|
|
164
|
+
if (target <= 0) return
|
|
165
|
+
let done = 0
|
|
166
|
+
// in chunks the size of a real round, so warmup exercises the same shape of
|
|
167
|
+
// work rather than one long unbroken call
|
|
168
|
+
const chunk = Math.max(1, Math.min(target, this.options.operationsPerRound))
|
|
169
|
+
while (done < target) {
|
|
170
|
+
const requested = Math.min(chunk, target - done)
|
|
171
|
+
const completed = benchCase.run(requested)
|
|
172
|
+
// a case that reports less than it was asked for would never let this
|
|
173
|
+
// loop reach its target, so refuse it here rather than spin
|
|
174
|
+
if (completed !== requested) {
|
|
175
|
+
throw new Error(`${benchCase.name}: asked for ${requested} operations during warmup but completed ${completed}`)
|
|
176
|
+
}
|
|
177
|
+
done += completed
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private measureRound (benchCase: IBenchCase, round: number): IRoundResult {
|
|
182
|
+
const requested = this.options.operationsPerRound
|
|
183
|
+
const startedAt = process.hrtime.bigint()
|
|
184
|
+
const operations = benchCase.run(requested)
|
|
185
|
+
const elapsedNanos = Number(process.hrtime.bigint() - startedAt)
|
|
186
|
+
if (operations !== requested) {
|
|
187
|
+
throw new Error(`${benchCase.name}: asked for ${requested} operations in round ${round} but completed ${operations}`)
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
round,
|
|
191
|
+
operations,
|
|
192
|
+
elapsedNanos,
|
|
193
|
+
nanosPerOperation: operations === 0 ? 0 : elapsedNanos / operations
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ISampleStats } from './bench-types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Descriptive statistics over a set of measured rounds.
|
|
5
|
+
*
|
|
6
|
+
* min matters as much as median here. A round can only ever be made slower by
|
|
7
|
+
* interference - a collection landing mid round, the scheduler taking the core
|
|
8
|
+
* away - so the fastest round is the closest reading to the true cost of the
|
|
9
|
+
* work, while the median tells you what to expect in practice.
|
|
10
|
+
*/
|
|
11
|
+
export function summarise (values: number[]): ISampleStats {
|
|
12
|
+
if (values.length === 0) {
|
|
13
|
+
throw new Error('cannot summarise an empty sample set')
|
|
14
|
+
}
|
|
15
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
16
|
+
const n = sorted.length
|
|
17
|
+
const mean = sorted.reduce((a, v) => a + v, 0) / n
|
|
18
|
+
const variance = sorted.reduce((a, v) => a + (v - mean) * (v - mean), 0) / n
|
|
19
|
+
const stdDev = Math.sqrt(variance)
|
|
20
|
+
return {
|
|
21
|
+
samples: n,
|
|
22
|
+
min: sorted[0],
|
|
23
|
+
max: sorted[n - 1],
|
|
24
|
+
mean,
|
|
25
|
+
median: median(sorted),
|
|
26
|
+
p95: percentile(sorted, 95),
|
|
27
|
+
p99: percentile(sorted, 99),
|
|
28
|
+
stdDev,
|
|
29
|
+
coefficientOfVariation: mean === 0 ? 0 : stdDev / mean
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param sorted values in ascending order
|
|
35
|
+
*/
|
|
36
|
+
export function median (sorted: number[]): number {
|
|
37
|
+
const n = sorted.length
|
|
38
|
+
if (n === 0) return 0
|
|
39
|
+
const mid = n >> 1
|
|
40
|
+
return n % 2 === 1
|
|
41
|
+
? sorted[mid]
|
|
42
|
+
: (sorted[mid - 1] + sorted[mid]) / 2
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Nearest rank percentile - no interpolation, so every value reported is a
|
|
47
|
+
* reading that actually occurred.
|
|
48
|
+
*
|
|
49
|
+
* @param sorted values in ascending order
|
|
50
|
+
* @param p percentile in the range 0 to 100
|
|
51
|
+
*/
|
|
52
|
+
export function percentile (sorted: number[], p: number): number {
|
|
53
|
+
const n = sorted.length
|
|
54
|
+
if (n === 0) return 0
|
|
55
|
+
const rank = Math.ceil((p / 100) * n)
|
|
56
|
+
const index = Math.min(Math.max(rank - 1, 0), n - 1)
|
|
57
|
+
return sorted[index]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface IComparison {
|
|
61
|
+
/** positive means the candidate took longer than the baseline */
|
|
62
|
+
readonly deltaPercent: number
|
|
63
|
+
/**
|
|
64
|
+
* true when the change is larger than the noise both runs displayed. Below
|
|
65
|
+
* this threshold a difference is not evidence of anything.
|
|
66
|
+
*/
|
|
67
|
+
readonly significant: boolean
|
|
68
|
+
readonly noiseThresholdPercent: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compare a candidate timing against a baseline, using the run to run spread
|
|
73
|
+
* each of them showed to decide whether the difference is real.
|
|
74
|
+
*/
|
|
75
|
+
export function compare (baseline: ISampleStats, candidate: ISampleStats): IComparison {
|
|
76
|
+
const deltaPercent = baseline.median === 0
|
|
77
|
+
? 0
|
|
78
|
+
: ((candidate.median - baseline.median) / baseline.median) * 100
|
|
79
|
+
// both runs contribute their own noise to any difference between them
|
|
80
|
+
const noiseThresholdPercent = (baseline.coefficientOfVariation + candidate.coefficientOfVariation) * 100
|
|
81
|
+
return {
|
|
82
|
+
deltaPercent,
|
|
83
|
+
significant: Math.abs(deltaPercent) > noiseThresholdPercent,
|
|
84
|
+
noiseThresholdPercent
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A unit of work the runner can drive. run is handed the number of operations
|
|
3
|
+
* to perform and returns how many it actually completed, which the runner
|
|
4
|
+
* checks - a case that silently does less work than asked would otherwise look
|
|
5
|
+
* like a large improvement.
|
|
6
|
+
*/
|
|
7
|
+
export interface IBenchCase {
|
|
8
|
+
readonly name: string
|
|
9
|
+
readonly description?: string
|
|
10
|
+
/** called once before warmup, outside all timing */
|
|
11
|
+
setup?: () => void
|
|
12
|
+
run: (operations: number) => number
|
|
13
|
+
teardown?: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IBenchOptions {
|
|
17
|
+
/** operations run and discarded before measuring, to let v8 tier the code up */
|
|
18
|
+
readonly warmupOperations: number
|
|
19
|
+
/** measured rounds - each contributes one sample */
|
|
20
|
+
readonly rounds: number
|
|
21
|
+
readonly operationsPerRound: number
|
|
22
|
+
/** collect between rounds so one round's garbage is not charged to the next */
|
|
23
|
+
readonly gcBetweenRounds: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ISampleStats {
|
|
27
|
+
readonly samples: number
|
|
28
|
+
readonly min: number
|
|
29
|
+
readonly max: number
|
|
30
|
+
readonly mean: number
|
|
31
|
+
readonly median: number
|
|
32
|
+
readonly p95: number
|
|
33
|
+
readonly p99: number
|
|
34
|
+
readonly stdDev: number
|
|
35
|
+
/** stdDev as a fraction of the mean - the run to run noise floor */
|
|
36
|
+
readonly coefficientOfVariation: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IGcTotals {
|
|
40
|
+
readonly minor: number
|
|
41
|
+
readonly major: number
|
|
42
|
+
readonly incremental: number
|
|
43
|
+
readonly weakCallback: number
|
|
44
|
+
readonly total: number
|
|
45
|
+
readonly totalPauseMs: number
|
|
46
|
+
readonly longestPauseMs: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IMemoryResult {
|
|
50
|
+
/** operations in the diagnostics pass these figures were taken over */
|
|
51
|
+
readonly operations: number
|
|
52
|
+
/**
|
|
53
|
+
* heapUsed after a forced collection, less the same reading taken before the
|
|
54
|
+
* measured rounds - what the work left behind rather than what it churned
|
|
55
|
+
*/
|
|
56
|
+
readonly retainedHeapBytes: number
|
|
57
|
+
readonly retainedBytesPerOperation: number
|
|
58
|
+
/**
|
|
59
|
+
* heapUsed sampled the instant the pass finished. Not a high water mark -
|
|
60
|
+
* it is wherever the sawtooth happened to be - so read it as a rough sense of
|
|
61
|
+
* the working set, not as a peak.
|
|
62
|
+
*/
|
|
63
|
+
readonly heapAtEndOfPassBytes: number
|
|
64
|
+
readonly gc: IGcTotals
|
|
65
|
+
/**
|
|
66
|
+
* collections per million operations. allocation volume is not directly
|
|
67
|
+
* observable from node, so this is the comparable proxy for it - the same
|
|
68
|
+
* work triggering fewer scavenges is the same work allocating less.
|
|
69
|
+
*/
|
|
70
|
+
readonly minorGcPerMillionOperations: number
|
|
71
|
+
readonly majorGcPerMillionOperations: number
|
|
72
|
+
readonly gcPauseMsPerMillionOperations: number
|
|
73
|
+
/** false when the process was started without --expose-gc */
|
|
74
|
+
readonly forcedGcAvailable: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface IRoundResult {
|
|
78
|
+
readonly round: number
|
|
79
|
+
readonly operations: number
|
|
80
|
+
readonly elapsedNanos: number
|
|
81
|
+
readonly nanosPerOperation: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface IEnvironment {
|
|
85
|
+
readonly node: string
|
|
86
|
+
readonly v8: string
|
|
87
|
+
readonly platform: string
|
|
88
|
+
readonly arch: string
|
|
89
|
+
readonly cpu: string
|
|
90
|
+
readonly cpuCount: number
|
|
91
|
+
readonly forcedGcAvailable: boolean
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface IBenchResult {
|
|
95
|
+
readonly name: string
|
|
96
|
+
readonly description?: string
|
|
97
|
+
readonly options: IBenchOptions
|
|
98
|
+
readonly rounds: IRoundResult[]
|
|
99
|
+
/** statistics over the per round nanosPerOperation samples */
|
|
100
|
+
readonly timing: ISampleStats
|
|
101
|
+
readonly operationsPerSecond: number
|
|
102
|
+
readonly memory: IMemoryResult
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface IBenchSuiteResult {
|
|
106
|
+
readonly label: string
|
|
107
|
+
readonly capturedAt: string
|
|
108
|
+
readonly environment: IEnvironment
|
|
109
|
+
readonly results: IBenchResult[]
|
|
110
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { PerformanceObserver, constants } from 'perf_hooks'
|
|
2
|
+
import { IGcTotals } from './bench-types'
|
|
3
|
+
|
|
4
|
+
interface IGcDetail {
|
|
5
|
+
kind: number
|
|
6
|
+
flags: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* a gc performance entry. detail is not on the PerformanceEntry the node types
|
|
11
|
+
* describe, it is only present on entries of this type
|
|
12
|
+
*/
|
|
13
|
+
interface IGcEntry {
|
|
14
|
+
duration: number
|
|
15
|
+
detail?: IGcDetail
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const emptyTotals: IGcTotals = {
|
|
19
|
+
minor: 0,
|
|
20
|
+
major: 0,
|
|
21
|
+
incremental: 0,
|
|
22
|
+
weakCallback: 0,
|
|
23
|
+
total: 0,
|
|
24
|
+
totalPauseMs: 0,
|
|
25
|
+
longestPauseMs: 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Counts collections and the pause they cost while a benchmark runs.
|
|
30
|
+
*
|
|
31
|
+
* Node does not expose how many bytes a stretch of code allocated, so the
|
|
32
|
+
* number of scavenges is the practical stand in - the same work provoking
|
|
33
|
+
* fewer minor collections is the same work producing less garbage, and that
|
|
34
|
+
* is the number worth moving for a drop copy engine where the cost of
|
|
35
|
+
* allocation is felt as pause rather than as throughput.
|
|
36
|
+
*/
|
|
37
|
+
export class GcProbe {
|
|
38
|
+
private observer: PerformanceObserver | null = null
|
|
39
|
+
private minor: number = 0
|
|
40
|
+
private major: number = 0
|
|
41
|
+
private incremental: number = 0
|
|
42
|
+
private weakCallback: number = 0
|
|
43
|
+
private totalPauseMs: number = 0
|
|
44
|
+
private longestPauseMs: number = 0
|
|
45
|
+
|
|
46
|
+
/** whether the process was started with --expose-gc */
|
|
47
|
+
public static forcedGcAvailable (): boolean {
|
|
48
|
+
return typeof global.gc === 'function'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Collect until the heap settles, so a reading taken afterwards reflects what
|
|
53
|
+
* is genuinely retained. Two passes because the first can promote survivors
|
|
54
|
+
* into the old space that the second is then able to reclaim. A no-op without
|
|
55
|
+
* --expose-gc, in which case retained figures are indicative only.
|
|
56
|
+
*/
|
|
57
|
+
public static forceGc (): void {
|
|
58
|
+
const gc = global.gc
|
|
59
|
+
if (typeof gc !== 'function') return
|
|
60
|
+
gc()
|
|
61
|
+
gc()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** entries recorded so far - lets a caller wait for delivery to go quiet */
|
|
65
|
+
public seen (): number {
|
|
66
|
+
return this.minor + this.major + this.incremental + this.weakCallback
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public start (): void {
|
|
70
|
+
this.reset()
|
|
71
|
+
const observer = new PerformanceObserver((list) => {
|
|
72
|
+
for (const entry of list.getEntries()) {
|
|
73
|
+
const gcEntry = entry as unknown as IGcEntry
|
|
74
|
+
this.record(gcEntry.duration, gcEntry.detail?.kind ?? 0)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
// buffered so collections are not missed while the loop is busy - the
|
|
78
|
+
// callback cannot run until the measured work yields
|
|
79
|
+
observer.observe({ entryTypes: ['gc'], buffered: true })
|
|
80
|
+
this.observer = observer
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stop observing and return what was seen. Any entries still queued are
|
|
85
|
+
* drained first - the observer callback is a macrotask and the benchmark
|
|
86
|
+
* loop never yields, so without this every collection would be lost.
|
|
87
|
+
*/
|
|
88
|
+
public stop (): IGcTotals {
|
|
89
|
+
const observer = this.observer
|
|
90
|
+
if (!observer) return emptyTotals
|
|
91
|
+
for (const entry of observer.takeRecords()) {
|
|
92
|
+
const gcEntry = entry as unknown as IGcEntry
|
|
93
|
+
this.record(gcEntry.duration, gcEntry.detail?.kind ?? 0)
|
|
94
|
+
}
|
|
95
|
+
observer.disconnect()
|
|
96
|
+
this.observer = null
|
|
97
|
+
return this.totals()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private reset (): void {
|
|
101
|
+
this.minor = 0
|
|
102
|
+
this.major = 0
|
|
103
|
+
this.incremental = 0
|
|
104
|
+
this.weakCallback = 0
|
|
105
|
+
this.totalPauseMs = 0
|
|
106
|
+
this.longestPauseMs = 0
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private record (durationMs: number, kind: number): void {
|
|
110
|
+
this.totalPauseMs += durationMs
|
|
111
|
+
this.longestPauseMs = Math.max(this.longestPauseMs, durationMs)
|
|
112
|
+
switch (kind) {
|
|
113
|
+
case constants.NODE_PERFORMANCE_GC_MINOR:
|
|
114
|
+
++this.minor
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
case constants.NODE_PERFORMANCE_GC_MAJOR:
|
|
118
|
+
++this.major
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
case constants.NODE_PERFORMANCE_GC_INCREMENTAL:
|
|
122
|
+
++this.incremental
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
case constants.NODE_PERFORMANCE_GC_WEAKCB:
|
|
126
|
+
++this.weakCallback
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
default:
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private totals (): IGcTotals {
|
|
135
|
+
return {
|
|
136
|
+
minor: this.minor,
|
|
137
|
+
major: this.major,
|
|
138
|
+
incremental: this.incremental,
|
|
139
|
+
weakCallback: this.weakCallback,
|
|
140
|
+
total: this.minor + this.major + this.incremental + this.weakCallback,
|
|
141
|
+
totalPauseMs: this.totalPauseMs,
|
|
142
|
+
longestPauseMs: this.longestPauseMs
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|