Skip to content

Commit a16260c

Browse files
committed
perf: improve sort algorithm
1 parent 7936d69 commit a16260c

File tree

7 files changed

+493
-23
lines changed

7 files changed

+493
-23
lines changed

benchmarks/headers.mjs

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { bench, group, run } from 'mitata'
2+
import { Headers } from '../lib/fetch/headers.js'
3+
4+
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
5+
const charactersLength = characters.length
6+
7+
function generateAsciiString (length) {
8+
let result = ''
9+
for (let i = 0; i < length; ++i) {
10+
result += characters[Math.floor(Math.random() * charactersLength)]
11+
}
12+
return result
13+
}
14+
15+
const settings = {
16+
'fast-path (tiny array)': 4,
17+
'fast-path (small array)': 8,
18+
'fast-path (middle array)': 16,
19+
'fast-path': 32,
20+
'slow-path': 64
21+
}
22+
23+
for (const [name, length] of Object.entries(settings)) {
24+
const headers = new Headers(
25+
Array.from(Array(length), () => [generateAsciiString(12), ''])
26+
)
27+
28+
const headersSorted = new Headers(headers)
29+
30+
const kHeadersList = Reflect.ownKeys(headers).find(
31+
(c) => String(c) === 'Symbol(headers list)'
32+
)
33+
34+
const headersList = headers[kHeadersList]
35+
36+
const headersListSorted = headersSorted[kHeadersList]
37+
38+
const kHeadersSortedMap = Reflect.ownKeys(headersList).find(
39+
(c) => String(c) === 'Symbol(headers map sorted)'
40+
)
41+
42+
group(`length ${length} #${name}`, () => {
43+
bench('Headers@@iterator', () => {
44+
// prevention of memoization of results
45+
headersList[kHeadersSortedMap] = null
46+
return [...headers]
47+
})
48+
49+
bench('Headers@@iterator (sorted)', () => {
50+
// prevention of memoization of results
51+
headersListSorted[kHeadersSortedMap] = null
52+
return [...headersSorted]
53+
})
54+
})
55+
}
56+
57+
await run()

benchmarks/sort.mjs

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { bench, group, run } from 'mitata'
2+
import { sort, binaryInsertionSort, heapSort, introSort } from '../lib/fetch/sort.js'
3+
// import { sort as timSort } from './tim-sort.mjs'
4+
5+
function compare (a, b) {
6+
return a < b ? -1 : 1
7+
}
8+
9+
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
10+
const charactersLength = characters.length
11+
12+
function generateAsciiString (length) {
13+
let result = ''
14+
for (let i = 0; i < length; ++i) {
15+
result += characters[Math.floor(Math.random() * charactersLength)]
16+
}
17+
return result
18+
}
19+
20+
const settings = {
21+
tiny: 32,
22+
small: 64,
23+
middle: 128,
24+
large: 512
25+
}
26+
27+
for (const [name, length] of Object.entries(settings)) {
28+
group(`sort (${name})`, () => {
29+
const array = Array.from(new Array(length), () => generateAsciiString(12))
30+
// sort(array, compare)
31+
bench('Array#sort', () => array.slice().sort(compare))
32+
bench('sort (mixed sort)', () => sort(array.slice(), compare))
33+
// bench('tim sort', () => timSort(array.slice(), compare, 0, array.length))
34+
35+
// sort(array, start, end, compare)
36+
bench('intro sort', () => introSort(array.slice(), 0, array.length, compare))
37+
bench('heap sort', () => heapSort(array.slice(), 0, array.length, compare))
38+
39+
// Do not run them in large arrays as they are slow.
40+
if (array.length <= 1000) bench('binary insertion sort', () => binaryInsertionSort(array.slice(), 0, array.length, compare))
41+
})
42+
43+
group(`sort sortedArray (${name})`, () => {
44+
const array = Array.from(new Array(length), () => generateAsciiString(12)).sort(compare)
45+
// sort(array, compare)
46+
bench('Array#sort', () => array.sort(compare))
47+
bench('sort (mixed sort)', () => sort(array, compare))
48+
// bench('tim sort', () => timSort(array, compare, 0, array.length))
49+
50+
// sort(array, start, end, compare)
51+
bench('intro sort', () => introSort(array, 0, array.length, compare))
52+
bench('heap sort', () => heapSort(array, 0, array.length, compare))
53+
54+
// Do not run them in large arrays as they are slow.
55+
if (array.length <= 1000) bench('binary insertion sort', () => binaryInsertionSort(array, 0, array.length, compare))
56+
})
57+
}
58+
59+
await run()

lib/fetch/headers.js

+92-22
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
} = require('./util')
1313
const { webidl } = require('./webidl')
1414
const assert = require('node:assert')
15+
const { sort } = require('./sort')
1516

1617
const kHeadersMap = Symbol('headers map')
1718
const kHeadersSortedMap = Symbol('headers map sorted')
@@ -120,6 +121,10 @@ function appendHeader (headers, name, value) {
120121
// privileged no-CORS request headers from headers
121122
}
122123

124+
function compareHeaderName (a, b) {
125+
return a[0] < b[0] ? -1 : 1
126+
}
127+
123128
class HeadersList {
124129
/** @type {[string, string][]|null} */
125130
cookies = null
@@ -237,7 +242,7 @@ class HeadersList {
237242

238243
* [Symbol.iterator] () {
239244
// use the lowercased name
240-
for (const [name, { value }] of this[kHeadersMap]) {
245+
for (const { 0: name, 1: { value } } of this[kHeadersMap]) {
241246
yield [name, value]
242247
}
243248
}
@@ -253,6 +258,79 @@ class HeadersList {
253258

254259
return headers
255260
}
261+
262+
// https://fetch.spec.whatwg.org/#convert-header-names-to-a-sorted-lowercase-set
263+
toSortedArray () {
264+
const size = this[kHeadersMap].size
265+
const array = new Array(size)
266+
// In most cases, you will use the fast-path.
267+
// fast-path: Use binary insertion sort for small arrays.
268+
if (size <= 32) {
269+
if (size === 0) {
270+
// If empty, it is an empty array. To avoid the first index assignment.
271+
return array
272+
}
273+
// Improve performance by unrolling loop and avoiding double-loop.
274+
// Double-loop-less version of the binary insertion sort.
275+
const iterator = this[kHeadersMap][Symbol.iterator]()
276+
const firstValue = iterator.next().value
277+
// set [name, value] to first index.
278+
array[0] = [firstValue[0], firstValue[1].value]
279+
// https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine
280+
// 3.2.2. Assert: value is non-null.
281+
assert(firstValue[1].value !== null)
282+
for (
283+
let i = 1, j = 0, right = 0, left = 0, pivot = 0, x, value;
284+
i < size;
285+
++i
286+
) {
287+
// get next value
288+
value = iterator.next().value
289+
// set [name, value] to current index.
290+
x = array[i] = [value[0], value[1].value]
291+
// https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine
292+
// 3.2.2. Assert: value is non-null.
293+
assert(x[1] !== null)
294+
left = 0
295+
right = i
296+
// binary search
297+
while (left < right) {
298+
// middle index
299+
pivot = left + ((right - left) >> 1)
300+
// compare header name
301+
if (array[pivot][0] <= x[0]) {
302+
left = pivot + 1
303+
} else {
304+
right = pivot
305+
}
306+
}
307+
if (i !== pivot) {
308+
j = i
309+
while (j > left) {
310+
array[j] = array[--j]
311+
}
312+
array[left] = x
313+
}
314+
}
315+
/* c8 ignore next 4 */
316+
if (!iterator.next().done) {
317+
// This is for debugging and will never be called.
318+
throw new TypeError('Unreachable')
319+
}
320+
return array
321+
} else {
322+
// This case would be a rare occurrence.
323+
// slow-path: fallback
324+
let i = 0
325+
for (const { 0: name, 1: { value } } of this[kHeadersMap]) {
326+
array[i++] = [name, value]
327+
// https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine
328+
// 3.2.2. Assert: value is non-null.
329+
assert(value !== null)
330+
}
331+
return sort(array, compareHeaderName)
332+
}
333+
}
256334
}
257335

258336
// https://fetch.spec.whatwg.org/#headers-class
@@ -454,27 +532,19 @@ class Headers {
454532

455533
// 2. Let names be the result of convert header names to a sorted-lowercase
456534
// set with all the names of the headers in list.
457-
const names = [...this[kHeadersList]]
458-
const namesLength = names.length
459-
if (namesLength <= 16) {
460-
// Note: Use insertion sort for small arrays.
461-
for (let i = 1, value, j = 0; i < namesLength; ++i) {
462-
value = names[i]
463-
for (j = i - 1; j >= 0; --j) {
464-
if (names[j][0] <= value[0]) break
465-
names[j + 1] = names[j]
466-
}
467-
names[j + 1] = value
468-
}
469-
} else {
470-
names.sort((a, b) => a[0] < b[0] ? -1 : 1)
471-
}
535+
const names = this[kHeadersList].toSortedArray()
472536

473537
const cookies = this[kHeadersList].cookies
474538

539+
// fast-path
540+
if (cookies === null) {
541+
// Note: The non-null assertion of value has already been done by `HeadersList#toSortedArray`
542+
return (this[kHeadersList][kHeadersSortedMap] = names)
543+
}
544+
475545
// 3. For each name of names:
476-
for (let i = 0; i < namesLength; ++i) {
477-
const [name, value] = names[i]
546+
for (let i = 0; i < names.length; ++i) {
547+
const { 0: name, 1: value } = names[i]
478548
// 1. If name is `set-cookie`, then:
479549
if (name === 'set-cookie') {
480550
// 1. Let values be a list of all values of headers in list whose name
@@ -491,17 +561,15 @@ class Headers {
491561
// 1. Let value be the result of getting name from list.
492562

493563
// 2. Assert: value is non-null.
494-
assert(value !== null)
564+
// Note: This operation was done by `HeadersList#toSortedArray`.
495565

496566
// 3. Append (name, value) to headers.
497567
headers.push([name, value])
498568
}
499569
}
500570

501-
this[kHeadersList][kHeadersSortedMap] = headers
502-
503571
// 4. Return headers.
504-
return headers
572+
return (this[kHeadersList][kHeadersSortedMap] = headers)
505573
}
506574

507575
[Symbol.for('nodejs.util.inspect.custom')] () {
@@ -546,6 +614,8 @@ webidl.converters.HeadersInit = function (V) {
546614

547615
module.exports = {
548616
fill,
617+
// for test.
618+
compareHeaderName,
549619
Headers,
550620
HeadersList
551621
}

0 commit comments

Comments
 (0)