mirror of
https://github.com/electron/electron.git
synced 2026-10-28 11:44:46 +08:00
* build: trim the non-link overhead of macOS release builds Four independent, low-risk changes aimed at the macOS release job, which today spends well over an hour outside the ThinLTO links themselves: * Zip Symbols: `tar --options xz:threads=0` does not actually multi-thread on macOS (Apple's liblzma emits a single block), so the ~7 GB of dSYMs were being compressed by one core at preset -6, ~30 minutes per job. Pipe the tar stream through the xz binary with -T0 instead (falling back to the tar options when xz is not on PATH), and use preset -3, which is ~5x faster than -6 for ~12% larger output. Same .tar.xz format. * siso: set SISO_EXPERIMENTS=no-fallback on macOS. When a remote compile exceeds siso's 2 minute client-side deadline (RBE queueing, an upload stall) siso re-runs it locally; on the 5-core / 14 GB runners a local ThinLTO compile takes 5-10 minutes and lands on the critical path (75 such fallbacks in a recent release build, 570 in a testing build). no-fallback retries remotely with a 4x longer deadline instead. * GN: thin_lto_enable_cache=false for macOS release builds. CI starts from a fresh out dir, so the ThinLTO cache is written (~100 GB across a release build's links) and never read, competing for IO with links that are already swapping. Local builds keep the default. * build-stats: hash object files on worker threads and only walk obj/ and <toolchain>/obj/. The previous recursive readdir of the whole out dir (~800k entries) plus single-threaded sha256 of ~6 GB took 2-3 minutes; this takes seconds. Objects outside those trees (SDK crt*.o, rust sysroot copies) are no longer counted, which does not affect the change rate metric since only files present in both snapshots are compared. No-Verification-Needed: CI config and build scripts only, no product code * build: use context managers for the tar|xz pipeline (pylint R1732) * build: read only the tail of siso.INFO in build-stats.mjs siso.INFO grows with the step count and a release build's already exceeds V8's maximum string length once a second toolchain is in the graph, which made build-stats.mjs fail with 'Invalid string length'. The stats line is at the end of the log, so read the last 16 MB instead of the whole file. * build: drop SISO_EXPERIMENTS=no-fallback on macOS Under RBE queue pressure a remote compile can stay EXECUTING past siso's deadline; with no-fallback siso retries (deduped onto the same running op) and then fails the build outright, which is worse than a slow local fallback for PR CI. Give remote compiles a longer deadline in build-tools' siso config instead and keep the fallback as the safety net. * build: link the macOS snapshot tools without ThinLTO in release builds (#52884) * build: link the macOS snapshot tools without ThinLTO in release builds In official builds v8_snapshot_toolchain is the target toolchain, so mksnapshot, node_mksnapshot, v8_context_snapshot_generator and the natives code-cache generator are each a full ThinLTO+PGO link of V8 (and Blink, and Node) with dsymutil. On the 5-core / 14 GB macOS CI runners those four links take 12 + 6 + 20 + 2 minutes (21 + 46 min on x64, where they also run under Rosetta), run serially through a link pool of depth 1, and page heavily (5-9 GB RSS each), all before the Electron Framework link can start. Their outputs are snapshot/code-cache data that does not depend on how optimized the tool binary is. Add //electron/build/toolchain/mac with variants of the mac toolchain that turn off ThinLTO, PGO and debug info (symbol_level=0, so the extra objects are small and dsymutil has nothing to link) and point macOS release builds' v8_snapshot_toolchain at them: clang_arm64_snapshot for arm64 and clang_x64_snapshot for x64 (still x64 so mksnapshot.zip keeps shipping an x64 mksnapshot). V8/Blink/Node compile a second time in that toolchain, but remotely and cacheably. Because the tools now live under $root_out_dir/<toolchain>/, mksnapshot.zip is flattened on macOS so its layout is unchanged (mksnapshot, v8_context_snapshot_generator, their dylibs and icudtl.dat at the root), and build/zip.py learns to apply PATHS_TO_SKIP relative to a toolchain dir and to drop duplicates when flattening. The resulting file set is identical to the default-toolchain one. Testing builds are unaffected (no LTO there, so nothing to gain from a second toolchain). No-Verification-Needed: build config and packaging script only * build: update build-tools for remote generators and longer remote compile deadlines (#52896) Picks up electron/build-tools#889 and #890: * siso runs the mojom, Blink bindings, grit strings and TypeScript/WebUI generators on RBE workers instead of locally (all hosts). On the 5-core macOS runners those steps are about a CPU-hour of python/node per release build, contending with siso's include scanning during the fetch phase; remotely they are also cached, so unchanged generators become cache hits on every host. * remote clang steps on macOS hosts get a 10 minute client-side deadline instead of 2 minutes, so ordinary RBE queueing no longer triggers the 5-10 minute local ThinLTO fallback compiles that land on the critical path (75 in one release build, 570 in a testing build). Local fallback stays as the safety net.
352 lines
11 KiB
JavaScript
352 lines
11 KiB
JavaScript
import { createHash } from 'node:crypto';
|
|
import * as fs from 'node:fs/promises';
|
|
import { availableParallelism } from 'node:os';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { parseArgs } from 'node:util';
|
|
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
|
|
|
|
import { getChromiumVersionFromDEPS } from './lib/utils.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ELECTRON_DIR = resolve(__dirname, '..');
|
|
|
|
function getCommonTags() {
|
|
const tags = [];
|
|
|
|
if (process.env.TARGET_ARCH) tags.push(`target-arch:${process.env.TARGET_ARCH}`);
|
|
if (process.env.TARGET_PLATFORM) tags.push(`target-platform:${process.env.TARGET_PLATFORM}`);
|
|
if (process.env.GITHUB_HEAD_REF) {
|
|
// Will be set in pull requests
|
|
tags.push(`branch:${process.env.GITHUB_HEAD_REF}`);
|
|
} else if (process.env.GITHUB_REF_NAME) {
|
|
// Will be set for release branches
|
|
tags.push(`branch:${process.env.GITHUB_REF_NAME}`);
|
|
}
|
|
|
|
return tags;
|
|
}
|
|
|
|
async function uploadSeriesToDatadog(series) {
|
|
await fetch('https://api.datadoghq.com/api/v2/series', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'DD-API-KEY': process.env.DD_API_KEY
|
|
},
|
|
body: JSON.stringify({ series })
|
|
});
|
|
}
|
|
|
|
async function uploadCacheHitRateStats(hitRate, stats) {
|
|
const timestamp = Math.round(new Date().getTime() / 1000);
|
|
const tags = getCommonTags();
|
|
|
|
const series = [
|
|
{
|
|
metric: 'electron.build.effective-cache-hit-rate',
|
|
points: [{ timestamp, value: (hitRate * 100).toFixed(2) }],
|
|
type: 3, // GAUGE
|
|
unit: 'percent',
|
|
tags
|
|
}
|
|
];
|
|
|
|
// Add all raw stats as individual metrics
|
|
for (const [key, value] of Object.entries(stats)) {
|
|
series.push({
|
|
metric: `electron.build.stats.${key.toLowerCase()}`,
|
|
points: [{ timestamp, value }],
|
|
type: 1, // COUNT
|
|
tags
|
|
});
|
|
}
|
|
|
|
await uploadSeriesToDatadog(series);
|
|
}
|
|
|
|
async function uploadObjectChangeStats(stats) {
|
|
const timestamp = Math.round(new Date().getTime() / 1000);
|
|
const tags = getCommonTags();
|
|
|
|
if (stats['previous-chromium-version']) tags.push(`previous-chromium-version:${stats['previous-chromium-version']}`);
|
|
if (stats['chromium-version']) tags.push(`chromium-version:${stats['chromium-version']}`);
|
|
|
|
if (stats['previous-chromium-version'] && stats['chromium-version']) {
|
|
tags.push(`chromium-version-changed:${stats['previous-chromium-version'] !== stats['chromium-version']}`);
|
|
}
|
|
|
|
const series = [
|
|
{
|
|
metric: 'electron.build.object-change-rate',
|
|
points: [{ timestamp, value: (stats['change-rate'] * 100).toFixed(2) }],
|
|
type: 3, // GAUGE
|
|
unit: 'percent',
|
|
tags
|
|
},
|
|
{
|
|
metric: 'electron.build.object-change-size',
|
|
points: [{ timestamp, value: stats['change-size'] }],
|
|
type: 1, // COUNT
|
|
unit: 'byte',
|
|
tags
|
|
},
|
|
{
|
|
metric: 'electron.build.object-total-size',
|
|
points: [{ timestamp, value: stats['total-size'] }],
|
|
type: 3, // GAUGE
|
|
unit: 'byte',
|
|
tags
|
|
},
|
|
{
|
|
metric: 'electron.build.new-object-count',
|
|
points: [{ timestamp, value: stats['new-object-count'] }],
|
|
type: 1, // COUNT
|
|
unit: 'count',
|
|
tags
|
|
}
|
|
];
|
|
|
|
await uploadSeriesToDatadog(series);
|
|
}
|
|
|
|
// Returns the last `maxBytes` of `filename` as a string.
|
|
async function readTail(filename, maxBytes) {
|
|
const handle = await fs.open(filename, 'r');
|
|
try {
|
|
const { size } = await handle.stat();
|
|
const length = Math.min(size, maxBytes);
|
|
const buffer = Buffer.alloc(length);
|
|
await handle.read(buffer, 0, length, size - length);
|
|
return buffer.toString('utf-8');
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
// Lists the .o/.obj files under `outDir`, relative to it. Objects only live
|
|
// under `obj/` and `<secondary-toolchain>/obj/`, so only those trees are
|
|
// walked: a recursive readdir of the whole out dir touches ~800k entries
|
|
// (gen/, bundles, dSYMs) and dominated this step's runtime.
|
|
async function listObjectFiles(outDir) {
|
|
const objectFiles = [];
|
|
const isObject = (name) => name.endsWith('.o') || name.endsWith('.obj');
|
|
const walk = async (rel) => {
|
|
const entries = await fs.readdir(resolve(outDir, rel), { withFileTypes: true });
|
|
await Promise.all(
|
|
entries.map(async (entry) => {
|
|
const entryRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) {
|
|
await walk(entryRel);
|
|
} else if (entry.isFile() && isObject(entry.name)) {
|
|
objectFiles.push(entryRel);
|
|
}
|
|
})
|
|
);
|
|
};
|
|
const top = await fs.readdir(outDir, { withFileTypes: true });
|
|
for (const entry of top) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (entry.name === 'obj') {
|
|
await walk('obj');
|
|
continue;
|
|
}
|
|
const objDir = `${entry.name}/obj`;
|
|
const st = await fs.stat(resolve(outDir, objDir)).catch(() => null);
|
|
if (st && st.isDirectory()) await walk(objDir);
|
|
}
|
|
return objectFiles.sort();
|
|
}
|
|
|
|
// Hashes `files` (relative to `dir`) on a pool of worker threads. A release
|
|
// build has ~6 GB of objects; hashing them serially on the main thread took
|
|
// 2-3 minutes on the 5-core macOS runners.
|
|
async function checksumFiles(dir, files) {
|
|
const workers = Math.max(1, Math.min(availableParallelism(), files.length));
|
|
const shard = Math.ceil(files.length / workers);
|
|
const results = await Promise.all(
|
|
Array.from({ length: workers }, (_, i) => {
|
|
const chunk = files.slice(i * shard, (i + 1) * shard);
|
|
return new Promise((resolve, reject) => {
|
|
const worker = new Worker(new URL(import.meta.url), {
|
|
workerData: { dir, files: chunk }
|
|
});
|
|
worker.once('message', resolve);
|
|
worker.once('error', reject);
|
|
worker.once('exit', (code) => {
|
|
if (code !== 0) reject(new Error(`checksum worker exited with code ${code}`));
|
|
});
|
|
});
|
|
})
|
|
);
|
|
return Object.assign({}, ...results);
|
|
}
|
|
|
|
if (!isMainThread) {
|
|
const { dir, files } = workerData;
|
|
const checksums = {};
|
|
for (const file of files) {
|
|
const content = await fs.readFile(resolve(dir, file));
|
|
checksums[file] = {
|
|
size: content.byteLength,
|
|
checksum: createHash('sha256').update(content).digest('hex')
|
|
};
|
|
}
|
|
parentPort.postMessage(checksums);
|
|
}
|
|
|
|
async function main() {
|
|
const {
|
|
positionals: [filename],
|
|
values
|
|
} = parseArgs({
|
|
allowPositionals: true,
|
|
options: {
|
|
'upload-stats': {
|
|
type: 'boolean',
|
|
default: false
|
|
},
|
|
'out-dir': {
|
|
type: 'string'
|
|
},
|
|
'input-object-checksums': {
|
|
type: 'string'
|
|
},
|
|
'output-object-checksums': {
|
|
type: 'string'
|
|
}
|
|
}
|
|
});
|
|
|
|
const {
|
|
'upload-stats': uploadStats,
|
|
'out-dir': outDir,
|
|
'input-object-checksums': inputObjectChecksums,
|
|
'output-object-checksums': outputObjectChecksums
|
|
} = values;
|
|
|
|
if (!filename) {
|
|
throw new Error('filename is required (should be a siso.INFO file)');
|
|
}
|
|
|
|
if ((inputObjectChecksums || outputObjectChecksums) && !outDir) {
|
|
throw new Error('--out-dir is required when using --input-object-checksums or --output-object-checksums');
|
|
} else if (outDir && !inputObjectChecksums && !outputObjectChecksums) {
|
|
throw new Error('--out-dir only makes sense with --input-object-checksums or --output-object-checksums');
|
|
}
|
|
|
|
// We expect to find a line which looks like stats=build.Stats{..., CacheHit:39008, Local:4778, Remote:0, LocalFallback:0, ...}
|
|
// near the end of the log. siso.INFO can exceed V8's maximum string length
|
|
// (it grows with the step count; a release build's is several hundred MB),
|
|
// so read the tail of the file rather than the whole thing.
|
|
const match = (await readTail(filename, 16 * 1024 * 1024)).match(/stats=build\.Stats{(.*)}/);
|
|
|
|
if (!match) {
|
|
throw new Error('could not find stats=build.Stats in log');
|
|
}
|
|
|
|
const stats = Object.fromEntries(
|
|
match[1].split(',').map((part) => {
|
|
const [key, value] = part.trim().split(':');
|
|
return [key, parseInt(value)];
|
|
})
|
|
);
|
|
const hitRate = stats.CacheHit / (stats.Remote + stats.CacheHit + stats.LocalFallback);
|
|
|
|
const messagePrefix = process.env.GITHUB_ACTIONS ? '::notice title=Build Stats::' : '';
|
|
|
|
console.log(`${messagePrefix}Effective cache hit rate: ${(hitRate * 100).toFixed(2)}%`);
|
|
|
|
const objectChangeStats = {};
|
|
|
|
if (inputObjectChecksums || outputObjectChecksums) {
|
|
const depsContent = await fs.readFile(resolve(ELECTRON_DIR, 'DEPS'), 'utf8');
|
|
const currentVersion = getChromiumVersionFromDEPS(depsContent);
|
|
|
|
// Calculate the SHA256 for each object file under `outDir`
|
|
const objectFiles = await listObjectFiles(outDir);
|
|
const checksums = await checksumFiles(outDir, objectFiles);
|
|
|
|
if (outputObjectChecksums) {
|
|
const outputData = {
|
|
chromiumVersion: currentVersion,
|
|
checksums
|
|
};
|
|
|
|
await fs.writeFile(outputObjectChecksums, JSON.stringify(outputData, null, 2));
|
|
}
|
|
|
|
if (inputObjectChecksums) {
|
|
const inputData = JSON.parse(await fs.readFile(inputObjectChecksums, 'utf8'));
|
|
const inputFiles = Object.keys(inputData.checksums);
|
|
let changedCount = 0;
|
|
let newObjectCount = 0;
|
|
let changedSize = 0;
|
|
|
|
// Previously filenames mapped directly to checksum values, but now
|
|
// they map to objects containing both checksum and size. Handle both
|
|
// formats for backwards compatibility.
|
|
const getInputChecksum = (file) => {
|
|
const value = inputData.checksums[file];
|
|
return typeof value === 'string' ? value : value.checksum;
|
|
};
|
|
|
|
// Count changed files (only those present in both input and current)
|
|
for (const file of inputFiles) {
|
|
if (!(file in checksums)) continue; // Skip deleted files
|
|
if (getInputChecksum(file) !== checksums[file].checksum) {
|
|
changedCount++;
|
|
changedSize += checksums[file].size;
|
|
}
|
|
}
|
|
|
|
// Count new files (in current but not in input)
|
|
for (const file of Object.keys(checksums)) {
|
|
if (!(file in inputData.checksums)) {
|
|
newObjectCount++;
|
|
changedSize += checksums[file].size;
|
|
}
|
|
}
|
|
|
|
const changeRate = inputFiles.length > 0 ? changedCount / inputFiles.length : 0;
|
|
const totalSize = Object.values(checksums).reduce((sum, { size }) => sum + size, 0);
|
|
console.log(`${messagePrefix}Object change rate: ${(changeRate * 100).toFixed(2)}%`);
|
|
if (newObjectCount > 0) {
|
|
console.log(`${messagePrefix}New object count: ${newObjectCount}`);
|
|
}
|
|
console.log(`${messagePrefix}Cumulative changed object sizes: ${changedSize.toLocaleString()} bytes`);
|
|
console.log(`${messagePrefix}Total object sizes: ${totalSize.toLocaleString()} bytes`);
|
|
|
|
objectChangeStats['change-rate'] = changeRate;
|
|
objectChangeStats['change-size'] = changedSize;
|
|
objectChangeStats['total-size'] = totalSize;
|
|
objectChangeStats['new-object-count'] = newObjectCount;
|
|
objectChangeStats['previous-chromium-version'] = inputData.chromiumVersion;
|
|
objectChangeStats['chromium-version'] = currentVersion;
|
|
}
|
|
}
|
|
|
|
if (uploadStats) {
|
|
if (!process.env.DD_API_KEY) {
|
|
throw new Error('DD_API_KEY is not set');
|
|
}
|
|
|
|
await uploadCacheHitRateStats(hitRate, stats);
|
|
|
|
if (Object.keys(objectChangeStats).length > 0) {
|
|
await uploadObjectChangeStats(objectChangeStats);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isMainThread && (await fs.realpath(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
main()
|
|
.then(() => {
|
|
process.exit(0);
|
|
})
|
|
.catch((err) => {
|
|
console.error(`ERROR: ${err.message}`);
|
|
process.exit(1);
|
|
});
|
|
}
|