@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
586 lines (511 loc) • 21.4 kB
text/typescript
import path from "node:path";
import {Worker, spawn} from "@chainsafe/threads";
// `threads` library creates self global variable which breaks `timeout-abort-controller` https://github.com/jacobheun/timeout-abort-controller/issues/9
// @ts-expect-error
// biome-ignore lint/suspicious/noGlobalAssign: We need the global `self` to reassign module properties later
self = undefined;
import {PublicKey} from "@chainsafe/blst";
import {ISignatureSet, PubkeyCache} from "@lodestar/state-transition";
import {Logger} from "@lodestar/utils";
import {Metrics} from "../../../metrics/index.js";
import {LinkedList} from "../../../util/array.js";
import {callInNextEventLoop} from "../../../util/eventLoop.js";
import {QueueError, QueueErrorCode} from "../../../util/queue/index.js";
import {IBlsVerifier, VerifySignatureOpts} from "../interface.js";
import {verifySignatureSetsMaybeBatch} from "../maybeBatch.js";
import {getAggregatedPubkey, getAggregatedPubkeysCount} from "../utils.js";
import {
JobQueueItem,
JobQueueItemSameMessage,
JobQueueItemType,
jobItemSameMessageToMultiSet,
jobItemSigSets,
jobItemWorkReq,
} from "./jobItem.js";
import {defaultPoolSize} from "./poolSize.js";
import {BlsWorkReq, BlsWorkResult, WorkResultCode, WorkResultError, WorkerData} from "./types.js";
import {chunkifyMaximizeChunkSize} from "./utils.js";
// Worker constructor consider the path relative to the current working directory
const workerDir = process.env.NODE_ENV === "test" ? "../../../../lib/chain/bls/multithread" : "./";
export type BlsMultiThreadWorkerPoolModules = {
logger: Logger;
metrics: Metrics | null;
pubkeyCache: PubkeyCache;
};
export type BlsMultiThreadWorkerPoolOptions = {
blsVerifyAllMultiThread?: boolean;
};
export type {JobQueueItemType};
// 1 worker for the main thread
const blsPoolSize = Math.max(defaultPoolSize - 1, 1);
/**
* Split big signature sets into smaller sets so they can be sent to multiple workers.
*
* The biggest sets happen during sync, on mainnet batches of 64 blocks have around ~8000 signatures.
* The latency cost of sending the job to and from the worker is approx a single sig verification.
* If you split a big signature into 2, the extra time cost is `(2+2N)/(1+2N)`.
* For 128, the extra time cost is about 0.3%. No specific reasoning for `128`, it's just good enough.
*/
const MAX_SIGNATURE_SETS_PER_JOB = 128;
/**
* If there are more than `MAX_BUFFERED_SIGS` buffered sigs, verify them immediately without waiting `MAX_BUFFER_WAIT_MS`.
*
* The efficiency improvement of batching sets asymptotically reaches x2. However, for batching large sets
* has more risk in case a signature is invalid, requiring to revalidate all sets in the batch. 32 is sweet
* point for this tradeoff.
*/
const MAX_BUFFERED_SIGS = 32;
/**
* Gossip objects usually come in bursts. Buffering them for a short period of time allows to increase batching
* efficiency, at the cost of delaying validation. Unless running in production shows otherwise, it's not critical
* to hold attestations and aggregates for 100ms. Lodestar existing queues may hold those objects for much more anyway.
*
* There's no exact reasoning for the `100` milliseconds number. The metric `batchSigsSuccess` should indicate if this
* value needs revision
*/
const MAX_BUFFER_WAIT_MS = 100;
/**
* Max concurrent jobs on `canAcceptWork` status
*/
const MAX_JOBS_CAN_ACCEPT_WORK = 512;
type WorkerApi = {
verifyManySignatureSets(workReqArr: BlsWorkReq[]): Promise<BlsWorkResult>;
};
enum WorkerStatusCode {
notInitialized,
initializing,
initializationError,
idle,
running,
}
type WorkerStatus =
| {code: WorkerStatusCode.notInitialized}
| {code: WorkerStatusCode.initializing; initPromise: Promise<WorkerApi>}
| {code: WorkerStatusCode.initializationError; error: Error}
| {code: WorkerStatusCode.idle; workerApi: WorkerApi}
| {code: WorkerStatusCode.running; workerApi: WorkerApi};
type WorkerDescriptor = {
worker: Worker;
status: WorkerStatus;
};
/**
* Wraps "threads" library thread pool queue system with the goals:
* - Complete total outstanding jobs in total minimum time possible.
* Will split large signature sets into smaller sets and send to different workers
* - Reduce the latency cost for small signature sets. In NodeJS 12,14 worker <-> main thread
* communication has very high latency, of around ~5 ms. So package multiple small signature
* sets into packages of work and send at once to a worker to distribute the latency cost
*/
export class BlsMultiThreadWorkerPool implements IBlsVerifier {
private readonly logger: Logger;
private readonly metrics: Metrics | null;
private readonly pubkeyCache: PubkeyCache;
private readonly workers: WorkerDescriptor[];
private readonly jobs = new LinkedList<JobQueueItem>();
private bufferedJobs: {
jobs: LinkedList<JobQueueItem>;
prioritizedJobs: LinkedList<JobQueueItem>;
sigCount: number;
firstPush: number;
timeout: NodeJS.Timeout;
} | null = null;
private blsVerifyAllMultiThread: boolean;
private closed = false;
private workersBusy = 0;
constructor(options: BlsMultiThreadWorkerPoolOptions, modules: BlsMultiThreadWorkerPoolModules) {
const {logger, metrics, pubkeyCache} = modules;
this.logger = logger;
this.metrics = metrics;
this.pubkeyCache = pubkeyCache;
this.blsVerifyAllMultiThread = options.blsVerifyAllMultiThread ?? false;
// Use compressed for herumi for now.
// THe worker is not able to deserialize from uncompressed
// `Error: err _wrapDeserialize`
this.workers = this.createWorkers(blsPoolSize);
if (metrics) {
metrics.blsThreadPool.queueLength.addCollect(() => {
metrics.blsThreadPool.queueLength.set(this.jobs.length);
metrics.blsThreadPool.workersBusy.set(this.workersBusy);
});
}
}
canAcceptWork(): boolean {
return (
this.workersBusy < blsPoolSize &&
// TODO: Should also bound the jobs queue?
this.jobs.length < MAX_JOBS_CAN_ACCEPT_WORK
);
}
async verifySignatureSets(sets: ISignatureSet[], opts: VerifySignatureOpts = {}): Promise<boolean> {
// Pubkeys are aggregated in the main thread regardless if verified in workers or in main thread
this.metrics?.bls.aggregatedPubkeys.inc(getAggregatedPubkeysCount(sets));
this.metrics?.blsThreadPool.totalSigSets.inc(sets.length);
if (opts.priority) {
this.metrics?.blsThreadPool.prioritizedSigSets.inc(sets.length);
}
if (opts.batchable) {
this.metrics?.blsThreadPool.batchableSigSets.inc(sets.length);
}
if (opts.verifyOnMainThread && !this.blsVerifyAllMultiThread) {
const timer = this.metrics?.blsThreadPool.mainThreadDurationInThreadPool.startTimer();
try {
return verifySignatureSetsMaybeBatch(
sets.map((set) => ({
publicKey: getAggregatedPubkey(set, this.pubkeyCache),
message: set.signingRoot.valueOf(),
signature: set.signature,
}))
);
} finally {
if (timer) timer();
}
}
// Split large array of sets into smaller.
// Very helpful when syncing finalized, sync may submit +1000 sets so chunkify allows to distribute to many workers
const results = await Promise.all(
chunkifyMaximizeChunkSize(sets, MAX_SIGNATURE_SETS_PER_JOB).map(
(setsChunk) =>
new Promise<boolean>((resolve, reject) => {
return this.queueBlsWork({
type: JobQueueItemType.default,
resolve,
reject,
addedTimeMs: Date.now(),
opts,
sets: setsChunk,
});
})
)
);
// .every on an empty array returns true
if (results.length === 0) {
throw Error("Empty results array");
}
return results.every((isValid) => isValid === true);
}
/**
* Verify signature sets of the same message, only supports worker verification.
*/
async verifySignatureSetsSameMessage(
sets: {publicKey: PublicKey; signature: Uint8Array}[],
message: Uint8Array,
opts: Omit<VerifySignatureOpts, "verifyOnMainThread"> = {}
): Promise<boolean[]> {
// chunkify so that it reduce the risk of retrying when there is at least one invalid signature
const results = await Promise.all(
chunkifyMaximizeChunkSize(sets, MAX_SIGNATURE_SETS_PER_JOB).map(
(setsChunk) =>
new Promise<boolean[]>((resolve, reject) => {
this.queueBlsWork({
type: JobQueueItemType.sameMessage,
resolve,
reject,
addedTimeMs: Date.now(),
opts,
sets: setsChunk,
message,
});
})
)
);
return results.flat();
}
async close(): Promise<void> {
if (this.bufferedJobs) {
clearTimeout(this.bufferedJobs.timeout);
}
// Abort all jobs
for (const job of this.jobs) {
job.reject(new QueueError({code: QueueErrorCode.QUEUE_ABORTED}));
}
this.jobs.clear();
// Terminate all workers. await to ensure no workers are left hanging
await Promise.all(
Array.from(this.workers.entries()).map(([id, worker]) =>
// NOTE: 'threads' has not yet updated types, and NodeJS complains with
// [DEP0132] DeprecationWarning: Passing a callback to worker.terminate() is deprecated. It returns a Promise instead.
(worker.worker.terminate() as unknown as Promise<void>).catch((e: Error) => {
this.logger.error("Error terminating worker", {id}, e);
})
)
);
}
private createWorkers(poolSize: number): WorkerDescriptor[] {
const workers: WorkerDescriptor[] = [];
for (let i = 0; i < poolSize; i++) {
const workerData: WorkerData = {workerId: i};
const worker = new Worker(path.join(workerDir, "worker.js"), {
suppressTranspileTS: Boolean(globalThis.Bun),
workerData,
} as ConstructorParameters<typeof Worker>[1]);
const workerDescriptor: WorkerDescriptor = {
worker,
status: {code: WorkerStatusCode.notInitialized},
};
workers.push(workerDescriptor);
// TODO: Consider initializing only when necessary
const initPromise = spawn<WorkerApi>(worker, {
// A Lodestar Node may do very expensive task at start blocking the event loop and causing
// the initialization to timeout. The number below is big enough to almost disable the timeout
timeout: 5 * 60 * 1000,
});
workerDescriptor.status = {code: WorkerStatusCode.initializing, initPromise};
initPromise
.then((workerApi) => {
workerDescriptor.status = {code: WorkerStatusCode.idle, workerApi};
// Potentially run jobs that were queued before initialization of the first worker
setTimeout(this.runJob, 0);
})
.catch((error: Error) => {
workerDescriptor.status = {code: WorkerStatusCode.initializationError, error};
});
}
return workers;
}
/**
* Register BLS work to be done eventually in a worker
*/
private queueBlsWork(job: JobQueueItem): void {
if (this.closed) {
throw new QueueError({code: QueueErrorCode.QUEUE_ABORTED});
}
// TODO: Consider if limiting queue size is necessary here.
// It would be bad to reject signatures because the node is slow.
// However, if the worker communication broke jobs won't ever finish
if (
this.workers.length > 0 &&
this.workers[0].status.code === WorkerStatusCode.initializationError &&
this.workers.every((worker) => worker.status.code === WorkerStatusCode.initializationError)
) {
job.reject(this.workers[0].status.error);
return;
}
// Append batchable sets to `bufferedJobs`, starting a timeout to push them into `jobs`.
// Do not call `runJob()`, it is called from `runBufferedJobs()`
if (job.opts.batchable) {
if (!this.bufferedJobs) {
this.bufferedJobs = {
jobs: new LinkedList(),
prioritizedJobs: new LinkedList(),
sigCount: 0,
firstPush: Date.now(),
timeout: setTimeout(this.runBufferedJobs, MAX_BUFFER_WAIT_MS),
};
}
const jobs = job.opts.priority ? this.bufferedJobs.prioritizedJobs : this.bufferedJobs.jobs;
jobs.push(job);
this.bufferedJobs.sigCount += jobItemSigSets(job);
if (this.bufferedJobs.sigCount > MAX_BUFFERED_SIGS) {
clearTimeout(this.bufferedJobs.timeout);
this.runBufferedJobs();
}
}
// Push job and schedule to call `runJob` in the next macro event loop cycle.
// This is useful to allow batching job submitted from a synchronous for loop,
// and to prevent large stacks since runJob may be called recursively.
else {
if (job.opts.priority) {
this.jobs.unshift(job);
} else {
this.jobs.push(job);
}
callInNextEventLoop(this.runJob);
}
}
/**
* Potentially submit jobs to an idle worker, only if there's a worker and jobs
*/
private runJob = async (): Promise<void> => {
if (this.closed) {
return;
}
// Find idle worker
const worker = this.workers.find((worker) => worker.status.code === WorkerStatusCode.idle);
if (!worker || worker.status.code !== WorkerStatusCode.idle) {
return;
}
// Prepare work package
const jobsInput = this.prepareWork();
if (jobsInput.length === 0) {
return;
}
// TODO: After sending the work to the worker the main thread can drop the job arguments
// and free-up memory, only needs to keep the job's Promise handlers.
// Maybe it's not useful since all data referenced in jobs is likely referenced by others
const workerApi = worker.status.workerApi;
worker.status = {code: WorkerStatusCode.running, workerApi};
this.workersBusy++;
try {
let startedJobsDefault = 0;
let startedJobsSameMessage = 0;
let startedSetsDefault = 0;
let startedSetsSameMessage = 0;
const workReqs: BlsWorkReq[] = [];
const jobsStarted: JobQueueItem[] = [];
for (const job of jobsInput) {
this.metrics?.blsThreadPool.jobWaitTime.observe((Date.now() - job.addedTimeMs) / 1000);
let workReq: BlsWorkReq;
try {
// Note: This can throw, must be handled per-job.
// Pubkey and signature aggregation is defered here
workReq = await jobItemWorkReq(job, this.pubkeyCache, this.metrics);
} catch (e) {
this.metrics?.blsThreadPool.errorAggregateSignatureSetsCount.inc({type: job.type});
switch (job.type) {
case JobQueueItemType.default:
job.reject(e as Error);
break;
case JobQueueItemType.sameMessage:
// there could be an invalid pubkey/signature, retry each individually
this.retryJobItemSameMessage(job);
break;
}
continue;
}
// Re-push all jobs with matching workReq for easier accounting of results
workReqs.push(workReq);
jobsStarted.push(job);
if (job.type === JobQueueItemType.sameMessage) {
startedJobsSameMessage += 1;
startedSetsSameMessage += job.sets.length;
} else {
startedJobsDefault += 1;
startedSetsDefault += job.sets.length;
}
}
const startedSigSets = startedSetsDefault + startedSetsSameMessage;
this.metrics?.blsThreadPool.totalJobsGroupsStarted.inc(1);
this.metrics?.blsThreadPool.totalJobsStarted.inc({type: JobQueueItemType.default}, startedJobsDefault);
this.metrics?.blsThreadPool.totalJobsStarted.inc({type: JobQueueItemType.sameMessage}, startedJobsSameMessage);
this.metrics?.blsThreadPool.totalSigSetsStarted.inc({type: JobQueueItemType.default}, startedSetsDefault);
this.metrics?.blsThreadPool.totalSigSetsStarted.inc({type: JobQueueItemType.sameMessage}, startedSetsSameMessage);
// Send work package to the worker
// If the job, metrics or any code below throws: the job will reject never going stale.
// Only downside is the job promise may be resolved twice, but that's not an issue
const [jobStartSec, jobStartNs] = process.hrtime();
const workResult = await workerApi.verifyManySignatureSets(workReqs);
const [jobEndSec, jobEndNs] = process.hrtime();
const {workerId, batchRetries, batchSigsSuccess, workerStartTime, workerEndTime, results} = workResult;
const [workerStartSec, workerStartNs] = workerStartTime;
const [workerEndSec, workerEndNs] = workerEndTime;
let successCount = 0;
let errorCount = 0;
// Un-wrap work package
for (let i = 0; i < jobsStarted.length; i++) {
const job = jobsStarted[i];
const jobResult = results[i];
const sigSetCount = jobItemSigSets(job);
// TODO: enable exhaustive switch case checks lint rule
switch (job.type) {
case JobQueueItemType.default:
if (!jobResult || jobResult.code !== WorkResultCode.success) {
job.reject(getJobResultError(jobResult, i));
errorCount += sigSetCount;
} else {
job.resolve(jobResult.result);
successCount += sigSetCount;
}
break;
// handle result of the verification of aggregated signature against aggregated pubkeys
case JobQueueItemType.sameMessage:
if (!jobResult || jobResult.code !== WorkResultCode.success) {
job.reject(getJobResultError(jobResult, i));
errorCount += 1;
} else {
if (jobResult.result) {
// All are valid, most of the time it goes here
job.resolve(job.sets.map(() => true));
} else {
// Retry each individually
this.retryJobItemSameMessage(job);
}
successCount += 1;
}
break;
}
}
const workerJobTimeSec = workerEndSec - workerStartSec + (workerEndNs - workerStartNs) / 1e9;
const latencyToWorkerSec = workerStartSec - jobStartSec + (workerStartNs - jobStartNs) / 1e9;
const latencyFromWorkerSec = jobEndSec - workerEndSec + Number(jobEndNs - workerEndNs) / 1e9;
this.metrics?.blsThreadPool.timePerSigSet.observe(workerJobTimeSec / startedSigSets);
this.metrics?.blsThreadPool.jobsWorkerTime.inc({workerId}, workerJobTimeSec);
this.metrics?.blsThreadPool.latencyToWorker.observe(latencyToWorkerSec);
this.metrics?.blsThreadPool.latencyFromWorker.observe(latencyFromWorkerSec);
this.metrics?.blsThreadPool.successJobsSignatureSetsCount.inc(successCount);
this.metrics?.blsThreadPool.errorJobsSignatureSetsCount.inc(errorCount);
this.metrics?.blsThreadPool.batchRetries.inc(batchRetries);
this.metrics?.blsThreadPool.batchSigsSuccess.inc(batchSigsSuccess);
} catch (e) {
// Worker communications should never reject
if (!this.closed) {
this.logger.error("BlsMultiThreadWorkerPool error", {}, e as Error);
}
// Reject all
for (const job of jobsInput) {
job.reject(e as Error);
}
}
worker.status = {code: WorkerStatusCode.idle, workerApi};
this.workersBusy--;
// Potentially run a new job
callInNextEventLoop(this.runJob);
};
/**
* Grab pending work up to a max number of signatures
*/
private prepareWork(): JobQueueItem[] {
const jobs: JobQueueItem[] = [];
let totalSigs = 0;
while (totalSigs < MAX_SIGNATURE_SETS_PER_JOB) {
const job = this.jobs.shift();
if (!job) {
break;
}
jobs.push(job);
totalSigs += jobItemSigSets(job);
}
return jobs;
}
/**
* Add all buffered jobs to the job queue and potentially run them immediately
*/
private runBufferedJobs = (): void => {
if (this.bufferedJobs) {
for (const job of this.bufferedJobs.jobs) {
this.jobs.push(job);
}
for (const job of this.bufferedJobs.prioritizedJobs) {
this.jobs.unshift(job);
}
this.bufferedJobs = null;
callInNextEventLoop(this.runJob);
}
};
private retryJobItemSameMessage(job: JobQueueItemSameMessage): void {
// Create new jobs for each pubkey set, and Promise.all all the results
for (const j of jobItemSameMessageToMultiSet(job)) {
if (j.opts.priority) {
this.jobs.unshift(j);
} else {
this.jobs.push(j);
}
}
this.metrics?.blsThreadPool.sameMessageRetryJobs.inc(1);
this.metrics?.blsThreadPool.sameMessageRetrySets.inc(job.sets.length);
}
/** For testing */
protected async waitTillInitialized(): Promise<void> {
await Promise.all(
this.workers.map(async (worker) => {
if (worker.status.code === WorkerStatusCode.initializing) {
await worker.status.initPromise;
}
})
);
}
}
function getJobResultError(jobResult: WorkResultError | null, i: number): Error {
const workerError = jobResult ? Error(jobResult.error.message) : Error(`No jobResult for index ${i}`);
if (jobResult?.error?.stack) workerError.stack = jobResult.error.stack;
return workerError;
}