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.
Files changed (180) hide show
  1. package/.gitattributes +4 -0
  2. package/BACKPORT_PLAN.md +84 -0
  3. package/DEMO_PORT_PLAN.md +10 -0
  4. package/README.md +3 -0
  5. package/WORKLOG.md +119 -0
  6. package/dist/benchmark/bench-report.d.ts +5 -0
  7. package/dist/benchmark/bench-report.js +91 -0
  8. package/dist/benchmark/bench-report.js.map +1 -0
  9. package/dist/benchmark/bench-runner.d.ts +12 -0
  10. package/dist/benchmark/bench-runner.js +193 -0
  11. package/dist/benchmark/bench-runner.js.map +1 -0
  12. package/dist/benchmark/bench-stats.d.ts +10 -0
  13. package/dist/benchmark/bench-stats.js +56 -0
  14. package/dist/benchmark/bench-stats.js.map +1 -0
  15. package/dist/benchmark/bench-types.d.ts +74 -0
  16. package/dist/benchmark/bench-types.js +3 -0
  17. package/dist/benchmark/bench-types.js.map +1 -0
  18. package/dist/benchmark/gc-probe.d.ts +18 -0
  19. package/dist/benchmark/gc-probe.js +103 -0
  20. package/dist/benchmark/gc-probe.js.map +1 -0
  21. package/dist/benchmark/index.d.ts +6 -0
  22. package/dist/benchmark/index.js +23 -0
  23. package/dist/benchmark/index.js.map +1 -0
  24. package/dist/benchmark/parse-bench.d.ts +28 -0
  25. package/dist/benchmark/parse-bench.js +85 -0
  26. package/dist/benchmark/parse-bench.js.map +1 -0
  27. package/dist/buffer/ascii/ascii-parser.d.ts +1 -0
  28. package/dist/buffer/ascii/ascii-parser.js +4 -1
  29. package/dist/buffer/ascii/ascii-parser.js.map +1 -1
  30. package/dist/buffer/ascii/ascii-segment-parser.js +3 -2
  31. package/dist/buffer/ascii/ascii-segment-parser.js.map +1 -1
  32. package/dist/buffer/ascii/tag-index.js.map +1 -1
  33. package/dist/buffer/msg-view.d.ts +1 -0
  34. package/dist/buffer/msg-view.js +25 -19
  35. package/dist/buffer/msg-view.js.map +1 -1
  36. package/dist/config/js-fix-config.d.ts +4 -0
  37. package/dist/config/js-fix-config.js.map +1 -1
  38. package/dist/dictionary/parser/quickfix/dictionary-validator.d.ts +1 -1
  39. package/dist/dictionary/parser/quickfix/dictionary-validator.js.map +1 -1
  40. package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.d.ts +1 -1
  41. package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.js +1 -2
  42. package/dist/dictionary/parser/quickfix/quick-fix-graph-parser.js.map +1 -1
  43. package/dist/dictionary/parser/quickfix/validation-error.d.ts +2 -2
  44. package/dist/dictionary/parser/quickfix/validation-error.js.map +1 -1
  45. package/dist/jsfix-bench.d.ts +1 -0
  46. package/dist/jsfix-bench.js +213 -0
  47. package/dist/jsfix-bench.js.map +1 -0
  48. package/dist/runtime/session-launcher.js +1 -1
  49. package/dist/runtime/session-launcher.js.map +1 -1
  50. package/dist/runtime/session-scope.d.ts +3 -0
  51. package/dist/runtime/session-scope.js +47 -0
  52. package/dist/runtime/session-scope.js.map +1 -0
  53. package/dist/sample/http/oms/app.js +3 -3
  54. package/dist/sample/http/oms/app.js.map +1 -1
  55. package/dist/sample/tcp/qf-md/app.js +3 -3
  56. package/dist/sample/tcp/qf-md/app.js.map +1 -1
  57. package/dist/sample/tcp/skeleton/app.js +1 -1
  58. package/dist/sample/tcp/skeleton/app.js.map +1 -1
  59. package/dist/sample/tcp/trade-capture/app-launcher.js +3 -3
  60. package/dist/sample/tcp/trade-capture/app-launcher.js.map +1 -1
  61. package/dist/store/file-session-store.js +1 -1
  62. package/dist/store/file-session-store.js.map +1 -1
  63. package/dist/store/file-session-stream-provider.d.ts +1 -0
  64. package/dist/store/file-session-stream-provider.js +13 -13
  65. package/dist/store/file-session-stream-provider.js.map +1 -1
  66. package/dist/store/fix-session-store-factory.d.ts +1 -1
  67. package/dist/store/fix-session-store-factory.js.map +1 -1
  68. package/dist/store/fix-session-store.d.ts +11 -11
  69. package/dist/store/fix-session-store.js.map +1 -1
  70. package/dist/store/memory-session-store.js.map +1 -1
  71. package/dist/store/session-stream-provider.d.ts +13 -13
  72. package/dist/store/session-stream-provider.js.map +1 -1
  73. package/dist/transport/ascii/ascii-session.d.ts +17 -3
  74. package/dist/transport/ascii/ascii-session.js +121 -8
  75. package/dist/transport/ascii/ascii-session.js.map +1 -1
  76. package/dist/transport/duplex/fix-duplex.d.ts +1 -0
  77. package/dist/transport/duplex/fix-duplex.js +3 -0
  78. package/dist/transport/duplex/fix-duplex.js.map +1 -1
  79. package/dist/transport/duplex/tcp-duplex.d.ts +1 -0
  80. package/dist/transport/duplex/tcp-duplex.js +5 -0
  81. package/dist/transport/duplex/tcp-duplex.js.map +1 -1
  82. package/dist/transport/factory/msg-transport.d.ts +2 -0
  83. package/dist/transport/factory/msg-transport.js +17 -2
  84. package/dist/transport/factory/msg-transport.js.map +1 -1
  85. package/dist/transport/session/a-session-msg-factory.d.ts +1 -0
  86. package/dist/transport/session/a-session-msg-factory.js +4 -0
  87. package/dist/transport/session/a-session-msg-factory.js.map +1 -1
  88. package/dist/transport/session/clone-session-description.d.ts +6 -0
  89. package/dist/transport/session/clone-session-description.js +11 -0
  90. package/dist/transport/session/clone-session-description.js.map +1 -0
  91. package/dist/transport/session/fix-clock.d.ts +1 -1
  92. package/dist/transport/session/fix-clock.js.map +1 -1
  93. package/dist/transport/session/fix-session.d.ts +5 -1
  94. package/dist/transport/session/fix-session.js +93 -24
  95. package/dist/transport/session/fix-session.js.map +1 -1
  96. package/dist/transport/session/index.d.ts +2 -0
  97. package/dist/transport/session/index.js +2 -0
  98. package/dist/transport/session/index.js.map +1 -1
  99. package/dist/transport/session/resend-request-manager.d.ts +2 -2
  100. package/dist/transport/session/resend-request-manager.js.map +1 -1
  101. package/dist/transport/session/session-msg-factory.d.ts +1 -0
  102. package/dist/transport/session/session-msg-factory.js.map +1 -1
  103. package/dist/transport/session/session-registry.d.ts +23 -0
  104. package/dist/transport/session/session-registry.js +59 -0
  105. package/dist/transport/session/session-registry.js.map +1 -0
  106. package/dist/transport/session/session-sequence-coordinator.d.ts +1 -1
  107. package/dist/transport/session/session-sequence-coordinator.js.map +1 -1
  108. package/dist/transport/session/session-sequence-store.d.ts +3 -3
  109. package/dist/transport/session/session-sequence-store.js.map +1 -1
  110. package/dist/transport/tcp/tcp-acceptor-listener.d.ts +3 -0
  111. package/dist/transport/tcp/tcp-acceptor-listener.js +26 -4
  112. package/dist/transport/tcp/tcp-acceptor-listener.js.map +1 -1
  113. package/dist/transport/tcp/tcp-acceptor.d.ts +5 -0
  114. package/dist/transport/tcp/tcp-acceptor.js +74 -20
  115. package/dist/transport/tcp/tcp-acceptor.js.map +1 -1
  116. package/dist/transport/tcp/tcp-initiator.js +15 -5
  117. package/dist/transport/tcp/tcp-initiator.js.map +1 -1
  118. package/dist/transport/tcp/tcp-transport-description.d.ts +1 -0
  119. package/dist/transport/tcp/tcp-transport-description.js.map +1 -1
  120. package/dist/transport/tcp/tls-diagnostics.d.ts +5 -0
  121. package/dist/transport/tcp/tls-diagnostics.js +64 -0
  122. package/dist/transport/tcp/tls-diagnostics.js.map +1 -0
  123. package/dist/transport/tcp/tls-options-factory.d.ts +1 -0
  124. package/dist/transport/tcp/tls-options-factory.js +24 -2
  125. package/dist/transport/tcp/tls-options-factory.js.map +1 -1
  126. package/docs/acceptor.md +207 -0
  127. package/docs/benchmarking.md +117 -0
  128. package/eslint.config.mjs +15 -1
  129. package/jsfix.test_client.txt +62 -75
  130. package/jsfix.test_server.txt +72 -72
  131. package/package.json +19 -10
  132. package/script/pack-local.js +68 -0
  133. package/src/benchmark/bench-report.ts +101 -0
  134. package/src/benchmark/bench-runner.ts +196 -0
  135. package/src/benchmark/bench-stats.ts +86 -0
  136. package/src/benchmark/bench-types.ts +110 -0
  137. package/src/benchmark/gc-probe.ts +145 -0
  138. package/src/benchmark/index.ts +6 -0
  139. package/src/benchmark/parse-bench.ts +144 -0
  140. package/src/buffer/ascii/ascii-parser.ts +16 -1
  141. package/src/buffer/ascii/ascii-segment-parser.ts +9 -6
  142. package/src/buffer/ascii/tag-index.ts +3 -3
  143. package/src/buffer/msg-view.ts +35 -18
  144. package/src/config/js-fix-config.ts +8 -0
  145. package/src/dictionary/parser/quickfix/dictionary-validator.ts +1 -1
  146. package/src/dictionary/parser/quickfix/quick-fix-graph-parser.ts +2 -3
  147. package/src/dictionary/parser/quickfix/validation-error.ts +1 -1
  148. package/src/jsfix-bench.ts +204 -0
  149. package/src/runtime/session-launcher.ts +5 -1
  150. package/src/runtime/session-scope.ts +84 -0
  151. package/src/sample/http/oms/app.ts +3 -3
  152. package/src/sample/tcp/qf-md/app.ts +3 -3
  153. package/src/sample/tcp/skeleton/app.ts +1 -1
  154. package/src/sample/tcp/trade-capture/app-launcher.ts +3 -3
  155. package/src/store/file-session-store.ts +2 -2
  156. package/src/store/file-session-stream-provider.ts +17 -11
  157. package/src/store/fix-session-store-factory.ts +1 -1
  158. package/src/store/fix-session-store.ts +11 -11
  159. package/src/store/memory-session-store.ts +1 -1
  160. package/src/store/session-stream-provider.ts +13 -13
  161. package/src/transport/ascii/ascii-session.ts +186 -11
  162. package/src/transport/duplex/fix-duplex.ts +9 -0
  163. package/src/transport/duplex/tcp-duplex.ts +10 -0
  164. package/src/transport/factory/msg-transport.ts +20 -0
  165. package/src/transport/session/a-session-msg-factory.ts +18 -0
  166. package/src/transport/session/clone-session-description.ts +41 -0
  167. package/src/transport/session/fix-clock.ts +1 -1
  168. package/src/transport/session/fix-session.ts +136 -25
  169. package/src/transport/session/index.ts +2 -0
  170. package/src/transport/session/resend-request-manager.ts +4 -4
  171. package/src/transport/session/session-msg-factory.ts +13 -0
  172. package/src/transport/session/session-registry.ts +112 -0
  173. package/src/transport/session/session-sequence-coordinator.ts +1 -1
  174. package/src/transport/session/session-sequence-store.ts +3 -3
  175. package/src/transport/tcp/tcp-acceptor-listener.ts +43 -5
  176. package/src/transport/tcp/tcp-acceptor.ts +92 -18
  177. package/src/transport/tcp/tcp-initiator.ts +26 -5
  178. package/src/transport/tcp/tcp-transport-description.ts +7 -0
  179. package/src/transport/tcp/tls-diagnostics.ts +76 -0
  180. 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
+ }
@@ -0,0 +1,6 @@
1
+ export * from './bench-types'
2
+ export * from './bench-stats'
3
+ export * from './bench-runner'
4
+ export * from './bench-report'
5
+ export * from './gc-probe'
6
+ export * from './parse-bench'