mirror of
https://github.com/astral-sh/setup-uv.git
synced 2026-03-15 09:35:17 +00:00
484 lines
13 KiB
JavaScript
484 lines
13 KiB
JavaScript
import { performance } from "node:perf_hooks";
|
|
import * as pep440 from "@renovatebot/pep440";
|
|
import * as semver from "semver";
|
|
import { ProxyAgent, fetch as undiciFetch } from "undici";
|
|
|
|
const DEFAULT_URL =
|
|
"https://raw.githubusercontent.com/astral-sh/versions/main/v1/uv.ndjson";
|
|
const DEFAULT_ITERATIONS = 100;
|
|
const DEFAULT_ARCH = "aarch64";
|
|
const DEFAULT_PLATFORM = "apple-darwin";
|
|
|
|
function getProxyAgent() {
|
|
const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy;
|
|
if (httpProxy) {
|
|
return new ProxyAgent(httpProxy);
|
|
}
|
|
|
|
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
|
if (httpsProxy) {
|
|
return new ProxyAgent(httpsProxy);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async function fetch(url) {
|
|
return await undiciFetch(url, {
|
|
dispatcher: getProxyAgent(),
|
|
});
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
arch: DEFAULT_ARCH,
|
|
iterations: DEFAULT_ITERATIONS,
|
|
platform: DEFAULT_PLATFORM,
|
|
url: DEFAULT_URL,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
const next = argv[index + 1];
|
|
|
|
if (arg === "--iterations" && next !== undefined) {
|
|
options.iterations = Number.parseInt(next, 10);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--url" && next !== undefined) {
|
|
options.url = next;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--arch" && next !== undefined) {
|
|
options.arch = next;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--platform" && next !== undefined) {
|
|
options.platform = next;
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
if (!Number.isInteger(options.iterations) || options.iterations <= 0) {
|
|
throw new Error("--iterations must be a positive integer");
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function parseVersionLine(line, sourceDescription, lineNumber) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(line);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Failed to parse version data from ${sourceDescription} at line ${lineNumber}: ${error.message}`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
typeof parsed !== "object" ||
|
|
parsed === null ||
|
|
typeof parsed.version !== "string" ||
|
|
!Array.isArray(parsed.artifacts)
|
|
) {
|
|
throw new Error(
|
|
`Invalid NDJSON record in ${sourceDescription} at line ${lineNumber}.`,
|
|
);
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function parseVersionData(data, sourceDescription) {
|
|
const versions = [];
|
|
|
|
for (const [index, line] of data.split("\n").entries()) {
|
|
const trimmed = line.trim();
|
|
if (trimmed === "") {
|
|
continue;
|
|
}
|
|
|
|
versions.push(parseVersionLine(trimmed, sourceDescription, index + 1));
|
|
}
|
|
|
|
if (versions.length === 0) {
|
|
throw new Error(`No version data found in ${sourceDescription}.`);
|
|
}
|
|
|
|
return versions;
|
|
}
|
|
|
|
async function readEntireResponse(response) {
|
|
if (response.body === null) {
|
|
const text = await response.text();
|
|
return {
|
|
bytesRead: Buffer.byteLength(text, "utf8"),
|
|
text,
|
|
};
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
const chunks = [];
|
|
let bytesRead = 0;
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
chunks.push(decoder.decode());
|
|
break;
|
|
}
|
|
|
|
bytesRead += value.byteLength;
|
|
chunks.push(decoder.decode(value, { stream: true }));
|
|
}
|
|
|
|
return {
|
|
bytesRead,
|
|
text: chunks.join(""),
|
|
};
|
|
}
|
|
|
|
async function fetchAllVersions(url) {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to fetch version data: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
|
|
const { bytesRead, text } = await readEntireResponse(response);
|
|
return {
|
|
bytesRead,
|
|
versions: parseVersionData(text, url),
|
|
};
|
|
}
|
|
|
|
async function streamUntil(url, predicate) {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to fetch version data: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
|
|
if (response.body === null) {
|
|
const { bytesRead, versions } = await fetchAllVersions(url);
|
|
return {
|
|
bytesRead,
|
|
matchedVersion: versions.find(predicate),
|
|
};
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let bytesRead = 0;
|
|
let buffer = "";
|
|
let lineNumber = 0;
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
buffer += decoder.decode();
|
|
break;
|
|
}
|
|
|
|
bytesRead += value.byteLength;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
let newlineIndex = buffer.indexOf("\n");
|
|
while (newlineIndex !== -1) {
|
|
const line = buffer.slice(0, newlineIndex);
|
|
buffer = buffer.slice(newlineIndex + 1);
|
|
const trimmed = line.trim();
|
|
|
|
if (trimmed !== "") {
|
|
lineNumber += 1;
|
|
const versionData = parseVersionLine(trimmed, url, lineNumber);
|
|
if (predicate(versionData)) {
|
|
await reader.cancel();
|
|
return { bytesRead, matchedVersion: versionData };
|
|
}
|
|
}
|
|
|
|
newlineIndex = buffer.indexOf("\n");
|
|
}
|
|
}
|
|
|
|
if (buffer.trim() !== "") {
|
|
lineNumber += 1;
|
|
const versionData = parseVersionLine(buffer.trim(), url, lineNumber);
|
|
if (predicate(versionData)) {
|
|
return { bytesRead, matchedVersion: versionData };
|
|
}
|
|
}
|
|
|
|
return { bytesRead, matchedVersion: undefined };
|
|
}
|
|
|
|
function versionSatisfies(version, versionSpecifier) {
|
|
return (
|
|
semver.satisfies(version, versionSpecifier) ||
|
|
pep440.satisfies(version, versionSpecifier)
|
|
);
|
|
}
|
|
|
|
function maxSatisfying(versions, versionSpecifier) {
|
|
const semverMatch = semver.maxSatisfying(versions, versionSpecifier);
|
|
if (semverMatch !== null) {
|
|
return semverMatch;
|
|
}
|
|
|
|
return pep440.maxSatisfying(versions, versionSpecifier) ?? undefined;
|
|
}
|
|
|
|
function selectArtifact(artifacts) {
|
|
if (artifacts.length === 1) {
|
|
return artifacts[0];
|
|
}
|
|
|
|
const defaultVariant = artifacts.find(
|
|
(candidate) => candidate.variant === "default",
|
|
);
|
|
if (defaultVariant !== undefined) {
|
|
return defaultVariant;
|
|
}
|
|
|
|
return artifacts[0];
|
|
}
|
|
|
|
async function benchmarkCase(name, expected, implementations, iterations) {
|
|
const results = {
|
|
name,
|
|
new: [],
|
|
old: [],
|
|
};
|
|
|
|
for (let iteration = 0; iteration < iterations; iteration += 1) {
|
|
const order = iteration % 2 === 0 ? ["old", "new"] : ["new", "old"];
|
|
|
|
for (const label of order) {
|
|
const implementation = implementations[label];
|
|
const startedAt = performance.now();
|
|
const outcome = await implementation.run();
|
|
const durationMs = performance.now() - startedAt;
|
|
|
|
if (outcome.value !== expected) {
|
|
throw new Error(
|
|
`${name} ${label} produced ${JSON.stringify(outcome.value)}; expected ${JSON.stringify(expected)}`,
|
|
);
|
|
}
|
|
|
|
results[label].push({
|
|
bytesRead: outcome.bytesRead,
|
|
durationMs,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function summarize(samples) {
|
|
const durations = samples
|
|
.map((sample) => sample.durationMs)
|
|
.sort((left, right) => left - right);
|
|
const bytes = samples
|
|
.map((sample) => sample.bytesRead)
|
|
.sort((left, right) => left - right);
|
|
|
|
const sum = (values) => values.reduce((total, value) => total + value, 0);
|
|
const percentile = (values, ratio) => {
|
|
const index = Math.min(
|
|
values.length - 1,
|
|
Math.max(0, Math.ceil(values.length * ratio) - 1),
|
|
);
|
|
return values[index];
|
|
};
|
|
|
|
return {
|
|
avgBytes: sum(bytes) / bytes.length,
|
|
avgMs: sum(durations) / durations.length,
|
|
maxMs: durations[durations.length - 1],
|
|
medianMs: percentile(durations, 0.5),
|
|
minMs: durations[0],
|
|
p95Ms: percentile(durations, 0.95),
|
|
};
|
|
}
|
|
|
|
function formatNumber(value, digits = 2) {
|
|
return value.toFixed(digits);
|
|
}
|
|
|
|
function formatSummary(name, oldSummary, newSummary) {
|
|
const speedup = oldSummary.avgMs / newSummary.avgMs;
|
|
const timeReduction =
|
|
((oldSummary.avgMs - newSummary.avgMs) / oldSummary.avgMs) * 100;
|
|
const byteReduction =
|
|
((oldSummary.avgBytes - newSummary.avgBytes) / oldSummary.avgBytes) * 100;
|
|
|
|
return [
|
|
`Scenario: ${name}`,
|
|
` old avg: ${formatNumber(oldSummary.avgMs)} ms | median: ${formatNumber(oldSummary.medianMs)} ms | p95: ${formatNumber(oldSummary.p95Ms)} ms | avg bytes: ${Math.round(oldSummary.avgBytes)}`,
|
|
` new avg: ${formatNumber(newSummary.avgMs)} ms | median: ${formatNumber(newSummary.medianMs)} ms | p95: ${formatNumber(newSummary.p95Ms)} ms | avg bytes: ${Math.round(newSummary.avgBytes)}`,
|
|
` delta: ${formatNumber(timeReduction)}% faster | ${formatNumber(speedup)}x speedup | ${formatNumber(byteReduction)}% fewer bytes read`,
|
|
].join("\n");
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
console.log(`Preparing benchmark data from ${options.url}`);
|
|
const baseline = await fetchAllVersions(options.url);
|
|
const latestVersion = baseline.versions[0]?.version;
|
|
if (!latestVersion) {
|
|
throw new Error("No versions found in NDJSON data");
|
|
}
|
|
|
|
const latestArtifact = selectArtifact(
|
|
baseline.versions[0].artifacts.filter(
|
|
(candidate) =>
|
|
candidate.platform === `${options.arch}-${options.platform}`,
|
|
),
|
|
);
|
|
if (!latestArtifact) {
|
|
throw new Error(
|
|
`No artifact found for ${options.arch}-${options.platform} in ${latestVersion}`,
|
|
);
|
|
}
|
|
|
|
const rangeSpecifier = `^${latestVersion.split(".")[0]}.${latestVersion.split(".")[1]}.0`;
|
|
|
|
console.log(
|
|
`Running ${options.iterations} iterations per scenario against ${options.url}`,
|
|
);
|
|
console.log(`Latest version: ${latestVersion}`);
|
|
console.log(`Range benchmark: ${rangeSpecifier}`);
|
|
console.log(`Artifact benchmark: ${options.arch}-${options.platform}`);
|
|
console.log("");
|
|
|
|
const scenarios = [
|
|
await benchmarkCase(
|
|
"latest version",
|
|
latestVersion,
|
|
{
|
|
new: {
|
|
run: async () => {
|
|
const { bytesRead, matchedVersion } = await streamUntil(
|
|
options.url,
|
|
() => true,
|
|
);
|
|
return {
|
|
bytesRead,
|
|
value: matchedVersion?.version,
|
|
};
|
|
},
|
|
},
|
|
old: {
|
|
run: async () => {
|
|
const { bytesRead, versions } = await fetchAllVersions(options.url);
|
|
return {
|
|
bytesRead,
|
|
value: versions[0]?.version,
|
|
};
|
|
},
|
|
},
|
|
},
|
|
options.iterations,
|
|
),
|
|
await benchmarkCase(
|
|
"highest satisfying range",
|
|
latestVersion,
|
|
{
|
|
new: {
|
|
run: async () => {
|
|
const { bytesRead, matchedVersion } = await streamUntil(
|
|
options.url,
|
|
(candidate) =>
|
|
versionSatisfies(candidate.version, rangeSpecifier),
|
|
);
|
|
return {
|
|
bytesRead,
|
|
value: matchedVersion?.version,
|
|
};
|
|
},
|
|
},
|
|
old: {
|
|
run: async () => {
|
|
const { bytesRead, versions } = await fetchAllVersions(options.url);
|
|
return {
|
|
bytesRead,
|
|
value: maxSatisfying(
|
|
versions.map((versionData) => versionData.version),
|
|
rangeSpecifier,
|
|
),
|
|
};
|
|
},
|
|
},
|
|
},
|
|
options.iterations,
|
|
),
|
|
await benchmarkCase(
|
|
"exact version artifact",
|
|
latestArtifact.url,
|
|
{
|
|
new: {
|
|
run: async () => {
|
|
const { bytesRead, matchedVersion } = await streamUntil(
|
|
options.url,
|
|
(candidate) => candidate.version === latestVersion,
|
|
);
|
|
const artifact = matchedVersion
|
|
? selectArtifact(
|
|
matchedVersion.artifacts.filter(
|
|
(candidate) =>
|
|
candidate.platform ===
|
|
`${options.arch}-${options.platform}`,
|
|
),
|
|
)
|
|
: undefined;
|
|
return {
|
|
bytesRead,
|
|
value: artifact?.url,
|
|
};
|
|
},
|
|
},
|
|
old: {
|
|
run: async () => {
|
|
const { bytesRead, versions } = await fetchAllVersions(options.url);
|
|
const versionData = versions.find(
|
|
(candidate) => candidate.version === latestVersion,
|
|
);
|
|
const artifact = selectArtifact(
|
|
versionData.artifacts.filter(
|
|
(candidate) =>
|
|
candidate.platform === `${options.arch}-${options.platform}`,
|
|
),
|
|
);
|
|
return {
|
|
bytesRead,
|
|
value: artifact?.url,
|
|
};
|
|
},
|
|
},
|
|
},
|
|
options.iterations,
|
|
),
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
const oldSummary = summarize(scenario.old);
|
|
const newSummary = summarize(scenario.new);
|
|
console.log(formatSummary(scenario.name, oldSummary, newSummary));
|
|
console.log("");
|
|
}
|
|
}
|
|
|
|
await main();
|