diff --git a/yarn-project/cli-wallet/src/cmds/authorize_action.ts b/yarn-project/cli-wallet/src/cmds/authorize_action.ts index a2a5c9c4f292..2314b15ec5db 100644 --- a/yarn-project/cli-wallet/src/cmds/authorize_action.ts +++ b/yarn-project/cli-wallet/src/cmds/authorize_action.ts @@ -2,6 +2,8 @@ import { type AccountWalletWithSecretKey, type AztecAddress, Contract } from '@a import { prepTx } from '@aztec/cli/utils'; import type { LogFn } from '@aztec/foundation/log'; +import { DEFAULT_TX_TIMEOUT_S } from '../utils/pxe_wrapper.js'; + export async function authorizeAction( wallet: AccountWalletWithSecretKey, functionName: string, @@ -28,7 +30,7 @@ export async function authorizeAction( const action = contract.methods[functionName](...functionArgs); const setAuthwitnessInteraction = await wallet.setPublicAuthWit({ caller, action }, true); - const witness = await setAuthwitnessInteraction.send().wait(); + const witness = await setAuthwitnessInteraction.send().wait({ timeout: DEFAULT_TX_TIMEOUT_S }); log(`Authorized action ${functionName} on contract ${contractAddress} for caller ${caller}`); diff --git a/yarn-project/cli-wallet/src/cmds/cancel_tx.ts b/yarn-project/cli-wallet/src/cmds/cancel_tx.ts index 0ca1572d5b6e..7445a7ef25ac 100644 --- a/yarn-project/cli-wallet/src/cmds/cancel_tx.ts +++ b/yarn-project/cli-wallet/src/cmds/cancel_tx.ts @@ -5,6 +5,8 @@ import { Fr } from '@aztec/foundation/fields'; import type { LogFn } from '@aztec/foundation/log'; import { GasFees, GasSettings } from '@aztec/stdlib/gas'; +import { DEFAULT_TX_TIMEOUT_S } from '../utils/pxe_wrapper.js'; + export async function cancelTx( wallet: AccountWalletWithSecretKey, { @@ -46,7 +48,7 @@ export async function cancelTx( const txProvingResult = await wallet.proveTx(txRequest, txSimulationResult.privateExecutionResult); const sentTx = new SentTx(wallet, wallet.sendTx(txProvingResult.toTx())); try { - await sentTx.wait(); + await sentTx.wait({ timeout: DEFAULT_TX_TIMEOUT_S }); log('Transaction has been cancelled'); diff --git a/yarn-project/cli-wallet/src/cmds/create_account.ts b/yarn-project/cli-wallet/src/cmds/create_account.ts index f02f92f61d74..cada0b8e8552 100644 --- a/yarn-project/cli-wallet/src/cmds/create_account.ts +++ b/yarn-project/cli-wallet/src/cmds/create_account.ts @@ -6,6 +6,7 @@ import type { LogFn, Logger } from '@aztec/foundation/log'; import { type AccountType, createOrRetrieveAccount } from '../utils/accounts.js'; import { type IFeeOpts, printGasEstimates } from '../utils/options/fees.js'; import { printProfileResult } from '../utils/profiling.js'; +import { DEFAULT_TX_TIMEOUT_S } from '../utils/pxe_wrapper.js'; export async function createAccount( client: PXE, @@ -118,7 +119,7 @@ export async function createAccount( if (!json) { log(`\nWaiting for account contract deployment...`); } - txReceipt = await tx.wait(); + txReceipt = await tx.wait({ timeout: DEFAULT_TX_TIMEOUT_S }); out.txReceipt = { status: txReceipt.status, transactionFee: txReceipt.transactionFee, diff --git a/yarn-project/cli-wallet/src/cmds/deploy.ts b/yarn-project/cli-wallet/src/cmds/deploy.ts index 5db26149e5c0..373ed48e3519 100644 --- a/yarn-project/cli-wallet/src/cmds/deploy.ts +++ b/yarn-project/cli-wallet/src/cmds/deploy.ts @@ -6,6 +6,7 @@ import { PublicKeys } from '@aztec/stdlib/keys'; import { type IFeeOpts, printGasEstimates } from '../utils/options/fees.js'; import { printProfileResult } from '../utils/profiling.js'; +import { DEFAULT_TX_TIMEOUT_S } from '../utils/pxe_wrapper.js'; export async function deploy( wallet: AccountWalletWithSecretKey, @@ -71,7 +72,7 @@ export async function deploy( const txHash = await tx.getTxHash(); debugLogger.debug(`Deploy tx sent with hash ${txHash}`); if (wait) { - const deployed = await tx.wait(); + const deployed = await tx.wait({ timeout: DEFAULT_TX_TIMEOUT_S }); const { address, partialAddress, instance } = deployed.contract; if (json) { logJson({ diff --git a/yarn-project/cli-wallet/src/cmds/deploy_account.ts b/yarn-project/cli-wallet/src/cmds/deploy_account.ts index 7d08c9251061..23b005e985c3 100644 --- a/yarn-project/cli-wallet/src/cmds/deploy_account.ts +++ b/yarn-project/cli-wallet/src/cmds/deploy_account.ts @@ -4,6 +4,7 @@ import type { LogFn, Logger } from '@aztec/foundation/log'; import { type IFeeOpts, printGasEstimates } from '../utils/options/fees.js'; import { printProfileResult } from '../utils/profiling.js'; +import { DEFAULT_TX_TIMEOUT_S } from '../utils/pxe_wrapper.js'; export async function deployAccount( account: AccountManager, @@ -95,7 +96,7 @@ export async function deployAccount( if (!json) { log(`\nWaiting for account contract deployment...`); } - txReceipt = await tx.wait(); + txReceipt = await tx.wait({ timeout: DEFAULT_TX_TIMEOUT_S }); out.txReceipt = { status: txReceipt.status, transactionFee: txReceipt.transactionFee, diff --git a/yarn-project/cli-wallet/src/cmds/send.ts b/yarn-project/cli-wallet/src/cmds/send.ts index 012843b8489a..cca4d7912613 100644 --- a/yarn-project/cli-wallet/src/cmds/send.ts +++ b/yarn-project/cli-wallet/src/cmds/send.ts @@ -12,6 +12,7 @@ import { GasSettings } from '@aztec/stdlib/gas'; import { type IFeeOpts, printGasEstimates } from '../utils/options/fees.js'; import { printProfileResult } from '../utils/profiling.js'; +import { DEFAULT_TX_TIMEOUT_S } from '../utils/pxe_wrapper.js'; export async function send( wallet: AccountWalletWithSecretKey, @@ -57,7 +58,7 @@ export async function send( log(`\nTransaction hash: ${txHash.toString()}`); if (wait) { try { - await tx.wait(); + await tx.wait({ timeout: DEFAULT_TX_TIMEOUT_S }); log('Transaction has been mined'); diff --git a/yarn-project/cli-wallet/src/utils/profiling.ts b/yarn-project/cli-wallet/src/utils/profiling.ts index f6b3058b8bbe..942c63d3a4f0 100644 --- a/yarn-project/cli-wallet/src/utils/profiling.ts +++ b/yarn-project/cli-wallet/src/utils/profiling.ts @@ -4,6 +4,12 @@ import type { ProvingTimings, SimulationTimings } from '@aztec/stdlib/tx'; import { format } from 'util'; +const FN_NAME_PADDING = 50; +const COLUMN_MIN_WIDTH = 13; +const COLUMN_MAX_WIDTH = 15; + +const ORACLE_NAME_PADDING = 50; + export function printProfileResult( timings: ProvingTimings | SimulationTimings, log: LogFn, @@ -13,13 +19,23 @@ export function printProfileResult( log( format( ' ', - 'Function name'.padEnd(50), - 'Time'.padStart(13).padEnd(15), - executionSteps ? 'Gates'.padStart(13).padEnd(15) : '', - executionSteps ? 'Subtotal'.padStart(13).padEnd(15) : '', + 'Function name'.padEnd(FN_NAME_PADDING), + 'Time'.padStart(COLUMN_MIN_WIDTH).padEnd(COLUMN_MAX_WIDTH), + executionSteps ? 'Gates'.padStart(COLUMN_MIN_WIDTH).padEnd(COLUMN_MAX_WIDTH) : '', + executionSteps ? 'Subtotal'.padStart(COLUMN_MIN_WIDTH).padEnd(COLUMN_MAX_WIDTH) : '', + ), + ); + log( + format( + ''.padEnd( + FN_NAME_PADDING + + COLUMN_MAX_WIDTH + + COLUMN_MAX_WIDTH + + (executionSteps ? COLUMN_MAX_WIDTH + COLUMN_MAX_WIDTH : 0), + '-', + ), ), ); - log(format(''.padEnd(50 + 15 + 15 + (executionSteps ? 15 + 15 : 0), '-'))); let acc = 0; let biggest: PrivateExecutionStep | undefined = executionSteps?.[0]; @@ -32,13 +48,37 @@ export function printProfileResult( log( format( - ' ', - fn.functionName.padEnd(50), - `${fn.time.toFixed(2)}ms`.padStart(13).padEnd(15), - currentExecutionStep ? currentExecutionStep.gateCount!.toLocaleString().padStart(13).padEnd(15) : '', - currentExecutionStep ? acc.toLocaleString().padStart(15) : '', + ' - ', + fn.functionName.padEnd(FN_NAME_PADDING), + `${fn.time.toFixed(2)}ms`.padStart(COLUMN_MIN_WIDTH).padEnd(COLUMN_MAX_WIDTH), + currentExecutionStep + ? currentExecutionStep.gateCount!.toLocaleString().padStart(COLUMN_MIN_WIDTH).padEnd(COLUMN_MAX_WIDTH) + : '', + currentExecutionStep ? acc.toLocaleString().padStart(COLUMN_MAX_WIDTH) : '', ), ); + if (fn.oracles) { + log(''); + for (const [oracleName, { times }] of Object.entries(fn.oracles)) { + const calls = times.length; + const min = Math.min(...times); + const max = Math.max(...times); + const total = times.reduce((acc, time) => acc + time, 0); + const avg = total / calls; + log( + format( + ' ', + oracleName.padEnd(ORACLE_NAME_PADDING), + `${calls} calls`.padStart(COLUMN_MIN_WIDTH).padEnd(COLUMN_MAX_WIDTH), + `${total.toFixed(2)}ms`.padStart(COLUMN_MIN_WIDTH).padEnd(COLUMN_MAX_WIDTH), + `min: ${min.toFixed(2)}ms`.padStart(COLUMN_MIN_WIDTH).padEnd(COLUMN_MAX_WIDTH), + `avg: ${avg.toFixed(2)}ms`.padStart(COLUMN_MIN_WIDTH).padEnd(COLUMN_MAX_WIDTH), + `max: ${max.toFixed(2)}ms`.padStart(COLUMN_MIN_WIDTH).padEnd(COLUMN_MAX_WIDTH), + ), + ); + } + } + log(''); }); if (biggest) { @@ -55,31 +95,39 @@ export function printProfileResult( log( format( 'Private simulation time:'.padEnd(25), - `${timings.perFunction.reduce((acc, { time }) => acc + time, 0).toFixed(2)}ms`.padStart(15), + `${timings.perFunction.reduce((acc, { time }) => acc + time, 0).toFixed(2)}ms`.padStart(COLUMN_MAX_WIDTH), ), ); if ((timings as ProvingTimings).proving) { - log(format('Proving time:'.padEnd(25), `${(timings as ProvingTimings).proving?.toFixed(2)}ms`.padStart(15))); + log( + format( + 'Proving time:'.padEnd(25), + `${(timings as ProvingTimings).proving?.toFixed(2)}ms`.padStart(COLUMN_MAX_WIDTH), + ), + ); } if ((timings as SimulationTimings).publicSimulation) { log( format( 'Public simulation time:'.padEnd(25), - `${(timings as SimulationTimings).publicSimulation?.toFixed(2)}ms`.padStart(15), + `${(timings as SimulationTimings).publicSimulation?.toFixed(2)}ms`.padStart(COLUMN_MAX_WIDTH), ), ); } if ((timings as SimulationTimings).validation) { log( - format('Validation time:'.padEnd(25), `${(timings as SimulationTimings).validation?.toFixed(2)}ms`.padStart(15)), + format( + 'Validation time:'.padEnd(25), + `${(timings as SimulationTimings).validation?.toFixed(2)}ms`.padStart(COLUMN_MAX_WIDTH), + ), ); } log( format( 'Total time:'.padEnd(25), - `${timings.total.toFixed(2)}ms`.padStart(15), + `${timings.total.toFixed(2)}ms`.padStart(COLUMN_MAX_WIDTH), `(${timings.unaccounted.toFixed(2)}ms unaccounted)`, ), ); diff --git a/yarn-project/cli-wallet/src/utils/pxe_wrapper.ts b/yarn-project/cli-wallet/src/utils/pxe_wrapper.ts index 7346fa715125..5d1a4c0601bd 100644 --- a/yarn-project/cli-wallet/src/utils/pxe_wrapper.ts +++ b/yarn-project/cli-wallet/src/utils/pxe_wrapper.ts @@ -1,6 +1,8 @@ import { type PXEServiceConfig, createPXEService, getPXEServiceConfig } from '@aztec/pxe/server'; import { type AztecNode, type PXE, createAztecNodeClient } from '@aztec/stdlib/interfaces/client'; +export const DEFAULT_TX_TIMEOUT_S = 180; + /* * Wrapper class for PXE service, avoids initialization issues due to * closures when providing PXE service to injected commander.js commands diff --git a/yarn-project/end-to-end/src/bench/client_flows/benchmark.ts b/yarn-project/end-to-end/src/bench/client_flows/benchmark.ts index 801009f81b78..a0d38e340aae 100644 --- a/yarn-project/end-to-end/src/bench/client_flows/benchmark.ts +++ b/yarn-project/end-to-end/src/bench/client_flows/benchmark.ts @@ -94,7 +94,19 @@ export class ProxyLogger { export type ProverType = 'wasm' | 'native'; -type Step = Pick & { time: number; accGateCount?: number }; +type OracleRecording = { + calls: number; + max: number; + min: number; + avg: number; + total: number; +}; + +type Step = Pick & { + time: number; + accGateCount?: number; + oracles: Record; +}; type ClientFlowBenchmark = { name: string; @@ -169,6 +181,21 @@ export function generateBenchmark( gateCount: step.gateCount, accGateCount: previousAccGateCount + step.gateCount!, time: step.timings.witgen, + oracles: Object.entries(step.timings.oracles ?? {}).reduce( + (acc, [oracleName, oracleData]) => { + const total = oracleData.times.reduce((sum, time) => sum + time, 0); + const calls = oracleData.times.length; + acc[oracleName] = { + calls, + max: Math.max(...oracleData.times), + min: Math.min(...oracleData.times), + total, + avg: total / calls, + }; + return acc; + }, + {} as Record, + ), }, ]; }, []); diff --git a/yarn-project/end-to-end/src/fixtures/utils.ts b/yarn-project/end-to-end/src/fixtures/utils.ts index 724379e719d9..6dca9a346dff 100644 --- a/yarn-project/end-to-end/src/fixtures/utils.ts +++ b/yarn-project/end-to-end/src/fixtures/utils.ts @@ -59,8 +59,8 @@ import { } from '@aztec/pxe/server'; import type { SequencerClient } from '@aztec/sequencer-client'; import type { TestSequencerClient } from '@aztec/sequencer-client/test'; -import { WASMSimulator } from '@aztec/simulator/client'; -import { SimulationProviderRecorderWrapper } from '@aztec/simulator/testing'; +import { MemoryCircuitRecorder, SimulationProviderRecorderWrapper, WASMSimulator } from '@aztec/simulator/client'; +import { FileCircuitRecorder } from '@aztec/simulator/testing'; import { getContractClassFromArtifact, getContractInstanceFromDeployParams } from '@aztec/stdlib/contract'; import type { AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; import type { PublicDataTreeLeaf } from '@aztec/stdlib/trees'; @@ -171,7 +171,10 @@ export async function setupPXEService( } const simulationProvider = new WASMSimulator(); - const simulationProviderWithRecorder = new SimulationProviderRecorderWrapper(simulationProvider); + const recorder = process.env.CIRCUIT_RECORD_DIR + ? new FileCircuitRecorder(process.env.CIRCUIT_RECORD_DIR) + : new MemoryCircuitRecorder(); + const simulationProviderWithRecorder = new SimulationProviderRecorderWrapper(simulationProvider, recorder); const pxe = await createPXEServiceWithSimulationProvider( aztecNode, simulationProviderWithRecorder, diff --git a/yarn-project/pxe/src/entrypoints/server/utils.ts b/yarn-project/pxe/src/entrypoints/server/utils.ts index 3402d8d63f0a..24d93ac2c6d4 100644 --- a/yarn-project/pxe/src/entrypoints/server/utils.ts +++ b/yarn-project/pxe/src/entrypoints/server/utils.ts @@ -3,8 +3,13 @@ import { BBWASMBundlePrivateKernelProver } from '@aztec/bb-prover/client/wasm/bu import { randomBytes } from '@aztec/foundation/crypto'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle'; -import { type SimulationProvider, WASMSimulator } from '@aztec/simulator/client'; -import { SimulationProviderRecorderWrapper } from '@aztec/simulator/testing'; +import { + MemoryCircuitRecorder, + type SimulationProvider, + SimulationProviderRecorderWrapper, + WASMSimulator, +} from '@aztec/simulator/client'; +import { FileCircuitRecorder } from '@aztec/simulator/testing'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import type { PXEServiceConfig } from '../../config/index.js'; @@ -26,7 +31,10 @@ export function createPXEService( options: PXECreationOptions = { loggers: {} }, ) { const simulationProvider = new WASMSimulator(); - const simulationProviderWithRecorder = new SimulationProviderRecorderWrapper(simulationProvider); + const recorder = process.env.CIRCUIT_RECORD_DIR + ? new FileCircuitRecorder(process.env.CIRCUIT_RECORD_DIR) + : new MemoryCircuitRecorder(); + const simulationProviderWithRecorder = new SimulationProviderRecorderWrapper(simulationProvider, recorder); return createPXEServiceWithSimulationProvider(aztecNode, simulationProviderWithRecorder, config, options); } diff --git a/yarn-project/pxe/src/private_kernel/private_kernel_execution_prover.ts b/yarn-project/pxe/src/private_kernel/private_kernel_execution_prover.ts index bc9250128e34..2b82d0f5c886 100644 --- a/yarn-project/pxe/src/private_kernel/private_kernel_execution_prover.ts +++ b/yarn-project/pxe/src/private_kernel/private_kernel_execution_prover.ts @@ -154,6 +154,7 @@ export class PrivateKernelExecutionProver { vk: currentExecution.vk, timings: { witgen: currentExecution.profileResult?.timings.witgen ?? 0, + oracles: currentExecution.profileResult?.timings.oracles, }, }); diff --git a/yarn-project/pxe/src/pxe_service/pxe_service.ts b/yarn-project/pxe/src/pxe_service/pxe_service.ts index c1c266e45f92..6fdb1388b689 100644 --- a/yarn-project/pxe/src/pxe_service/pxe_service.ts +++ b/yarn-project/pxe/src/pxe_service/pxe_service.ts @@ -679,9 +679,10 @@ export class PXEService implements PXE { const totalTime = totalTimer.ms(); - const perFunction = executionSteps.map(({ functionName, timings: { witgen } }) => ({ + const perFunction = executionSteps.map(({ functionName, timings: { witgen, oracles } }) => ({ functionName, time: witgen, + oracles, })); const timings: ProvingTimings = { @@ -746,10 +747,13 @@ export class PXEService implements PXE { const totalTime = totalTimer.ms(); - const perFunction = executionSteps.map(({ functionName, timings: { witgen } }) => ({ - functionName, - time: witgen, - })); + const perFunction = executionSteps.map(({ functionName, timings: { witgen, oracles } }) => { + return { + functionName, + time: witgen, + oracles, + }; + }); // Gate computation is time is not relevant for profiling, so we subtract it from the total time. const gateCountComputationTime = @@ -849,9 +853,10 @@ export class PXEService implements PXE { const totalTime = totalTimer.ms(); - const perFunction = executionSteps.map(({ functionName, timings: { witgen } }) => ({ + const perFunction = executionSteps.map(({ functionName, timings: { witgen, oracles } }) => ({ functionName, time: witgen, + oracles, })); const timings: SimulationTimings = { diff --git a/yarn-project/simulator/src/client.ts b/yarn-project/simulator/src/client.ts index e156792b24e0..1d0666dc5726 100644 --- a/yarn-project/simulator/src/client.ts +++ b/yarn-project/simulator/src/client.ts @@ -1,4 +1,6 @@ export * from './private/index.js'; export { WASMSimulator } from './private/providers/acvm_wasm.js'; +export { SimulationProviderRecorderWrapper } from './private/providers/circuit_recording/simulation_provider_recorder_wrapper.js'; +export { MemoryCircuitRecorder } from './private/providers/circuit_recording/memory_circuit_recorder.js'; export { type SimulationProvider, type DecodedError } from './private/providers/simulation_provider.js'; export * from './common/index.js'; diff --git a/yarn-project/simulator/src/private/acvm/acvm.ts b/yarn-project/simulator/src/private/acvm/acvm.ts index 9108fa87f29a..aa6b468c88c3 100644 --- a/yarn-project/simulator/src/private/acvm/acvm.ts +++ b/yarn-project/simulator/src/private/acvm/acvm.ts @@ -17,6 +17,8 @@ import type { ORACLE_NAMES } from './oracle/index.js'; */ export type ACIRCallback = Record Promise>; +export type ACIRCallbackStats = { times: number[] }; + /** * The result of executing an ACIR. */ @@ -28,6 +30,7 @@ export interface ACIRExecutionResult { */ partialWitness: ACVMWitness; returnWitness: ACVMWitness; + oracles?: Record; } /** diff --git a/yarn-project/simulator/src/private/private_execution.ts b/yarn-project/simulator/src/private/private_execution.ts index 59b7479e0000..2d17e7b77851 100644 --- a/yarn-project/simulator/src/private/private_execution.ts +++ b/yarn-project/simulator/src/private/private_execution.ts @@ -97,6 +97,7 @@ export async function executePrivateFunction( // Due to the recursive nature of execution, we have to subtract the time taken by the first level of // child executions duration - nestedExecutions.reduce((acc, nested) => acc + (nested.profileResult?.timings.witgen ?? 0), 0), + oracles: acirExecutionResult.oracles, }, }, ); diff --git a/yarn-project/simulator/src/private/providers/circuit_recording/circuit_recorder.ts b/yarn-project/simulator/src/private/providers/circuit_recording/circuit_recorder.ts index 9adc155c259c..0870a15bab92 100644 --- a/yarn-project/simulator/src/private/providers/circuit_recording/circuit_recorder.ts +++ b/yarn-project/simulator/src/private/providers/circuit_recording/circuit_recorder.ts @@ -1,28 +1,59 @@ +import { sha512 } from '@aztec/foundation/crypto'; import { createLogger } from '@aztec/foundation/log'; +import { Timer } from '@aztec/foundation/timer'; import type { ForeignCallHandler, ForeignCallInput, ForeignCallOutput } from '@aztec/noir-acvm_js'; -import { createHash } from 'crypto'; -import fs from 'fs/promises'; -import path from 'path'; - import type { ACIRCallback } from '../../acvm/acvm.js'; import type { ACVMWitness } from '../../acvm/acvm_types.js'; import { Oracle } from '../../acvm/oracle/oracle.js'; +export type OracleCall = { + name: string; + inputs: unknown[]; + outputs: unknown; + time: number; + // Due to the recursive nature of the simulator, we might have + // oracle calls performed after a foreign call (which is itself an oracle call) + // We keep track of the stack depth in this variable to ensure the recorded oracle + // calls are correctly associated with the right circuit. + // This is only use as a debugging tool + stackDepth: number; +}; + +export class CircuitRecording { + circuitName: string; + functionName: string; + bytecodeSHA512Hash: string; + timestamp: number; + inputs: Record; + oracleCalls: OracleCall[]; + error?: string; + parent?: CircuitRecording; + + constructor(circuitName: string, functionName: string, bytecodeSHA512Hash: string, inputs: Record) { + this.circuitName = circuitName; + this.functionName = functionName; + this.bytecodeSHA512Hash = bytecodeSHA512Hash; + this.timestamp = Date.now(); + this.inputs = inputs; + this.oracleCalls = []; + } + + setParent(recording?: CircuitRecording): void { + this.parent = recording; + } +} + /** * Class responsible for recording circuit inputs necessary to replay the circuit. These inputs are the initial witness * map and the oracle calls made during the circuit execution/witness generation. * - * The recording is stored in a JSON file called `circuit_name_circuit_function_name_YYYY-MM-DD_N.json` where N is - * a counter to ensure unique filenames. The file is stored in the `recordDir` directory provided as a parameter to - * CircuitRecorder.start(). - * - * Example recording file: + * Example recording object: * ```json * { * "circuitName": "AMM", * "functionName": "add_liquidity", - * "bytecodeMd5Hash": "b46c640ed38f20eac5f61a5e41d8dd1e", + * "bytecodeSHA512Hash": "b46c640ed38f20eac5f61a5e41d8dd1e", * "timestamp": 1740691464360, * "inputs": { * "0": "0x1e89de1f0ad5204263733b7ddf65bec45b8f44714a4da85a46474dad677679ef", @@ -59,10 +90,14 @@ import { Oracle } from '../../acvm/oracle/oracle.js'; * ``` */ export class CircuitRecorder { - private readonly logger = createLogger('simulator:acvm:recording'); - private isFirstCall = true; + protected readonly logger = createLogger('simulator:acvm:recording'); - private constructor(private readonly filePath: string) {} + protected recording?: CircuitRecording; + + private stackDepth: number = 0; + private newCircuit: boolean = true; + + protected constructor() {} /** * Initializes a new circuit recording session. @@ -72,82 +107,20 @@ export class CircuitRecorder { * @param circuitName - Name of the circuit * @param functionName - Name of the circuit function (defaults to 'main'). This is meaningful only for * contracts as protocol circuits artifacts always contain a single entrypoint function called 'main'. - * @returns A new CircuitRecorder instance */ - static async start( - recordDir: string, - input: ACVMWitness, - circuitBytecode: Buffer, - circuitName: string, - functionName: string = 'main', - ): Promise { - const recording = { - circuitName: circuitName, - functionName: functionName, - bytecodeMd5Hash: createHash('md5').update(circuitBytecode).digest('hex'), - timestamp: Date.now(), - inputs: Object.fromEntries(input), - }; - - const recordingStringWithoutClosingBracket = JSON.stringify(recording, null, 2).slice(0, -2); - - try { - // Check if the recording directory exists and is a directory - const stats = await fs.stat(recordDir); - if (!stats.isDirectory()) { - throw new Error(`Recording path ${recordDir} exists but is not a directory`); - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - // The directory does not exist so we create it - await fs.mkdir(recordDir, { recursive: true }); - } else { - throw err; - } + start(input: ACVMWitness, circuitBytecode: Buffer, circuitName: string, functionName: string): Promise { + const parentRef = this.recording; + if (this.newCircuit) { + this.recording = new CircuitRecording( + circuitName, + functionName, + sha512(circuitBytecode).toString('hex'), + Object.fromEntries(input), + ); } + this.recording!.setParent(parentRef); - const filePath = await CircuitRecorder.#computeFilePathAndStoreInitialRecording( - recordDir, - circuitName, - functionName, - recordingStringWithoutClosingBracket, - ); - return new CircuitRecorder(filePath); - } - - /** - * Computes a unique file path for the recording by trying different counter values. - * This is needed because multiple recordings of the same circuit could be happening simultaneously or an older - * recording might be present. - * @param recordDir - Directory to store the recording - * @param circuitName - Name of the circuit - * @param functionName - Name of the circuit function - * @param recordingContent - Initial recording content - * @returns A unique file path for the recording - */ - static async #computeFilePathAndStoreInitialRecording( - recordDir: string, - circuitName: string, - functionName: string, - recordingContent: string, - ): Promise { - let counter = 0; - while (true) { - try { - const filePath = getFilePath(recordDir, circuitName, functionName, counter); - // Write the initial recording content to the file - await fs.writeFile(filePath, recordingContent + ',\n "oracleCalls": [\n', { - flag: 'wx', // wx flag fails if file exists - }); - return filePath; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EEXIST') { - counter++; - continue; - } - throw err; - } - } + return Promise.resolve(); } /** @@ -187,15 +160,38 @@ export class CircuitRecorder { throw new Error(`Oracle method ${name} not found when setting up recording callback`); } + const isExternalCall = (name as keyof ACIRCallback) === 'callPrivateFunction'; + recordingCallback[name as keyof ACIRCallback] = (...args: ForeignCallInput[]): ReturnType => { + const timer = new Timer(); + // If we're entering another circuit via `callPrivateFunction`, we increase the stack depth and set the + // newCircuit variable to ensure we are creating a new recording object. + if (isExternalCall) { + this.stackDepth++; + this.newCircuit = true; + } const result = fn.call(callback, ...args); if (result instanceof Promise) { return result.then(async r => { - await this.#recordCall(name, args, r); + // Once we leave the nested circuit, we decrease the stack depth and set newCircuit to false + // since we are going back to the "parent" circuit which can never be new + if (isExternalCall) { + this.stackDepth--; + this.newCircuit = false; + this.recording = this.recording!.parent; + } + await this.recordCall(name, args, r, timer.ms(), this.stackDepth); return r; }) as ReturnType; } - void this.#recordCall(name, args, result); + // Once we leave the nested circuit, we decrease the stack depth and set newCircuit to false + // since we are going back to the "parent" circuit which can never be new + if (isExternalCall) { + this.stackDepth--; + this.newCircuit = false; + this.recording = this.recording!.parent; + } + void this.recordCall(name, args, result, timer.ms(), this.stackDepth); return result; }; } @@ -210,8 +206,9 @@ export class CircuitRecorder { */ #wrapProtocolCircuitCallback(callback: ForeignCallHandler): ForeignCallHandler { return async (name: string, inputs: ForeignCallInput[]): Promise => { + const timer = new Timer(); const result = await callback(name, inputs); - await this.#recordCall(name, inputs, result); + await this.recordCall(name, inputs, result, timer.ms(), 0); return result; }; } @@ -222,62 +219,43 @@ export class CircuitRecorder { * @param inputs - Input arguments * @param outputs - Output results */ - async #recordCall(name: string, inputs: unknown[], outputs: unknown) { - try { - const entry = { - name, - inputs, - outputs, - }; - const prefix = this.isFirstCall ? ' ' : ' ,'; - this.isFirstCall = false; - await fs.appendFile(this.filePath, prefix + JSON.stringify(entry) + '\n'); - } catch (err) { - this.logger.error('Failed to log circuit call', { error: err }); - } + recordCall(name: string, inputs: unknown[], outputs: unknown, time: number, stackDepth: number): Promise { + const entry = { + name, + inputs, + outputs, + time, + stackDepth, + }; + this.recording!.oracleCalls.push(entry); + return Promise.resolve(entry); } /** - * Finalizes the recording file by adding closing brackets. Without calling this method, the recording file is - * incomplete and it fails to parse. + * Finalizes the recording by resetting the state and returning the recording object. */ - async finish(): Promise { - try { - await fs.appendFile(this.filePath, ' ]\n}\n'); - } catch (err) { - this.logger.error('Failed to finalize recording file', { error: err }); + finish(): Promise { + const result = this.recording; + // If this is the top-level circuit recording, we reset the state for the next simulator call + if (!result!.parent) { + this.newCircuit = true; + this.recording = undefined; } + return Promise.resolve(result!); } /** - * Finalizes the recording file by adding the error and closing brackets. Without calling this method or `finish`, - * the recording file is incomplete and it fails to parse. + * Finalizes the recording by resetting the state and returning the recording object with an attached error. * @param error - The error that occurred during circuit execution */ - async finishWithError(error: unknown): Promise { - try { - await fs.appendFile(this.filePath, ' ],\n'); - await fs.appendFile(this.filePath, ` "error": ${JSON.stringify(error)}\n`); - await fs.appendFile(this.filePath, '}\n'); - } catch (err) { - this.logger.error('Failed to finalize recording file with error', { error: err }); + finishWithError(error: unknown): Promise { + const result = this.recording; + // If this is the top-level circuit recording, we reset the state for the next simulator call + if (!result!.parent) { + this.newCircuit = true; + this.recording = undefined; } + result!.error = JSON.stringify(error); + return Promise.resolve(result!); } } - -/** - * Generates a file path for storing circuit recordings. The format of the filename is: - * `circuit_name_circuit_function_name_YYYY-MM-DD_N.json` where N is a counter to ensure unique filenames. - * @param recordDir - Base directory for recordings - * @param circuitName - Name of the circuit - * @param functionName - Name of the circuit function - * @param counter - Counter to ensure unique filenames. This is expected to be incremented in a loop until there is no - * existing file with the same name. - * @returns A file path for the recording. - */ -function getFilePath(recordDir: string, circuitName: string, functionName: string, counter: number): string { - const date = new Date(); - const formattedDate = date.toISOString().split('T')[0]; - const filename = `${circuitName}_${functionName}_${formattedDate}_${counter}.json`; - return path.join(recordDir, filename); -} diff --git a/yarn-project/simulator/src/private/providers/circuit_recording/file_circuit_recorder.ts b/yarn-project/simulator/src/private/providers/circuit_recording/file_circuit_recorder.ts new file mode 100644 index 000000000000..5f3d36afedde --- /dev/null +++ b/yarn-project/simulator/src/private/providers/circuit_recording/file_circuit_recorder.ts @@ -0,0 +1,158 @@ +import fs from 'fs/promises'; +import path from 'path'; + +import type { ACVMWitness } from '../../acvm/acvm_types.js'; +import { CircuitRecorder, type CircuitRecording } from './circuit_recorder.js'; + +export class FileCircuitRecorder extends CircuitRecorder { + declare recording?: CircuitRecording & { filePath: string; isFirstCall: boolean }; + + constructor(private readonly recordDir: string) { + super(); + } + + override async start( + input: ACVMWitness, + circuitBytecode: Buffer, + circuitName: string, + functionName: string = 'main', + ) { + await super.start(input, circuitBytecode, circuitName, functionName); + + const recordingStringWithoutClosingBracket = JSON.stringify( + { ...this.recording, isFirstCall: undefined, parent: undefined, oracleCalls: undefined, filePath: undefined }, + null, + 2, + ).slice(0, -2); + + try { + // Check if the recording directory exists and is a directory + const stats = await fs.stat(this.recordDir); + if (!stats.isDirectory()) { + throw new Error(`Recording path ${this.recordDir} exists but is not a directory`); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + // The directory does not exist so we create it + await fs.mkdir(this.recordDir, { recursive: true }); + } else { + throw err; + } + } + + this.recording!.isFirstCall = true; + this.recording!.filePath = await FileCircuitRecorder.#computeFilePathAndStoreInitialRecording( + this.recordDir, + this.recording!.circuitName, + this.recording!.functionName, + recordingStringWithoutClosingBracket, + ); + } + + /** + * Computes a unique file path for the recording by trying different counter values. + * This is needed because multiple recordings of the same circuit could be happening simultaneously or an older + * recording might be present. + * @param recordDir - Directory to store the recording + * @param circuitName - Name of the circuit + * @param functionName - Name of the circuit function + * @param recordingContent - Initial recording content + * @returns A unique file path for the recording + */ + static async #computeFilePathAndStoreInitialRecording( + recordDir: string, + circuitName: string, + functionName: string, + recordingContent: string, + ): Promise { + let counter = 0; + while (true) { + try { + const filePath = getFilePath(recordDir, circuitName, functionName, counter); + // Write the initial recording content to the file + await fs.writeFile(filePath, recordingContent + ',\n "oracleCalls": [\n', { + flag: 'wx', // wx flag fails if file exists + }); + return filePath; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') { + counter++; + continue; + } + throw err; + } + } + } + + /** + * Records a single oracle/foreign call with its inputs and outputs. + * @param name - Name of the call + * @param inputs - Input arguments + * @param outputs - Output results + */ + override async recordCall(name: string, inputs: unknown[], outputs: unknown, time: number, stackDepth: number) { + const entry = await super.recordCall(name, inputs, outputs, time, stackDepth); + try { + const prefix = this.recording!.isFirstCall ? ' ' : ' ,'; + this.recording!.isFirstCall = false; + await fs.appendFile(this.recording!.filePath, prefix + JSON.stringify(entry) + '\n'); + } catch (err) { + this.logger.error('Failed to log circuit call', { error: err }); + } + return entry; + } + + /** + * Finalizes the recording file by adding closing brackets. Without calling this method, the recording file is + * incomplete and it fails to parse. + */ + override async finish(): Promise { + // Finish sets the recording to undefined if we are at the topmost circuit, + // so we save the current file path before that + const filePath = this.recording!.filePath; + const result = await super.finish(); + try { + await fs.appendFile(filePath, ' ]\n}\n'); + } catch (err) { + this.logger.error('Failed to finalize recording file', { error: err }); + } + return result!; + } + + /** + * Finalizes the recording file by adding the error and closing brackets. Without calling this method or `finish`, + * the recording file is incomplete and it fails to parse. + * @param error - The error that occurred during circuit execution + */ + override async finishWithError(error: unknown): Promise { + // Finish sets the recording to undefined if we are at the topmost circuit, + // so we save the current file path before that + const filePath = this.recording!.filePath; + const result = await super.finishWithError(error); + try { + await fs.appendFile(filePath, ' ],\n'); + await fs.appendFile(filePath, ` "error": ${JSON.stringify(error)}\n`); + await fs.appendFile(filePath, '}\n'); + } catch (err) { + this.logger.error('Failed to finalize recording file with error', { error: err }); + } + return result!; + } +} + +/** + * Generates a file path for storing circuit recordings. The format of the filename is: + * `circuit_name_circuit_function_name_YYYY-MM-DD_N.json` where N is a counter to ensure unique filenames. + * @param recordDir - Base directory for recordings + * @param circuitName - Name of the circuit + * @param functionName - Name of the circuit function + * @param counter - Counter to ensure unique filenames. This is expected to be incremented in a loop until there is no + * existing file with the same name. + * @returns A file path for the recording. + */ +function getFilePath(recordDir: string, circuitName: string, functionName: string, counter: number): string { + const date = new Date(); + const formattedDate = date.toISOString().split('T')[0]; + const filename = `${circuitName}_${functionName}_${formattedDate}_${counter}.json`; + return path.join(recordDir, filename); +} diff --git a/yarn-project/simulator/src/private/providers/circuit_recording/memory_circuit_recorder.ts b/yarn-project/simulator/src/private/providers/circuit_recording/memory_circuit_recorder.ts new file mode 100644 index 000000000000..e01eb527fa0e --- /dev/null +++ b/yarn-project/simulator/src/private/providers/circuit_recording/memory_circuit_recorder.ts @@ -0,0 +1,11 @@ +import { CircuitRecorder } from './circuit_recorder.js'; + +/* + * In memory circuit recorder uses the default implementation. This is kept + * while we decide the fate of the FileCircuitRecorder + */ +export class MemoryCircuitRecorder extends CircuitRecorder { + constructor() { + super(); + } +} diff --git a/yarn-project/simulator/src/private/providers/circuit_recording/simulation_provider_recorder_wrapper.ts b/yarn-project/simulator/src/private/providers/circuit_recording/simulation_provider_recorder_wrapper.ts index fdd2123d8aa7..c2b13410d4b0 100644 --- a/yarn-project/simulator/src/private/providers/circuit_recording/simulation_provider_recorder_wrapper.ts +++ b/yarn-project/simulator/src/private/providers/circuit_recording/simulation_provider_recorder_wrapper.ts @@ -2,18 +2,21 @@ import type { ForeignCallHandler } from '@aztec/noir-protocol-circuits-types/typ import type { FunctionArtifactWithContractName } from '@aztec/stdlib/abi'; import type { NoirCompiledCircuitWithName } from '@aztec/stdlib/noir'; -import type { ACIRCallback, ACIRExecutionResult } from '../../acvm/acvm.js'; +import type { ACIRCallback, ACIRCallbackStats, ACIRExecutionResult } from '../../acvm/acvm.js'; import type { ACVMWitness } from '../../acvm/acvm_types.js'; import type { ACVMSuccess } from '../acvm_native.js'; import type { SimulationProvider } from '../simulation_provider.js'; -import { CircuitRecorder } from './circuit_recorder.js'; +import type { CircuitRecorder } from './circuit_recorder.js'; /** * Takes a simulation provider and wraps it in a circuit recorder. See CircuitRecorder for more details on how circuit * recording works. */ export class SimulationProviderRecorderWrapper implements SimulationProvider { - constructor(private simulator: SimulationProvider) {} + constructor( + private simulator: SimulationProvider, + private recorder: CircuitRecorder, + ) {} executeProtocolCircuit( input: ACVMWitness, @@ -47,7 +50,7 @@ export class SimulationProviderRecorderWrapper implements SimulationProvider { ); } - async #simulate( + async #simulate( simulateFn: (wrappedCallback: C) => Promise, input: ACVMWitness, bytecode: Buffer, @@ -55,28 +58,33 @@ export class SimulationProviderRecorderWrapper implements SimulationProvider { functionName: string, callback: C, ): Promise { - const recordDir = process.env.CIRCUIT_RECORD_DIR; - if (!recordDir) { - // Recording is not enabled so we just execute the circuit - return simulateFn(callback); - } - // Start recording circuit execution - const recorder = await CircuitRecorder.start(recordDir, input, bytecode, contractName, functionName); + await this.recorder.start(input, bytecode, contractName, functionName); // If callback was provided, we wrap it in a circuit recorder callback wrapper - const wrappedCallback = recorder.wrapCallback(callback); + const wrappedCallback = this.recorder.wrapCallback(callback); let result: T; try { result = await simulateFn(wrappedCallback as C); } catch (error) { // If an error occurs, we finalize the recording file with the error - await recorder.finishWithError(error); + await this.recorder.finishWithError(error); throw error; } // Witness generation is complete so we finish the circuit recorder - await recorder.finish(); + const recording = await this.recorder.finish(); + + (result as ACIRExecutionResult).oracles = recording.oracleCalls?.reduce( + (acc, { time, name }) => { + if (!acc[name]) { + acc[name] = { times: [] }; + } + acc[name].times.push(time); + return acc; + }, + {} as Record, + ); return result; } diff --git a/yarn-project/simulator/src/server.ts b/yarn-project/simulator/src/server.ts index 511043372fe8..b254852949aa 100644 --- a/yarn-project/simulator/src/server.ts +++ b/yarn-project/simulator/src/server.ts @@ -1,5 +1,7 @@ export * from './public/index.js'; export { WASMSimulatorWithBlobs } from './private/providers/acvm_wasm_with_blobs.js'; export { NativeACVMSimulator } from './private/providers/acvm_native.js'; +export { SimulationProviderRecorderWrapper } from './private/providers/circuit_recording/simulation_provider_recorder_wrapper.js'; +export { MemoryCircuitRecorder } from './private/providers/circuit_recording/memory_circuit_recorder.js'; export { type SimulationProvider } from './private/providers/simulation_provider.js'; export * from './common/index.js'; diff --git a/yarn-project/simulator/src/testing.ts b/yarn-project/simulator/src/testing.ts index 63f4dee1b954..e2116e9a0d5c 100644 --- a/yarn-project/simulator/src/testing.ts +++ b/yarn-project/simulator/src/testing.ts @@ -1 +1 @@ -export { SimulationProviderRecorderWrapper } from './private/providers/circuit_recording/simulation_provider_recorder_wrapper.js'; +export { FileCircuitRecorder } from './private/providers/circuit_recording/file_circuit_recorder.js'; diff --git a/yarn-project/stdlib/src/kernel/private_kernel_prover_output.ts b/yarn-project/stdlib/src/kernel/private_kernel_prover_output.ts index 4509a13c3740..82a9fa36b567 100644 --- a/yarn-project/stdlib/src/kernel/private_kernel_prover_output.ts +++ b/yarn-project/stdlib/src/kernel/private_kernel_prover_output.ts @@ -34,6 +34,7 @@ export interface PrivateExecutionStep { timings: { witgen: number; gateCount?: number; + oracles?: Record; }; } diff --git a/yarn-project/stdlib/src/tx/private_execution_result.ts b/yarn-project/stdlib/src/tx/private_execution_result.ts index 428566c2da5e..e46551f0e6b7 100644 --- a/yarn-project/stdlib/src/tx/private_execution_result.ts +++ b/yarn-project/stdlib/src/tx/private_execution_result.ts @@ -255,5 +255,5 @@ export function collectNested( } export class PrivateExecutionProfileResult { - constructor(public timings: { witgen: number }) {} + constructor(public timings: { witgen: number; oracles?: Record }) {} } diff --git a/yarn-project/stdlib/src/tx/profiling.ts b/yarn-project/stdlib/src/tx/profiling.ts index 4ccbdfcc91ee..f641cd012ddd 100644 --- a/yarn-project/stdlib/src/tx/profiling.ts +++ b/yarn-project/stdlib/src/tx/profiling.ts @@ -10,9 +10,14 @@ import { AbiDecodedSchema } from '../schemas/schemas.js'; type FunctionTiming = { functionName: string; time: number; + oracles?: Record; }; -const FunctionTimingSchema = z.object({ functionName: z.string(), time: z.number() }); +const FunctionTimingSchema = z.object({ + functionName: z.string(), + time: z.number(), + oracles: optional(z.record(z.string(), z.object({ times: z.array(z.number()) }))), +}); export type ProvingTimings = { sync?: number;