Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: improve sort algorithm #2756

Merged
merged 7 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
}