diff --git a/benchmarks/headers-length32.mjs b/benchmarks/headers-length32.mjs new file mode 100644 index 00000000000..d057ed77eeb --- /dev/null +++ b/benchmarks/headers-length32.mjs @@ -0,0 +1,56 @@ +import { bench, run } from 'mitata' +import { Headers } from '../lib/fetch/headers.js' + +const headers = new Headers( + [ + 'Origin-Agent-Cluster', + 'RTT', + 'Accept-CH-Lifetime', + 'X-Frame-Options', + 'Sec-CH-UA-Platform-Version', + 'Digest', + 'Cache-Control', + 'Sec-CH-UA-Platform', + 'If-Range', + 'SourceMap', + 'Strict-Transport-Security', + 'Want-Digest', + 'Cross-Origin-Resource-Policy', + 'Width', + 'Accept-CH', + 'Via', + 'Refresh', + 'Server', + 'Sec-Fetch-Dest', + 'Sec-CH-UA-Model', + 'Access-Control-Request-Method', + 'Access-Control-Request-Headers', + 'Date', + 'Expires', + 'DNT', + 'Proxy-Authorization', + 'Alt-Svc', + 'Alt-Used', + 'ETag', + 'Sec-Fetch-User', + 'Sec-CH-UA-Full-Version-List', + 'Referrer-Policy' + ].map((v) => [v, '']) +) + +const kHeadersList = Reflect.ownKeys(headers).find( + (c) => String(c) === 'Symbol(headers list)' +) + +const headersList = headers[kHeadersList] + +const kHeadersSortedMap = Reflect.ownKeys(headersList).find( + (c) => String(c) === 'Symbol(headers map sorted)' +) + +bench('Headers@@iterator', () => { + headersList[kHeadersSortedMap] = null + return [...headers] +}) + +await run() diff --git a/benchmarks/headers.mjs b/benchmarks/headers.mjs new file mode 100644 index 00000000000..0484e6e1316 --- /dev/null +++ b/benchmarks/headers.mjs @@ -0,0 +1,57 @@ +import { bench, group, run } from 'mitata' +import { Headers } from '../lib/fetch/headers.js' + +const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' +const charactersLength = characters.length + +function generateAsciiString (length) { + let result = '' + for (let i = 0; i < length; ++i) { + result += characters[Math.floor(Math.random() * charactersLength)] + } + return result +} + +const settings = { + 'fast-path (tiny array)': 4, + 'fast-path (small array)': 8, + 'fast-path (middle array)': 16, + 'fast-path': 32, + 'slow-path': 64 +} + +for (const [name, length] of Object.entries(settings)) { + const headers = new Headers( + Array.from(Array(length), () => [generateAsciiString(12), '']) + ) + + const headersSorted = new Headers(headers) + + const kHeadersList = Reflect.ownKeys(headers).find( + (c) => String(c) === 'Symbol(headers list)' + ) + + const headersList = headers[kHeadersList] + + const headersListSorted = headersSorted[kHeadersList] + + const kHeadersSortedMap = Reflect.ownKeys(headersList).find( + (c) => String(c) === 'Symbol(headers map sorted)' + ) + + group(`length ${length} #${name}`, () => { + bench('Headers@@iterator', () => { + // prevention of memoization of results + headersList[kHeadersSortedMap] = null + return [...headers] + }) + + bench('Headers@@iterator (sorted)', () => { + // prevention of memoization of results + headersListSorted[kHeadersSortedMap] = null + return [...headersSorted] + }) + }) +} + +await run() diff --git a/benchmarks/sort.mjs b/benchmarks/sort.mjs new file mode 100644 index 00000000000..a1c413a00de --- /dev/null +++ b/benchmarks/sort.mjs @@ -0,0 +1,50 @@ +import { bench, group, run } from 'mitata' +import { sort, heapSort, introSort } from '../lib/fetch/sort.js' + +function compare (a, b) { + return a < b ? -1 : 1 +} + +const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' +const charactersLength = characters.length + +function generateAsciiString (length) { + let result = '' + for (let i = 0; i < length; ++i) { + result += characters[Math.floor(Math.random() * charactersLength)] + } + return result +} + +const settings = { + tiny: 32, + small: 64, + middle: 128, + large: 512 +} + +for (const [name, length] of Object.entries(settings)) { + group(`sort (${name})`, () => { + const array = Array.from(new Array(length), () => generateAsciiString(12)) + // sort(array, compare) + bench('Array#sort', () => array.slice().sort(compare)) + bench('sort (intro sort)', () => sort(array.slice(), compare)) + + // sort(array, start, end, compare) + bench('intro sort', () => introSort(array.slice(), 0, array.length, compare)) + bench('heap sort', () => heapSort(array.slice(), 0, array.length, compare)) + }) + + group(`sort sortedArray (${name})`, () => { + const array = Array.from(new Array(length), () => generateAsciiString(12)).sort(compare) + // sort(array, compare) + bench('Array#sort', () => array.sort(compare)) + bench('sort (intro sort)', () => sort(array, compare)) + + // sort(array, start, end, compare) + bench('intro sort', () => introSort(array, 0, array.length, compare)) + bench('heap sort', () => heapSort(array, 0, array.length, compare)) + }) +} + +await run() diff --git a/lib/fetch/headers.js b/lib/fetch/headers.js index 41ae9b02368..8ee66fbf996 100644 --- a/lib/fetch/headers.js +++ b/lib/fetch/headers.js @@ -12,6 +12,7 @@ const { } = require('./util') const { webidl } = require('./webidl') const assert = require('node:assert') +const { sort } = require('./sort') const kHeadersMap = Symbol('headers map') const kHeadersSortedMap = Symbol('headers map sorted') @@ -120,6 +121,10 @@ function appendHeader (headers, name, value) { // privileged no-CORS request headers from headers } +function compareHeaderName (a, b) { + return a[0] < b[0] ? -1 : 1 +} + class HeadersList { /** @type {[string, string][]|null} */ cookies = null @@ -237,7 +242,7 @@ class HeadersList { * [Symbol.iterator] () { // use the lowercased name - for (const [name, { value }] of this[kHeadersMap]) { + for (const { 0: name, 1: { value } } of this[kHeadersMap]) { yield [name, value] } } @@ -253,6 +258,79 @@ class HeadersList { return headers } + + // https://fetch.spec.whatwg.org/#convert-header-names-to-a-sorted-lowercase-set + toSortedArray () { + const size = this[kHeadersMap].size + const array = new Array(size) + // In most cases, you will use the fast-path. + // fast-path: Use binary insertion sort for small arrays. + if (size <= 32) { + if (size === 0) { + // If empty, it is an empty array. To avoid the first index assignment. + return array + } + // Improve performance by unrolling loop and avoiding double-loop. + // Double-loop-less version of the binary insertion sort. + const iterator = this[kHeadersMap][Symbol.iterator]() + const firstValue = iterator.next().value + // set [name, value] to first index. + array[0] = [firstValue[0], firstValue[1].value] + // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine + // 3.2.2. Assert: value is non-null. + assert(firstValue[1].value !== null) + for ( + let i = 1, j = 0, right = 0, left = 0, pivot = 0, x, value; + i < size; + ++i + ) { + // get next value + value = iterator.next().value + // set [name, value] to current index. + x = array[i] = [value[0], value[1].value] + // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine + // 3.2.2. Assert: value is non-null. + assert(x[1] !== null) + left = 0 + right = i + // binary search + while (left < right) { + // middle index + pivot = left + ((right - left) >> 1) + // compare header name + if (array[pivot][0] <= x[0]) { + left = pivot + 1 + } else { + right = pivot + } + } + if (i !== pivot) { + j = i + while (j > left) { + array[j] = array[--j] + } + array[left] = x + } + } + /* c8 ignore next 4 */ + if (!iterator.next().done) { + // This is for debugging and will never be called. + throw new TypeError('Unreachable') + } + return array + } else { + // This case would be a rare occurrence. + // slow-path: fallback + let i = 0 + for (const { 0: name, 1: { value } } of this[kHeadersMap]) { + array[i++] = [name, value] + // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine + // 3.2.2. Assert: value is non-null. + assert(value !== null) + } + return sort(array, compareHeaderName) + } + } } // https://fetch.spec.whatwg.org/#headers-class @@ -454,27 +532,19 @@ class Headers { // 2. Let names be the result of convert header names to a sorted-lowercase // set with all the names of the headers in list. - const names = [...this[kHeadersList]] - const namesLength = names.length - if (namesLength <= 16) { - // Note: Use insertion sort for small arrays. - for (let i = 1, value, j = 0; i < namesLength; ++i) { - value = names[i] - for (j = i - 1; j >= 0; --j) { - if (names[j][0] <= value[0]) break - names[j + 1] = names[j] - } - names[j + 1] = value - } - } else { - names.sort((a, b) => a[0] < b[0] ? -1 : 1) - } + const names = this[kHeadersList].toSortedArray() const cookies = this[kHeadersList].cookies + // fast-path + if (cookies === null) { + // Note: The non-null assertion of value has already been done by `HeadersList#toSortedArray` + return (this[kHeadersList][kHeadersSortedMap] = names) + } + // 3. For each name of names: - for (let i = 0; i < namesLength; ++i) { - const [name, value] = names[i] + for (let i = 0; i < names.length; ++i) { + const { 0: name, 1: value } = names[i] // 1. If name is `set-cookie`, then: if (name === 'set-cookie') { // 1. Let values be a list of all values of headers in list whose name @@ -491,17 +561,15 @@ class Headers { // 1. Let value be the result of getting name from list. // 2. Assert: value is non-null. - assert(value !== null) + // Note: This operation was done by `HeadersList#toSortedArray`. // 3. Append (name, value) to headers. headers.push([name, value]) } } - this[kHeadersList][kHeadersSortedMap] = headers - // 4. Return headers. - return headers + return (this[kHeadersList][kHeadersSortedMap] = headers) } [Symbol.for('nodejs.util.inspect.custom')] () { @@ -546,6 +614,8 @@ webidl.converters.HeadersInit = function (V) { module.exports = { fill, + // for test. + compareHeaderName, Headers, HeadersList } diff --git a/lib/fetch/sort.js b/lib/fetch/sort.js new file mode 100644 index 00000000000..230f2e2645c --- /dev/null +++ b/lib/fetch/sort.js @@ -0,0 +1,187 @@ +'use strict' + +/** **binary insertion sort** + * - Best -> O(n) + * - Average -> O(n^2) + * - Worst -> O(n^2) + * - Memory -> O(n) total, O(1) auxiliary + * - Stable -> true + * @param {any[]} array + * @param {number} begin begin + * @param {number} end end + * @param {(a: any, b: any) => number} compare + */ +function binaryInsertionSort (array, begin, end, compare) { + for ( + let i = begin + 1, j = 0, right = 0, left = 0, pivot = 0, x; + i < end; + ++i + ) { + x = array[i] + left = 0 + right = i + // binary search + while (left < right) { + // middle index + pivot = left + ((right - left) >> 1) + if (compare(array[pivot], x) <= 0) { + left = pivot + 1 + } else { + right = pivot + } + } + if (i !== pivot) { + j = i + while (j > left) { + array[j] = array[--j] + } + array[left] = x + } + } + return array +} + +/** + * @param {number} num + */ +function log2 (num) { + // Math.floor(Math.log2(num)) + let log = 0 + // eslint-disable-next-line no-cond-assign + while ((num >>= 1)) ++log + return log +} + +/** **intro sort** + * - Average -> O(n log n) + * - Worst -> O(n log n) + * - Stable -> false + * @param {any[]} array + * @param {number} begin begin + * @param {number} end end + * @param {(a: any, b: any) => number} compare + */ +function introSort (array, begin, end, compare) { + return _introSort(array, begin, end, log2(end - begin) << 1, compare) +} + +/** + * @param {any[]} array + * @param {number} begin + * @param {number} end + * @param {number} depth + * @param {(a: any, b: any) => number} compare + */ +function _introSort (array, begin, end, depth, compare) { + if (end - begin <= 32) { + return binaryInsertionSort(array, begin, end, compare) + } + if (depth-- <= 0) { + return heapSort(array, begin, end, compare) + } + // median of three quick sort + let i = begin + let j = end - 1 + const pivot = medianOf3( + array[i], + array[i + ((j - i) >> 1)], + array[j], + compare + ) + let firstPass = true + while (true) { + while (compare(array[i], pivot) < 0) ++i + while (compare(pivot, array[j]) < 0) --j + if (i >= j) break; + [array[i], array[j]] = [array[j], array[i]] + ++i + --j + firstPass = false + } + if (i - begin > 1 && !firstPass) _introSort(array, begin, i, depth, compare) + // if (end - (j + 1) > 1) ... + if (end - j > 2 && !firstPass) _introSort(array, j + 1, end, depth, compare) + return array +} + +/** **heap sort (bottom up)** + * - Best -> Ω(n) + * - Average -> O(n log n) + * - Worst -> O(n log n) + * - Memory -> O(n) total, O(1) auxiliary + * - Stable -> false + * @param {any[]} array + * @param {number} begin + * @param {number} end + * @param {(a: any, b: any) => number} compare + */ +function heapSort (array, begin, end, compare) { + const N = end - begin + let p = N >> 1 + let q = N - 1 + let x + while (p > 0) { + downHeap(array, array[begin + p - 1], begin, --p, q, compare) + } + while (q > 0) { + x = array[begin + q] + array[begin + q] = array[begin] + downHeap(array, (array[begin] = x), begin, 0, --q, compare) + } + return array +} + +/** + * @param {any[]} array + * @param {any} x + * @param {number} begin + * @param {number} p + * @param {number} q + * @param {(a: any, b: any) => number} compare + */ +function downHeap (array, x, begin, p, q, compare) { + let c + while ((c = (p << 1) + 1) <= q) { + if (c < q && compare(array[begin + c], array[begin + c + 1]) < 0) ++c + if (compare(x, array[begin + c]) >= 0) break + array[begin + p] = array[begin + c] + p = c + } + array[begin + p] = x +} + +/** + * @param {any} x + * @param {any} y + * @param {any} z + * @param {(a: any, b: any) => number} compare + */ +function medianOf3 (x, y, z, compare) { + return compare(x, y) < 0 + ? compare(y, z) < 0 + ? y + : compare(z, x) < 0 + ? x + : z + : compare(z, y) < 0 + ? y + : compare(x, z) < 0 + ? x + : z +} + +/** + * @param {any[]} array + * @param {(a: any, b: any) => number} compare + */ +function sort (array, compare) { + const length = array.length + return _introSort(array, 0, length, log2(length) << 1, compare) +} + +module.exports = { + sort, + binaryInsertionSort, + introSort, + heapSort +} diff --git a/package.json b/package.json index c85ec55102f..fabb4d5f1ba 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "jest": "^29.0.2", "jsdom": "^24.0.0", "jsfuzz": "^1.0.15", - "mitata": "^0.1.8", + "mitata": "^0.1.10", "node-fetch": "^3.3.2", "pre-commit": "^1.2.2", "proxy": "^1.0.2", diff --git a/test/fetch/headerslist-sortedarray.js b/test/fetch/headerslist-sortedarray.js new file mode 100644 index 00000000000..72112f92288 --- /dev/null +++ b/test/fetch/headerslist-sortedarray.js @@ -0,0 +1,38 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { HeadersList, compareHeaderName } = require('../../lib/fetch/headers') + +const characters = 'abcdefghijklmnopqrstuvwxyz0123456789' +const charactersLength = characters.length + +function generateAsciiString (length) { + let result = '' + for (let i = 0; i < length; ++i) { + result += characters[Math.floor(Math.random() * charactersLength)] + } + return result +} + +const SORT_RUN = 4000 + +test('toSortedArray (fast-path)', () => { + for (let i = 0; i < SORT_RUN; ++i) { + const headersList = new HeadersList() + for (let j = 0; j < 32; ++j) { + headersList.append(generateAsciiString(4), generateAsciiString(4)) + } + assert.deepStrictEqual(headersList.toSortedArray(), [...headersList].sort(compareHeaderName)) + } +}) + +test('toSortedArray (slow-path)', () => { + for (let i = 0; i < SORT_RUN; ++i) { + const headersList = new HeadersList() + for (let j = 0; j < 64; ++j) { + headersList.append(generateAsciiString(4), generateAsciiString(4)) + } + assert.deepStrictEqual(headersList.toSortedArray(), [...headersList].sort(compareHeaderName)) + } +}) diff --git a/test/fetch/sort.js b/test/fetch/sort.js new file mode 100644 index 00000000000..a373c2a62ef --- /dev/null +++ b/test/fetch/sort.js @@ -0,0 +1,90 @@ +'use strict' + +const { describe, test } = require('node:test') +const assert = require('node:assert') +const { sort, heapSort, binaryInsertionSort, introSort } = require('../../lib/fetch/sort') + +function generateRandomNumberArray (length) { + const array = new Uint16Array(length) + for (let i = 0; i < length; ++i) { + array[i] = (65535 * Math.random()) | 0 + } + return array +} + +const compare = (a, b) => a - b + +const SORT_RUN = 4000 + +const SORT_ELEMENT = 200 + +describe('sort', () => { + const arrays = new Array(SORT_RUN) + const expectedArrays = new Array(SORT_RUN) + + for (let i = 0; i < SORT_RUN; ++i) { + const array = generateRandomNumberArray(SORT_ELEMENT) + const expected = array.slice().sort(compare) + arrays[i] = array + expectedArrays[i] = expected + } + + test('binary insertion sort', () => { + for (let i = 0; i < SORT_RUN; ++i) { + assert.deepStrictEqual(binaryInsertionSort(arrays[i].slice(), 0, SORT_ELEMENT, compare), expectedArrays[i]) + } + }) + + test('heap sort', () => { + for (let i = 0; i < SORT_RUN; ++i) { + assert.deepStrictEqual(heapSort(arrays[i].slice(), 0, SORT_ELEMENT, compare), expectedArrays[i]) + } + }) + + test('intro sort', () => { + for (let i = 0; i < SORT_RUN; ++i) { + assert.deepStrictEqual(introSort(arrays[i].slice(), 0, SORT_ELEMENT, compare), expectedArrays[i]) + } + }) + + test('sort', () => { + for (let i = 0; i < SORT_RUN; ++i) { + assert.deepStrictEqual(sort(arrays[i].slice(), compare), expectedArrays[i]) + } + }) +}) + +describe('sorted', () => { + const arrays = new Array(SORT_RUN) + const expectedArrays = new Array(SORT_RUN) + + for (let i = 0; i < SORT_RUN; ++i) { + const array = generateRandomNumberArray(SORT_ELEMENT).sort(compare) + arrays[i] = array + expectedArrays[i] = array.slice() + } + + test('binary insertion sort', () => { + for (let i = 0; i < SORT_RUN; ++i) { + assert.deepStrictEqual(binaryInsertionSort(arrays[i].slice(), 0, SORT_ELEMENT, compare), expectedArrays[i]) + } + }) + + test('heap sort', () => { + for (let i = 0; i < SORT_RUN; ++i) { + assert.deepStrictEqual(heapSort(arrays[i].slice(), 0, SORT_ELEMENT, compare), expectedArrays[i]) + } + }) + + test('intro sort', () => { + for (let i = 0; i < SORT_RUN; ++i) { + assert.deepStrictEqual(introSort(arrays[i].slice(), 0, SORT_ELEMENT, compare), expectedArrays[i]) + } + }) + + test('sort', () => { + for (let i = 0; i < SORT_RUN; ++i) { + assert.deepStrictEqual(sort(arrays[i].slice(), compare), expectedArrays[i]) + } + }) +})