diff --git a/benchmark/bench-cmp-lib.js b/benchmark/bench-cmp-lib.js index 57a377d1..794e2dd4 100644 --- a/benchmark/bench-cmp-lib.js +++ b/benchmark/bench-cmp-lib.js @@ -1,6 +1,8 @@ 'use strict' +const assert = require('node:assert/strict') const { Bench } = require('tinybench') +const apiResponses = require('./fixtures/api-responses') const suite = new Bench({ name: 'Library Comparison Benchmarks', setup: (_task, mode) => { @@ -173,139 +175,162 @@ for (let i = 0; i < MULTI_ARRAY_LENGTH; i++) { multiArray[i] = obj } -suite.add('fast-json-stringify: creation', function () { - FJS(schema) +// Some serializers return unflattened strings. Fastify calls Buffer.byteLength() +// for Content-Length, which triggers flattening; include that cost in every case. +suite.add('fast-json-stringify: creation and first stringify', function () { + Buffer.byteLength(FJS(schema)(obj)) }) -suite.add('compile-json-stringify: creation', function () { - CJS(schemaCJS) +suite.add('compile-json-stringify: creation and first stringify', function () { + Buffer.byteLength(CJS(schemaCJS)(obj)) }) -suite.add('AJV: creation', function () { - ajv.compileSerializer(schemaAJVJTD) +suite.add('AJV: creation and first stringify', function () { + Buffer.byteLength(ajv.compileSerializer(schemaAJVJTD)(obj)) }) -suite.add('json-accelerator: creation', function () { - createAccelerator(schema) +suite.add('json-accelerator: creation and first stringify', function () { + Buffer.byteLength(createAccelerator(schema)(obj)) }) suite.add('JSON.stringify: array', function () { - JSON.stringify(multiArray) + Buffer.byteLength(JSON.stringify(multiArray)) }) suite.add('fast-json-stringify [default]: array', function () { - stringifyArrayDefault(multiArray) + Buffer.byteLength(stringifyArrayDefault(multiArray)) }) suite.add('json-accelerator: array', function () { - accelArray(multiArray) + Buffer.byteLength(accelArray(multiArray)) }) suite.add('fast-json-stringify [json-stringify]: array', function () { - stringifyArrayJSONStringify(multiArray) + Buffer.byteLength(stringifyArrayJSONStringify(multiArray)) }) suite.add('compile-json-stringify: array', function () { - CJSStringifyArray(multiArray) + Buffer.byteLength(CJSStringifyArray(multiArray)) }) suite.add('AJV: array', function () { - ajvSerializeArray(multiArray) + Buffer.byteLength(ajvSerializeArray(multiArray)) }) suite.add('JSON.stringify: large array', function () { - JSON.stringify(largeArray) + Buffer.byteLength(JSON.stringify(largeArray)) }) suite.add('fast-json-stringify [default]: large array', function () { - stringifyArrayDefault(largeArray) + Buffer.byteLength(stringifyArrayDefault(largeArray)) }) suite.add('fast-json-stringify [json-stringify]: large array', function () { - stringifyArrayJSONStringify(largeArray) + Buffer.byteLength(stringifyArrayJSONStringify(largeArray)) }) suite.add('compile-json-stringify: large array', function () { - CJSStringifyArray(largeArray) + Buffer.byteLength(CJSStringifyArray(largeArray)) }) suite.add('AJV: large array', function () { - ajvSerializeArray(largeArray) + Buffer.byteLength(ajvSerializeArray(largeArray)) }) suite.add('JSON.stringify: long string', function () { - JSON.stringify(str) + Buffer.byteLength(JSON.stringify(str)) }) suite.add('fast-json-stringify: long string', function () { - stringifyString(str) + Buffer.byteLength(stringifyString(str)) }) suite.add('json-accelerator: long string', function () { - stringifyString(str) + Buffer.byteLength(stringifyString(str)) }) suite.add('compile-json-stringify: long string', function () { - CJSStringifyString(str) + Buffer.byteLength(CJSStringifyString(str)) }) suite.add('AJV: long string', function () { - ajvSerializeString(str) + Buffer.byteLength(ajvSerializeString(str)) }) suite.add('JSON.stringify: short string', function () { - JSON.stringify('hello world') + Buffer.byteLength(JSON.stringify('hello world')) }) suite.add('fast-json-stringify: short string', function () { - stringifyString('hello world') + Buffer.byteLength(stringifyString('hello world')) }) suite.add('json-accelerator: short string', function () { - accelString('hello world') + Buffer.byteLength(accelString('hello world')) }) suite.add('compile-json-stringify: short string', function () { - CJSStringifyString('hello world') + Buffer.byteLength(CJSStringifyString('hello world')) }) suite.add('AJV: short string', function () { - ajvSerializeString('hello world') + Buffer.byteLength(ajvSerializeString('hello world')) }) suite.add('JSON.stringify: obj', function () { - JSON.stringify(obj) + Buffer.byteLength(JSON.stringify(obj)) }) suite.add('fast-json-stringify: obj', function () { - stringify(obj) + Buffer.byteLength(stringify(obj)) }) suite.add('json-accelerator: obj', function () { - accelStringify(obj) + Buffer.byteLength(accelStringify(obj)) }) suite.add('compile-json-stringify: obj', function () { - CJSStringify(obj) + Buffer.byteLength(CJSStringify(obj)) }) suite.add('AJV: obj', function () { - ajvSerialize(obj) + Buffer.byteLength(ajvSerialize(obj)) }) suite.add('JSON.stringify: date', function () { - JSON.stringify(date) + Buffer.byteLength(JSON.stringify(date)) }) suite.add('fast-json-stringify: date', function () { - stringifyDate(date) + Buffer.byteLength(stringifyDate(date)) }) suite.add('json-accelerate: date', function () { - accelDate(date) + Buffer.byteLength(accelDate(date)) }) suite.add('compile-json-stringify: date', function () { - CJSStringifyDate(date) + Buffer.byteLength(CJSStringifyDate(date)) }) +for (const { name, schema: responseSchema, input } of apiResponses) { + const stringifyResponse = FJS(responseSchema) + // Check outside the timed loop that the schema preserves the complete response. + assert.deepStrictEqual(JSON.parse(stringifyResponse(input)), JSON.parse(JSON.stringify(input))) + + suite.add(`JSON.stringify: ${name}`, function () { + Buffer.byteLength(JSON.stringify(input)) + }) + suite.add(`fast-json-stringify: ${name}`, function () { + Buffer.byteLength(stringifyResponse(input)) + }) +} + +if (process.argv.includes('--fjs-vs-json')) { + for (const task of suite.tasks) { + if (!task.name.startsWith('fast-json-stringify') && !task.name.startsWith('JSON.stringify:')) { + suite.remove(task.name) + } + } +} + suite.run().then(() => { const results = suite.tasks.map(task => ({ name: task.name, diff --git a/benchmark/bench-thread.js b/benchmark/bench-thread.js index 91f3cab8..89c247b2 100644 --- a/benchmark/bench-thread.js +++ b/benchmark/bench-thread.js @@ -18,10 +18,12 @@ const FJS = require('..') const stringify = benchmark.compile ? null : FJS(benchmark.schema, benchmark.options) bench.add(benchmark.name, () => { + // Some serializers return unflattened strings. Fastify calls Buffer.byteLength() + // for Content-Length, which triggers flattening; include that cost here too. if (benchmark.compile) { - FJS(benchmark.schema, benchmark.options)(benchmark.input) + Buffer.byteLength(FJS(benchmark.schema, benchmark.options)(benchmark.input)) } else { - stringify(benchmark.input) + Buffer.byteLength(stringify(benchmark.input)) } }).run().then(() => { const task = bench.tasks[0] diff --git a/benchmark/fixtures/api-responses.js b/benchmark/fixtures/api-responses.js new file mode 100644 index 00000000..d03ac8a3 --- /dev/null +++ b/benchmark/fixtures/api-responses.js @@ -0,0 +1,266 @@ +'use strict' + +const addressSchema = { + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' }, + postalCode: { type: 'string' }, + country: { type: 'string' }, + apartment: { type: ['string', 'null'] } + } +} + +const productSchema = { + type: 'object', + properties: { + id: { type: 'string' }, + sku: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' }, + price: { + type: 'object', + properties: { + amount: { type: 'integer' }, + currency: { type: 'string' }, + discount: { type: ['number', 'null'] } + } + }, + inventory: { + type: 'object', + properties: { + available: { type: 'boolean' }, + quantity: { type: 'integer' }, + warehouse: { type: 'string' } + } + }, + tags: { type: 'array', items: { type: 'string' } }, + images: { + type: 'array', + items: { + type: 'object', + properties: { + url: { type: 'string' }, + alt: { type: 'string' }, + primary: { type: 'boolean' } + } + } + }, + rating: { + type: 'object', + properties: { + average: { type: 'number' }, + count: { type: 'integer' } + } + }, + updatedAt: { type: 'string' } + } +} + +// Deterministic synthetic responses include Unicode and escaping, as API payloads do. +const address = { + street: '12 Example Street', + city: 'Łódź', + postalCode: '90-001', + country: 'PL', + apartment: null +} + +const products = Array.from({ length: 50 }, (_, i) => ({ + id: `product_${i + 1}`, + sku: `MUG-${1000 + i}`, + name: `Café mug "Everyday" — ${i + 1}`, + description: 'Handmade ceramic mug ☕\nDishwasher safe. Available in blue, green, and cream.', + price: { + amount: 1800 + i * 100, + currency: 'EUR', + discount: i % 2 === 0 ? null : 0.1 + }, + inventory: { available: i % 7 !== 0, quantity: i * 3, warehouse: 'eu-central' }, + tags: ['home', 'kitchen', i % 2 === 0 ? 'ceramic' : 'stoneware'], + images: [ + { url: `https://example.com/products/${i + 1}/front.jpg`, alt: 'Front view', primary: true }, + { url: `https://example.com/products/${i + 1}/side.jpg`, alt: 'Side view', primary: false } + ], + rating: { average: 4.2 + (i % 5) / 10, count: 10 + i * 7 }, + updatedAt: '2026-01-15T10:30:00.000Z' +})) + +const orderItems = products.slice(0, 5).map((product, i) => ({ + product, + quantity: i + 1, + unitPrice: product.price.amount, + total: product.price.amount * (i + 1) +})) +const subtotal = orderItems.reduce((sum, item) => sum + item.total, 0) + +module.exports = [ + { + name: 'user profile response', + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + id: { type: 'string' }, + email: { type: 'string' }, + displayName: { type: 'string' }, + bio: { type: 'string' }, + avatarUrl: { type: ['string', 'null'] }, + active: { type: 'boolean' }, + roles: { type: 'array', items: { type: 'string' } }, + address: addressSchema, + organization: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + plan: { type: 'string' } + } + }, + preferences: { + type: 'object', + properties: { + locale: { type: 'string' }, + timezone: { type: 'string' }, + notifications: { + type: 'object', + properties: { + email: { type: 'boolean' }, + push: { type: 'boolean' }, + digest: { type: 'string' } + } + } + } + }, + createdAt: { type: 'string' }, + lastLoginAt: { type: ['string', 'null'] } + } + }, + meta: { + type: 'object', + properties: { requestId: { type: 'string' }, version: { type: 'integer' } } + } + } + }, + input: { + data: { + id: 'user_123', + email: 'alex@example.com', + displayName: 'Alex Żółć', + bio: 'Engineer & coffee enthusiast ☕\nWorking on "Project Atlas".', + avatarUrl: null, + active: true, + roles: ['member', 'billing-admin'], + address, + organization: { id: 'org_456', name: 'Example & Co.', plan: 'business' }, + preferences: { + locale: 'pl-PL', + timezone: 'Europe/Warsaw', + notifications: { email: true, push: false, digest: 'weekly' } + }, + createdAt: '2025-06-01T08:00:00.000Z', + lastLoginAt: '2026-01-15T10:30:00.000Z' + }, + meta: { requestId: 'req_profile_123', version: 1 } + } + }, + { + name: 'order response', + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + status: { type: 'string' }, + customer: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + email: { type: 'string' } + } + }, + shippingAddress: addressSchema, + items: { + type: 'array', + items: { + type: 'object', + properties: { + product: productSchema, + quantity: { type: 'integer' }, + unitPrice: { type: 'integer' }, + total: { type: 'integer' } + } + } + }, + totals: { + type: 'object', + properties: { + subtotal: { type: 'integer' }, + shipping: { type: 'integer' }, + tax: { type: 'integer' }, + total: { type: 'integer' }, + currency: { type: 'string' } + } + }, + payment: { + type: 'object', + properties: { + method: { type: 'string' }, + paid: { type: 'boolean' }, + transactionId: { type: ['string', 'null'] } + } + }, + notes: { type: ['string', 'null'] }, + createdAt: { type: 'string' } + } + }, + input: { + id: 'order_789', + status: 'processing', + customer: { id: 'user_123', name: 'Alex Żółć', email: 'alex@example.com' }, + shippingAddress: address, + items: orderItems, + totals: { subtotal, shipping: 500, tax: 4200, total: subtotal + 4700, currency: 'EUR' }, + payment: { method: 'card', paid: true, transactionId: 'txn_987' }, + notes: 'Please ring the bell marked "Office".\nLeave with reception if unavailable.', + createdAt: '2026-01-15T10:30:00.000Z' + } + }, + { + name: 'paginated products response', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: productSchema }, + pagination: { + type: 'object', + properties: { + page: { type: 'integer' }, + pageSize: { type: 'integer' }, + total: { type: 'integer' }, + hasNextPage: { type: 'boolean' } + } + }, + links: { + type: 'object', + properties: { + self: { type: 'string' }, + next: { type: 'string' }, + previous: { type: ['string', 'null'] } + } + } + } + }, + input: { + data: products, + pagination: { page: 1, pageSize: 50, total: 1248, hasNextPage: true }, + links: { + self: '/products?page=1&pageSize=50', + next: '/products?page=2&pageSize=50', + previous: null + } + } + } +]