Files
Samuel Attard d36eebfc2e build: trim the non-link overhead of macOS release builds (#52883)
* 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.
2026-08-17 11:03:00 -07:00

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);
});
}