-
Notifications
You must be signed in to change notification settings - Fork 614
feat(pxe): speculatively sync predicted contract calls #25126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nchamo
wants to merge
3
commits into
merge-train/fairies
Choose a base branch
from
nchamo/rpc-optimizations-2
base: merge-train/fairies
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
190 changes: 190 additions & 0 deletions
190
yarn-project/pxe/src/contract/contract_call_dependencies.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| import { Fr } from '@aztec/foundation/curves/bn254'; | ||
| import { FunctionSelector } from '@aztec/stdlib/abi'; | ||
| import { AztecAddress } from '@aztec/stdlib/aztec-address'; | ||
|
|
||
| import { ContractCallDependencies, MAX_CONFIDENCE, PREDICTION_THRESHOLD } from './contract_call_dependencies.js'; | ||
|
|
||
| /** Missed jobs a dependency at full confidence survives while still being predicted. */ | ||
| const TOLERATED_MISSES = MAX_CONFIDENCE - PREDICTION_THRESHOLD; | ||
|
|
||
| describe('ContractCallDependencies', () => { | ||
| let callDependencies: ContractCallDependencies; | ||
|
|
||
| const account = makeAddress(1); | ||
| const token = makeAddress(2); | ||
| const fpc = makeAddress(3); | ||
| const otherAccount = makeAddress(4); | ||
| const entrypoint = makeSelector(0x11223344); | ||
| const otherFunction = makeSelector(0x55667788); | ||
| const scopes = [account]; | ||
| const accountEntryCall = { contract: account, functionToInvoke: entrypoint }; | ||
|
|
||
| beforeEach(() => { | ||
| callDependencies = new ContractCallDependencies(true); | ||
| }); | ||
|
|
||
| it('returns nothing for an entry call it has never seen', () => { | ||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual([]); | ||
| }); | ||
|
|
||
| it('does not return a contract until enough committed jobs have used it', () => { | ||
| runJobs({ count: PREDICTION_THRESHOLD - 1, entryCall: accountEntryCall, uses: [token, fpc], scopes }); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual([]); | ||
| }); | ||
|
|
||
| it('returns a contract once enough committed jobs of the same entry call use it', () => { | ||
| runJobs({ count: PREDICTION_THRESHOLD, entryCall: accountEntryCall, uses: [token, fpc], scopes }); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual(addressStrings([token, fpc])); | ||
| }); | ||
|
|
||
| it('does not share dependencies across different entry calls', () => { | ||
| runJobs({ count: PREDICTION_THRESHOLD, entryCall: accountEntryCall, uses: [token], scopes }); | ||
|
|
||
| const otherEntryContract = { contract: token, functionToInvoke: entrypoint }; | ||
| const otherEntryFunction = { contract: account, functionToInvoke: otherFunction }; | ||
| expect(knownDependencies({ entryCall: otherEntryContract, scopes })).toEqual([]); | ||
| expect(knownDependencies({ entryCall: otherEntryFunction, scopes })).toEqual([]); | ||
| }); | ||
|
|
||
| it('does not share dependencies across different scope sets', () => { | ||
| runJobs({ count: PREDICTION_THRESHOLD, entryCall: accountEntryCall, uses: [token, fpc], scopes }); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes: [otherAccount] })).toEqual([]); | ||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes: [account, otherAccount] })).toEqual([]); | ||
| }); | ||
|
|
||
| it('ignores scope order when identifying an entry call', () => { | ||
| const bothAccounts = [account, otherAccount]; | ||
| runJobs({ count: PREDICTION_THRESHOLD, entryCall: accountEntryCall, uses: [token], scopes: bothAccounts }); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes: [otherAccount, account] })).toEqual( | ||
| addressStrings([token]), | ||
| ); | ||
| }); | ||
|
|
||
| it('distinguishes an entry call with no function from one with a function', () => { | ||
| const noFunctionEntryCall = { contract: account, functionToInvoke: null }; | ||
| runJobs({ count: PREDICTION_THRESHOLD, entryCall: noFunctionEntryCall, uses: [token], scopes }); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual([]); | ||
| expect(knownDependencies({ entryCall: noFunctionEntryCall, scopes })).toEqual(addressStrings([token])); | ||
| }); | ||
|
|
||
| it('does not learn from discarded jobs', () => { | ||
| runJobs({ count: PREDICTION_THRESHOLD - 1, entryCall: accountEntryCall, uses: [token], scopes }); | ||
| callDependencies.onContractUsed('discarded', account, entrypoint, scopes); | ||
| callDependencies.onContractUsed('discarded', token, otherFunction, scopes); | ||
| callDependencies.discardJob('discarded'); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual([]); | ||
| }); | ||
|
|
||
| it('keeps returning a dependency at full confidence through every miss it tolerates', () => { | ||
| runJobs({ count: MAX_CONFIDENCE, entryCall: accountEntryCall, uses: [token, fpc], scopes }); | ||
| runJobs({ count: TOLERATED_MISSES, entryCall: accountEntryCall, uses: [token], scopes }); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual(addressStrings([token, fpc])); | ||
| }); | ||
|
|
||
| it('caps confidence, so a heavily used dependency stops being returned one miss past that tolerance', () => { | ||
| runJobs({ count: MAX_CONFIDENCE * 2, entryCall: accountEntryCall, uses: [token, fpc], scopes }); | ||
| runJobs({ count: TOLERATED_MISSES + 1, entryCall: accountEntryCall, uses: [token], scopes }); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual(addressStrings([token])); | ||
| }); | ||
|
|
||
| it('stops returning a dependency whose confidence falls below the threshold', () => { | ||
| runJobs({ count: PREDICTION_THRESHOLD, entryCall: accountEntryCall, uses: [token, fpc], scopes }); | ||
| runJob({ jobId: 'miss', entryCall: accountEntryCall, uses: [token], scopes }); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual(addressStrings([token])); | ||
| }); | ||
|
|
||
| it('returns a dependency that fell below the threshold as soon as one job uses it again', () => { | ||
| runJobs({ count: PREDICTION_THRESHOLD, entryCall: accountEntryCall, uses: [token, fpc], scopes }); | ||
| runJob({ jobId: 'miss', entryCall: accountEntryCall, uses: [token], scopes }); | ||
| runJob({ jobId: 'refresh', entryCall: accountEntryCall, uses: [token, fpc], scopes }); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual(addressStrings([token, fpc])); | ||
| }); | ||
|
|
||
| it('stops returning a contract as soon as it is forgotten', () => { | ||
| runJobs({ count: PREDICTION_THRESHOLD, entryCall: accountEntryCall, uses: [token, fpc], scopes }); | ||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual(addressStrings([token, fpc])); | ||
|
|
||
| // Forgetting needs an active job, since that is what identifies the entry call. | ||
| callDependencies.onContractUsed('forgetting', account, entrypoint, scopes); | ||
| callDependencies.forget('forgetting', fpc); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual(addressStrings([token])); | ||
| }); | ||
|
|
||
| it('ignores forget calls for unknown jobs', () => { | ||
| runJobs({ count: PREDICTION_THRESHOLD, entryCall: accountEntryCall, uses: [token], scopes }); | ||
| callDependencies.forget('unknownJob', token); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual(addressStrings([token])); | ||
| }); | ||
|
|
||
| it('never returns or records dependencies when disabled', () => { | ||
| callDependencies = new ContractCallDependencies(false); | ||
| runJobs({ count: PREDICTION_THRESHOLD, entryCall: accountEntryCall, uses: [token, fpc], scopes }); | ||
|
|
||
| expect(knownDependencies({ entryCall: accountEntryCall, scopes })).toEqual([]); | ||
| }); | ||
|
|
||
| /** Runs `count` whole jobs of the given entry call, each using the given contracts. */ | ||
| function runJobs({ count, ...job }: { count: number } & Omit<JobRun, 'jobId'>) { | ||
| for (let i = 0; i < count; i++) { | ||
| runJob({ jobId: `job${i}`, ...job }); | ||
| } | ||
| } | ||
|
|
||
| /** Runs a whole job: starts it with the given entry call, uses the given contracts, and commits. */ | ||
| function runJob({ jobId, entryCall, uses, scopes: jobScopes }: JobRun) { | ||
| callDependencies.onContractUsed(jobId, entryCall.contract, entryCall.functionToInvoke, jobScopes); | ||
| for (const contract of uses) { | ||
| callDependencies.onContractUsed(jobId, contract, otherFunction, jobScopes); | ||
| } | ||
| callDependencies.commitJob(jobId); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the dependencies known for the given entry call, through a job that starts with it and uses nothing | ||
| * else. Committing such a job records no dependencies, so probing does not change what is known. | ||
| */ | ||
| function knownDependencies({ entryCall, scopes: jobScopes }: JobStart): string[] { | ||
| const known = callDependencies.onContractUsed('probe', entryCall.contract, entryCall.functionToInvoke, jobScopes); | ||
| callDependencies.commitJob('probe'); | ||
| return known.map(address => address.toString()).sort(); | ||
| } | ||
| }); | ||
|
|
||
| /** How a job starts: the entry call it makes first, and the scopes every one of its uses runs under. */ | ||
| type JobStart = { | ||
| entryCall: EntryCall; | ||
| scopes: AztecAddress[]; | ||
| }; | ||
|
|
||
| /** A job to run: how it starts, the id it runs under, and the contracts it uses after the start. */ | ||
| type JobRun = JobStart & { | ||
| jobId: string; | ||
| uses: AztecAddress[]; | ||
| }; | ||
|
|
||
| /** The (contract, function) a job starts with. */ | ||
| type EntryCall = { contract: AztecAddress; functionToInvoke: FunctionSelector | null }; | ||
|
|
||
| function makeAddress(index: number): AztecAddress { | ||
| return AztecAddress.fromNumberUnsafe(0x1000 + index); | ||
| } | ||
|
|
||
| function makeSelector(value: number): FunctionSelector { | ||
| return FunctionSelector.fromField(new Fr(value)); | ||
| } | ||
|
|
||
| function addressStrings(addresses: AztecAddress[]): string[] { | ||
| return addresses.map(address => address.toString()).sort(); | ||
| } |
152 changes: 152 additions & 0 deletions
152
yarn-project/pxe/src/contract/contract_call_dependencies.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| import { type Logger, createLogger } from '@aztec/foundation/log'; | ||
| import type { FunctionSelector } from '@aztec/stdlib/abi'; | ||
| import { AztecAddress } from '@aztec/stdlib/aztec-address'; | ||
|
|
||
| /** Confidence a dependency must reach to be returned, requiring a second committed job to use its contract. */ | ||
| export const PREDICTION_THRESHOLD = 2; | ||
|
|
||
| /** Cap on a dependency's confidence, so a contract used by many jobs is still forgotten within a few missed ones. */ | ||
| export const MAX_CONFIDENCE = 5; | ||
|
|
||
| /** | ||
| * Predicts a job's contract dependencies from past jobs of its entry call, so the caller can sync them in parallel. | ||
| * | ||
| * Wallet workloads are repetitive: jobs starting with the same entry call (see {@link EntryCallId}) usually depend on | ||
| * the same contracts. Each dependency carries a confidence score: every committed job of the entry call that uses it | ||
| * adds a point (capped at {@link MAX_CONFIDENCE}) and every one that does not subtracts one, forgetting it at zero. | ||
| * Only dependencies at {@link PREDICTION_THRESHOLD} or above are returned, so one-off dependencies picked by a call's | ||
| * parameters (e.g. the tokens of a single swap) are never predicted. | ||
| * | ||
| * Purely in-memory bookkeeping: the dependencies are lost when PXE is rebuilt (e.g. on restart). | ||
|
nventuro marked this conversation as resolved.
Outdated
|
||
| */ | ||
| export class ContractCallDependencies { | ||
| private readonly activeJobs: Map<JobId, ActiveJob> = new Map(); | ||
|
|
||
| // entry call → dependency → confidence score | ||
| private readonly dependencyConfidence: Map<EntryCallId, Map<ContractAddress, number>> = new Map(); | ||
|
|
||
| constructor( | ||
| private readonly enabled: boolean, | ||
| private readonly log: Logger = createLogger('pxe:contract_call_dependencies'), | ||
|
nventuro marked this conversation as resolved.
Outdated
|
||
| ) {} | ||
|
|
||
| /** | ||
| * Records that a job used a contract. | ||
| * @param functionToInvoke - The function that will be invoked on `contractAddress`, or null when nothing will be | ||
| * invoked (e.g. reading notes/events directly). | ||
| * @returns The dependencies of the job's entry call whose confidence reached {@link PREDICTION_THRESHOLD}. | ||
| */ | ||
| onContractUsed( | ||
| jobId: JobId, | ||
| contractAddress: AztecAddress, | ||
| functionToInvoke: FunctionSelector | null, | ||
| scopes: AztecAddress[], | ||
| ): AztecAddress[] { | ||
| if (!this.enabled) { | ||
| return []; | ||
| } | ||
| let job = this.activeJobs.get(jobId); | ||
| if (!job) { | ||
| const entryCallId = toEntryCallId(contractAddress, functionToInvoke, scopes); | ||
| this.log.debug(`Job started with entry call ${entryCallId}`, { jobId, entryCallId }); | ||
| job = { entryCallId, used: new Set() }; | ||
| this.activeJobs.set(jobId, job); | ||
| } else { | ||
| // Only contracts used after the start are considered dependencies. | ||
| job.used.add(contractAddress.toString()); | ||
| } | ||
|
|
||
| const dependencies = this.dependencyConfidence.get(job.entryCallId); | ||
| if (!dependencies) { | ||
| return []; | ||
| } | ||
| return [...dependencies.entries()] | ||
| .filter(([, confidence]) => confidence >= PREDICTION_THRESHOLD) | ||
| .map(([contract]) => AztecAddress.fromStringUnsafe(contract)); | ||
| } | ||
|
|
||
| /** Raises the confidence of the dependencies the committed job used and lowers the rest, forgetting any at zero. */ | ||
| commitJob(jobId: JobId): void { | ||
| const job = this.activeJobs.get(jobId); | ||
| this.activeJobs.delete(jobId); | ||
| if (!job || job.used.size === 0) { | ||
| return; | ||
| } | ||
|
|
||
| const dependencies = this.dependencyConfidence.get(job.entryCallId) ?? new Map<ContractAddress, number>(); | ||
| for (const [contract, confidence] of dependencies) { | ||
| const delta = job.used.has(contract) ? 1 : -1; | ||
| const updated = Math.min(confidence + delta, MAX_CONFIDENCE); | ||
| if (updated === 0) { | ||
| dependencies.delete(contract); | ||
| } else { | ||
| dependencies.set(contract, updated); | ||
| } | ||
| } | ||
| // Contracts used for the first time enter at confidence 1, below the prediction threshold. | ||
| [...job.used].filter(contract => !dependencies.has(contract)).forEach(contract => dependencies.set(contract, 1)); | ||
| this.dependencyConfidence.set(job.entryCallId, dependencies); | ||
|
|
||
| this.log.debug(`Remembering ${dependencies.size} contract(s) for entry call ${job.entryCallId}`, { | ||
| jobId, | ||
| entryCallId: job.entryCallId, | ||
| confidence: Object.fromEntries(dependencies), | ||
| }); | ||
| } | ||
|
|
||
| /** Drops a discarded job without learning. */ | ||
| discardJob(jobId: JobId): void { | ||
| this.activeJobs.delete(jobId); | ||
| } | ||
|
|
||
| /** Stops returning a contract for the given job's entry call. */ | ||
| forget(jobId: JobId, contractAddress: AztecAddress): void { | ||
| const entryCallId = this.activeJobs.get(jobId)?.entryCallId; | ||
| if (!entryCallId) { | ||
| return; | ||
| } | ||
| const dependencies = this.dependencyConfidence.get(entryCallId); | ||
| if (!dependencies?.delete(contractAddress.toString())) { | ||
| return; | ||
| } | ||
| if (dependencies.size === 0) { | ||
| this.dependencyConfidence.delete(entryCallId); | ||
| } | ||
| this.log.debug(`Dropped ${contractAddress} from the remembered dependencies of entry call ${entryCallId}`, { | ||
| jobId, | ||
| entryCallId, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Builds an entry call id from a call's contract, function and scopes. | ||
| * The scopes are sorted first, so their order does not matter. | ||
| */ | ||
| function toEntryCallId( | ||
| contract: AztecAddress, | ||
| functionToInvoke: FunctionSelector | null, | ||
| scopes: AztecAddress[], | ||
| ): EntryCallId { | ||
| const scopeSet = scopes | ||
| .map(scope => scope.toString()) | ||
| .sort() | ||
| .join(','); | ||
| return `${contract.toString()}:${functionToInvoke?.toString() ?? ''}:${scopeSet}`; | ||
| } | ||
|
|
||
| type JobId = string; | ||
|
|
||
| /** | ||
| * Identifies how a job started: the first (contract, function) it used, plus that use's scope set. The scope set | ||
| * separates entry calls that share an entry contract across accounts (e.g. a shared multi-call entrypoint). | ||
| */ | ||
| type EntryCallId = string; | ||
|
|
||
| type ContractAddress = string; | ||
|
|
||
| /** An active job's entry call, plus the addresses of the contracts it has used since it started. */ | ||
| type ActiveJob = { | ||
| readonly entryCallId: EntryCallId; | ||
| readonly used: Set<ContractAddress>; | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.