Skip to content

Commit fdbc221

Browse files
authored
perf: improve sort algorithm (nodejs#2756)
* 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
1 parent bdfb863 commit fdbc221

File tree

8 files changed

+571
-23
lines changed

8 files changed

+571
-23
lines changed

benchmarks/headers-length32.mjs

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { bench, run } from 'mitata'
2+
import { Headers } from '../lib/fetch/headers.js'
3+
4+
const headers = new Headers(
5+
[
6+
'Origin-Agent-Cluster',
7+
'RTT',
8+
'Accept-CH-Lifetime',
9+
'X-Frame-Options',
10+
'Sec-CH-UA-Platform-Version',
11+
'Digest',
12+
'Cache-Control',
13+
'Sec-CH-UA-Platform',
14+
'If-Range',
15+
'SourceMap',
16+
'Strict-Transport-Security',
17+
'Want-Digest',
18+
'Cross-Origin-Resource-Policy',
19+
'Width',
20+
'Accept-CH',
21+
'Via',
22+
'Refresh',
23+
'Server',
24+
'Sec-Fetch-Dest',
25+
'Sec-CH-UA-Model',
26+
'Access-Control-Request-Method',
27+
'Access-Control-Request-Headers',
28+
'Date',
29+
'Expires',
30+
'DNT',
31+
'Proxy-Authorization',
32+
'Alt-Svc',
33+
'Alt-Used',
34+
'ETag',
35+
'Sec-Fetch-User',
36+
'Sec-CH-UA-Full-Version-List',
37+
'Referrer-Policy'
38+
].map((v) => [v, ''])
39+
)
40+
41+
const kHeadersList = Reflect.ownKeys(headers).find(
42+
(c) => String(c) === 'Symbol(headers list)'
43+
)
44+
45+
const headersList = headers[kHeadersList]
46+
47+
const kHeadersSortedMap = Reflect.ownKeys(headersList).find(
48+
(c) => String(c) === 'Symbol(headers map sorted)'
49+
)
50+
51+
bench('Headers@@iterator', () => {
52+
headersList[kHeadersSortedMap] = null
53+
return [...headers]
54+
})
55+
56+
await run()

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

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