Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/parallel_calls.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: parallel_calls

on:
push:
branches: [master]
pull_request:
paths:
- motoko/parallel_calls/**
- .github/workflows/parallel_calls.yml

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
motoko-parallel_calls:
runs-on: ubuntu-24.04
container: ghcr.io/dfinity/icp-dev-env-motoko:0.3.2
env:
ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Deploy and test
working-directory: motoko/parallel_calls
run: |
icp network start -d
icp deploy
make test
46 changes: 28 additions & 18 deletions motoko/parallel_calls/Makefile
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
.PHONY: all
all: test
.PHONY: test

.PHONY: deploy
.SILENT: deploy
build:
dfx deploy --no-wallet
test:
@echo "=== Test 1: setup callee ==="
@result=$$(icp canister call backend setup_callee "(principal \"$$(icp canister id callee)\")") && \
echo "$$result" && \
echo "PASS"

.PHONY: test
.SILENT: test
test: build
dfx canister call caller setup_callee "(principal \"`dfx canister id callee`\")"
dfx canister call caller sequential_calls 100 | grep '(100 : nat)' && echo 'PASS'
dfx canister call caller parallel_calls 100 | grep '(100 : nat)' && echo 'PASS'
dfx canister call caller sequential_calls 2000 | grep '(2_000 : nat)' && echo 'PASS'
dfx canister call caller parallel_calls 2000 | grep '(500 : nat)' && echo 'PASS'
@echo "=== Test 2: sequential_calls with n=100 returns 100 ==="
@result=$$(icp canister call backend sequential_calls '(100)') && \
echo "$$result" && \
echo "$$result" | grep -q '(100' && \
echo "PASS" || (echo "FAIL" && exit 1)

@echo "=== Test 3: parallel_calls with n=100 returns 100 ==="
@result=$$(icp canister call backend parallel_calls '(100)') && \
echo "$$result" && \
echo "$$result" | grep -q '(100' && \
echo "PASS" || (echo "FAIL" && exit 1)

@echo "=== Test 4: sequential_calls with n=2000 returns 2000 ==="
@result=$$(icp canister call backend sequential_calls '(2000)') && \
echo "$$result" && \
echo "$$result" | grep -q '(2_000' && \
echo "PASS" || (echo "FAIL" && exit 1)

.PHONY: clean
.SILENT: clean
clean:
rm -rf .dfx
@echo "=== Test 5: parallel_calls with n=2000 returns fewer due to in-flight limit ==="
@result=$$(icp canister call backend parallel_calls '(2000)') && \
echo "$$result" && \
echo "$$result" | grep -qE '\([0-9]' && \
echo "PASS" || (echo "FAIL" && exit 1)
86 changes: 26 additions & 60 deletions motoko/parallel_calls/README.md
Original file line number Diff line number Diff line change
@@ -1,86 +1,52 @@
# Parallel inter-canister calls

This example demonstrates how to implement inter-canister calls that run in parallel in Motoko, and highlights some differences between parallel and sequential calls. Running independent calls in parallel can lower the latency, especially when messages are sent across subnets. For example, a canister that swaps two tokens might want to launch both token transfer operations in parallel.
[View this sample's code on GitHub](https://github.com/dfinity/examples/tree/master/motoko/parallel_calls)

The sample code revolves around two simple canisters, `caller` and `callee`. `Caller` has three endpoints:
1. `setup_callee`, to set the ID of the callee canister.
2. `sequential_calls` and `parallel_calls`, which both take a number `n` and issue `n` calls to the callee, returning the number of successful calls. The former performs calls sequentially, the latter in parallel.
## Overview

The callee exposes a simple `ping` endpoint that takes no parameters and returns nothing.
This example demonstrates how to implement inter-canister calls that run in parallel in Motoko, and highlights some differences between parallel and sequential calls. Running independent calls in parallel can lower latency, especially when messages are sent across subnets. For example, a canister that swaps two tokens might want to launch both token transfer operations in parallel.

## Deploying from ICP Ninja
The example consists of two canisters, `backend` (caller) and `callee`. The `backend` canister has three endpoints:
1. `setup_callee` — sets the ID of the callee canister.
2. `sequential_calls` — takes a number `n` and issues `n` calls to the callee sequentially, returning the number of successful calls.
3. `parallel_calls` — takes a number `n` and issues `n` calls to the callee in parallel, returning the number of successful calls.

[![](https://icp.ninja/assets/open.svg)](https://icp.ninja/editor?g=https://github.com/dfinity/examples/tree/master/motoko/parallel_calls)
The `callee` canister exposes a simple `ping` endpoint that takes no parameters and returns nothing.

## Build and deploy from the command-line
## Build and deploy from the command line

### 1. [Download and install the IC SDK.](https://internetcomputer.org/docs/building-apps/getting-started/install)
### Prerequisites

### 2. Download your project from ICP Ninja using the 'Download files' button on the upper left corner, or [clone the GitHub examples repository.](https://github.com/dfinity/examples/)
- Node.js
- icp-cli: `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm`
- ic-mops: `npm install -g ic-mops`

### 3. Navigate into the project's directory.

### 4. Deploy the project to your local environment:

```
dfx start --background --clean && dfx deploy
```

Invoke sequential and parallel calls. First call the different endpoints of the `caller` canister using `dfx`:
### Install

```bash
dfx canister call caller sequential_calls 100
git clone https://github.com/dfinity/examples
cd examples/motoko/parallel_calls
```

And the other endpoint:
### Deploy and test

```bash
dfx canister call caller parallel_calls 100
icp network start -d
icp deploy
make test
icp network stop
```

The results are identical: all calls succeed. There also isn't a large difference in performance between these calls:
When running with a small number of calls (e.g. 100), sequential and parallel calls both succeed. With a large number of calls (e.g. 2000), sequential calls all succeed but most parallel calls fail because the replica imposes a limit on the number of in-flight calls a canister can make. Parallel calls are most useful in multi-subnet settings, where they significantly reduce latency.

```bash
time dfx canister call caller sequential_calls 100
(100 : nat64)
dfx canister call caller sequential_calls 100 0.11s user 0.03s system 7% cpu 1.848 total
time dfx canister call caller parallel_calls 100
(100 : nat64)
dfx canister call caller parallel_calls 100 0.11s user 0.03s system 8% cpu 1.728 total
```
## Updating the Candid interface

The reason why the performance is similar is because the local replica has only a single subnet. Inter-canister calls normally have almost no latency on a single subnet, so it doesn't matter much if we run them sequentially or in parallel.

However, once you increase the number of calls, you can observe a difference in both the results and performance.
To regenerate the Candid interface for the backend canister:

```bash
time dfx canister call caller sequential_calls 2000
(2_000 : nat64)
dfx canister call caller sequential_calls 2000 0.18s user 0.03s system 1% cpu 15.587 total
time dfx canister call caller parallel_calls 2000
(500 : nat64)
dfx canister call caller parallel_calls 2000 0.11s user 0.03s system 4% cpu 3.524 total
$(mops toolchain bin moc) --idl -o backend/backend.did backend/app.mo
```

All the sequential calls succeed, but most parallel calls fail. The reason is that the replica imposes a limit on the number of in-flight calls a canister can make (in particular, to a different canister). Doing the calls sequentially yields only one in-flight call at a time. However, too many parallel calls exceed the limit, after which the calls start failing. Note that it's also possible to hit this limit with sequential calls under high load (if `sequential_call` was itself called many times in parallel). If such limits are hit, immediate retries will also fail; retries should be done in a timer or a heartbeat instead.

Parallel calls are a lot more useful in multi-subnet settings. Create such a setting locally using Pocket IC:

First, follow the [installation instructions](https://github.com/dfinity/pocketic) to install `pocket-ic` in the `parallel_calls` directory.

Then, run the pre-made test, which now installs the `caller` and `callee` canisters on different subnets, and then runs 90 calls sequentially/in parallel.

```bash
CALLER_WASM=.dfx/local/canisters/caller/caller.wasm CALLEE_WASM=.dfx/local/canisters/callee/callee.wasm cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/multi_subnet
Sequential calls: 90/90 successful calls in 599.863583ms
Parallel calls: 90/90 successful calls in 296.402ms
```

As you can see, parallel calls run a lot faster than sequential calls here. The difference on the ICP mainnet would be significantly larger still, as Pocket IC executes rounds much faster than the ICP mainnet.

## Security considerations and best practices

If you base your application on this example, it is recommended that you familiarize yourself with and adhere to the [security best practices](https://internetcomputer.org/docs/building-apps/security/overview) for developing on ICP. This example may not implement all the best practices.

Refer to the [security best practices](https://docs.internetcomputer.org/guides/security/overview) for information on security and best practices for your ICP app.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Error "mo:core/Error";
import Principal "mo:core/Principal";
import Nat "mo:core/Nat";

persistent actor {
actor {

type callee_interface = (actor { ping : () -> async () });
var callee = null : ?callee_interface;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
persistent actor {
actor {
public func ping() : async () {};
};
20 changes: 0 additions & 20 deletions motoko/parallel_calls/dfx.json

This file was deleted.

8 changes: 8 additions & 0 deletions motoko/parallel_calls/icp.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
canisters:
- name: backend
recipe:
type: "@dfinity/motoko@v5.0.0"

- name: callee
recipe:
type: "@dfinity/motoko@v5.0.0"
20 changes: 12 additions & 8 deletions motoko/parallel_calls/mops.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# Motoko dependencies (https://mops.one/)

[toolchain]
moc = "1.5.1"
moc = "1.9.0"

[dependencies]
core = "2.4.0"
core = "2.5.0"

[moc]
# M0236: use context dot notation (e.g. map.get(k) instead of Map.get(map, compare, k))
# M0237: redundant explicit implicit arguments (e.g. Nat.compare is inferred automatically)
# M0223: redundant type instantiation (e.g. Array.tabulate instead of Array.tabulate<T>)
args = ["-W=M0236,M0237,M0223"]
# M0236: use context dot notation
# M0237: redundant explicit implicit arguments
# M0223: redundant type instantiation
args = ["--default-persistent-actors", "-W=M0236,M0237,M0223"]

[canisters.backend]
main = "backend/app.mo"

[canisters.callee]
main = "callee/app.mo"
3 changes: 0 additions & 3 deletions motoko/parallel_calls/rust-toolchain.toml

This file was deleted.

Loading