oxc/tasks/benchmark/codspeed/upload.mjs
overlookmotel 93e1ea4419
feat(tasks): shard benchmarks in CI (#2751)
This PR shards benchmarks when running on CI. Each benchmark (parser, minifier etc) runs as a separate job, and then a final job combines the results and uploads to Codspeed.

A bit of a hacky implementation. Uses a small NodeJS HTTP server to intercept the results from `codspeed-runner`, and then another NodeJS script to combine them all together, and upload to CodSpeed.

I will submit PRs on Codspeed's runner + action to do it properly, but as I imagine it'll be a slow process getting that merged upstream, I wanted to see if it worked first. We can replace this once it's supported upstream.

Sharding only reduces total time to run the benchmarks by about 70 secs at present, because linter benchmark takes 6 mins alone and holds up the whole process (all the rest are done in ~2 mins). If we can split up the linter benchmark, we can likely get total run time down to around 3 mins. I'll try that in a follow-on PR.

I guess the other upside is we can now add as many benchmarks as we like with impunity - they'll run in parallel, and so won't slow things down overall.
2024-03-18 10:45:44 +08:00

135 lines
4.1 KiB
JavaScript

/*
* Combine benchmark data from different jobs, and upload to Codspeed.
*/
import {createReadStream} from 'fs';
import fs from 'fs/promises';
import {join as pathJoin} from 'path';
import {createHash} from 'crypto';
import assert from 'assert';
import tar from 'tar';
import axios from 'axios';
const METADATA_SUFFIX = 'metadata.json',
ARCHIVE_SUFFIX = `archive.tar.gz`,
CODSPEED_UPLOAD_URL = 'https://api.codspeed.io/upload';
const dataDir = process.env.DATA_DIR,
token = process.env.CODSPEED_TOKEN;
// Get list of components
const components = (await fs.readdir(dataDir))
.filter(filename => filename.endsWith(METADATA_SUFFIX))
.map(filename => filename.slice(0, -METADATA_SUFFIX.length - 1));
// Unzip tarballs
const unzipDir = pathJoin(dataDir, 'unzip');
await fs.mkdir(unzipDir);
for (const component of components) {
console.log(`Unzipping profile data: ${component}`);
const archivePath = pathJoin(dataDir, `${component}_${ARCHIVE_SUFFIX}`);
const componentUnzipDir = pathJoin(unzipDir, component);
await fs.mkdir(componentUnzipDir);
await tar.extract({file: archivePath, cwd: componentUnzipDir});
await fs.rm(archivePath);
}
// Move all `.out` files to one directory
console.log('Combining profiles');
const outDir = pathJoin(dataDir, 'out');
await fs.mkdir(outDir);
const pids = new Set(),
duplicates = [];
let highestPid = -1;
for (const component of components) {
const componentDir = pathJoin(unzipDir, component);
const outFiles = await fs.readdir(componentDir);
for (const filename of outFiles) {
if (!filename.endsWith('.out')) continue;
let pid = filename.slice(0, -4);
assert(/^\d+$/.test(pid), `Unexpected file: ${component}/${filename}`);
pid *= 1;
const path = pathJoin(componentDir, filename);
if (pids.has(pid)) {
// Duplicate PID
duplicates.push({pid, path});
} else {
pids.add(pid);
if (pid > highestPid) highestPid = pid;
await fs.rename(path, pathJoin(outDir, `${pid}.out`));
}
}
}
// Alter PIDs for `.out` files with duplicate filenames
for (let {pid, path} of duplicates) {
let content = await fs.readFile(path, 'utf8');
const pidLine = `\npid: ${pid}\n`;
const index = content.indexOf(pidLine);
assert(index !== -1, `Could not locate PID in ${path}`);
const before = content.slice(0, index);
assert(before.split('\n').length === 3, `Unexpected formatting in ${path}`);
pid = ++highestPid;
content = `${before}\npid: ${pid}\n${content.slice(index + pidLine.length)}`;
await fs.writeFile(pathJoin(outDir, `${pid}.out`), content);
await fs.rm(path);
}
// Add log files to output dir
for (const filename of ['runner.log', 'valgrind.log']) {
await fs.rename(pathJoin(unzipDir, components[0], filename), pathJoin(outDir, filename));
}
// ZIP combined profile directory
console.log('Zipping combined profile directory');
const archivePath = pathJoin(dataDir, 'archive.tar.gz');
await tar.create({file: archivePath, gzip: true, cwd: outDir}, ['./']);
// Get size + MD5 hash of archive
console.log('Hashing ZIP');
const {size} = await fs.stat(archivePath);
const hash = createHash('md5');
const inputStream = createReadStream(archivePath);
for await (const chunk of inputStream) {
hash.update(chunk);
}
const md5 = hash.digest('base64');
// Alter MD5 hash in metadata object
const metadata = JSON.parse(
await fs.readFile(pathJoin(dataDir, `${components[0]}_${METADATA_SUFFIX}`), 'utf8')
);
metadata.profileMd5 = md5;
// Upload metadata to CodSpeed
console.log('Uploading metadata to CodSpeed');
const {data} = await axios({
method: 'post',
url: CODSPEED_UPLOAD_URL,
data: metadata,
headers: {Authorization: token},
});
assert(data?.status === 'success', 'Failed to upload metadata to Codspeed');
const {uploadUrl} = data;
// Upload profile ZIP to Codspeed
console.log('Uploading profile ZIP to CodSpeed');
await axios({
method: 'put',
url: uploadUrl,
data: createReadStream(archivePath),
headers: {
'Content-Type': 'application/gzip',
'Content-Length': size,
'Content-MD5': md5,
}
});