Skip to content

Commit

Permalink
perf: improve sort algorithm (#2756)
Browse files Browse the repository at this point in the history
* perf: improve sort algorithm

* benchmark: add headers-length32.mjs

* fix: benchmark

* fix: fix performance regression for sorted arrays

* test: add sorted test

* refactor: simplify

* refactor: remove comment
  • Loading branch information
tsctx committed Feb 21, 2024
1 parent bdfb863 commit fdbc221
Show file tree
Hide file tree
Showing 8 changed files with 571 additions and 23 deletions.
56 changes: 56 additions & 0 deletions benchmarks/headers-length32.mjs
Original file line number Diff line number Diff line change
@@ -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()
57 changes: 57 additions & 0 deletions benchmarks/headers.mjs
Original file line number Diff line number Diff line change
@@ -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()
50 changes: 50 additions & 0 deletions benchmarks/sort.mjs
Original file line number Diff line number Diff line change
@@ -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()
114 changes: 92 additions & 22 deletions lib/fetch/headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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')] () {
Expand Down Expand Up @@ -546,6 +614,8 @@ webidl.converters.HeadersInit = function (V) {

module.exports = {
fill,
// for test.
compareHeaderName,
Headers,
HeadersList
}

0 comments on commit fdbc221

Please sign in to comment.