perf(deploy): parallelize publish-wasm brotli (q11) across files

brotli q11 over the ~320MB wasm set ran serially on one core (~0.4MB/s),
dominating the release publish job. Compress every to-be-uploaded file
concurrently on the libuv threadpool (compressBytesAsync + UV_THREADPOOL_SIZE
sized to the machine), so wall time drops from sum-of-files to roughly the
largest single file (kicad_editor.wasm): 329s for the full set locally vs
~11.5min of CPU. Upload ordering invariants unchanged: meta.json still last
per tool, registry last overall; moved-tag guard and reuse path untouched.
Also adds BROTLI_PARAM_SIZE_HINT and moves the publish-wasm job to
ubicloud-standard-8 so there is a core per file.

Verified byte-identical CDN layout vs the old script (local driver, pinned
builtAt), blob roundtrip to source sha, reuse + --from-registry modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQ9npuB6uLvkv6gzBV5GPh
This commit is contained in:
Gergő Törcsvári 2026-07-28 12:56:10 +02:00
commit f8ae378645
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 101 additions and 46 deletions

View file

@ -75,10 +75,12 @@ jobs:
upload_output: true upload_output: true
# 2) Publish the build to the CDN (content-addressed; unchanged tools reuse) # 2) Publish the build to the CDN (content-addressed; unchanged tools reuse)
# and write manifest-<tag>.json. Cheap runner — just downloads + uploads. # and write manifest-<tag>.json. The slow step is brotli-q11 over ~300MB of
# wasm; the script compresses all files in parallel (one core per file), so
# give it enough cores that wall time ≈ the largest single file.
publish-wasm: publish-wasm:
needs: [meta, build] needs: [meta, build]
runs-on: ubuntu-latest runs-on: ubicloud-standard-8
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:

View file

@ -13,7 +13,14 @@ import {
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { gzipSync, brotliCompressSync, constants as zc } from "node:zlib"; import {
brotliCompress,
brotliCompressSync,
gzip,
gzipSync,
constants as zc,
} from "node:zlib";
import { promisify } from "node:util";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
@ -25,6 +32,13 @@ export const NO_STORE = "no-store";
export const sha256hex = (buf) => createHash("sha256").update(buf).digest("hex"); export const sha256hex = (buf) => createHash("sha256").update(buf).digest("hex");
const brotliOpts = (bytes, quality) => ({
params: {
[zc.BROTLI_PARAM_QUALITY]: quality ?? 5,
[zc.BROTLI_PARAM_SIZE_HINT]: bytes.length,
},
});
/** Compress bytes; returns { bytes, encoding }. mode: "gzip" | "br" | "none". */ /** Compress bytes; returns { bytes, encoding }. mode: "gzip" | "br" | "none". */
export function compressBytes(bytes, mode, quality) { export function compressBytes(bytes, mode, quality) {
if (mode === "none") return { bytes, encoding: null }; if (mode === "none") return { bytes, encoding: null };
@ -32,9 +46,28 @@ export function compressBytes(bytes, mode, quality) {
return { bytes: gzipSync(bytes, { level: quality ?? 6 }), encoding: "gzip" }; return { bytes: gzipSync(bytes, { level: quality ?? 6 }), encoding: "gzip" };
// brotli — default quality 5 (good ratio, far faster than 11 on 100s of MB). // brotli — default quality 5 (good ratio, far faster than 11 on 100s of MB).
return { return {
bytes: brotliCompressSync(bytes, { bytes: brotliCompressSync(bytes, brotliOpts(bytes, quality)),
params: { [zc.BROTLI_PARAM_QUALITY]: quality ?? 5 }, encoding: "br",
}), };
}
const gzipAsync = promisify(gzip);
const brotliCompressAsync = promisify(brotliCompress);
/**
* Async compressBytes: runs on the libuv threadpool, so N calls awaited
* together compress on N cores (set UV_THREADPOOL_SIZE before the first call).
* Byte-identical output to compressBytes.
*/
export async function compressBytesAsync(bytes, mode, quality) {
if (mode === "none") return { bytes, encoding: null };
if (mode === "gzip")
return {
bytes: await gzipAsync(bytes, { level: quality ?? 6 }),
encoding: "gzip",
};
return {
bytes: await brotliCompressAsync(bytes, brotliOpts(bytes, quality)),
encoding: "br", encoding: "br",
}; };
} }

View file

@ -17,9 +17,10 @@
// needs only CLOUDFLARE_API_TOKEN (+ CLOUDFLARE_ACCOUNT_ID). // needs only CLOUDFLARE_API_TOKEN (+ CLOUDFLARE_ACCOUNT_ID).
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { availableParallelism } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { import {
compressBytes, compressBytesAsync,
IMMUTABLE, IMMUTABLE,
makeStore, makeStore,
NO_STORE, NO_STORE,
@ -27,6 +28,10 @@ import {
sha256hex, sha256hex,
} from "./lib/cdn-store.mjs"; } from "./lib/cdn-store.mjs";
// Compression fans out over the libuv threadpool (compressBytesAsync); size it
// to the machine BEFORE the first async zlib call or the default of 4 sticks.
process.env.UV_THREADPOOL_SIZE ||= String(Math.max(4, availableParallelism()));
// --- tools & per-file rules --------------------------------------------------- // --- tools & per-file rules ---------------------------------------------------
// Bundles served to the browser editor. The four editor tools (pcbnew, eeschema, // Bundles served to the browser editor. The four editor tools (pcbnew, eeschema,
@ -134,24 +139,33 @@ function gather(tool, srcDir) {
}); });
} }
function putFile(store, key, bytes, name, compress, quality) { // Compress a gathered file in place (adds .body/.encoding). Async so the whole
const rule = fileRule(name); // upload set compresses concurrently on the threadpool — brotli q11 over the
let body = bytes; // ~300MB wasm set is BY FAR the slow step of a publish, and serially it took
let encoding = null; // sum-of-files; in parallel it takes roughly the largest single file.
async function compressFile(f, compress, quality) {
const rule = fileRule(f.name);
if (rule.compress && compress !== "none") { if (rule.compress && compress !== "none") {
const c = compressBytes(bytes, compress, quality); const c = await compressBytesAsync(f.bytes, compress, quality);
body = c.bytes; f.body = c.bytes;
encoding = c.encoding; f.encoding = c.encoding;
} else {
f.body = f.bytes;
f.encoding = null;
} }
store.put(key, body, {
contentType: rule.contentType,
contentEncoding: encoding,
cacheControl: rule.cacheControl,
});
return { name, raw: bytes.length, stored: body.length, encoding };
} }
function main() { function putFile(store, key, f) {
const rule = fileRule(f.name);
store.put(key, f.body, {
contentType: rule.contentType,
contentEncoding: f.encoding,
cacheControl: rule.cacheControl,
});
return { name: f.name, raw: f.bytes.length, stored: f.body.length, encoding: f.encoding };
}
async function main() {
const a = parseArgs(process.argv); const a = parseArgs(process.argv);
const builtAt = a.builtAt || new Date().toISOString(); const builtAt = a.builtAt || new Date().toISOString();
const store = makeStore(a.driver, a); const store = makeStore(a.driver, a);
@ -191,9 +205,10 @@ function main() {
`publish-wasm: tag=${a.tag} src=${a.src} driver=${store.kind} compress=${a.compress}`, `publish-wasm: tag=${a.tag} src=${a.src} driver=${store.kind} compress=${a.compress}`,
); );
let uploaded = 0;
let reused = 0; let reused = 0;
// Plan: hash every tool, decide reuse vs upload (moved-tag guard included).
const plan = [];
for (const tool of a.tools) { for (const tool of a.tools) {
const files = gather(tool, a.src); const files = gather(tool, a.src);
const hash = toolContentHash(files); const hash = toolContentHash(files);
@ -205,8 +220,7 @@ function main() {
console.log(` ${tool}: reuse ${ver} (${hash.slice(0, 19)}…)`); console.log(` ${tool}: reuse ${ver} (${hash.slice(0, 19)}…)`);
} else { } else {
ver = a.tag; ver = a.tag;
const metaKey = `${P}/${tool}/${ver}/meta.json`; const existing = store.getJSON(`${P}/${tool}/${ver}/meta.json`)?.hash ?? null;
const existing = store.getJSON(metaKey)?.hash ?? null;
if (existing && existing !== hash) { if (existing && existing !== hash) {
throw new Error( throw new Error(
`moved-tag guard: ${P}/${tool}/${ver}/ already holds ${existing} ` + `moved-tag guard: ${P}/${tool}/${ver}/ already holds ${existing} ` +
@ -214,15 +228,25 @@ function main() {
`overwrite an immutable folder.`, `overwrite an immutable folder.`,
); );
} }
const sizes = []; plan.push({ tool, files, hash, ver });
for (const f of files) { idx[hash] = ver;
const key = `${P}/${tool}/${ver}/${f.name}`;
sizes.push(putFile(store, key, f.bytes, f.name, a.compress, a.quality));
} }
// meta.json LAST — its presence marks the bundle complete. registry.tools[tool] = { version: ver, hash };
manifest.tools[tool] = ver;
}
// Compress every to-be-uploaded file concurrently, then upload. Uploads stay
// sequential per tool with meta.json LAST — its presence marks the bundle
// complete — and the registry is only written after every bundle is.
await Promise.all(
plan.flatMap(({ files }) => files.map((f) => compressFile(f, a.compress, a.quality))),
);
for (const { tool, files, hash, ver } of plan) {
const sizes = files.map((f) => putFile(store, `${P}/${tool}/${ver}/${f.name}`, f));
putJSON( putJSON(
store, store,
metaKey, `${P}/${tool}/${ver}/meta.json`,
{ {
tool, tool,
ver, ver,
@ -232,17 +256,13 @@ function main() {
}, },
IMMUTABLE, IMMUTABLE,
); );
idx[hash] = ver;
uploaded++;
const tot = sizes.reduce((s, x) => s + x.stored, 0); const tot = sizes.reduce((s, x) => s + x.stored, 0);
console.log( console.log(
` ${tool}: UPLOAD ${ver} (${hash.slice(0, 19)}…) ` + ` ${tool}: UPLOAD ${ver} (${hash.slice(0, 19)}…) ` +
`${(tot / 1e6).toFixed(1)}MB stored`, `${(tot / 1e6).toFixed(1)}MB stored`,
); );
} }
registry.tools[tool] = { version: ver, hash }; const uploaded = plan.length;
manifest.tools[tool] = ver;
}
// Browser-facing manifest + convenience pointer, both uncached. // Browser-facing manifest + convenience pointer, both uncached.
putJSON(store, `${P}/manifest-${a.tag}.json`, manifest, NO_STORE); putJSON(store, `${P}/manifest-${a.tag}.json`, manifest, NO_STORE);
@ -256,4 +276,4 @@ function main() {
if (store.kind === "local") console.log(`local layout under: ${a.out}`); if (store.kind === "local") console.log(`local layout under: ${a.out}`);
} }
main(); await main();