Skip to content

Commit 87a8372

Browse files
GregBrimbleclaude
andcommitted
perf: graduate asset-server binary search experiment to 100%
The improved iterative binary search implementation has been graduated from a 50% experiment to the default implementation. This provides better performance for asset manifest lookups by replacing the recursive binary search with an iterative approach. Changes: - Replace experimental logic in worker.ts with direct usage of improved implementation - Update assets-manifest.ts to use iterative binary search algorithm - Remove experimental files (assets-manifest.2.ts and its test file) - Update tests to work with new implementation signature - Add changeset documenting the performance improvement 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 25dbe54 commit 87a8372

File tree

6 files changed

+115
-471
lines changed

6 files changed

+115
-471
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@cloudflare/workers-shared": patch
3+
---
4+
5+
perf: graduate asset-server binary search experiment to 100%
6+
7+
The improved iterative binary search implementation has been graduated from a 50% experiment to the default implementation. This provides better performance for asset manifest lookups by replacing the recursive binary search with an iterative approach.

packages/workers-shared/asset-worker/src/assets-manifest.2.ts

Lines changed: 0 additions & 122 deletions
This file was deleted.
Lines changed: 94 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,22 @@
11
import {
2+
CONTENT_HASH_OFFSET,
23
CONTENT_HASH_SIZE,
34
ENTRY_SIZE,
45
HEADER_SIZE,
56
PATH_HASH_SIZE,
67
} from "../../utils/constants";
78

89
export class AssetsManifest {
9-
private data: ArrayBuffer;
10+
private data: Uint8Array;
1011

1112
constructor(data: ArrayBuffer) {
12-
this.data = data;
13+
this.data = new Uint8Array(data);
1314
}
1415

1516
async get(pathname: string) {
1617
const pathHash = await hashPath(pathname);
17-
const entry = binarySearch(
18-
new Uint8Array(this.data, HEADER_SIZE),
19-
pathHash
20-
);
21-
return entry ? contentHashToKey(entry) : null;
18+
const entry = binarySearch(this.data, pathHash);
19+
return entry ? Uint8ToHexString(entry) : null;
2220
}
2321
}
2422

@@ -32,41 +30,103 @@ export const hashPath = async (path: string) => {
3230
return new Uint8Array(hashBuffer, 0, PATH_HASH_SIZE);
3331
};
3432

33+
/**
34+
* Search for an entry with the given hash path.
35+
*
36+
* @param manifest the manifest bytes
37+
* @param pathHash the path hash to find in the manifest
38+
* @returns The content hash when the entry is found and `false` otherwise
39+
*/
3540
export const binarySearch = (
36-
arr: Uint8Array,
37-
searchValue: Uint8Array
41+
manifest: Uint8Array,
42+
pathHash: Uint8Array
3843
): Uint8Array | false => {
39-
if (arr.byteLength === 0) {
40-
return false;
41-
}
42-
const offset =
43-
arr.byteOffset + ((arr.byteLength / ENTRY_SIZE) >> 1) * ENTRY_SIZE;
44-
const current = new Uint8Array(arr.buffer, offset, PATH_HASH_SIZE);
45-
if (current.byteLength !== searchValue.byteLength) {
44+
if (pathHash.byteLength !== PATH_HASH_SIZE) {
4645
throw new TypeError(
47-
"Search value and current value are of different lengths"
46+
`Search value should have a length of ${PATH_HASH_SIZE}`
4847
);
4948
}
50-
const cmp = compare(searchValue, current);
51-
if (cmp < 0) {
52-
const nextOffset = arr.byteOffset;
53-
const nextLength = offset - arr.byteOffset;
54-
return binarySearch(
55-
new Uint8Array(arr.buffer, nextOffset, nextLength),
56-
searchValue
57-
);
58-
} else if (cmp > 0) {
59-
const nextOffset = offset + ENTRY_SIZE;
60-
const nextLength = arr.buffer.byteLength - offset - ENTRY_SIZE;
61-
return binarySearch(
62-
new Uint8Array(arr.buffer, nextOffset, nextLength),
63-
searchValue
49+
50+
const numberOfEntries = (manifest.byteLength - HEADER_SIZE) / ENTRY_SIZE;
51+
52+
if (numberOfEntries === 0) {
53+
return false;
54+
}
55+
56+
let lowIndex = 0;
57+
let highIndex = numberOfEntries - 1;
58+
59+
while (lowIndex <= highIndex) {
60+
const middleIndex = (lowIndex + highIndex) >> 1;
61+
62+
const cmp = comparePathHashWithEntry(pathHash, manifest, middleIndex);
63+
64+
if (cmp < 0) {
65+
highIndex = middleIndex - 1;
66+
continue;
67+
}
68+
69+
if (cmp > 0) {
70+
lowIndex = middleIndex + 1;
71+
continue;
72+
}
73+
74+
return new Uint8Array(
75+
manifest.buffer,
76+
HEADER_SIZE + middleIndex * ENTRY_SIZE + CONTENT_HASH_OFFSET,
77+
CONTENT_HASH_SIZE
6478
);
65-
} else {
66-
return new Uint8Array(arr.buffer, offset, ENTRY_SIZE);
6779
}
80+
81+
return false;
82+
};
83+
84+
/**
85+
* Compares a search value with an entry in the manifest
86+
*
87+
* @param searchValue a `Uint8Array` of size `PATH_HASH_SIZE`
88+
* @param manifest the manifest bytes
89+
* @param entryIndex the index in the manifest of the entry to compare
90+
*/
91+
function comparePathHashWithEntry(
92+
searchValue: Uint8Array,
93+
manifest: Uint8Array,
94+
entryIndex: number
95+
) {
96+
let entryOffset = HEADER_SIZE + entryIndex * ENTRY_SIZE;
97+
for (let offset = 0; offset < PATH_HASH_SIZE; offset++, entryOffset++) {
98+
// We know that both values could not be undefined
99+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
100+
const s = searchValue[offset]!;
101+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
102+
const e = manifest[entryOffset]!;
103+
if (s < e) {
104+
return -1;
105+
}
106+
if (s > e) {
107+
return 1;
108+
}
109+
}
110+
111+
return 0;
112+
}
113+
114+
/**
115+
* Converts an Uint8Array to an hex string
116+
*
117+
* @param array The content hash
118+
* @returns padded hex string
119+
*/
120+
const Uint8ToHexString = (array: Uint8Array) => {
121+
return [...array].map((b) => b.toString(16).padStart(2, "0")).join("");
68122
};
69123

124+
/**
125+
* Compare two Uint8Array values
126+
* @param a First array
127+
* @param b Second array
128+
* @returns -1 if a < b, 1 if a > b, 0 if equal
129+
*/
70130
export const compare = (a: Uint8Array, b: Uint8Array) => {
71131
if (a.byteLength < b.byteLength) {
72132
return -1;
@@ -86,12 +146,4 @@ export const compare = (a: Uint8Array, b: Uint8Array) => {
86146
}
87147

88148
return 0;
89-
};
90-
91-
const contentHashToKey = (buffer: Uint8Array) => {
92-
const contentHash = buffer.slice(
93-
PATH_HASH_SIZE,
94-
PATH_HASH_SIZE + CONTENT_HASH_SIZE
95-
);
96-
return [...contentHash].map((b) => b.toString(16).padStart(2, "0")).join("");
97-
};
149+
};

packages/workers-shared/asset-worker/src/worker.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { setupSentry } from "../../utils/sentry";
44
import { mockJaegerBinding } from "../../utils/tracing";
55
import { Analytics } from "./analytics";
66
import { AssetsManifest } from "./assets-manifest";
7-
import { AssetsManifest as AssetsManifest2 } from "./assets-manifest.2";
87
import { normalizeConfiguration } from "./configuration";
98
import { ExperimentAnalytics } from "./experiment-analytics";
109
import { canFetch, handleRequest } from "./handler";
@@ -232,12 +231,6 @@ export default class<TEnv extends Env = Env> extends WorkerEntrypoint<TEnv> {
232231
pathname: string,
233232
_request?: Request
234233
): Promise<string | null> {
235-
const BINARY_SEARCH_EXPERIMENT_SAMPLE_RATE = 0.5;
236-
const binarySearchVersion =
237-
Math.random() < BINARY_SEARCH_EXPERIMENT_SAMPLE_RATE
238-
? "current"
239-
: "perfTest";
240-
241234
const analytics = new ExperimentAnalytics(this.env.EXPERIMENT_ANALYTICS);
242235
const performance = new PerformanceTimer(this.env.UNSAFE_PERFORMANCE);
243236
const jaeger = this.env.JAEGER ?? mockJaegerBinding();
@@ -250,31 +243,13 @@ export default class<TEnv extends Env = Env> extends WorkerEntrypoint<TEnv> {
250243
analytics.setData({
251244
accountId: this.env.CONFIG.account_id,
252245
experimentName: "manifest-read-timing",
253-
binarySearchVersion,
254246
});
255247
}
256248

257249
const startTimeMs = performance.now();
258250
try {
259-
let eTag: string | null;
260-
261-
if (binarySearchVersion === "perfTest") {
262-
try {
263-
const assetsManifest = new AssetsManifest2(
264-
this.env.ASSETS_MANIFEST
265-
);
266-
eTag = await assetsManifest.get(pathname);
267-
} catch {
268-
// Fallback to the "current" impl if the new one throws.
269-
// We use "current-fallback" to surface errors in the analytics data
270-
analytics.setData({ binarySearchVersion: "current-fallback" });
271-
const assetsManifest = new AssetsManifest(this.env.ASSETS_MANIFEST);
272-
eTag = await assetsManifest.get(pathname);
273-
}
274-
} else {
275-
const assetsManifest = new AssetsManifest(this.env.ASSETS_MANIFEST);
276-
eTag = await assetsManifest.get(pathname);
277-
}
251+
const assetsManifest = new AssetsManifest(this.env.ASSETS_MANIFEST);
252+
const eTag = await assetsManifest.get(pathname);
278253

279254
span.setTags({
280255
path: pathname,

0 commit comments

Comments
 (0)