diff --git a/.github/workflows/build-brev-tutorial-docker-images.yml b/.github/workflows/build-brev-tutorial-docker-images.yml index b207d5ce..673f9374 100644 --- a/.github/workflows/build-brev-tutorial-docker-images.yml +++ b/.github/workflows/build-brev-tutorial-docker-images.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "event/**" - "pull-request/[0-9]+" schedule: # Nightly build at midnight EST (5 AM UTC). @@ -18,6 +19,15 @@ on: # pull_request is not supported for this workflow due to self-hosted runners # see the "Reviewing PRs from forks" section in CONTRIBUTING.md for more details +env: + GIT_BRANCH_NAME: ${{ github.ref_name }} + GIT_SHA: ${{ github.sha }} + +# Prevent an older run from replacing a newer run's branch-latest tag. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: discover-tutorials: runs-on: linux-amd64-cpu4 @@ -27,7 +37,10 @@ jobs: run: working-directory: ${{ github.workspace }} outputs: - tutorials: ${{ steps.find-tutorials.outputs.tutorials }} + build-matrix: ${{ steps.find-tutorials.outputs.build-matrix }} + publish-matrix: ${{ steps.find-tutorials.outputs.publish-matrix }} + docker-tag-branch: ${{ steps.find-tutorials.outputs.docker-tag-branch }} + git-short-sha: ${{ steps.find-tutorials.outputs.git-short-sha }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -35,61 +48,118 @@ jobs: - name: Find tutorial directories id: find-tutorials run: | - tutorials=$(brev/discover-tutorials.bash | jq -R -s -c 'split("\n") | map(select(length > 0))') - echo "tutorials=${tutorials}" >> $GITHUB_OUTPUT + set -euo pipefail + + tutorials=$(brev/discover-tutorials.bash | \ + jq -R -s -c 'split("\n") | map(select(length > 0))') + build_entries=$( + while IFS= read -r tutorial; do + compose_config=$(docker compose \ + -f "${tutorial}/brev/docker-compose.yml" \ + config --format json) + tutorial_name=$(basename "${tutorial}") + image_name=$(jq -r '."x-config".image | sub(":[^/:]+$"; "")' \ + <<< "${compose_config}") + architectures=$(jq -c '."x-config".architectures // ["amd64"]' <<< "${compose_config}") + large=$(jq -r '."x-config".large // false' <<< "${compose_config}") + if ! jq -e 'type == "array" and length > 0 and length == (unique | length) and all(.[]; . == "amd64" or . == "arm64")' \ + <<< "${architectures}" > /dev/null; then + echo "${tutorial}: architectures must contain unique amd64/arm64 values" >&2 + exit 1 + fi + + while IFS= read -r architecture; do + case "${architecture}" in + amd64) + if [ "${large}" = "true" ]; then + runner=linux-amd64-cpu16 + else + runner=linux-amd64-cpu4 + fi + ;; + arm64) runner=linux-arm64-cpu16 ;; + esac + jq -cn \ + --arg tutorial "${tutorial}" \ + --arg tutorial_name "${tutorial_name}" \ + --arg image_name "${image_name,,}" \ + --arg architecture "${architecture}" \ + --arg runner "${runner}" \ + '{tutorial: $tutorial, tutorial_name: $tutorial_name, + image_name: $image_name, architecture: $architecture, + runner: $runner}' + done < <(jq -r '.[]' <<< "${architectures}") + done < <(jq -r '.[]' <<< "${tutorials}") + ) + build_matrix=$(jq -sc '{include: .}' <<< "${build_entries}") + publish_matrix=$(jq -sc ' + {include: (group_by(.tutorial) | map({ + tutorial: .[0].tutorial, + tutorial_name: .[0].tutorial_name, + image_name: .[0].image_name, + architectures: (map(.architecture) | join(" ")) + }))}' <<< "${build_entries}") + docker_tag_branch=${GIT_BRANCH_NAME//[^a-zA-Z0-9._-]/-} + docker_tag_branch=${docker_tag_branch,,} + git_short_sha=${GIT_SHA::7} + + { + echo "build-matrix=${build_matrix}" + echo "publish-matrix=${publish_matrix}" + echo "docker-tag-branch=${docker_tag_branch}" + echo "git-short-sha=${git_short_sha}" + } >> "${GITHUB_OUTPUT}" echo "Found tutorials: ${tutorials}" + echo "Build matrix: ${build_matrix}" build-and-push: needs: discover-tutorials - runs-on: linux-amd64-cpu4 + runs-on: ${{ matrix.runner }} + env: + DOCKER_TAG_BRANCH: ${{ needs.discover-tutorials.outputs.docker-tag-branch }} + GIT_SHORT_SHA: ${{ needs.discover-tutorials.outputs.git-short-sha }} + TUTORIAL_DIR: ${{ matrix.tutorial }} + IMAGE_NAME: ${{ matrix.image_name }} + ARCHITECTURE: ${{ matrix.architecture }} defaults: run: working-directory: ${{ github.workspace }} permissions: - contents: write + contents: read packages: write - actions: write - statuses: write strategy: - matrix: - tutorial: ${{ fromJson(needs.discover-tutorials.outputs.tutorials) }} + matrix: ${{ fromJson(needs.discover-tutorials.outputs.build-matrix) }} fail-fast: false steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set Git branch variables - run: | - GIT_BRANCH_NAME=${GITHUB_REF#refs/heads/} - # Sanitize branch name for Docker tags (replace invalid characters with hyphens and convert to lowercase) - DOCKER_TAG_BRANCH=$(echo "${GIT_BRANCH_NAME}" | sed 's/[^a-zA-Z0-9._-]/-/g' | tr '[:upper:]' '[:lower:]') - GIT_SHA=${{ github.sha }} - GIT_SHORT_SHA=${GIT_SHA::7} - echo "GIT_BRANCH_NAME=${GIT_BRANCH_NAME}" >> $GITHUB_ENV - echo "DOCKER_TAG_BRANCH=${DOCKER_TAG_BRANCH}" >> $GITHUB_ENV - echo "GIT_SHA=${GIT_SHA}" >> $GITHUB_ENV - echo "GIT_SHORT_SHA=${GIT_SHORT_SHA}" >> $GITHUB_ENV - - - name: Set image name and tutorial name + - name: Verify native runner architecture run: | - TUTORIAL_NAME=$(basename ${{ matrix.tutorial }}) - IMAGE_NAME="ghcr.io/${{ github.repository_owner }}/${TUTORIAL_NAME}-tutorial" - echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV - echo "TUTORIAL_NAME=${TUTORIAL_NAME}" >> $GITHUB_ENV + case "${ARCHITECTURE}" in + amd64) expected=x86_64 ;; + arm64) expected=aarch64 ;; + *) echo "Unsupported architecture: ${ARCHITECTURE}" >&2; exit 1 ;; + esac + actual=$(uname -m) + if [ "${actual}" != "${expected}" ]; then + echo "Expected ${expected} runner for ${ARCHITECTURE}, got ${actual}" >&2 + exit 1 + fi - name: Check for HPCCM recipe run: | - if [ -f "${{ matrix.tutorial }}/brev/docker-recipe.py" ]; then + if [ -f "${TUTORIAL_DIR}/brev/docker-recipe.py" ]; then HAS_HPCCM=true else HAS_HPCCM=false fi - echo "HAS_HPCCM=${HAS_HPCCM}" >> $GITHUB_ENV + echo "HAS_HPCCM=${HAS_HPCCM}" >> "${GITHUB_ENV}" - name: Set up Python (for HPCCM) if: env.HAS_HPCCM == 'true' - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: '3.x' @@ -102,74 +172,7 @@ jobs: - name: Generate Dockerfile from HPCCM recipe if: env.HAS_HPCCM == 'true' run: | - hpccm --recipe ${{ matrix.tutorial }}/brev/docker-recipe.py --format docker > ${{ matrix.tutorial }}/brev/dockerfile - - - name: Generate Docker Compose files - run: | - python3 brev/generate-tagged-docker-composes.py \ - --image-tag "${DOCKER_TAG_BRANCH}-git-${GIT_SHORT_SHA}" \ - --output-dir "artifacts/commit-specific" \ - --tutorial "${TUTORIAL_NAME}" \ - --type all - - python3 brev/generate-tagged-docker-composes.py \ - --image-tag "${DOCKER_TAG_BRANCH}-latest" \ - --output-dir "artifacts/branch-latest" \ - --tutorial "${TUTORIAL_NAME}" \ - --type all - - - name: Upload commit-specific Docker Compose artifacts - uses: actions/upload-artifact@v4 - with: - name: docker-compose-${{ env.TUTORIAL_NAME }}-${{ env.DOCKER_TAG_BRANCH }}-git-${{ env.GIT_SHORT_SHA }} - path: artifacts/commit-specific/${{ env.TUTORIAL_NAME }}/ - retention-days: 30 - - - name: Push branch-latest Docker Compose to generated branch - run: | - # Configure git - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Fetch the generated branch - git fetch origin generated:generated - git checkout generated - - # Create directory structure for this branch and tutorial - mkdir -p "${GIT_BRANCH_NAME}/tutorials" - - # Remove only this tutorial's old files (if they exist) - rm -rf "${GIT_BRANCH_NAME}/tutorials/${TUTORIAL_NAME}" - - # Copy this tutorial's generated files - cp -r artifacts/branch-latest/${TUTORIAL_NAME} "${GIT_BRANCH_NAME}/tutorials/" - - # Stage only this tutorial's directory - git add "${GIT_BRANCH_NAME}/tutorials/${TUTORIAL_NAME}" - - # Check if there are changes to commit - if git diff --staged --quiet; then - echo "No changes to commit for ${TUTORIAL_NAME}" - else - git commit -m "Update docker-compose for ${GIT_BRANCH_NAME}/${TUTORIAL_NAME}" - - # Push with retry logic for concurrent updates - for i in {1..5}; do - if git push origin generated; then - echo "✓ Successfully pushed to generated branch" - break - else - echo "Push failed (attempt $i/5), pulling and retrying..." - git pull --rebase origin generated - sleep 2 - fi - done - fi - - # Return to original branch - git checkout ${GITHUB_REF#refs/heads/} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + hpccm --recipe "${TUTORIAL_DIR}/brev/docker-recipe.py" --format docker > "${TUTORIAL_DIR}/brev/dockerfile" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -200,41 +203,209 @@ jobs: max_attempts: 3 retry_wait_seconds: 30 command: | - cd ${{ matrix.tutorial }}/brev + cd "${TUTORIAL_DIR}/brev" # Show buildx version and check for cache images docker buildx version echo "Checking for existing cache images..." - docker manifest inspect "${IMAGE_NAME}:buildcache-${DOCKER_TAG_BRANCH}" > /dev/null 2>&1 && echo "✓ Branch cache exists" || echo "✗ Branch cache not found" - docker manifest inspect "${IMAGE_NAME}:buildcache-main" > /dev/null 2>&1 && echo "✓ Main cache exists" || echo "✗ Main cache not found" + docker manifest inspect \ + "${IMAGE_NAME}:buildcache-${DOCKER_TAG_BRANCH}-${ARCHITECTURE}" \ + > /dev/null 2>&1 && echo "Branch cache exists" || \ + echo "Branch cache not found" + docker manifest inspect "${IMAGE_NAME}:buildcache-main-${ARCHITECTURE}" \ + > /dev/null 2>&1 && echo "Main cache exists" || \ + echo "Main cache not found" # Nightly scheduled builds and manual purge builds skip cache-from to rebuild from scratch. + CACHE_FROM_FLAGS=() if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name == 'workflow_dispatch' && inputs.purge_cache }}" = "true" ]; then - CACHE_FROM_FLAGS="" echo "Purge build: skipping cache-from to rebuild from scratch." else - CACHE_FROM_FLAGS="--set base.cache-from=type=registry,ref=${IMAGE_NAME}:buildcache-${DOCKER_TAG_BRANCH} --set base.cache-from=type=registry,ref=${IMAGE_NAME}:buildcache-main" + CACHE_FROM_FLAGS=( + --set "base.cache-from=type=registry,ref=${IMAGE_NAME}:buildcache-${DOCKER_TAG_BRANCH}-${ARCHITECTURE}" + --set "base.cache-from=type=registry,ref=${IMAGE_NAME}:buildcache-main-${ARCHITECTURE}" + ) + if [ "${ARCHITECTURE}" = "amd64" ]; then + CACHE_FROM_FLAGS+=( + --set "base.cache-from=type=registry,ref=${IMAGE_NAME}:buildcache-${DOCKER_TAG_BRANCH}" + --set "base.cache-from=type=registry,ref=${IMAGE_NAME}:buildcache-main" + ) + fi fi docker buildx bake \ --progress=plain \ --allow=fs.read=/home/runner \ --set "base.output=type=registry" \ - --set "base.tags=${IMAGE_NAME}:${DOCKER_TAG_BRANCH}-latest" \ - --set "base.tags=${IMAGE_NAME}:${DOCKER_TAG_BRANCH}-git-${GIT_SHORT_SHA}" \ - $([ "${GIT_BRANCH_NAME}" = "main" ] && echo "--set base.tags=${IMAGE_NAME}:latest") \ - $CACHE_FROM_FLAGS \ - --set "base.cache-to=type=registry,ref=${IMAGE_NAME}:buildcache-${DOCKER_TAG_BRANCH},mode=max,image-manifest=true,compression=zstd,compression-level=3" \ - --set "base.platform=linux/amd64" \ + --set "base.tags=${IMAGE_NAME}:${DOCKER_TAG_BRANCH}-git-${GIT_SHORT_SHA}-${ARCHITECTURE}" \ + "${CACHE_FROM_FLAGS[@]}" \ + --set "base.cache-to=type=registry,ref=${IMAGE_NAME}:buildcache-${DOCKER_TAG_BRANCH}-${ARCHITECTURE},mode=max,image-manifest=true,compression=zstd,compression-level=3" \ + --set "base.platform=linux/${ARCHITECTURE}" \ -f docker-compose.yml \ base + - name: Check disk space after build + if: always() + run: | + echo "Disk space after build:" + df -h / + echo "" + echo "Disk usage by directory:" + du -sh /var/lib/docker/* 2>/dev/null || true + du -sh /var/lib/buildkit/* 2>/dev/null || true + + publish-and-test: + needs: + - discover-tutorials + - build-and-push + # Publish tutorials whose platform images succeeded even if an unrelated + # tutorial in the build matrix failed. Missing source tags fail only that + # tutorial's publish matrix entry. + if: ${{ !cancelled() && needs.discover-tutorials.result == 'success' }} + runs-on: linux-amd64-cpu4 + timeout-minutes: 30 + env: + DOCKER_TAG_BRANCH: ${{ needs.discover-tutorials.outputs.docker-tag-branch }} + GIT_SHORT_SHA: ${{ needs.discover-tutorials.outputs.git-short-sha }} + TUTORIAL_NAME: ${{ matrix.tutorial_name }} + IMAGE_NAME: ${{ matrix.image_name }} + ARCHITECTURES: ${{ matrix.architectures }} + permissions: + actions: write + contents: write + packages: write + statuses: write + strategy: + matrix: ${{ fromJson(needs.discover-tutorials.outputs.publish-matrix) }} + fail-fast: false + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Generate Docker Compose files + run: | + python3 brev/generate-tagged-docker-composes.py \ + --image-tag "${DOCKER_TAG_BRANCH}-git-${GIT_SHORT_SHA}" \ + --output-dir "artifacts/commit-specific" \ + --tutorial "${TUTORIAL_NAME}" \ + --type all + + python3 brev/generate-tagged-docker-composes.py \ + --image-tag "${DOCKER_TAG_BRANCH}-latest" \ + --output-dir "artifacts/branch-latest" \ + --tutorial "${TUTORIAL_NAME}" \ + --type all + + - name: Generate CSCS EDF + run: | + brev/generate-cscs-edf.bash \ + --output "artifacts/branch-latest/${TUTORIAL_NAME}/brev/cscs.toml" \ + "artifacts/branch-latest/${TUTORIAL_NAME}/brev/docker-compose.yml" + + - name: Upload commit-specific Docker Compose artifacts + uses: actions/upload-artifact@v4 + with: + name: docker-compose-${{ env.TUTORIAL_NAME }}-${{ env.DOCKER_TAG_BRANCH }}-git-${{ env.GIT_SHORT_SHA }} + path: artifacts/commit-specific/${{ env.TUTORIAL_NAME }}/ + retention-days: 30 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + buildkitd-config: /etc/buildkit/buildkitd.toml + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish and verify image manifest + run: | + set -euo pipefail + + verify_platforms() { + local image=$1 + local platforms + platforms=$(docker manifest inspect --verbose "${image}" | \ + jq -r '(if type == "array" then .[] else . end) | .Descriptor.platform | select(. != null and .os != null and .architecture != null) | select(.os != "unknown" and .architecture != "unknown") | "\(.os)/\(.architecture)"' | \ + sort -u) + if [ "${platforms}" != "${EXPECTED_PLATFORMS}" ]; then + echo "Unexpected platforms in ${image}:" >&2 + echo "${platforms}" >&2 + echo "Expected:" >&2 + echo "${EXPECTED_PLATFORMS}" >&2 + exit 1 + fi + } + + COMMIT_IMAGE="${IMAGE_NAME}:${DOCKER_TAG_BRANCH}-git-${GIT_SHORT_SHA}" + LATEST_IMAGE="${IMAGE_NAME}:${DOCKER_TAG_BRANCH}-latest" + SOURCES=() + EXPECTED_PLATFORMS="" + for architecture in ${ARCHITECTURES}; do + SOURCES+=("${COMMIT_IMAGE}-${architecture}") + EXPECTED_PLATFORMS+="linux/${architecture}"$'\n' + done + EXPECTED_PLATFORMS=$(printf '%s' "${EXPECTED_PLATFORMS}" | sort -u) + + TAG_FLAGS=(--tag "${COMMIT_IMAGE}" --tag "${LATEST_IMAGE}") + if [ "${GIT_BRANCH_NAME}" = "main" ]; then + TAG_FLAGS+=(--tag "${IMAGE_NAME}:latest") + fi + docker buildx imagetools create \ + "${TAG_FLAGS[@]}" \ + "${SOURCES[@]}" + + verify_platforms "${COMMIT_IMAGE}" + verify_platforms "${LATEST_IMAGE}" + if [ "${GIT_BRANCH_NAME}" = "main" ]; then + verify_platforms "${IMAGE_NAME}:latest" + fi + + - name: Push branch-latest deployment files to generated branch + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git fetch origin generated:generated + git checkout generated + mkdir -p "${GIT_BRANCH_NAME}/tutorials" + rm -rf "${GIT_BRANCH_NAME}/tutorials/${TUTORIAL_NAME}" + cp -r "artifacts/branch-latest/${TUTORIAL_NAME}" "${GIT_BRANCH_NAME}/tutorials/" + git add "${GIT_BRANCH_NAME}/tutorials/${TUTORIAL_NAME}" + + if git diff --staged --quiet; then + echo "No changes to commit for ${TUTORIAL_NAME}" + else + git commit -m "Update docker-compose for ${GIT_BRANCH_NAME}/${TUTORIAL_NAME}" + for i in {1..5}; do + if git push origin generated; then + echo "Successfully pushed to generated branch" + break + fi + if [ "${i}" -eq 5 ]; then + echo "Failed to push generated branch after ${i} attempts" >&2 + exit 1 + fi + echo "Push failed (attempt ${i}/5), pulling and retrying..." + git pull --rebase origin generated + sleep 2 + done + fi + + git checkout "${GITHUB_REF#refs/heads/}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create pending commit status for test run: | gh api \ --method POST \ -H "Accept: application/vnd.github+json" \ - /repos/${{ github.repository }}/statuses/${GIT_SHA} \ + "/repos/${{ github.repository }}/statuses/${GIT_SHA}" \ -f state='pending' \ -f target_url="${{ github.server_url }}/${{ github.repository }}/actions/workflows/test-brev-tutorial-docker-images.yml" \ -f description='Test started' \ @@ -247,19 +418,9 @@ jobs: echo "Triggering test for tutorial: ${TUTORIAL_NAME}" gh workflow run test-brev-tutorial-docker-images.yml \ --ref ${{ github.ref_name }} \ - -f tutorial=${TUTORIAL_NAME} \ - -f git_sha=${GIT_SHA} \ + -f tutorial="${TUTORIAL_NAME}" \ + -f git_sha="${GIT_SHA}" \ -f workflow_run_id=${{ github.run_id }} echo "Test workflow triggered successfully" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Check disk space after build - if: always() - run: | - echo "Disk space after build:" - df -h / - echo "" - echo "Disk usage by directory:" - du -sh /var/lib/docker/* 2>/dev/null || true - du -sh /var/lib/buildkit/* 2>/dev/null || true diff --git a/.github/workflows/pr-comment-git-signatures.yml b/.github/workflows/pr-comment-git-signatures.yml index 4dacce5f..723d3ef8 100644 --- a/.github/workflows/pr-comment-git-signatures.yml +++ b/.github/workflows/pr-comment-git-signatures.yml @@ -33,14 +33,26 @@ jobs: const pullRequests = context.payload.workflow_run.pull_requests; let prNumber; if (pullRequests && pullRequests.length > 0) { + const pullRequest = pullRequests[0]; + const currentRepo = process.env.GITHUB_REPOSITORY; + const currentPullPrefix = + `${process.env.GITHUB_API_URL}/repos/${currentRepo}/pulls/`; + const baseRepo = pullRequest.base?.repo?.full_name; + if ((pullRequest.url && !pullRequest.url.startsWith(currentPullPrefix)) || + (baseRepo && baseRepo !== currentRepo)) { + console.log('Associated pull request belongs to another repository; skipping.'); + return; + } prNumber = pullRequests[0].number; } else { - try { - prNumber = parseInt(fs.readFileSync('pr_number', 'utf8').trim()); - } catch (e) { - console.log('No PR number found, skipping comment.'); + const mirror = context.payload.workflow_run.head_branch.match( + /^pull-request\/([1-9]\d*)$/ + ); + if (!mirror) { + console.log('Workflow run is not associated with a pull request; skipping.'); return; } + prNumber = Number(mirror[1]); } if (!prNumber || isNaN(prNumber)) { console.log('No valid PR number found, skipping comment.'); @@ -55,6 +67,14 @@ jobs: return; } + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + const baseCloneUrl = pullRequest.base.repo.clone_url; + const baseRef = pullRequest.base.ref; + const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${{ github.event.workflow_run.id }}`; const commentBody = ` @@ -85,7 +105,8 @@ jobs: 2. **Re-sign your commits:** \`\`\`bash - git rebase -i origin/main --exec "git commit --amend --no-edit -S" + git fetch ${baseCloneUrl} ${baseRef} + git rebase -i FETCH_HEAD --exec "git commit --amend --no-edit -S" git push --force-with-lease \`\`\` diff --git a/.github/workflows/test-git-signatures.yml b/.github/workflows/test-git-signatures.yml index 6ca6a8eb..8e1e0982 100644 --- a/.github/workflows/test-git-signatures.yml +++ b/.github/workflows/test-git-signatures.yml @@ -14,6 +14,7 @@ concurrency: # Minimal permissions - only read access needed for checks permissions: contents: read + pull-requests: read jobs: test-git-signatures: @@ -23,40 +24,133 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - fetch-depth: 0 # Need full history to compare with main + fetch-depth: 0 # Needed for merge-group commit ranges - name: Check commit signatures id: signature-check uses: actions/github-script@v7 with: script: | - const { execSync } = require('child_process'); - const fs = require('fs'); + const { execFileSync } = require('child_process'); - // Get current branch name const currentBranch = process.env.GITHUB_REF_NAME; console.log(`Current branch: ${currentBranch}`); - // Skip signature check on main branch to avoid failing on historical unsigned commits + // Main contains historical unsigned commits. Feature and event + // branches are checked when their commits are introduced. if (currentBranch === 'main') { console.log('On main branch - skipping signature check for historical commits.'); console.log('Signature verification only runs on PR branches.'); return; } - // Get commits that are on this branch but not on origin/main - let commitShas; - try { - const output = execSync('git rev-list origin/main..HEAD', { encoding: 'utf-8' }); - commitShas = output.trim().split('\n').filter(sha => sha.length > 0); - } catch (error) { - console.log('Could not compare with origin/main, checking all commits on this branch.'); - // Fallback: just check the current commit + const unique = values => [...new Set(values.filter(Boolean))]; + + const commitsForOpenPullRequests = async () => { + try { + const mirror = currentBranch.match(/^pull-request\/(\d+)$/); + let pulls; + if (mirror) { + const pull = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: Number(mirror[1]) + }); + pulls = [pull.data]; + } else { + pulls = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.payload.after, + per_page: 100 + } + ); + pulls = pulls.filter(pull => + pull.state === 'open' && pull.head.sha === context.payload.after + ); + } + + if (pulls.length === 0) { + return null; + } + + const shas = []; + for (const pull of pulls) { + const targetOwner = pull.base.repo.owner.login; + const targetRepo = pull.base.repo.name; + console.log( + `Checking commits from ${targetOwner}/${targetRepo}#${pull.number} ` + + `(base=${pull.base.ref}).` + ); + const commits = await github.paginate( + github.rest.repos.compareCommitsWithBasehead, + { + owner: targetOwner, + repo: targetRepo, + basehead: `${pull.base.sha}...${context.payload.after}`, + per_page: 100 + }, + response => response.data.commits + ); + shas.push(...commits.map(commit => commit.sha)); + } + return unique(shas); + } catch (error) { + throw new Error(`Could not determine pull-request commits: ${error.message}`); + } + }; + + const commitsFromPush = () => { + const pushed = context.payload.commits || []; + if (pushed.length >= 2048) { + throw new Error( + 'Push exposes 2048 commits, the event payload limit; ' + + 'refusing to skip signature checks.' + ); + } + return pushed.map(commit => commit.id); + }; + + const commitsInRange = (base, head) => { + const shaPattern = /^[0-9a-f]{40}$/; + if (!shaPattern.test(base) || !shaPattern.test(head)) { + throw new Error(`Invalid commit range: ${base}..${head}`); + } + const output = execFileSync( + 'git', ['rev-list', `${base}..${head}`], { encoding: 'utf-8' } + ); + return output.trim().split('\n').filter(Boolean); + }; + + let commitShas = null; + if (context.eventName === 'push') { + if (context.payload.deleted) { + console.log('Branch deleted - no commits to check.'); + return; + } + + commitShas = await commitsForOpenPullRequests(); + if (commitShas === null) { + if (context.payload.created) { + console.log('New branch without an open PR - checking pushed commits.'); + } else { + console.log('No open PR found - checking commits introduced by this push.'); + } + commitShas = commitsFromPush(); + } + } else if (context.eventName === 'merge_group') { + const mergeGroup = context.payload.merge_group; + commitShas = commitsInRange(mergeGroup.base_sha, mergeGroup.head_sha); + } else { commitShas = [process.env.GITHUB_SHA]; } + commitShas = unique(commitShas); + if (commitShas.length === 0) { - console.log('No new commits to check (branch is up to date with main).'); + console.log('No new commits to check.'); return; } @@ -90,8 +184,10 @@ jobs: }); } } catch (error) { - console.log(`⚠ ${sha.substring(0, 7)} - Could not verify (${error.message})`); - // Don't fail on API errors for individual commits + core.setFailed( + `Could not verify ${sha.substring(0, 7)} via GitHub API: ${error.message}` + ); + return; } } @@ -111,11 +207,6 @@ jobs: console.log(`\n✅ All ${commitShas.length} commit(s) are properly signed.`); } - - name: Get PR info - id: get-pr-info - if: always() && startsWith(github.ref_name, 'pull-request/') - uses: nv-gha-runners/get-pr-info@main - - name: Save PR comment data if: always() run: | @@ -124,8 +215,6 @@ jobs: cat << 'EOF' > ./pr-comment-data/unsigned_commits ${{ steps.signature-check.outputs.unsigned_commits }} EOF - PR_NUMBER='${{ startsWith(github.ref_name, 'pull-request/') && fromJSON(steps.get-pr-info.outputs.pr-info).number || '' }}' - echo "${PR_NUMBER}" > ./pr-comment-data/pr_number - name: Upload PR comment data if: always() diff --git a/README.md b/README.md index 6f4a7d85..951d5ab4 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The following interactive tutorials are available and can be used on [NVIDIA Bre | [CUDA C++ Tutorial](tutorials/cuda-cpp/README.md) | [docker-compose.yml](tutorials/cuda-cpp/brev/docker-compose.yml) | L40S, L4, or T4 | Crusoe or any other with Flexible Ports | | [Standard Parallelism Tutorial](tutorials/stdpar/README.md) | [docker-compose.yml](tutorials/stdpar/brev/docker-compose.yml) | 4xL4, 2xL4, 2xL40S, or 1x L40S | GCP, AWS, or any other with Flexible Ports and Linux 6.1.24+, 6.2.11+, or 6.3+ (for HMM) | | [Accelerated Python Tutorial](tutorials/accelerated-python/README.md) | [docker-compose.yml](tutorials/accelerated-python/brev/docker-compose.yml) | L40S, L4, or T4; 4xL4 or 2xL4 for distributed | Crusoe or any other with Flexible Ports | +| [PyHPC Tutorial](tutorials/pyhpc/README.md) | [docker-compose.yml](tutorials/pyhpc/brev/docker-compose.yml) | L40S, L4, or T4 | Crusoe or any other with Flexible Ports; host driver must support CUDA 13 | | [nvmath-python Tutorial](tutorials/nvmath-python/README.md) | [docker-compose.yml](tutorials/nvmath-python/brev/docker-compose.yml) | 4xL4, 2xL4, 2xL40S, or 1x L40S | Crusoe or any other with Flexible Ports | | [CUDA Python Tutorial - CuPy, cuDF, CCCL, & Kernels - 8 Hours](tutorials/accelerated-python/notebooks/syllabi/cuda_python__cupy_cudf_cccl_kernels__8_hours.ipynb) | [docker-compose.yml](https://github.com/NVIDIA/accelerated-computing-hub/blob/generated/main/tutorials/accelerated-python/notebooks/syllabi/cuda_python__cupy_cudf_cccl_kernels__8_hours__docker_compose.yml) | L40S, L4, or T4 | Crusoe or any other with Flexible Ports | | [CUDA Python Tutorial - cuda.core & CCCL - 2 Hours](tutorials/accelerated-python/notebooks/syllabi/cuda_python__cuda_core_cccl__2_hours.ipynb) | [docker-compose.yml](https://github.com/NVIDIA/accelerated-computing-hub/blob/generated/main/tutorials/accelerated-python/notebooks/syllabi/cuda_python__cuda_core_cccl__2_hours__docker_compose.yml) | L40S, L4, or T4 | Crusoe or any other with Flexible Ports | diff --git a/brev/dev-build.bash b/brev/dev-build.bash index 82caf10e..30aac687 100755 --- a/brev/dev-build.bash +++ b/brev/dev-build.bash @@ -1,6 +1,6 @@ #! /bin/bash -# This script builds Docker containers for tutorials. +# This script builds containers for tutorials. # # Usage: # ./dev-build.bash [--no-cache] [] @@ -29,6 +29,9 @@ done SCRIPT_PATH=$(cd $(dirname ${0}); pwd -P) REPO_ROOT=$(cd ${SCRIPT_PATH}/..; pwd -P) +source "${SCRIPT_PATH}/dev-common.bash" +setup_container_engine + # Function to build a single tutorial build_tutorial() { local ACH_TUTORIAL_PATH=${1} @@ -55,7 +58,7 @@ build_tutorial() { echo "Dockerfile generated successfully" fi - docker compose --progress=plain -f "${ACH_TUTORIAL_PATH}/brev/docker-compose.yml" build ${NO_CACHE} + compose -f "${ACH_TUTORIAL_PATH}/brev/docker-compose.yml" build ${NO_CACHE} echo "Successfully built image for ${ACH_TUTORIAL}" echo "" diff --git a/brev/dev-common.bash b/brev/dev-common.bash index 20c41e5b..8360fc26 100644 --- a/brev/dev-common.bash +++ b/brev/dev-common.bash @@ -1,10 +1,10 @@ #! /bin/bash # -# Helper functions for setting up development mounts with Docker's --user flag. +# Helper functions for setting up development mounts with container --user flag. # # This library provides functions for: -# - Exporting HOST_UID/HOST_GID for Docker to match host user permissions -# - Creating Docker volumes (bind-mount to local repo or plain image-backed) +# - Exporting HOST_UID/HOST_GID for the container engine to match host user permissions +# - Creating container volumes (bind-mount to local repo or plain image-backed) # # Usage: # source ./brev/dev-common.bash @@ -12,7 +12,103 @@ # setup_docker_volume "tutorial-name" true # bind-mount local repo # setup_docker_volume "tutorial-name" false # image content only -# Export host user's UID/GID/username for Docker Compose + +# Select the local container engine. Docker remains the default for Brev and CI, +# while ACH_CONTAINER_ENGINE=podman uses rootless Podman where available (CSCS). +setup_container_engine() { + if [ -n "${ACH_CONTAINER_ENGINE_CMD:-}" ] && [ -n "${ACH_COMPOSE_CMD:-}" ]; then + return 0 + fi + + local requested_engine="${ACH_CONTAINER_ENGINE:-auto}" + + if [ "${requested_engine}" = "auto" ]; then + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + requested_engine="docker" + elif command -v podman >/dev/null 2>&1; then + requested_engine="podman" + else + echo "Error: neither Docker nor Podman is available" >&2 + return 1 + fi + fi + + case "${requested_engine}" in + docker) + if ! command -v docker >/dev/null 2>&1; then + echo "Error: Docker is not installed or not in PATH" >&2 + return 1 + fi + export ACH_CONTAINER_ENGINE="docker" + export ACH_CONTAINER_ENGINE_CMD="docker" + export ACH_COMPOSE_CMD="docker compose" + ;; + podman) + if ! command -v podman >/dev/null 2>&1; then + echo "Error: Podman is not installed or not in PATH" >&2 + return 1 + fi + if command -v podman-compose >/dev/null 2>&1; then + export ACH_COMPOSE_CMD="podman-compose" + elif podman compose version >/dev/null 2>&1; then + export ACH_COMPOSE_CMD="podman compose" + elif command -v docker-compose >/dev/null 2>&1; then + export DOCKER_HOST="${DOCKER_HOST:-unix://${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/podman/podman.sock}" + export ACH_COMPOSE_CMD="docker-compose" + else + echo "Error: Podman is available, but no Compose frontend was found" >&2 + echo "Install podman-compose or a podman compose provider." >&2 + return 1 + fi + export ACH_CONTAINER_ENGINE="podman" + export ACH_CONTAINER_ENGINE_CMD="podman" + ;; + *) + echo "Error: unsupported ACH_CONTAINER_ENGINE='${requested_engine}' (expected auto, docker, or podman)" >&2 + return 1 + ;; + esac + + echo "🔧 Container engine: ${ACH_CONTAINER_ENGINE} (${ACH_COMPOSE_CMD})" >&2 +} + +container() { + setup_container_engine + ${ACH_CONTAINER_ENGINE_CMD} "$@" +} + +compose() { + setup_container_engine + if [ "${ACH_CONTAINER_ENGINE}" = "docker" ]; then + BUILDKIT_PROGRESS=plain ${ACH_COMPOSE_CMD} "$@" + else + ${ACH_COMPOSE_CMD} "$@" + fi +} + +prepare_compose_file() { + local compose_file=${1} + + setup_container_engine + + if [ "${ACH_CONTAINER_ENGINE}" != "podman" ]; then + echo "${compose_file}" + return 0 + fi + + local output_file + local script_path + output_file="${TMPDIR:-/tmp}/$(basename "${compose_file}").podman.$$" + script_path=$(cd "$(dirname "${BASH_SOURCE[0]}")" || return; pwd -P) + python3 "${script_path}/prepare-podman-compose.py" \ + "${compose_file}" \ + "${output_file}" \ + "${ACH_REPO_ROOT:-}" \ + "${ACH_PODMAN_BIND_REPO:-}" + echo "${output_file}" +} + +# Export host user's UID/GID/username for Docker/Podman Compose setup_dev_env() { local REPO_ROOT=${1} @@ -25,31 +121,33 @@ setup_dev_env() { echo "" } -# Set up the Docker volume for a tutorial. +# Set up the container volume for a tutorial. # # Always removes any existing volume first for a clean state. # When MOUNT=true, creates a bind-mount volume pointing to the local repo. -# When MOUNT=false, does nothing (docker compose will create a plain volume +# When MOUNT=false, does nothing (compose will create a plain volume # populated from the image on first use). setup_docker_volume() { local ACH_TUTORIAL=${1} local MOUNT=${2:-true} local VOLUME_NAME="${ACH_TUTORIAL}_accelerated-computing-hub" + setup_container_engine + # Remove existing volume for clean state - if docker volume inspect "${VOLUME_NAME}" &>/dev/null; then - echo "🗑️ Removing existing Docker volume: ${VOLUME_NAME}" - docker volume rm "${VOLUME_NAME}" &>/dev/null || true + if ${ACH_CONTAINER_ENGINE_CMD} volume inspect "${VOLUME_NAME}" &>/dev/null; then + echo "🗑️ Removing existing container volume: ${VOLUME_NAME}" + ${ACH_CONTAINER_ENGINE_CMD} volume rm "${VOLUME_NAME}" &>/dev/null || true fi if [ "${MOUNT}" = "true" ]; then - echo "🔧 Creating Docker volume (bind mount to local repo)..." - docker volume create --driver local \ + echo "🔧 Creating container volume (bind mount to local repo)..." + ${ACH_CONTAINER_ENGINE_CMD} volume create --driver local \ --opt type=none \ --opt o=bind \ --opt device="${ACH_REPO_ROOT}" \ "${ACH_TUTORIAL}_accelerated-computing-hub" > /dev/null - echo -e "${GREEN:-}✅ Docker volume created (local mount)${NC:-}" + echo -e "${GREEN:-}✅ container volume created (local mount)${NC:-}" else echo "📦 Using image content (no local mount)" fi diff --git a/brev/dev-shell.bash b/brev/dev-shell.bash index d6e81127..27dc7b51 100755 --- a/brev/dev-shell.bash +++ b/brev/dev-shell.bash @@ -1,6 +1,6 @@ #! /bin/bash -# This script starts an interactive shell in a Docker container for a tutorial. +# This script starts an interactive shell in a container for a tutorial. # # Usage: # ./brev/dev-shell.bash [--mount|--no-mount] @@ -29,7 +29,7 @@ usage() { cat << EOF Usage: $(basename "$0") [--mount|--no-mount] -Start an interactive shell in a Docker container for a tutorial. +Start an interactive shell in a container for a tutorial. Options: --mount Bind-mount local repo into the container (default) @@ -46,7 +46,7 @@ Examples: $(basename "$0") tutorials/accelerated-python/brev/docker-compose.yml jupyter Requirements: - - Docker and Docker Compose must be installed + - Docker Compose or Podman Compose must be installed EOF exit 1 } @@ -101,13 +101,15 @@ fi # Validate docker-compose file exists if [ ! -f "${COMPOSE_FILE}" ]; then - echo -e "${RED}Error: Docker Compose file not found: ${COMPOSE_FILE}${NC}" + echo -e "${RED}Error: Docker/Podman Compose file not found: ${COMPOSE_FILE}${NC}" exit 1 fi +setup_container_engine +COMPOSE_FILE=$(prepare_compose_file "${COMPOSE_FILE}") echo "================================================================================" echo "Starting interactive shell for: ${ACH_TUTORIAL} (service: ${SERVICE})" -echo "Docker Compose file: ${COMPOSE_FILE}" +echo "Docker/Podman Compose file: ${COMPOSE_FILE}" echo "================================================================================" echo "" @@ -119,7 +121,13 @@ echo "🚀 Starting interactive shell as ${ACH_USER}..." echo " (Type 'exit' or press Ctrl+D to exit the shell)" echo "" -docker compose -f "${COMPOSE_FILE}" run --rm -it \ +# Podman development consumes the published image. Pull it before `run` so a +# missing image cannot make podman-compose fall back to the build stanza. +if [ "${ACH_CONTAINER_ENGINE}" = "podman" ]; then + compose -f "${COMPOSE_FILE}" pull base "${SERVICE}" +fi + +compose -f "${COMPOSE_FILE}" run --rm -it \ --entrypoint "/accelerated-computing-hub/brev/entrypoint.bash" \ "${SERVICE}" shell diff --git a/brev/dev-start.bash b/brev/dev-start.bash index 94119fb4..07154a3c 100755 --- a/brev/dev-start.bash +++ b/brev/dev-start.bash @@ -1,6 +1,6 @@ #! /bin/bash -# This script starts Docker containers for a tutorial. +# This script starts containers for a tutorial. # # Usage: # ./dev-start.bash [--mount|--no-mount] @@ -64,11 +64,18 @@ setup_docker_volume "${ACH_TUTORIAL}" "${MOUNT}" echo "Starting tutorial: ${ACH_TUTORIAL}" cd ${REPO_ROOT} +DOCKER_COMPOSE=$(prepare_compose_file "${DOCKER_COMPOSE}") + # Create a modified docker-compose file that binds to 0.0.0.0 instead of 127.0.0.1 # This is needed for local development so services are accessible from outside the container sed 's/127\.0\.0\.1:/0.0.0.0:/g' "${DOCKER_COMPOSE}" > "${DOCKER_COMPOSE_DEV}" # Filter out the "volume already exists" warning while preserving all other warnings/errors on stderr -docker compose -f ${DOCKER_COMPOSE_DEV} up -d 2> >(grep -v "already exists but was not created by Docker Compose" >&2) +UP_ARGS=(up -d) +if [ "${ACH_CONTAINER_ENGINE}" = "podman" ]; then + UP_ARGS+=(--no-build) +fi +compose -f "${DOCKER_COMPOSE_DEV}" "${UP_ARGS[@]}" \ + 2> >(grep -v "already exists but was not created by Docker Compose" >&2) echo "Tutorial ${ACH_TUTORIAL} started successfully!" diff --git a/brev/dev-stop.bash b/brev/dev-stop.bash index e8021e99..aef6a8f2 100755 --- a/brev/dev-stop.bash +++ b/brev/dev-stop.bash @@ -1,6 +1,6 @@ #! /bin/bash -# This script stops Docker containers for a tutorial. +# This script stops containers for a tutorial. # # Usage: # ./dev-stop.bash @@ -13,6 +13,9 @@ set -eu SCRIPT_PATH=$(cd $(dirname ${0}); pwd -P) REPO_ROOT=$(cd ${SCRIPT_PATH}/..; pwd -P) +source "${SCRIPT_PATH}/dev-common.bash" +setup_container_engine + # Check argument if [ $# -ne 1 ]; then echo "Error: Tutorial name is required" @@ -39,6 +42,6 @@ fi echo "Stopping tutorial: ${ACH_TUTORIAL}" cd ${REPO_ROOT} -docker compose -f ${DOCKER_COMPOSE} down +compose -f ${DOCKER_COMPOSE} down echo "Tutorial ${ACH_TUTORIAL} stopped successfully!" diff --git a/brev/dev-test.bash b/brev/dev-test.bash index 33df9c28..bf40d248 100755 --- a/brev/dev-test.bash +++ b/brev/dev-test.bash @@ -1,9 +1,9 @@ #! /bin/bash # -# Test a Docker Compose file with local repository mounted. +# Test a Docker/Podman Compose file with local repository mounted. # # This script sets up the development environment and then calls -# test-docker-compose.bash to validate Docker Compose configurations. +# test-docker-compose.bash to validate Docker/Podman Compose configurations. # # Usage: # ./brev/dev-test.bash [--mount|--no-mount] [test-args...] @@ -29,7 +29,7 @@ usage() { cat << EOF Usage: $(basename "$0") [--mount|--no-mount] [test-args...] -Test a Docker Compose file with local repository mounted. +Test a Docker/Podman Compose file with local repository mounted. Options: --mount Bind-mount local repo into the container (live local files) @@ -46,7 +46,7 @@ Examples: $(basename "$0") --mount accelerated-python Requirements: - - Docker and Docker Compose must be installed + - Docker Compose or Podman Compose must be installed EOF exit 1 } @@ -62,7 +62,7 @@ fi # Check argument if [ $# -lt 1 ]; then - echo -e "${RED}Error: Tutorial name or Docker Compose file path is required${NC}" + echo -e "${RED}Error: Tutorial name or Docker/Podman Compose file path is required${NC}" usage fi diff --git a/brev/entrypoint-base-user.bash b/brev/entrypoint-base-user.bash index c5b17f3e..30ff6060 100755 --- a/brev/entrypoint-base-user.bash +++ b/brev/entrypoint-base-user.bash @@ -11,14 +11,22 @@ export HOME="${ACH_TARGET_HOME}" # services can read them. TURN_CREDENTIALS_FILE="/accelerated-computing-hub/.turn-credentials" -if [ ! -f "${TURN_CREDENTIALS_FILE}" ]; then +if { [ -n "${TURN_USERNAME:-}" ] && [ -z "${TURN_PASSWORD:-}" ]; } || \ + { [ -z "${TURN_USERNAME:-}" ] && [ -n "${TURN_PASSWORD:-}" ]; }; then + echo "Error: TURN_USERNAME and TURN_PASSWORD must be set together" >&2 + exit 1 +fi + +if [ -z "${TURN_USERNAME:-}" ] && [ ! -f "${TURN_CREDENTIALS_FILE}" ]; then TURN_USERNAME="turn_$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 16)" TURN_PASSWORD="$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 32)" echo "TURN_USERNAME=${TURN_USERNAME}" > "${TURN_CREDENTIALS_FILE}" echo "TURN_PASSWORD=${TURN_PASSWORD}" >> "${TURN_CREDENTIALS_FILE}" +fi - chmod 644 "${TURN_CREDENTIALS_FILE}" +if [ -f "${TURN_CREDENTIALS_FILE}" ]; then + chmod 600 "${TURN_CREDENTIALS_FILE}" fi # Run per-tutorial start tests if they exist. @@ -41,7 +49,8 @@ if [ -n "${ACH_TUTORIAL:-}" ] && [ -n "${ACH_RUN_TESTS:-}" ]; then } | tee -a "${LOG_FILE}" # Run tests with output to both console and log file - if bash "${TEST_SCRIPT}" ${ACH_TEST_ARGS:-} 2>&1 | tee -a "${LOG_FILE}"; then + read -r -a TEST_ARGS <<< "${ACH_TEST_ARGS:-}" + if bash "${TEST_SCRIPT}" "${TEST_ARGS[@]}" 2>&1 | tee -a "${LOG_FILE}"; then { echo "==========================================" echo "Tests completed successfully for: ${ACH_TUTORIAL}" diff --git a/brev/entrypoint-base.bash b/brev/entrypoint-base.bash index e8d33bb3..b46188fe 100755 --- a/brev/entrypoint-base.bash +++ b/brev/entrypoint-base.bash @@ -4,5 +4,8 @@ set -euo pipefail -# Switch to user and run user-level entrypoint -exec gosu "${ACH_TARGET_USER}" /accelerated-computing-hub/brev/entrypoint-base-user.bash "$@" +if [ "$(id -u)" = "0" ] && [ "${ACH_TARGET_USER}" != "$(id -un)" ]; then + exec gosu "${ACH_TARGET_USER}" /accelerated-computing-hub/brev/entrypoint-base-user.bash "$@" +else + exec /accelerated-computing-hub/brev/entrypoint-base-user.bash "$@" +fi diff --git a/brev/entrypoint-jupyter-user.bash b/brev/entrypoint-jupyter-user.bash index 5c78eac6..3c25981a 100755 --- a/brev/entrypoint-jupyter-user.bash +++ b/brev/entrypoint-jupyter-user.bash @@ -6,8 +6,8 @@ set -euo pipefail export HOME="${ACH_TARGET_HOME}" -# Generate Jupyter plugin settings -/accelerated-computing-hub/brev/jupyter-generate-plugin-settings.bash +# Generate JupyterLab user settings +/accelerated-computing-hub/brev/jupyter-generate-settings.bash mkdir -p /accelerated-computing-hub/logs diff --git a/brev/entrypoint-jupyter.bash b/brev/entrypoint-jupyter.bash index 2ba3ec67..95674d6c 100755 --- a/brev/entrypoint-jupyter.bash +++ b/brev/entrypoint-jupyter.bash @@ -4,5 +4,46 @@ set -euo pipefail -# Switch to user and run user-level entrypoint -exec gosu "${ACH_TARGET_USER}" /accelerated-computing-hub/brev/entrypoint-jupyter-user.bash "$@" +# Keep this wrapper alive so an unrequested Jupyter exit can restart the +# sibling services before the container restart policy starts Jupyter again. +# An operator stopping the container sends TERM/INT to this wrapper; that path +# deliberately does not restart siblings so `compose down` can finish. +TERMINATING=0 +JUPYTER_PID="" + +# shellcheck disable=SC2317 # Invoked indirectly by the signal traps below. +terminate_jupyter() { + TERMINATING=1 + if [ -n "${JUPYTER_PID}" ] && kill -0 "${JUPYTER_PID}" 2>/dev/null; then + kill -TERM "${JUPYTER_PID}" + fi +} + +trap terminate_jupyter TERM INT + +if [ "$(id -u)" = "0" ] && [ "${ACH_TARGET_USER}" != "$(id -un)" ]; then + gosu "${ACH_TARGET_USER}" /accelerated-computing-hub/brev/entrypoint-jupyter-user.bash "$@" & +else + /accelerated-computing-hub/brev/entrypoint-jupyter-user.bash "$@" & +fi +JUPYTER_PID=$! + +set +e +wait "${JUPYTER_PID}" +JUPYTER_STATUS=$? +if [ "${TERMINATING}" -eq 1 ] && kill -0 "${JUPYTER_PID}" 2>/dev/null; then + wait "${JUPYTER_PID}" + JUPYTER_STATUS=$? +fi +set -e + +if [ "${TERMINATING}" -eq 0 ] && \ + [ "${ACH_RESTART_COMPOSE_SERVICES:-}" = "1" ] && \ + [ -S /var/run/docker.sock ]; then + echo "Jupyter exited with status ${JUPYTER_STATUS}; restarting sibling services." + if ! python3 /accelerated-computing-hub/brev/restart-compose-services.py; then + echo "Error: Failed to restart one or more sibling services." >&2 + fi +fi + +exit "${JUPYTER_STATUS}" diff --git a/brev/entrypoint-nsight.bash b/brev/entrypoint-nsight.bash index 81ad70b9..a8094430 100755 --- a/brev/entrypoint-nsight.bash +++ b/brev/entrypoint-nsight.bash @@ -9,6 +9,26 @@ set -euo pipefail export USER="${ACH_TARGET_USER}" export HOME="${ACH_TARGET_HOME}" +# Nsight Streamer constructs HOME as /home/$USER in its supervisor config. +# Rootless Podman runs the bind-mounted checkout as root, whose real home is +# /root, so provide the path the Streamer expects. +STREAMER_HOME="/home/${ACH_TARGET_USER}" +if [ "${ACH_TARGET_HOME}" != "${STREAMER_HOME}" ] && \ + [ ! -e "${STREAMER_HOME}" ] && [ ! -L "${STREAMER_HOME}" ]; then + ln -s "${ACH_TARGET_HOME}" "${STREAMER_HOME}" +fi + +# The pinned Streamer images wait specifically for the X4 filesystem socket. +# With host networking the two X servers need distinct display numbers because +# their abstract sockets share a network namespace. Let the ncu service use X5 +# while satisfying the upstream readiness check with a filesystem symlink. +DISPLAY_NUMBER="${DISPLAY:-:4}" +DISPLAY_NUMBER="${DISPLAY_NUMBER#:}" +if [ "${DISPLAY_NUMBER}" != "4" ]; then + mkdir -p /tmp/.X11-unix + ln -sfn "X${DISPLAY_NUMBER}" /tmp/.X11-unix/X4 +fi + # Ensure a group with the user's name exists. # The Nsight Streamer's entrypoint does `chown $USER:$USER` which requires a group # with the same name as the user. Our main entrypoint may have reused an existing @@ -24,7 +44,7 @@ if [ "${ACH_TARGET_HOME}" != "/home/nvidia" ]; then cp "/home/nvidia/.config/NVIDIA Corporation/NVIDIA Nsight Systems.ini" "${ACH_TARGET_HOME}/.config/NVIDIA Corporation/" fi cp /home/nvidia/.bashrc "${ACH_TARGET_HOME}/.bashrc" - chown -R "${ACH_TARGET_USER}:$(id -gn ${ACH_TARGET_USER})" "${ACH_TARGET_HOME}" + chown -R "${ACH_TARGET_USER}:$(id -gn "${ACH_TARGET_USER}")" "${ACH_TARGET_HOME}" fi # Generate Nsight Compute config with the tutorial's notebooks as the default folder @@ -32,29 +52,33 @@ mkdir -p "${ACH_TARGET_HOME}/.config/NVIDIA Corporation" echo "[CorePlugin.Environment]" > "${ACH_TARGET_HOME}/.config/NVIDIA Corporation/NVIDIA Nsight Compute.ini" echo "CorePlugin.DefaultDocumentsFolder=/accelerated-computing-hub/tutorials/${ACH_TUTORIAL}/notebooks" >> "${ACH_TARGET_HOME}/.config/NVIDIA Corporation/NVIDIA Nsight Compute.ini" -# Install curl if not present -if ! command -v curl &> /dev/null; then +# Curl is only needed to discover a public TURN address. Tunnels provide an +# explicit address and should not mutate the published Streamer image. +if ! command -v curl &> /dev/null && [ -z "${SELKIES_TURN_HOST:-}" ]; then apt-get update apt-get install -y curl fi # Load TURN credentials generated by the base service TURN_CREDENTIALS_FILE="/accelerated-computing-hub/.turn-credentials" -if [ -f "${TURN_CREDENTIALS_FILE}" ]; then - source "${TURN_CREDENTIALS_FILE}" -else - echo "Warning: TURN credentials file not found at ${TURN_CREDENTIALS_FILE}" >&2 +if [ -z "${TURN_USERNAME:-}" ] || [ -z "${TURN_PASSWORD:-}" ]; then + if [ -f "${TURN_CREDENTIALS_FILE}" ]; then + source "${TURN_CREDENTIALS_FILE}" + else + echo "Warning: TURN credentials file not found at ${TURN_CREDENTIALS_FILE}" >&2 + fi fi # Set environment variables for Nsight Streamer export NVIDIA_DRIVER_CAPABILITIES=all -export HOST_IP=0.0.0.0 -export WEB_USERNAME="" -export WEB_PASSWORD="" -export SELKIES_TURN_HOST=$(curl -sSL ifconfig.me) +export HOST_IP="${HOST_IP:-0.0.0.0}" +export WEB_USERNAME="${WEB_USERNAME:-}" +export WEB_PASSWORD="${WEB_PASSWORD:-}" +export SELKIES_ADDR="${SELKIES_ADDR:-0.0.0.0}" +export SELKIES_TURN_HOST="${SELKIES_TURN_HOST:-$(curl -sSL ifconfig.me)}" -VARS=("NVIDIA_DRIVER_CAPABILITIES" "HOST_IP" "WEB_USERNAME" "WEB_PASSWORD" "SELKIES_TURN_HOST") -OPTIONAL_VARS=("TURN_USERNAME" "TURN_PASSWORD" "HTTP_PORT" "TURN_PORT") +VARS=("NVIDIA_DRIVER_CAPABILITIES" "HOST_IP" "WEB_USERNAME" "WEB_PASSWORD" "SELKIES_ADDR" "SELKIES_TURN_HOST") +OPTIONAL_VARS=("TURN_USERNAME" "TURN_PASSWORD" "HTTP_PORT" "TURN_PORT" "SELKIES_ENABLE_HTTPS" "SELKIES_HTTPS_CERT" "SELKIES_HTTPS_KEY") for VAR in "${OPTIONAL_VARS[@]}"; do if [ -n "${!VAR+x}" ]; then export "${VAR}" @@ -64,19 +88,9 @@ done for VAR in "${VARS[@]}"; do if [[ -n "${!VAR+x}" ]]; then - echo "export $VAR=${!VAR}" >> "${HOME}/.bashrc" + printf 'export %s=%q\n' "${VAR}" "${!VAR}" >> "${HOME}/.bashrc" fi done -# Workaround: The Nsight Streamer container isn't restartable because it unconditionally creates -# symlinks every time it starts, which fails if the symlinks already exist. -if test -e /usr/lib/x86_64-linux-gnu/libnvrtc.so; then - rm -f /usr/lib/x86_64-linux-gnu/libnvrtc.so -fi - -if test -e /mnt/persist/home/host; then - rm -rf /mnt/persist/home/host -fi - # Hand off to nsight streamer's entrypoint (which handles user switching via USER env var) source /setup/entrypoint.sh "$@" diff --git a/brev/entrypoint-shell.bash b/brev/entrypoint-shell.bash index a4161500..a6ef7a86 100755 --- a/brev/entrypoint-shell.bash +++ b/brev/entrypoint-shell.bash @@ -4,5 +4,8 @@ set -euo pipefail -# Switch to user and run login shell -exec gosu "${ACH_TARGET_USER}" bash -l +if [ "$(id -u)" = "0" ] && [ "${ACH_TARGET_USER}" != "$(id -un)" ]; then + exec gosu "${ACH_TARGET_USER}" bash -l +else + exec bash -l +fi diff --git a/brev/entrypoint.bash b/brev/entrypoint.bash index 63bd06d1..41d6a115 100755 --- a/brev/entrypoint.bash +++ b/brev/entrypoint.bash @@ -16,10 +16,23 @@ if [ -z "${SERVICE}" ]; then exit 1 fi -# Install gosu if not present -if ! command -v gosu &> /dev/null; then - apt-get update -y - apt-get install -y gosu +# Rootless Podman receives the host driver as individual bind-mounted files. +# Create the soname links expected by CUDA without changing Docker entrypoints. +if [ "${ACH_ROOTLESS_PODMAN:-}" = "1" ]; then + for library_link in ${ACH_NVIDIA_LIBRARY_LINKS:-}; do + library=${library_link%%:*} + soname=${library_link#*:} + for path in "/usr/lib/"*-linux-gnu/"${library}".so.* /usr/lib64/"${library}".so.*; do + if [ -f "${path}" ] && [ ! -L "${path}" ]; then + ln -sf "$(basename "${path}")" "$(dirname "${path}")/${soname}" 2>/dev/null || true + break + fi + done + done + + if [ -n "${ACH_NVIDIA_LIB_DIRS:-}" ]; then + export LD_LIBRARY_PATH="${ACH_NVIDIA_LIB_DIRS}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" + fi fi # Create user if running as root and user doesn't exist @@ -53,23 +66,36 @@ if [ "$(id -u)" = "0" ]; then # Setup user environment (one-time setup, not on every shell) export HOME="${ACH_TARGET_HOME}" + # Streamer containers do not include gosu. Rootless Podman deliberately + # uses root for a directly bind-mounted checkout, so no user switch is + # needed in that case. + if [ "${TARGET_USER}" = "$(id -un)" ]; then + run_as_target() { "$@"; } + else + if ! command -v gosu &> /dev/null; then + apt-get update -y + apt-get install -y gosu + fi + run_as_target() { gosu "${TARGET_USER}" "$@"; } + fi + # Setup Jupyter configuration directories - gosu "${TARGET_USER}" mkdir -p "${HOME}/.jupyter" - gosu "${TARGET_USER}" mkdir -p "${HOME}/.local/share/jupyter" - gosu "${TARGET_USER}" mkdir -p "${HOME}/.ipython/profile_default/startup" - gosu "${TARGET_USER}" mkdir -p "${HOME}/.local/state" + run_as_target mkdir -p "${HOME}/.jupyter" + run_as_target mkdir -p "${HOME}/.local/share/jupyter" + run_as_target mkdir -p "${HOME}/.ipython/profile_default/startup" + run_as_target mkdir -p "${HOME}/.local/state" # Link Jupyter server config if not already present if [ ! -e "${HOME}/.jupyter/jupyter_server_config.py" ]; then - gosu "${TARGET_USER}" ln -sf /accelerated-computing-hub/brev/jupyter-server-config.py "${HOME}/.jupyter/jupyter_server_config.py" + run_as_target ln -sf /accelerated-computing-hub/brev/jupyter-server-config.py "${HOME}/.jupyter/jupyter_server_config.py" fi # Link IPython startup scripts if not already present if [ ! -e "${HOME}/.ipython/profile_default/startup/00-add-cwd-to-path.py" ]; then - gosu "${TARGET_USER}" ln -sf /accelerated-computing-hub/brev/ipython-startup-add-cwd-to-path.py "${HOME}/.ipython/profile_default/startup/00-add-cwd-to-path.py" + run_as_target ln -sf /accelerated-computing-hub/brev/ipython-startup-add-cwd-to-path.py "${HOME}/.ipython/profile_default/startup/00-add-cwd-to-path.py" fi # Setup Git safe directory (run as target user) - gosu "${TARGET_USER}" git config --global --add safe.directory "/accelerated-computing-hub" 2>/dev/null || true + run_as_target git config --global --add safe.directory "/accelerated-computing-hub" 2>/dev/null || true # Ensure logs directory exists and is writable by the target user. mkdir -p /accelerated-computing-hub/logs @@ -78,6 +104,13 @@ if [ "$(id -u)" = "0" ]; then # Relax profiling permissions so Nsight tools can run as non-root. sysctl -w kernel.perf_event_paranoid=0 > /dev/null 2>&1 || true sysctl -w kernel.kptr_restrict=0 > /dev/null 2>&1 || true +else + export ACH_TARGET_USER="${ACH_TARGET_USER:-$(id -un)}" + export ACH_TARGET_HOME="${ACH_TARGET_HOME:-${HOME:-}}" + if [ -z "${ACH_TARGET_HOME}" ]; then + export ACH_TARGET_HOME="$(getent passwd "$(id -u)" | cut -d: -f6 || true)" + fi + export HOME="${ACH_TARGET_HOME:-/tmp}" fi # Dispatch to service-specific entrypoint diff --git a/brev/generate-cscs-edf.bash b/brev/generate-cscs-edf.bash new file mode 100755 index 00000000..60ae033b --- /dev/null +++ b/brev/generate-cscs-edf.bash @@ -0,0 +1,133 @@ +#! /bin/bash +# +# Generate a CSCS Slurm Container Engine EDF for a tutorial image. +# +# The EDF can be used on CSCS systems with Slurm's container support, e.g.: +# srun --environment=${SCRATCH}/ach/pyhpc.toml bash + +set -eu + +SCRIPT_PATH=$(cd $(dirname ${0}); pwd -P) +REPO_ROOT=$(cd ${SCRIPT_PATH}/..; pwd -P) + +usage() { + cat << USAGE +Usage: $(basename "$0") [options] + +Options: + --image IMAGE Container image URI (default: image from docker-compose.yml) + --tag TAG Replace the image tag before writing the EDF + --output FILE EDF output path (default: /brev/cscs.toml) + --mount Bind-mount this repository at /accelerated-computing-hub + --no-mount Do not bind-mount this repository (default) + --workdir DIR Working directory inside the container (default: compose working_dir) + +Generate a CSCS Slurm Container Engine EDF for use on systems where the +interactive Docker Compose stack is not available. +USAGE + exit 1 +} + +IMAGE="" +IMAGE_TAG="" +OUTPUT="" +MOUNT=false +WORKDIR="" + +format_cscs_image_ref() { + local image_ref=$1 + local registry=${image_ref%%/*} + + if [[ "${image_ref}" == /* ]] || [[ "${image_ref}" == *#* ]]; then + echo "${image_ref}" + elif [[ "${registry}" == *.* ]] || [[ "${registry}" == *:* ]] || [ "${registry}" = "localhost" ]; then + echo "${registry}#${image_ref#*/}" + else + echo "${image_ref}" + fi +} + +while [ $# -gt 0 ]; do + case "$1" in + --image) IMAGE="$2"; shift 2 ;; + --tag) IMAGE_TAG="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + --mount) MOUNT=true; shift ;; + --no-mount) MOUNT=false; shift ;; + --workdir) WORKDIR="$2"; shift 2 ;; + -h|--help) usage ;; + --*) echo "Error: unknown option $1" >&2; usage ;; + *) break ;; + esac +done + +[ $# -eq 1 ] || usage +ARG=$1 + +if [[ "${ARG}" == *"/"* ]]; then + ARG_PATH="${ARG}" + [[ "${ARG_PATH}" == /* ]] || ARG_PATH="${REPO_ROOT}/${ARG_PATH}" + if [ -d "${ARG_PATH}" ]; then + TUTORIAL_DIR="${ARG_PATH}" + COMPOSE_FILE="${TUTORIAL_DIR}/brev/docker-compose.yml" + else + COMPOSE_FILE="${ARG_PATH}" + TUTORIAL_DIR=$(dirname "$(dirname "${COMPOSE_FILE}")") + fi + ACH_TUTORIAL=$(basename "${TUTORIAL_DIR}") +else + ACH_TUTORIAL="${ARG}" + TUTORIAL_DIR="${REPO_ROOT}/tutorials/${ACH_TUTORIAL}" + COMPOSE_FILE="${TUTORIAL_DIR}/brev/docker-compose.yml" +fi + +if [ ! -f "${COMPOSE_FILE}" ]; then + echo "Error: compose file not found: ${COMPOSE_FILE}" >&2 + exit 1 +fi + +if [ -z "${IMAGE}" ] || [ -z "${WORKDIR}" ]; then + COMPOSE_CONFIG=$(docker compose -f "${COMPOSE_FILE}" config --format json) +fi +if [ -z "${IMAGE}" ]; then + IMAGE=$(jq -r '."x-config".image // empty' <<< "${COMPOSE_CONFIG}") + if [ -z "${IMAGE}" ]; then + echo "Error: x-config.image is not set in ${COMPOSE_FILE}" >&2 + exit 1 + fi +fi +if [ -n "${IMAGE_TAG}" ]; then + IMAGE="${IMAGE%%:*}:${IMAGE_TAG}" +fi +IMAGE=$(format_cscs_image_ref "${IMAGE}") +if [ -z "${WORKDIR}" ]; then + WORKDIR=$(jq -r '."x-config"."working-dir" // empty' <<< "${COMPOSE_CONFIG}") +fi +if [ -z "${OUTPUT}" ]; then + OUTPUT="${TUTORIAL_DIR}/brev/cscs.toml" +fi + +mkdir -p "$(dirname "${OUTPUT}")" +{ + echo "# Generated by accelerated-computing-hub/brev/generate-cscs-edf.bash" + echo "# Tutorial: ${ACH_TUTORIAL}" + echo "image = \"${IMAGE}\"" + echo "workdir = \"${WORKDIR:-/accelerated-computing-hub}\"" + echo "mounts = [" + if [ "${MOUNT}" = "true" ]; then + echo " \"${REPO_ROOT}:/accelerated-computing-hub\"," + fi + echo "]" + echo "[env]" + echo "ACH_TUTORIAL = \"${ACH_TUTORIAL}\"" + echo "ACH_USER = \"${ACH_USER:-ach}\"" + echo "ACH_UID = \"${ACH_UID:-1000}\"" + echo "ACH_GID = \"${ACH_GID:-1000}\"" + if [ "${ACH_TUTORIAL}" = "pyhpc" ]; then + echo "PMIX_MCA_gds = \"hash\"" + echo "PMIX_MCA_psec = \"native\"" + fi +} > "${OUTPUT}" + +echo "Generated CSCS EDF: ${OUTPUT}" +echo "Image: ${IMAGE}" diff --git a/brev/jupyter-generate-plugin-settings.bash b/brev/jupyter-generate-plugin-settings.bash deleted file mode 100755 index f5999499..00000000 --- a/brev/jupyter-generate-plugin-settings.bash +++ /dev/null @@ -1,59 +0,0 @@ -#! /bin/bash - -set -eu - -JUPYTER_HOST="jupyter0-${BREV_ENV_ID:-local}.brevlab.com" -NSYS_HTTP_URL="https://nsys0-${BREV_ENV_ID:-local}.brevlab.com" - -# Theme -mkdir -p "${HOME}/.jupyter/lab/user-settings/@jupyterlab/apputils-extension" -cat << EOF > "${HOME}/.jupyter/lab/user-settings/@jupyterlab/apputils-extension/themes.jupyterlab-settings" -{ - "theme": "JupyterLab Dark", - "adaptive-theme": true, - "preferred-light-theme": "JupyterLab Light", - "preferred-dark-theme": "JupyterLab Dark", - "theme-scrollbars": false -} -EOF - -# Load TURN credentials generated by the base service -TURN_CREDENTIALS_FILE="/accelerated-computing-hub/.turn-credentials" -if [ -f "${TURN_CREDENTIALS_FILE}" ]; then - source "${TURN_CREDENTIALS_FILE}" -else - echo "Warning: TURN credentials file not found at ${TURN_CREDENTIALS_FILE}" >&2 -fi - -# Nsight JupyterLab Plugin -mkdir -p "${HOME}/.jupyter/lab/user-settings/jupyterlab-nvidia-nsight" - -TURN_SETTINGS="" -if [ -n "${TURN_USERNAME+x}" ] && [ -n "${TURN_PASSWORD+x}" ]; then - TURN_SETTINGS=$(cat < "${HOME}/.jupyter/lab/user-settings/jupyterlab-nvidia-nsight/plugin.jupyterlab-settings" -{ - "ui": { - "enabled": true, - "suppressServerAddressWarning": true, - "host": "${JUPYTER_HOST}", - "dockerHost": "${JUPYTER_HOST}", - "defaultStreamerAddress": "${NSYS_HTTP_URL}"${TURN_SETTINGS} - } -} -EOF - -# Execution timing -mkdir -p "${HOME}/.jupyter/lab/user-settings/@jupyterlab/notebook-extension" -cat << EOF > "${HOME}/.jupyter/lab/user-settings/@jupyterlab/notebook-extension/tracker.jupyterlab-settings" -{ - "recordTiming": true -} -EOF diff --git a/brev/jupyter-generate-settings.bash b/brev/jupyter-generate-settings.bash new file mode 100755 index 00000000..02912f98 --- /dev/null +++ b/brev/jupyter-generate-settings.bash @@ -0,0 +1,23 @@ +#! /bin/bash + +set -eu + +# Theme +mkdir -p "${HOME}/.jupyter/lab/user-settings/@jupyterlab/apputils-extension" +cat << EOF > "${HOME}/.jupyter/lab/user-settings/@jupyterlab/apputils-extension/themes.jupyterlab-settings" +{ + "theme": "JupyterLab Dark", + "adaptive-theme": true, + "preferred-light-theme": "JupyterLab Light", + "preferred-dark-theme": "JupyterLab Dark", + "theme-scrollbars": false +} +EOF + +# Execution timing +mkdir -p "${HOME}/.jupyter/lab/user-settings/@jupyterlab/notebook-extension" +cat << EOF > "${HOME}/.jupyter/lab/user-settings/@jupyterlab/notebook-extension/tracker.jupyterlab-settings" +{ + "recordTiming": true +} +EOF diff --git a/brev/jupyter-server-config.py b/brev/jupyter-server-config.py index 47a925a8..4886becc 100644 --- a/brev/jupyter-server-config.py +++ b/brev/jupyter-server-config.py @@ -1,6 +1,10 @@ +import os + c = get_config() #noqa -c.ServerApp.ip = '0.0.0.0' +c.ServerApp.ip = os.environ.get('JUPYTER_IP', '0.0.0.0') +c.ServerApp.certfile = os.environ.get('JUPYTER_HTTPS_CERT', '') +c.ServerApp.keyfile = os.environ.get('JUPYTER_HTTPS_KEY', '') c.ServerApp.open_browser = False @@ -30,25 +34,27 @@ } } -c.ServerProxy.servers = { - "nsys": { - "command": [], - "port": 8080, - "launcher_entry": { - "title": "Nsight Systems", - "category": "Console", # Must be Console or Notebook to render icons. - "icon_path": "/accelerated-computing-hub/brev/icons/nsight_systems.svg", +c.ServerProxy.servers = {} +if os.environ.get('SELKIES_ENABLE_HTTPS', 'false').lower() != 'true': + c.ServerProxy.servers = { + "nsys": { + "command": [], + "port": 8080, + "launcher_entry": { + "title": "Nsight Systems", + "category": "Console", # Must be Console or Notebook to render icons. + "icon_path": "/accelerated-computing-hub/brev/icons/nsight_systems.svg", + }, + "new_browser_tab": False, }, - "new_browser_tab": False, - }, - "ncu": { - "command": [], - "port": 8081, - "launcher_entry": { - "title": "Nsight Compute", - "category": "Console", # Must be Console or Notebook to render icons. - "icon_path": "/accelerated-computing-hub/brev/icons/nsight_compute.svg", + "ncu": { + "command": [], + "port": 8081, + "launcher_entry": { + "title": "Nsight Compute", + "category": "Console", # Must be Console or Notebook to render icons. + "icon_path": "/accelerated-computing-hub/brev/icons/nsight_compute.svg", + }, + "new_browser_tab": False, }, - "new_browser_tab": False, } -} diff --git a/brev/prepare-podman-compose.py b/brev/prepare-podman-compose.py new file mode 100755 index 00000000..6af42836 --- /dev/null +++ b/brev/prepare-podman-compose.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Adapt an ACH Docker Compose file for rootless Podman.""" + +import glob +import os +import stat +import sys + +import yaml + + +GPU_SERVICES = {"base", "jupyter", "nsight", "nsys", "ncu"} +NVIDIA_CONTROL_DEVICES = ( + "/dev/nvidiactl", + "/dev/nvidia-uvm", + "/dev/nvidia-uvm-tools", +) +NVIDIA_DRIVER_LIBRARIES = ( + ("libcuda", "libcuda.so.1"), + ("libnvidia-ml", "libnvidia-ml.so.1"), + ("libnvidia-ptxjitcompiler", "libnvidia-ptxjitcompiler.so.1"), + ("libnvidia-nvvm", "libnvidia-nvvm.so.4"), +) +NVIDIA_LIBRARY_DIRECTORIES = ("/usr/lib/*-linux-gnu", "/usr/lib64") + + +def strip_podman_incompatible(value): + """Remove Compose keys unsupported by the rootless Podman setup.""" + if isinstance(value, dict): + for key in ("privileged", "ulimits", "deploy"): + value.pop(key, None) + volumes = value.get("volumes") + if isinstance(volumes, list): + value["volumes"] = [ + mount + for mount in volumes + if not ( + isinstance(mount, str) + and mount.split(":", 1)[0] == "/var/run/docker.sock" + ) + ] + for child in value.values(): + strip_podman_incompatible(child) + elif isinstance(value, list): + for child in value: + strip_podman_incompatible(child) + + +def find_nvidia_mounts(): + """Return host NVIDIA files to bind-mount and their library directories.""" + paths = [] + if os.path.isfile("/usr/bin/nvidia-smi"): + paths.append("/usr/bin/nvidia-smi") + + for directory in NVIDIA_LIBRARY_DIRECTORIES: + for library, _ in NVIDIA_DRIVER_LIBRARIES: + paths.extend(glob.glob(f"{directory}/{library}.so.*")) + + files = sorted( + {path for path in paths if os.path.isfile(path) and not os.path.islink(path)} + ) + mounts = [f"{path}:{path}:ro" for path in files] + directories = sorted( + {os.path.dirname(path) for path in files if path != "/usr/bin/nvidia-smi"} + ) + return mounts, directories + + +def find_nvidia_devices(): + """Return allocated GPU devices plus the available control devices.""" + + def is_character_device(path): + try: + return stat.S_ISCHR(os.stat(path).st_mode) + except OSError: + return False + + gpu_devices = sorted( + path + for path in glob.glob("/dev/nvidia[0-9]*") + if path.removeprefix("/dev/nvidia").isdigit() and is_character_device(path) + ) + if not gpu_devices: + return [] + return gpu_devices + [ + path for path in NVIDIA_CONTROL_DEVICES if is_character_device(path) + ] + + +def replace_repo_volume(data, repo_root): + """Bind one checkout as the container repository.""" + volume_name = "accelerated-computing-hub" + bind_mount = f"{repo_root}:/accelerated-computing-hub" + for service in (data.get("services") or {}).values(): + volumes = service.get("volumes") or [] + service["volumes"] = [ + bind_mount if item == f"{volume_name}:/accelerated-computing-hub" else item + for item in volumes + ] + environment = service.get("environment") or {} + if isinstance(environment, dict): + environment.update({"ACH_USER": "root", "ACH_UID": "0", "ACH_GID": "0"}) + service["environment"] = environment + + volumes = data.get("volumes") + if isinstance(volumes, dict): + volumes.pop(volume_name, None) + + +def add_rootless_gpu_access(data): + """Pass the allocated GPU and host driver libraries through to Podman.""" + devices = find_nvidia_devices() + if not devices: + return + + mounts, library_directories = find_nvidia_mounts() + library_links = " ".join( + f"{library}:{soname}" for library, soname in NVIDIA_DRIVER_LIBRARIES + ) + + for service_name, service in (data.get("services") or {}).items(): + if service_name not in GPU_SERVICES: + continue + + service_devices = service.get("devices") or [] + service["devices"] = service_devices + [ + device for device in devices if device not in service_devices + ] + + volumes = service.get("volumes") or [] + service["volumes"] = volumes + [ + mount for mount in mounts if mount not in volumes + ] + + environment = service.get("environment") or {} + if isinstance(environment, dict): + environment["ACH_ROOTLESS_PODMAN"] = "1" + environment["ACH_NVIDIA_LIBRARY_LINKS"] = library_links + if library_directories: + environment["ACH_NVIDIA_LIB_DIRS"] = ":".join(library_directories) + service["environment"] = environment + + +def use_host_network(data, all_services=False): + """Avoid rootless veth creation where the host cannot create one.""" + services = data.get("services") or {} + selected = services.values() if all_services else (services.get("base"),) + for service in selected: + if service is None: + continue + service["network_mode"] = "host" + service.pop("ports", None) + + if all_services: + jupyter = services.get("jupyter") + if jupyter is not None: + environment = jupyter.get("environment") or {} + if isinstance(environment, dict): + environment = environment.copy() + # All services share localhost; socat cannot bind ports already + # owned by the Streamers and Compose DNS is unavailable. + environment.update( + { + "ACH_PORT_FORWARDS": "", + "JUPYTER_IP": "127.0.0.1", + } + ) + for name in ( + "JUPYTER_HTTPS_CERT", + "JUPYTER_HTTPS_KEY", + "JUPYTER_HOST", + "NSYS_HTTP_URL", + "SELKIES_ENABLE_HTTPS", + ): + if name in os.environ: + environment[name] = os.environ[name] + jupyter["environment"] = environment + + for service_name in ("nsight", "nsys", "ncu"): + service = services.get(service_name) + if service is None: + continue + environment = service.get("environment") or {} + if not isinstance(environment, dict): + continue + environment = environment.copy() + environment.update( + { + "HOST_IP": "127.0.0.1", + "SELKIES_ADDR": "127.0.0.1", + "SELKIES_TURN_HOST": "127.0.0.1", + } + ) + for name in ( + "SELKIES_ENABLE_HTTPS", + "SELKIES_HTTPS_CERT", + "SELKIES_HTTPS_KEY", + ): + if name in os.environ: + environment[name] = os.environ[name] + if service_name == "ncu": + # Host networking also shares the HTTP port and abstract X + # socket namespace. Keep NCU separate from NSYS. + environment.update( + {"DISPLAY": ":5", "HTTP_PORT": "8081", "TURN_PORT": "3479"} + ) + service["environment"] = environment + + +def prepare(source, destination, repo_root="", bind_repo=False): + """Write a Podman-compatible copy of source to destination.""" + with open(source, "r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) + + strip_podman_incompatible(data) + use_host_network( + data, + all_services=os.environ.get("ACH_PODMAN_HOST_NETWORK") == "1", + ) + if bind_repo and repo_root: + replace_repo_volume(data, repo_root) + add_rootless_gpu_access(data) + + with open(destination, "w", encoding="utf-8") as handle: + yaml.safe_dump(data, handle, default_flow_style=False, sort_keys=False) + + +def main(): + if len(sys.argv) != 5: + raise SystemExit(f"Usage: {sys.argv[0]} SOURCE DESTINATION REPO_ROOT BIND_REPO") + source, destination, repo_root, bind_repo = sys.argv[1:] + prepare(source, destination, repo_root, bind_repo == "1") + + +if __name__ == "__main__": + main() diff --git a/brev/restart-compose-services.py b/brev/restart-compose-services.py new file mode 100755 index 00000000..0456c65f --- /dev/null +++ b/brev/restart-compose-services.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Restart the current container's persistent Docker Compose siblings.""" + +import http.client +import json +import os +import socket +import sys +import urllib.parse + + +DOCKER_SOCKET = "/var/run/docker.sock" +NON_PERSISTENT_SERVICES = {"base"} + + +class UnixHTTPConnection(http.client.HTTPConnection): + """An HTTP connection transported over a Unix-domain socket.""" + + def __init__(self, socket_path): + super().__init__("localhost") + self.socket_path = socket_path + + def connect(self): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(self.socket_path) + + +def docker_request(method, path): + """Send one request to the Docker Engine API and return its body.""" + connection = UnixHTTPConnection(DOCKER_SOCKET) + try: + connection.request(method, path) + response = connection.getresponse() + body = response.read() + finally: + connection.close() + + if response.status >= 300: + detail = body.decode("utf-8", errors="replace") + raise RuntimeError( + f"Docker API {method} {path} returned {response.status}: {detail}" + ) + return body + + +def container_details(container_name): + """Return Docker metadata for a container name or ID.""" + container_path = urllib.parse.quote(container_name, safe="") + return json.loads(docker_request("GET", f"/containers/{container_path}/json")) + + +def main(): + if not os.path.exists(DOCKER_SOCKET): + print(f"Docker socket not found at {DOCKER_SOCKET}") + return 0 + + current = container_details(socket.gethostname()) + labels = current.get("Config", {}).get("Labels", {}) or {} + project = labels.get("com.docker.compose.project") + current_service = labels.get("com.docker.compose.service") + if not project or not current_service: + raise RuntimeError( + "Current container has no Docker Compose project/service labels" + ) + + filters = json.dumps({"label": [f"com.docker.compose.project={project}"]}) + query = urllib.parse.urlencode({"all": "1", "filters": filters}) + containers = json.loads(docker_request("GET", f"/containers/json?{query}")) + + siblings = [] + for container in containers: + sibling_labels = container.get("Labels", {}) or {} + service = sibling_labels.get("com.docker.compose.service") + one_off = sibling_labels.get("com.docker.compose.oneoff", "False") + if ( + not service + or service == current_service + or service in NON_PERSISTENT_SERVICES + ): + continue + if one_off.lower() == "true": + continue + siblings.append((service, container["Id"])) + + failed = False + for service, container_id in sorted(siblings): + try: + docker_request("POST", f"/containers/{container_id}/restart?t=10") + except (OSError, RuntimeError) as error: + print( + f"Error: Could not restart Compose service {project}/{service}: " + f"{error}", + file=sys.stderr, + ) + failed = True + else: + print(f"Restarted Compose service {project}/{service}") + + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/brev/test-docker-compose.bash b/brev/test-docker-compose.bash index 8fc94c62..838f6eab 100755 --- a/brev/test-docker-compose.bash +++ b/brev/test-docker-compose.bash @@ -1,8 +1,8 @@ #! /bin/bash # -# Test a Docker Compose file by starting and stopping containers. +# Test a Docker/Podman Compose file by starting and stopping containers. # -# This script validates Docker Compose configurations by attempting to start, +# This script validates Docker/Podman Compose configurations by attempting to start, # inspect, and cleanly stop containers. # # Usage: @@ -32,7 +32,7 @@ usage() { cat << EOF Usage: $(basename "$0") [--mount|--no-mount] [test-args...] -Test a Docker Compose file by starting and stopping containers. +Test a Docker/Podman Compose file by starting and stopping containers. Options: --mount Bind-mount local repo into the container @@ -50,7 +50,7 @@ Examples: $(basename "$0") tutorials/accelerated-python/brev/docker-compose.yml Requirements: - - Docker and Docker Compose must be installed + - Docker Compose or Podman Compose must be installed EOF exit 1 } @@ -66,7 +66,7 @@ fi # Check argument if [ $# -lt 1 ]; then - echo -e "${RED}Error: Tutorial name or Docker Compose file path is required${NC}" + echo -e "${RED}Error: Tutorial name or Docker/Podman Compose file path is required${NC}" usage fi @@ -105,23 +105,287 @@ fi # Validate docker-compose file exists if [ ! -f "${COMPOSE_FILE}" ]; then - echo -e "${RED}Error: Docker Compose file not found: ${COMPOSE_FILE}${NC}" + echo -e "${RED}Error: Docker/Podman Compose file not found: ${COMPOSE_FILE}${NC}" exit 1 fi +is_podman() { + [ "${ACH_CONTAINER_ENGINE}" = "podman" ] +} + +setup_test_volume() { + export ACH_PODMAN_BIND_REPO=0 + if [ "${MOUNT}" = "true" ] && is_podman; then + export ACH_PODMAN_BIND_REPO=1 + echo "🔧 Using direct Podman bind mount for local repo" + echo "" + else + setup_docker_volume "${ACH_TUTORIAL}" "${MOUNT}" + fi +} + +start_services() { + local up_args=(up -d) + + # Rootless Podman consumes the image published by GitHub CI; never build it + # on the target system. Docker development runs may still build with --mount. + if [ "${MOUNT}" != "true" ] || is_podman; then + up_args+=(--no-build) + fi + if is_podman; then + up_args+=(base) + else + up_args+=(--quiet-pull) + fi + + compose -f "${COMPOSE_FILE}" "${up_args[@]}" +} + +wait_for_services() { + if ! is_podman; then + echo "⏳ Waiting for containers to initialize..." + sleep 5 + return + fi + + echo "⏳ Waiting for base service tests to finish..." + BASE_CONTAINER=$(container ps -aq \ + --filter "name=${ACH_TUTORIAL}[-_]base" | head -n 1) + if [ -z "${BASE_CONTAINER}" ]; then + echo -e "${RED}❌ Could not find the base service container${NC}" + return 1 + fi + if ! container wait --condition=stopped "${BASE_CONTAINER}" >/dev/null; then + return 1 + fi + if ! BASE_EXIT_CODE=$(container inspect --format '{{.State.ExitCode}}' \ + "${BASE_CONTAINER}"); then + return 1 + fi + echo "Base service exited with code: ${BASE_EXIT_CODE}" + echo "" + [ "${BASE_EXIT_CODE}" -eq 0 ] +} + +services_started_successfully() { + [ "${START_FAILED:-1}" -eq 0 ] || return 1 + + if is_podman; then + local state + local exit_code + + [ "${WAIT_FAILED:-1}" -eq 0 ] || return 1 + [ -n "${BASE_CONTAINER:-}" ] || return 1 + state=$(container inspect --format '{{.State.Status}}' "${BASE_CONTAINER}" 2>/dev/null || true) + exit_code=$(container inspect --format '{{.State.ExitCode}}' "${BASE_CONTAINER}" 2>/dev/null || true) + [ "${state}" = "exited" ] && [ "${exit_code}" = "0" ] + else + compose -f "${COMPOSE_FILE}" ps | grep -q "Up\|running" + fi +} + +test_jupyter_shutdown_restart() { + if is_podman; then + echo "🔄 Skipping Jupyter shutdown test for one-shot Podman base service" + return 0 + fi + + local service + local container_id + local started_at + local state + local deadline + local all_restarted + local restart_group + local service_list + local -a services=() + local -A started_before=() + + # shellcheck disable=SC2016 # Expanded inside the container. + if ! restart_group=$(compose -f "${COMPOSE_FILE}" exec -T jupyter \ + sh -c 'printf %s "${ACH_RESTART_COMPOSE_SERVICES:-}"'); then + echo "Error: could not read Jupyter restart configuration" >&2 + return 1 + fi + if [ "${restart_group}" != "1" ]; then + echo "🔄 Skipping Jupyter shutdown test for a tutorial without group restart" + return 0 + fi + + if ! service_list=$(compose -f "${COMPOSE_FILE}" config --services); then + echo "Error: could not read Compose services" >&2 + return 1 + fi + mapfile -t services < <( + grep -E '^(jupyter|nsight|nsys|ncu)$' <<<"${service_list}" + ) + if [ "${#services[@]}" -eq 0 ]; then + echo "No persistent web services found; skipping Jupyter shutdown test" + return 0 + fi + + echo "🔄 Testing service-group restart after Jupyter shutdown..." + for service in "${services[@]}"; do + container_id=$(compose -f "${COMPOSE_FILE}" ps -q "${service}") + if [ -z "${container_id}" ]; then + echo "Error: no running container found for ${service}" >&2 + return 1 + fi + if ! started_at=$( + container inspect --format '{{.State.StartedAt}}' "${container_id}" + ); then + echo "Error: could not inspect ${service} container" >&2 + return 1 + fi + started_before["${service}"]=${started_at} + done + + if ! compose -f "${COMPOSE_FILE}" exec -T jupyter python3 - <<'PY' +import http.cookiejar +import time +import urllib.error +import urllib.request + +cookies = http.cookiejar.CookieJar() +opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(cookies) +) +deadline = time.monotonic() + 60 +while True: + try: + with opener.open("http://127.0.0.1:8888/lab", timeout=10): + break + except (OSError, urllib.error.URLError): + if time.monotonic() >= deadline: + raise + time.sleep(1) + +xsrf = next(cookie.value for cookie in cookies if cookie.name == "_xsrf") +request = urllib.request.Request( + "http://127.0.0.1:8888/api/shutdown", + data=b"", + headers={"X-XSRFToken": xsrf}, + method="POST", +) +with opener.open(request, timeout=10) as response: + if response.status >= 300: + raise RuntimeError(f"Jupyter shutdown returned HTTP {response.status}") +PY + then + echo "Error: failed to request Jupyter shutdown" >&2 + return 1 + fi + + deadline=$((SECONDS + 90)) + while [ "${SECONDS}" -lt "${deadline}" ]; do + all_restarted=1 + for service in "${services[@]}"; do + container_id=$(compose -f "${COMPOSE_FILE}" ps -q "${service}") + if [ -z "${container_id}" ]; then + all_restarted=0 + continue + fi + state=$(container inspect --format '{{.State.Status}}' \ + "${container_id}" 2>/dev/null || true) + started_at=$(container inspect --format '{{.State.StartedAt}}' \ + "${container_id}" 2>/dev/null || true) + if [ "${state}" != "running" ] || \ + [ "${started_at}" = "${started_before[${service}]}" ]; then + all_restarted=0 + fi + done + if [ "${all_restarted}" -eq 1 ]; then + echo -e "${GREEN}✅ Jupyter shutdown restarted the full service group${NC}" + echo "" + return 0 + fi + sleep 1 + done + + echo "Error: service group did not restart within 90 seconds" >&2 + for service in "${services[@]}"; do + container_id=$(compose -f "${COMPOSE_FILE}" ps -aq "${service}" | head -n 1) + if [ -n "${container_id}" ]; then + state=$(container inspect --format '{{.State.Status}}' "${container_id}") + started_at=$(container inspect --format '{{.State.StartedAt}}' "${container_id}") + printf ' %s: state=%s started=%s previous=%s\n' \ + "${service}" "${state}" "${started_at}" "${started_before[${service}]}" >&2 + fi + done + compose -f "${COMPOSE_FILE}" logs --tail=100 >&2 || true + return 1 +} + +restart_services() { + if is_podman; then + echo "🔄 Skipping service restart test for one-shot Podman base service" + return 0 + fi + + test_jupyter_shutdown_restart || return 1 + + if ! compose -f "${COMPOSE_FILE}" restart; then + echo "" + echo -e "${RED}❌ Failed to restart services${NC}" + echo "" + echo "📋 Container logs after failed restart:" + echo "--------------------------------------------------------------------------------" + compose -f "${COMPOSE_FILE}" logs --tail=50 + echo "--------------------------------------------------------------------------------" + echo "" + return 1 + fi + + echo "" + echo -e "${GREEN}✅ Services restarted successfully${NC}" + echo "" + echo "⏳ Waiting for services to stabilize..." + sleep 5 + + for i in 1 2 3; do + if compose -f "${COMPOSE_FILE}" ps 2>/dev/null | grep -qE "Restarting|restarting"; then + echo -e "${YELLOW}⚠️ Detected restarting service(s), waiting...${NC}" + sleep 5 + fi + done + echo "" + + echo "📊 Container status after restart:" + compose -f "${COMPOSE_FILE}" ps + echo "" + if compose -f "${COMPOSE_FILE}" ps | grep -qE "Exit|Restarting|restarting"; then + echo -e "${RED}⚠️ Warning: Some containers are not running after restart${NC}" + echo "" + echo "📋 Container logs after restart:" + echo "--------------------------------------------------------------------------------" + compose -f "${COMPOSE_FILE}" logs + echo "--------------------------------------------------------------------------------" + echo "" + return 1 + fi + + echo -e "${GREEN}✅ All containers running healthy after restart${NC}" + echo "" +} + +ORIGINAL_COMPOSE_FILE="${COMPOSE_FILE}" echo "================================================================================" -echo "Testing Docker Compose: ${COMPOSE_FILE}" +echo "Testing Docker/Podman Compose: ${ORIGINAL_COMPOSE_FILE}" echo "================================================================================" echo "" # Stop any existing containers first echo "🛑 Stopping any existing containers..." -docker compose -f "${COMPOSE_FILE}" down &>/dev/null || true +compose -f "${COMPOSE_FILE}" down &>/dev/null || true echo "" # Set up volume (cleanup + optional bind mount) setup_dev_env "${REPO_ROOT}" -setup_docker_volume "${ACH_TUTORIAL}" "${MOUNT}" +setup_test_volume +COMPOSE_FILE=$(prepare_compose_file "${COMPOSE_FILE}") +if [ "${COMPOSE_FILE}" != "${ORIGINAL_COMPOSE_FILE}" ]; then + echo "Using Podman-compatible Compose file: ${COMPOSE_FILE}" +fi export ACH_RUN_TESTS=1 export ACH_TEST_ARGS="$*" @@ -129,39 +393,43 @@ export ACH_TEST_ARGS="$*" # Start container echo "📦 Starting containers..." echo "" -if docker compose -f "${COMPOSE_FILE}" up -d --quiet-pull; then +if start_services; then + START_FAILED=0 echo "" echo -e "${GREEN}✅ Containers started successfully${NC}" echo "" # Wait for containers to initialize, checking for restart loops - echo "⏳ Waiting for containers to initialize..." - sleep 5 + if wait_for_services; then + WAIT_FAILED=0 + else + WAIT_FAILED=1 + fi # Check multiple times to catch restart loops for i in 1 2 3; do - if docker compose -f "${COMPOSE_FILE}" ps 2>/dev/null | grep -qE "Restarting|restarting"; then + if compose -f "${COMPOSE_FILE}" ps 2>/dev/null | grep -qE "Restarting|restarting"; then echo -e "${YELLOW}⚠️ Detected restarting service(s), waiting...${NC}" sleep 5 fi done # Final check for restart-looping services - if docker compose -f "${COMPOSE_FILE}" ps 2>/dev/null | grep -qE "Restarting|restarting"; then + if compose -f "${COMPOSE_FILE}" ps 2>/dev/null | grep -qE "Restarting|restarting"; then echo -e "${RED}❌ Service(s) stuck in restart loop${NC}" echo "" echo "📊 Container status:" - docker compose -f "${COMPOSE_FILE}" ps + compose -f "${COMPOSE_FILE}" ps echo "" echo "📋 Container logs:" echo "--------------------------------------------------------------------------------" - docker compose -f "${COMPOSE_FILE}" logs --tail=100 + compose -f "${COMPOSE_FILE}" logs --tail=100 echo "--------------------------------------------------------------------------------" echo "" # Clean up echo "🛑 Stopping containers..." - docker compose -f "${COMPOSE_FILE}" down || true + compose -f "${COMPOSE_FILE}" down || true echo "" echo "================================================================================" echo -e "${RED}❌ TEST FAILED: ${COMPOSE_FILE}${NC}" @@ -169,83 +437,35 @@ if docker compose -f "${COMPOSE_FILE}" up -d --quiet-pull; then exit 1 fi echo "" +else + START_FAILED=1 fi -if docker compose -f "${COMPOSE_FILE}" ps | grep -q "Up\|running"; then +if services_started_successfully; then # Show container status echo "📊 Container status:" - docker compose -f "${COMPOSE_FILE}" ps + compose -f "${COMPOSE_FILE}" ps echo "" # Capture and display logs echo "📋 Container logs:" echo "--------------------------------------------------------------------------------" - docker compose -f "${COMPOSE_FILE}" logs + compose -f "${COMPOSE_FILE}" logs echo "--------------------------------------------------------------------------------" echo "" # Test restart functionality echo "🔄 Testing service restart..." echo "" - if docker compose -f "${COMPOSE_FILE}" restart; then - echo "" - echo -e "${GREEN}✅ Services restarted successfully${NC}" - echo "" - - # Wait for services to stabilize, checking for restart loops - echo "⏳ Waiting for services to stabilize..." - sleep 5 - - # Check multiple times to catch restart loops - for i in 1 2 3; do - if docker compose -f "${COMPOSE_FILE}" ps 2>/dev/null | grep -qE "Restarting|restarting"; then - echo -e "${YELLOW}⚠️ Detected restarting service(s), waiting...${NC}" - sleep 5 - fi - done - echo "" - - # Verify containers are still running after restart - echo "📊 Container status after restart:" - docker compose -f "${COMPOSE_FILE}" ps - echo "" - - # Check if any containers are not in running state or stuck restarting - if docker compose -f "${COMPOSE_FILE}" ps | grep -qE "Exit|Restarting|restarting"; then - echo -e "${RED}⚠️ Warning: Some containers are not running after restart${NC}" - echo "" - - # Show logs for troubleshooting - echo "📋 Container logs after restart:" - echo "--------------------------------------------------------------------------------" - docker compose -f "${COMPOSE_FILE}" logs - echo "--------------------------------------------------------------------------------" - echo "" - - RESTART_FAILED=1 - else - echo -e "${GREEN}✅ All containers running healthy after restart${NC}" - echo "" - RESTART_FAILED=0 - fi + if restart_services; then + RESTART_FAILED=0 else - echo "" - echo -e "${RED}❌ Failed to restart services${NC}" - echo "" - - # Show logs for troubleshooting - echo "📋 Container logs after failed restart:" - echo "--------------------------------------------------------------------------------" - docker compose -f "${COMPOSE_FILE}" logs --tail=50 - echo "--------------------------------------------------------------------------------" - echo "" - RESTART_FAILED=1 fi # Stop containers echo "🛑 Stopping containers..." - if docker compose -f "${COMPOSE_FILE}" down; then + if compose -f "${COMPOSE_FILE}" down; then echo -e "${GREEN}✅ Containers stopped successfully${NC}" echo "" @@ -267,13 +487,13 @@ else # Try to capture any logs that might be available echo "📋 Attempting to capture logs from failed startup:" echo "--------------------------------------------------------------------------------" - docker compose -f "${COMPOSE_FILE}" logs || true + compose -f "${COMPOSE_FILE}" logs || true echo "--------------------------------------------------------------------------------" echo "" # Try to clean up echo "🛑 Attempting cleanup..." - docker compose -f "${COMPOSE_FILE}" down || true + compose -f "${COMPOSE_FILE}" down || true echo "" RETURN_CODE=1 diff --git a/brev/test-notebook-format.py b/brev/test-notebook-format.py index 2bed213f..ffb03d51 100755 --- a/brev/test-notebook-format.py +++ b/brev/test-notebook-format.py @@ -73,6 +73,24 @@ }, } +NSIGHT_SYSTEMS_KERNELSPEC = { + "display_name": "Python 3 (Nsight Systems)", + "language": "python", + "name": "nsightful-nsys", +} + +NSIGHT_COMPUTE_KERNELSPEC = { + "display_name": "Python 3 (Nsight Compute)", + "language": "python", + "name": "nsightful-ncu", +} + +ALLOWED_KERNELSPECS = ( + STANDARD_METADATA["kernelspec"], + NSIGHT_SYSTEMS_KERNELSPEC, + NSIGHT_COMPUTE_KERNELSPEC, +) + STANDARD_NBFORMAT = 4 STANDARD_NBFORMAT_MINOR = 5 @@ -91,6 +109,20 @@ def is_solution_notebook(notebook_path: Path) -> bool: return "SOLUTION" in notebook_path.name +def expected_metadata_for_notebook(notebook_path: Path) -> dict: + expected = dict(STANDARD_METADATA) + try: + with open(notebook_path, "r", encoding="utf-8") as f: + metadata = json.load(f).get("metadata", {}) + except (OSError, json.JSONDecodeError): + return expected + + kernelspec = metadata.get("kernelspec") + if kernelspec in ALLOWED_KERNELSPECS: + expected["kernelspec"] = kernelspec + return expected + + def diff_metadata(actual: dict, expected: dict, path: str = "") -> list[str]: """Recursively compare metadata. Returns human-readable differences.""" diffs: list[str] = [] @@ -147,7 +179,7 @@ def canonicalize_notebook(notebook_path: Path) -> tuple[str, list[str]]: # -- Detect original metadata problems before nbformat.read() ----------- actual_metadata = raw.get("metadata", {}) - expected_metadata = dict(STANDARD_METADATA) + expected_metadata = expected_metadata_for_notebook(notebook_path) metadata_diffs = diff_metadata(actual_metadata, expected_metadata, "metadata") if metadata_diffs: problems.extend(metadata_diffs) diff --git a/brev/wrappers/ncu b/brev/wrappers/ncu index 0732a80d..e057e255 100755 --- a/brev/wrappers/ncu +++ b/brev/wrappers/ncu @@ -3,9 +3,13 @@ UID_="$(id -u)" GID_="$(id -g)" -sudo -E -n setpriv \ - --reuid="$UID_" --regid="$GID_" --init-groups \ - --inh-caps=+sys_admin,+sys_ptrace \ - --ambient-caps=+sys_admin,+sys_ptrace \ - -- \ - "$ACH_NCU_PATH" "$@" +if [ "${ACH_ROOTLESS_PODMAN:-}" != "1" ] && sudo -E -n true >/dev/null 2>&1; then + exec sudo -E -n setpriv \ + --reuid="$UID_" --regid="$GID_" --init-groups \ + --inh-caps=+sys_admin,+sys_ptrace \ + --ambient-caps=+sys_admin,+sys_ptrace \ + -- \ + "$ACH_NCU_PATH" "$@" +else + exec "$ACH_NCU_PATH" "$@" +fi diff --git a/brev/wrappers/nsys b/brev/wrappers/nsys index d1fc62bb..1088a84c 100755 --- a/brev/wrappers/nsys +++ b/brev/wrappers/nsys @@ -3,9 +3,13 @@ UID_="$(id -u)" GID_="$(id -g)" -sudo -E -n setpriv \ - --reuid="$UID_" --regid="$GID_" --init-groups \ - --inh-caps=+sys_admin,+sys_ptrace \ - --ambient-caps=+sys_admin,+sys_ptrace \ - -- \ - "$ACH_NSYS_PATH" "$@" +if [ "${ACH_ROOTLESS_PODMAN:-}" != "1" ] && sudo -E -n true >/dev/null 2>&1; then + exec sudo -E -n setpriv \ + --reuid="$UID_" --regid="$GID_" --init-groups \ + --inh-caps=+sys_admin,+sys_ptrace \ + --ambient-caps=+sys_admin,+sys_ptrace \ + -- \ + "$ACH_NSYS_PATH" "$@" +else + exec "$ACH_NSYS_PATH" "$@" +fi diff --git a/docs/cscs.md b/docs/cscs.md new file mode 100644 index 00000000..d43d9e8f --- /dev/null +++ b/docs/cscs.md @@ -0,0 +1,246 @@ +# CSCS containers + +CSCS compute nodes do not provide a Docker daemon. GitHub Actions builds the +PyHPC image natively for `linux/amd64` and `linux/arm64` and publishes a single +multi-architecture image to GHCR. Daint pulls the ARM64 member through the CSCS +Slurm Container Engine; do not build the image on Daint. + +The event image is public and requires no registry credentials: + +```text +ghcr.io/nvidia/pyhpc-tutorial:event-2026-07-cscs-summer-school-latest +``` + +Wait for the event branch's **Build and Push Brev Tutorial Docker Images** +workflow to succeed before starting a Daint run. + +## Use the published image on Daint + +From the repository checkout, download the branch-specific EDF published by +GitHub CI, then set the account and EDF path: + +```bash +export CSCS_ACCOUNT="YOUR_ACCOUNT" +export ACH_REPO="$(git rev-parse --show-toplevel)" +export ACH_BRANCH="event/2026-07-cscs-summer-school" +export CSCS_EDF="${SCRATCH}/pyhpc-${ACH_BRANCH//\//-}.toml" +curl -fsSL \ + "https://raw.githubusercontent.com/NVIDIA/accelerated-computing-hub/generated/${ACH_BRANCH}/tutorials/pyhpc/brev/cscs.toml" \ + --output "${CSCS_EDF}" +``` + +The generated EDF selects the public event image without bind-mounting a +checkout, so the source and dependencies always come from the same CI build. +Slurm Container Engine selects and caches the ARM64 manifest automatically. + +Confirm the published image resolves to ARM64 on Daint: + +```bash +srun -A "${CSCS_ACCOUNT}" -p normal -t 00:10:00 -N1 -n1 \ + --environment="${CSCS_EDF}" \ + bash -lc 'test "$(dpkg --print-architecture)" = arm64' +``` + +For a reproducible run, generate a no-mount EDF pinned to the CI commit tag: + +```bash +./brev/generate-cscs-edf.bash \ + --tag "event-2026-07-cscs-summer-school-git-" \ + --output "${SCRATCH}/pyhpc-cscs-pinned.toml" \ + pyhpc +``` + +The generated EDF sets `PMIX_MCA_gds=hash` and `PMIX_MCA_psec=native`, which +are the PMIx settings used for clean Slurm MPI runs on Daint. + +## Set up workstation SSH access + +Do this once on the workstation that runs the browser. On x86_64 Linux or WSL, +install [`cscs-key`](https://docs.cscs.ch/access/ssh/) with: + +```bash +mkdir -p "${HOME}/.local/bin" +curl -fsSL https://github.com/eth-cscs/cscs-key/releases/download/v1.1.0/cscs-key-v1.1.0-x86_64-unknown-linux-musl.tar.gz | + tar -xz -C "${HOME}/.local/bin" +export PATH="${HOME}/.local/bin:${PATH}" +``` + +On macOS, use `brew install eth-cscs/tap/cscs-key`. Then create and sign the +key; `cscs-key sign` opens the CSCS MFA flow in the browser: + +```bash +mkdir -p -m 700 "${HOME}/.ssh" +ssh-keygen -t ed25519 -f "${HOME}/.ssh/cscs-key" +cscs-key sign +``` + +The helpers connect through `ela.cscs.ch` to `daint.alps.cscs.ch` directly; no +SSH aliases are required. Renew an expired certificate with `cscs-key sign`. + +## Run JupyterLab and Nsight Streamer for 10 hours + +The deployment pulls the tutorial image built by GitHub CI and the published +NVIDIA Streamer images; it never builds an image on Daint. It bind-mounts the +checkout's notebook directory into JupyterLab, Nsight Systems, and Nsight +Compute, so student work is saved under `$SCRATCH` and remains after the job +ends. A separate managed release checkout supplies runtime scripts and is never +used for student work; this lets the launcher preserve an older or modified +student checkout without running stale infrastructure from it. + +From the workstation, start and connect with one command: + +```bash +curl -fsSL https://raw.githubusercontent.com/NVIDIA/accelerated-computing-hub/event/2026-07-cscs-summer-school/tutorials/pyhpc/brev/cscs-run-tutorial.bash | bash +``` + +The helper downloads the three small helper scripts to a temporary directory; +it does not clone the repository on the workstation. It reads the CSCS username +from the signed SSH certificate and uses the user's primary CSCS project as the +Slurm account. It reuses one Daint SSH connection for the launch and +compute-node tunnel; if the certificate expired, it runs `cscs-key sign` and +retries once. +The Daint-side launcher: + +- clones the event branch when `$SCRATCH/accelerated-computing-hub` is absent; +- switches any existing clean branch to the event branch and updates it; and +- leaves a dirty checkout unchanged, including when it has untracked files. + +It submits the 10-hour job, waits until all three web services report ready, +prints `CSCS_WEB_JOB_ID` and `CSCS_WEB_NODE`, and exits. The workstation helper +then opens all five forwards and leaves the user in a shell on the allocated +compute node. There is no `tail` process to interrupt. + +Keep the compute-node shell open and visit these URLs: + +- JupyterLab: +- Nsight Systems: +- Nsight Compute: + +### Run the launch and connection separately + +To launch on a Daint login node without the end-to-end helper, copy or download +`cscs-launch-tutorial.bash` there and run: + +```bash +./cscs-launch-tutorial.bash +``` + +After it reports a node, run the workstation-side connection helper: + +```bash +curl -fsSLO https://raw.githubusercontent.com/NVIDIA/accelerated-computing-hub/event/2026-07-cscs-summer-school/tutorials/pyhpc/brev/cscs-connect-tutorial.bash +chmod +x cscs-connect-tutorial.bash +./cscs-connect-tutorial.bash nidXXXXXX +``` + +This second script prints the three URLs, opens the five forwards, and leaves +the user in a shell on `nidXXXXXX`. Exiting the shell closes the browser access +but does not stop the Slurm job. Re-run the connection script to reconnect. + +The selected local Jupyter port and the four fixed Streamer ports must be free. +Ports 8888 (the Jupyter default), 8080, and 8081 carry HTTP and WebSocket +signaling; ports 3478 and 3479 carry the two Streamers' WebRTC media and input +over TURN/TCP. Forwarding only the three HTTP ports displays the pages but does +not provide working Streamer desktops. If only local port 8888 is already in use, +set `ACH_JUPYTER_LOCAL_PORT` before running either workstation helper; the +helper prints the resulting JupyterLab URL. The helper keeps the four Streamer +ports fixed, and its TURN URLs advertise ports 3478 and 3479 to the browser. + +TURN/TCP through SSH was validated with both Streamers: ICE connected through +the relay, input data channels opened, and video frames continued to decode. +Because the media is TCP inside SSH's TCP connection, packet loss can cause +head-of-line stalls. A stable wired connection is recommended, and keeping only +one Streamer tab active reduces bandwidth. + +The web applications have no password. Their HTTP listeners bind only to +compute-node loopback, and the TURN services require random job credentials. +Access is therefore expected only through the SSH connection. + +Find or stop the deployment from the compute-node shell or a Daint login shell: + +```bash +squeue --me --name=ach-pyhpc-web \ + --format='%.18i %.9T %.10M %.10L %.20N' +scancel --full --signal=TERM JOB_ID +``` + +The job stops automatically after 10 hours. The full-job `TERM` above gives +the helper time to remove its containers and node-local image stores while +leaving student work in the checkout untouched. + +## Run tests + +Run the CSCS validation driver from the Daint login node: + +```bash +cd "${ACH_REPO}" +CSCS_ACCOUNT="${CSCS_ACCOUNT}" \ +CSCS_EDF="${CSCS_EDF}" \ + tutorials/pyhpc/brev/test-cscs.bash +``` + +The driver runs: + +- package smoke tests through the normal tutorial entrypoint +- the full notebook ladder, including `06__mpi4py` +- direct `nsys` and `ncu` command-line smoke checks + +The PyHPC image builds `mpi4py` against MPICH. Notebook 06 runs local ranks with +`mpirun.mpich -launcher fork`, avoiding nested use of the host `srun` launcher. + +For debugging, the individual commands are below. + +Run the package smoke tests: + +```bash +srun -A "${CSCS_ACCOUNT}" -p normal -t 00:20:00 -N1 -n1 \ + --environment="${CSCS_EDF}" \ + env ACH_RUN_TESTS=1 ACH_TEST_ARGS="test/test_packages.py" \ + /accelerated-computing-hub/brev/entrypoint.bash base +``` + +Run the MPI package smoke test directly: + +```bash +srun -A "${CSCS_ACCOUNT}" -p normal -t 00:10:00 -N1 -n1 \ + --environment="${CSCS_EDF}" \ + pytest -q /accelerated-computing-hub/tutorials/pyhpc/test/test_packages.py \ + -k mpi4py -s +``` + +Run the profiling notebooks: + +```bash +srun -A "${CSCS_ACCOUNT}" -p normal -t 01:00:00 -N1 -n1 \ + --environment="${CSCS_EDF}" \ + env ACH_RUN_TESTS=1 ACH_TEST_ARGS="03 or 04 or 05" \ + /accelerated-computing-hub/brev/entrypoint.bash base +``` + +Run the profilers directly when debugging notebook profiling failures: + +```bash +srun -A "${CSCS_ACCOUNT}" -p normal -t 00:20:00 -N1 -n1 \ + --environment="${CSCS_EDF}" bash -lc ' + set -euo pipefail + cd /tmp + cat > profile_smoke.py << "PY" +import cupy as cp +x = cp.arange(1 << 20, dtype=cp.float32) +y = cp.sin(x) + cp.cos(x) +print(float(y.sum())) +cp.cuda.runtime.deviceSynchronize() +PY + nsys profile --stats=false --cuda-event-trace=false \ + --force-overwrite true -o profile_smoke python profile_smoke.py + nsys export --type sqlite --quiet true --force-overwrite true \ + -o profile_smoke.sqlite profile_smoke.nsys-rep + ncu -f --kernel-name regex:.* --set full \ + -o profile_smoke python profile_smoke.py + ncu --import profile_smoke.ncu-rep --csv | sed -n "1,20p" + ' +``` + +Nsight Compute metric collection requires the site driver to allow access to GPU +performance counters. If NCU reports `ERR_NVGPUCTRPERM`, use the CSCS profiling +counter policy for the target system. diff --git a/tutorials/accelerated-python/brev/requirements.txt b/tutorials/accelerated-python/brev/requirements.txt index 44f7c95f..6a735f36 100644 --- a/tutorials/accelerated-python/brev/requirements.txt +++ b/tutorials/accelerated-python/brev/requirements.txt @@ -13,7 +13,6 @@ jupyter-dark-detect == 0.1.0 jupyter == 1.1.1 jupyter-server-proxy == 4.4.0 jupyterlab == 4.5.7 -jupyterlab-nvidia-nsight == 1.0.0 jupyterlab-execute-time == 3.3.0 # CUDA diff --git a/tutorials/accelerated-python/brev/test.bash b/tutorials/accelerated-python/brev/test.bash index 0aa69f5e..0937265e 100755 --- a/tutorials/accelerated-python/brev/test.bash +++ b/tutorials/accelerated-python/brev/test.bash @@ -17,7 +17,18 @@ START_TIME=$(date +%s.%N) -nvidia-smi +if command -v nvidia-smi >/dev/null 2>&1; then + nvidia-smi || exit 1 +else + NVIDIA_GPU_DEVICE=$(find /dev -maxdepth 1 -type c \ + -name 'nvidia[0-9]*' -print -quit 2>/dev/null) + if [ -n "${NVIDIA_GPU_DEVICE}" ]; then + echo "NVIDIA GPU device ${NVIDIA_GPU_DEVICE} is available; nvidia-smi is not installed" + else + echo "Error: no NVIDIA GPU is available" >&2 + exit 1 + fi +fi TUTORIAL_ROOT=/accelerated-computing-hub/tutorials/accelerated-python diff --git a/tutorials/cuda-cpp/brev/requirements.txt b/tutorials/cuda-cpp/brev/requirements.txt index c2ffd9b2..0b08b0c2 100644 --- a/tutorials/cuda-cpp/brev/requirements.txt +++ b/tutorials/cuda-cpp/brev/requirements.txt @@ -7,7 +7,6 @@ jupyter-dark-detect == 0.1.0 # Jupyter jupyter jupyter-server-proxy -jupyterlab-nvidia-nsight jupyterlab-execute-time # NVIDIA devtools diff --git a/tutorials/cuda-cpp/brev/test.bash b/tutorials/cuda-cpp/brev/test.bash index 94d1c8ed..080ad17b 100755 --- a/tutorials/cuda-cpp/brev/test.bash +++ b/tutorials/cuda-cpp/brev/test.bash @@ -11,7 +11,18 @@ TUTORIAL_ROOT=/accelerated-computing-hub/tutorials/cuda-cpp START_TIME=$(date +%s.%N) -nvidia-smi +if command -v nvidia-smi >/dev/null 2>&1; then + nvidia-smi || exit 1 +else + NVIDIA_GPU_DEVICE=$(find /dev -maxdepth 1 -type c \ + -name 'nvidia[0-9]*' -print -quit 2>/dev/null) + if [ -n "${NVIDIA_GPU_DEVICE}" ]; then + echo "NVIDIA GPU device ${NVIDIA_GPU_DEVICE} is available; nvidia-smi is not installed" + else + echo "Error: no NVIDIA GPU is available" >&2 + exit 1 + fi +fi if [ $# -gt 0 ]; then if [[ "$1" == -* ]] || [[ "$1" == */* ]] || [[ "$1" == *.py ]]; then diff --git a/tutorials/cuda-tile/brev/requirements.txt b/tutorials/cuda-tile/brev/requirements.txt index 775475ea..6db125f7 100644 --- a/tutorials/cuda-tile/brev/requirements.txt +++ b/tutorials/cuda-tile/brev/requirements.txt @@ -13,7 +13,6 @@ jupyter-dark-detect == 0.1.0 jupyter jupyter-server-proxy jupyterlab -jupyterlab-nvidia-nsight jupyterlab-execute-time # CUDA diff --git a/tutorials/floating-point-emulation/brev/requirements.txt b/tutorials/floating-point-emulation/brev/requirements.txt index cc6009f5..fd85fe66 100644 --- a/tutorials/floating-point-emulation/brev/requirements.txt +++ b/tutorials/floating-point-emulation/brev/requirements.txt @@ -15,7 +15,6 @@ matplotlib # Jupyter jupyterlab -jupyterlab-nvidia-nsight jupyterlab-execute-time ipywidgets ipykernel diff --git a/tutorials/floating-point-emulation/brev/test.bash b/tutorials/floating-point-emulation/brev/test.bash index 715d07c0..f6681b9e 100755 --- a/tutorials/floating-point-emulation/brev/test.bash +++ b/tutorials/floating-point-emulation/brev/test.bash @@ -1,3 +1,14 @@ #! /bin/bash -nvidia-smi +if command -v nvidia-smi >/dev/null 2>&1; then + nvidia-smi || exit 1 +else + NVIDIA_GPU_DEVICE=$(find /dev -maxdepth 1 -type c \ + -name 'nvidia[0-9]*' -print -quit 2>/dev/null) + if [ -n "${NVIDIA_GPU_DEVICE}" ]; then + echo "NVIDIA GPU device ${NVIDIA_GPU_DEVICE} is available; nvidia-smi is not installed" + else + echo "Error: no NVIDIA GPU is available" >&2 + exit 1 + fi +fi diff --git a/tutorials/gpu-deployment/gpu-deployment-from-scratch.md b/tutorials/gpu-deployment/gpu-deployment-from-scratch.md index f22c8003..83732f91 100644 --- a/tutorials/gpu-deployment/gpu-deployment-from-scratch.md +++ b/tutorials/gpu-deployment/gpu-deployment-from-scratch.md @@ -501,7 +501,6 @@ To be able to visualize the file, we can download it an use [nsight-systems](htt Further reading: - [Nsight Documentation](https://developer.nvidia.com/nsight-systems/get-started) -- [Jupyter Lab Nsight extension](https://pypi.org/project/jupyterlab-nvidia-nsight/) - [Towards Data Science community guide](https://medium.com/data-science/profiling-cuda-using-nsight-systems-a-numba-example-fc65003f8c52) diff --git a/tutorials/nvmath-python/brev/requirements.txt b/tutorials/nvmath-python/brev/requirements.txt index 5c928f91..f50dec0b 100644 --- a/tutorials/nvmath-python/brev/requirements.txt +++ b/tutorials/nvmath-python/brev/requirements.txt @@ -24,7 +24,6 @@ jupyter-dark-detect == 0.1.0 # Jupyter jupyter-server-proxy jupyterlab -jupyterlab-nvidia-nsight jupyterlab-execute-time ipywidgets ipykernel diff --git a/tutorials/nvmath-python/brev/test.bash b/tutorials/nvmath-python/brev/test.bash index 8fc597b3..d9b2730e 100755 --- a/tutorials/nvmath-python/brev/test.bash +++ b/tutorials/nvmath-python/brev/test.bash @@ -9,7 +9,18 @@ TUTORIAL_ROOT=/accelerated-computing-hub/tutorials/nvmath-python -nvidia-smi +if command -v nvidia-smi >/dev/null 2>&1; then + nvidia-smi || exit 1 +else + NVIDIA_GPU_DEVICE=$(find /dev -maxdepth 1 -type c \ + -name 'nvidia[0-9]*' -print -quit 2>/dev/null) + if [ -n "${NVIDIA_GPU_DEVICE}" ]; then + echo "NVIDIA GPU device ${NVIDIA_GPU_DEVICE} is available; nvidia-smi is not installed" + else + echo "Error: no NVIDIA GPU is available" >&2 + exit 1 + fi +fi if [ $# -gt 0 ]; then if [[ "$1" == -* ]] || [[ "$1" == */* ]] || [[ "$1" == *.py ]]; then diff --git a/tutorials/pyhpc/.gitignore b/tutorials/pyhpc/.gitignore new file mode 100644 index 00000000..f0c97fba --- /dev/null +++ b/tutorials/pyhpc/.gitignore @@ -0,0 +1,49 @@ +# Artifacts generated when the tutorial is run. +# +# SWE ladder (notebooks 08-14): +notebooks/build/ +notebooks/CMakeLists.txt +notebooks/swe_step.cpython-*.so +notebooks/swe_step_fast.cpp +notebooks/timings.json +notebooks/machine.json + +# Kernel, asynchrony, and MPI notebooks (03-06): %%writefile scripts, +# MPI results, Nsight profiler reports, the downloaded corpus, and plots. +# Patterns are applied at the notebooks/ root and under solutions/, since +# executing the solution notebooks (e.g. in CI) writes the same artifacts there. +notebooks/*.py +notebooks/solutions/*.py +!notebooks/swe_core.py +!notebooks/mpi4py_launcher.py +notebooks/*.nsys-rep +notebooks/solutions/*.nsys-rep +notebooks/*.sqlite +notebooks/solutions/*.sqlite +notebooks/*.ncu-rep +notebooks/solutions/*.ncu-rep +notebooks/*.qdstrm +notebooks/solutions/*.qdstrm +notebooks/books__15m.txt +notebooks/solutions/books__15m.txt +notebooks/test.png +notebooks/solutions/test.png + +# MPI notebook (06): verified field saved for the notebook visualization. +notebooks/heat_equation_result.npz +notebooks/solutions/heat_equation_result.npz + +# Generated C/C++ from %%writefile cells (nanobind / cffi / cppjit / interop +# exercises). The checked-in tutorial source is re-included below. +notebooks/solutions/*.c +notebooks/solutions/*.cpp +notebooks/solutions/*.cxx +notebooks/solutions/*.hpp +notebooks/solutions/*.hxx +!notebooks/solutions/swe_step.cpp +!notebooks/solutions/swe_cub_solver.cpp +!notebooks/solutions/swe_raw_cuda_solver.cpp + +__pycache__/ +*.egg-info/ +*.log diff --git a/tutorials/pyhpc/README.md b/tutorials/pyhpc/README.md new file mode 100644 index 00000000..af91149f --- /dev/null +++ b/tutorials/pyhpc/README.md @@ -0,0 +1,84 @@ +# PyHPC Tutorial + +This tutorial tours the high-performance Python landscape: the NumPy and CuPy array model, distributed computing with mpi4py, alternative programming models and Python/C++ interoperability, and authoring your own CUDA kernels. Along the way it solves the same 1D Shallow Water Equations bump pulse end to end with JAX, PyOMP, nanobind, CppJIT, and mpi4py, each measured against a NumPy baseline, and profiles real kernels with NVIDIA's developer tools. + +- [Notebooks](./notebooks) containing lessons and exercises, intended for self-paced or instructor-led learning, which can be run on [NVIDIA Brev](https://brev.nvidia.com), locally with Docker, or on [Google Colab](https://colab.research.google.com). +- [Syllabi](./notebooks/syllabi) that select a subset of the notebooks for a particular learning objective. +- [Docker Compose file](./brev/docker-compose.yml) for creating a Brev Launchable or running locally. + +Brev Launchables of this tutorial should use: +- L40S, L4, or T4 instances. The mpi4py notebook runs multiple local CPU ranks. +- A recent NVIDIA driver. The image ships a CUDA 13.1 toolkit, so the host driver must support CUDA 13. +- Crusoe or any other provider with Flexible Ports. + +## Syllabi + +- [PyHPC Tutorial - CuPy, Kernels, MPI, JAX, OMP, Interop - 2 Days](./notebooks/syllabi/pyhpc__cupy_kernels_mpi_jax_omp_interop__2_days.ipynb) + +## Notebooks + +Each exercise notebook that has a paired solution carries `# TODO:` cells with `...` placeholders; the solution fills them in. The intro/reference notebook (08) and the mpi4py walkthrough (06) are complete as written and have no separate solution. + +### Fundamentals + +| # | Notebook | Link | Solution | +|---|----------|------|----------| +| 00 | NumPy | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/00__numpy.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/00__numpy__SOLUTION.ipynb) | +| 01 | CuPy | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/01__cupy.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/01__cupy__SOLUTION.ipynb) | +| 02 | Power Iteration - CuPy - Memory Spaces | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/02__power_iteration__cupy__memory_spaces.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/02__power_iteration__cupy__memory_spaces__SOLUTION.ipynb) | + +### Kernels + +| # | Notebook | Link | Solution | +|---|----------|------|----------| +| 03 | Power Iteration - CuPy - Asynchrony | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/03__power_iteration__cupy__asynchrony.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/03__power_iteration__cupy__asynchrony__SOLUTION.ipynb) | +| 04 | Copy - Kernel Authoring | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/04__copy__kernel_authoring.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/04__copy__kernel_authoring__SOLUTION.ipynb) | +| 05 | Book Histogram - Kernel Authoring | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/05__book_histogram__kernel_authoring.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/05__book_histogram__kernel_authoring__SOLUTION.ipynb) | + +### Distributed + +| # | Notebook | Link | Solution | +|---|----------|------|----------| +| 06 | mpi4py | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/06__mpi4py.ipynb) | | + +### Python/C++ interoperability + +A standalone comparison of several ways to call C and C++ from Python (ctypes, cffi, nanobind, and CppJIT), benchmarked on the different kernels that expose various C++ features. + +| # | Notebook | Link | Solution | +|---|----------|------|----------| +| 07 | C++ Interop: ctypes, cffi, nanobind, and CppJIT compared | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/07__cpp_interop.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/07__cpp_interop__SOLUTION.ipynb) | + +### Programming models and interoperability + +Notebooks 08-14 are the Shallow Water Equations "ladder": an intro plus NumPy baseline, five solvers that each re-implement the same timestep with a different tool, and a synthesis that reads the measured timings and compares them. The synthesis notebook collects the per-tool rows from `timings.json` (written by notebooks 08 to 13) and closes with a matched-precision float64 comparison across the memory hierarchy. + +| # | Notebook | Link | Solution | +|---|----------|------|----------| +| 08 | SWE - Intro | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/08__swe__intro.ipynb) | | +| 09 | SWE - JAX | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/09__swe__jax.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/09__swe__jax__SOLUTION.ipynb) | +| 10 | SWE - PyOMP | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/10__swe__pyomp.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/10__swe__pyomp__SOLUTION.ipynb) | +| 11 | SWE - nanobind | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/11__swe__nanobind.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/11__swe__nanobind__SOLUTION.ipynb) | +| 12 | SWE - CppJIT - CUB | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/12__swe__cppjit__cub.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/12__swe__cppjit__cub__SOLUTION.ipynb) | +| 13 | SWE - mpi4py | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/13__swe__mpi4py.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/13__swe__mpi4py__SOLUTION.ipynb) | +| 14 | SWE - Synthesis | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/14__swe__synthesis.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/14__swe__synthesis__SOLUTION.ipynb) | + +## The Shallow Water Equations problem + +A 1D shallow-water bump pulse: a small mound of water at rest splits into two outgoing wave packets. Two conserved fields (`h`, `hu`) advance under a forward-Euler step. This PDE is small enough to read in full while exhibiting nonlinearity, and a fixed number of steps from the initial condition gives a result we can compare across tools. The full specification is in [`08__swe__intro.ipynb`](./notebooks/08__swe__intro.ipynb). + +## Running + +On Brev, deploy the [Docker Compose file](./brev/docker-compose.yml) as a Launchable and open the JupyterLab port. + +Locally, with an NVIDIA GPU and a CUDA-13-capable driver: + +```bash +docker compose -f tutorials/pyhpc/brev/docker-compose.yml up +``` + +Then open JupyterLab on port 8888. The notebooks are self-contained and can be run in any order, with one exception: the Shallow Water Equations ladder writes `timings.json` as you run notebooks 08 to 13, so run those before the synthesis notebook (14). + +## CppJIT toolchain + +Notebook 12 uses CppJIT to automatically bind our Python runtime with CUDA C++, using the clang-repl C++ interpreter and [CppInterOp](https://github.com/compiler-research/CppInterOp). This is currently source built in the [Docker image](./brev/dockerfile) and not a standard `pip install`, so this will only run in the tutorial image. [CppJIT](https://github.com/compiler-research/CppJIT) is the successor to the [cppyy](https://cppyy.readthedocs.io/) automatic bindings tool, and no official CppJIT release is published on PyPI yet (beta release planned for end of summer 2026). diff --git a/tutorials/pyhpc/assets/Shallow_water_waves.gif b/tutorials/pyhpc/assets/Shallow_water_waves.gif new file mode 100644 index 00000000..12b551a3 --- /dev/null +++ b/tutorials/pyhpc/assets/Shallow_water_waves.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0608c3961b37f438516bf1dcea0219431899f0a1815fe9e2853734423b5dd954 +size 7362657 diff --git a/tutorials/pyhpc/brev/benchmarks/stream.c b/tutorials/pyhpc/brev/benchmarks/stream.c new file mode 100644 index 00000000..b9a2cee3 --- /dev/null +++ b/tutorials/pyhpc/brev/benchmarks/stream.c @@ -0,0 +1,585 @@ +/*-----------------------------------------------------------------------*/ +/* Program: STREAM */ +/* Revision: $Id: stream.c,v 5.10 2013/01/17 16:01:06 mccalpin Exp mccalpin $ */ +/* Original code developed by John D. McCalpin */ +/* Programmers: John D. McCalpin */ +/* Joe R. Zagar */ +/* */ +/* This program measures memory transfer rates in MB/s for simple */ +/* computational kernels coded in C. */ +/*-----------------------------------------------------------------------*/ +/* Copyright 1991-2013: John D. McCalpin */ +/*-----------------------------------------------------------------------*/ +/* License: */ +/* 1. You are free to use this program and/or to redistribute */ +/* this program. */ +/* 2. You are free to modify this program for your own use, */ +/* including commercial use, subject to the publication */ +/* restrictions in item 3. */ +/* 3. You are free to publish results obtained from running this */ +/* program, or from works that you derive from this program, */ +/* with the following limitations: */ +/* 3a. In order to be referred to as "STREAM benchmark results", */ +/* published results must be in conformance to the STREAM */ +/* Run Rules, (briefly reviewed below) published at */ +/* http://www.cs.virginia.edu/stream/ref.html */ +/* and incorporated herein by reference. */ +/* As the copyright holder, John McCalpin retains the */ +/* right to determine conformity with the Run Rules. */ +/* 3b. Results based on modified source code or on runs not in */ +/* accordance with the STREAM Run Rules must be clearly */ +/* labelled whenever they are published. Examples of */ +/* proper labelling include: */ +/* "tuned STREAM benchmark results" */ +/* "based on a variant of the STREAM benchmark code" */ +/* Other comparable, clear, and reasonable labelling is */ +/* acceptable. */ +/* 3c. Submission of results to the STREAM benchmark web site */ +/* is encouraged, but not required. */ +/* 4. Use of this program or creation of derived works based on this */ +/* program constitutes acceptance of these licensing restrictions. */ +/* 5. Absolutely no warranty is expressed or implied. */ +/*-----------------------------------------------------------------------*/ +# include +# include +# include +# include +# include +# include + +/*----------------------------------------------------------------------- + * INSTRUCTIONS: + * + * 1) STREAM requires different amounts of memory to run on different + * systems, depending on both the system cache size(s) and the + * granularity of the system timer. + * You should adjust the value of 'STREAM_ARRAY_SIZE' (below) + * to meet *both* of the following criteria: + * (a) Each array must be at least 4 times the size of the + * available cache memory. I don't worry about the difference + * between 10^6 and 2^20, so in practice the minimum array size + * is about 3.8 times the cache size. + * Example 1: One Xeon E3 with 8 MB L3 cache + * STREAM_ARRAY_SIZE should be >= 4 million, giving + * an array size of 30.5 MB and a total memory requirement + * of 91.5 MB. + * Example 2: Two Xeon E5's with 20 MB L3 cache each (using OpenMP) + * STREAM_ARRAY_SIZE should be >= 20 million, giving + * an array size of 153 MB and a total memory requirement + * of 458 MB. + * (b) The size should be large enough so that the 'timing calibration' + * output by the program is at least 20 clock-ticks. + * Example: most versions of Windows have a 10 millisecond timer + * granularity. 20 "ticks" at 10 ms/tic is 200 milliseconds. + * If the chip is capable of 10 GB/s, it moves 2 GB in 200 msec. + * This means the each array must be at least 1 GB, or 128M elements. + * + * Version 5.10 increases the default array size from 2 million + * elements to 10 million elements in response to the increasing + * size of L3 caches. The new default size is large enough for caches + * up to 20 MB. + * Version 5.10 changes the loop index variables from "register int" + * to "ssize_t", which allows array indices >2^32 (4 billion) + * on properly configured 64-bit systems. Additional compiler options + * (such as "-mcmodel=medium") may be required for large memory runs. + * + * Array size can be set at compile time without modifying the source + * code for the (many) compilers that support preprocessor definitions + * on the compile line. E.g., + * gcc -O -DSTREAM_ARRAY_SIZE=100000000 stream.c -o stream.100M + * will override the default size of 10M with a new size of 100M elements + * per array. + */ +#ifndef STREAM_ARRAY_SIZE +# define STREAM_ARRAY_SIZE 10000000 +#endif + +/* 2) STREAM runs each kernel "NTIMES" times and reports the *best* result + * for any iteration after the first, therefore the minimum value + * for NTIMES is 2. + * There are no rules on maximum allowable values for NTIMES, but + * values larger than the default are unlikely to noticeably + * increase the reported performance. + * NTIMES can also be set on the compile line without changing the source + * code using, for example, "-DNTIMES=7". + */ +#ifdef NTIMES +#if NTIMES<=1 +# define NTIMES 10 +#endif +#endif +#ifndef NTIMES +# define NTIMES 10 +#endif + +/* Users are allowed to modify the "OFFSET" variable, which *may* change the + * relative alignment of the arrays (though compilers may change the + * effective offset by making the arrays non-contiguous on some systems). + * Use of non-zero values for OFFSET can be especially helpful if the + * STREAM_ARRAY_SIZE is set to a value close to a large power of 2. + * OFFSET can also be set on the compile line without changing the source + * code using, for example, "-DOFFSET=56". + */ +#ifndef OFFSET +# define OFFSET 0 +#endif + +/* + * 3) Compile the code with optimization. Many compilers generate + * unreasonably bad code before the optimizer tightens things up. + * If the results are unreasonably good, on the other hand, the + * optimizer might be too smart for me! + * + * For a simple single-core version, try compiling with: + * cc -O stream.c -o stream + * This is known to work on many, many systems.... + * + * To use multiple cores, you need to tell the compiler to obey the OpenMP + * directives in the code. This varies by compiler, but a common example is + * gcc -O -fopenmp stream.c -o stream_omp + * The environment variable OMP_NUM_THREADS allows runtime control of the + * number of threads/cores used when the resulting "stream_omp" program + * is executed. + * + * To run with single-precision variables and arithmetic, simply add + * -DSTREAM_TYPE=float + * to the compile line. + * Note that this changes the minimum array sizes required --- see (1) above. + * + * The preprocessor directive "TUNED" does not do much -- it simply causes the + * code to call separate functions to execute each kernel. Trivial versions + * of these functions are provided, but they are *not* tuned -- they just + * provide predefined interfaces to be replaced with tuned code. + * + * + * 4) Optional: Mail the results to mccalpin@cs.virginia.edu + * Be sure to include info that will help me understand: + * a) the computer hardware configuration (e.g., processor model, memory type) + * b) the compiler name/version and compilation flags + * c) any run-time information (such as OMP_NUM_THREADS) + * d) all of the output from the test case. + * + * Thanks! + * + *-----------------------------------------------------------------------*/ + +# define HLINE "-------------------------------------------------------------\n" + +# ifndef MIN +# define MIN(x,y) ((x)<(y)?(x):(y)) +# endif +# ifndef MAX +# define MAX(x,y) ((x)>(y)?(x):(y)) +# endif + +#ifndef STREAM_TYPE +#define STREAM_TYPE double +#endif + +static STREAM_TYPE a[STREAM_ARRAY_SIZE+OFFSET], + b[STREAM_ARRAY_SIZE+OFFSET], + c[STREAM_ARRAY_SIZE+OFFSET]; + +static double avgtime[4] = {0}, maxtime[4] = {0}, + mintime[4] = {FLT_MAX,FLT_MAX,FLT_MAX,FLT_MAX}; + +static char *label[4] = {"Copy: ", "Scale: ", + "Add: ", "Triad: "}; + +static double bytes[4] = { + 2 * sizeof(STREAM_TYPE) * STREAM_ARRAY_SIZE, + 2 * sizeof(STREAM_TYPE) * STREAM_ARRAY_SIZE, + 3 * sizeof(STREAM_TYPE) * STREAM_ARRAY_SIZE, + 3 * sizeof(STREAM_TYPE) * STREAM_ARRAY_SIZE + }; + +extern double mysecond(); +extern void checkSTREAMresults(); +#ifdef TUNED +extern void tuned_STREAM_Copy(); +extern void tuned_STREAM_Scale(STREAM_TYPE scalar); +extern void tuned_STREAM_Add(); +extern void tuned_STREAM_Triad(STREAM_TYPE scalar); +#endif +#ifdef _OPENMP +extern int omp_get_num_threads(); +#endif +int +main() + { + int quantum, checktick(); + int BytesPerWord; + int k; + ssize_t j; + STREAM_TYPE scalar; + double t, times[4][NTIMES]; + + /* --- SETUP --- determine precision and check timing --- */ + + printf(HLINE); + printf("STREAM version $Revision: 5.10 $\n"); + printf(HLINE); + BytesPerWord = sizeof(STREAM_TYPE); + printf("This system uses %d bytes per array element.\n", + BytesPerWord); + + printf(HLINE); +#ifdef N + printf("***** WARNING: ******\n"); + printf(" It appears that you set the preprocessor variable N when compiling this code.\n"); + printf(" This version of the code uses the preprocesor variable STREAM_ARRAY_SIZE to control the array size\n"); + printf(" Reverting to default value of STREAM_ARRAY_SIZE=%llu\n",(unsigned long long) STREAM_ARRAY_SIZE); + printf("***** WARNING: ******\n"); +#endif + + printf("Array size = %llu (elements), Offset = %d (elements)\n" , (unsigned long long) STREAM_ARRAY_SIZE, OFFSET); + printf("Memory per array = %.1f MiB (= %.1f GiB).\n", + BytesPerWord * ( (double) STREAM_ARRAY_SIZE / 1024.0/1024.0), + BytesPerWord * ( (double) STREAM_ARRAY_SIZE / 1024.0/1024.0/1024.0)); + printf("Total memory required = %.1f MiB (= %.1f GiB).\n", + (3.0 * BytesPerWord) * ( (double) STREAM_ARRAY_SIZE / 1024.0/1024.), + (3.0 * BytesPerWord) * ( (double) STREAM_ARRAY_SIZE / 1024.0/1024./1024.)); + printf("Each kernel will be executed %d times.\n", NTIMES); + printf(" The *best* time for each kernel (excluding the first iteration)\n"); + printf(" will be used to compute the reported bandwidth.\n"); + +#ifdef _OPENMP + printf(HLINE); +#pragma omp parallel + { +#pragma omp master + { + k = omp_get_num_threads(); + printf ("Number of Threads requested = %i\n",k); + } + } +#endif + +#ifdef _OPENMP + k = 0; +#pragma omp parallel +#pragma omp atomic + k++; + printf ("Number of Threads counted = %i\n",k); +#endif + + /* Get initial value for system clock. */ +#pragma omp parallel for + for (j=0; j= 1) + printf("Your clock granularity/precision appears to be " + "%d microseconds.\n", quantum); + else { + printf("Your clock granularity appears to be " + "less than one microsecond.\n"); + quantum = 1; + } + + t = mysecond(); +#pragma omp parallel for + for (j = 0; j < STREAM_ARRAY_SIZE; j++) + a[j] = 2.0E0 * a[j]; + t = 1.0E6 * (mysecond() - t); + + printf("Each test below will take on the order" + " of %d microseconds.\n", (int) t ); + printf(" (= %d clock ticks)\n", (int) (t/quantum) ); + printf("Increase the size of the arrays if this shows that\n"); + printf("you are not getting at least 20 clock ticks per test.\n"); + + printf(HLINE); + + printf("WARNING -- The above is only a rough guideline.\n"); + printf("For best results, please be sure you know the\n"); + printf("precision of your system timer.\n"); + printf(HLINE); + + /* --- MAIN LOOP --- repeat test cases NTIMES times --- */ + + scalar = 3.0; + for (k=0; k + +double mysecond() +{ + struct timeval tp; + struct timezone tzp; + int i; + + i = gettimeofday(&tp,&tzp); + return ( (double) tp.tv_sec + (double) tp.tv_usec * 1.e-6 ); +} + +#ifndef abs +#define abs(a) ((a) >= 0 ? (a) : -(a)) +#endif +void checkSTREAMresults () +{ + STREAM_TYPE aj,bj,cj,scalar; + STREAM_TYPE aSumErr,bSumErr,cSumErr; + STREAM_TYPE aAvgErr,bAvgErr,cAvgErr; + double epsilon; + ssize_t j; + int k,ierr,err; + + /* reproduce initialization */ + aj = 1.0; + bj = 2.0; + cj = 0.0; + /* a[] is modified during timing check */ + aj = 2.0E0 * aj; + /* now execute timing loop */ + scalar = 3.0; + for (k=0; k epsilon) { + err++; + printf ("Failed Validation on array a[], AvgRelAbsErr > epsilon (%e)\n",epsilon); + printf (" Expected Value: %e, AvgAbsErr: %e, AvgRelAbsErr: %e\n",aj,aAvgErr,abs(aAvgErr)/aj); + ierr = 0; + for (j=0; j epsilon) { + ierr++; +#ifdef VERBOSE + if (ierr < 10) { + printf(" array a: index: %ld, expected: %e, observed: %e, relative error: %e\n", + j,aj,a[j],abs((aj-a[j])/aAvgErr)); + } +#endif + } + } + printf(" For array a[], %d errors were found.\n",ierr); + } + if (abs(bAvgErr/bj) > epsilon) { + err++; + printf ("Failed Validation on array b[], AvgRelAbsErr > epsilon (%e)\n",epsilon); + printf (" Expected Value: %e, AvgAbsErr: %e, AvgRelAbsErr: %e\n",bj,bAvgErr,abs(bAvgErr)/bj); + printf (" AvgRelAbsErr > Epsilon (%e)\n",epsilon); + ierr = 0; + for (j=0; j epsilon) { + ierr++; +#ifdef VERBOSE + if (ierr < 10) { + printf(" array b: index: %ld, expected: %e, observed: %e, relative error: %e\n", + j,bj,b[j],abs((bj-b[j])/bAvgErr)); + } +#endif + } + } + printf(" For array b[], %d errors were found.\n",ierr); + } + if (abs(cAvgErr/cj) > epsilon) { + err++; + printf ("Failed Validation on array c[], AvgRelAbsErr > epsilon (%e)\n",epsilon); + printf (" Expected Value: %e, AvgAbsErr: %e, AvgRelAbsErr: %e\n",cj,cAvgErr,abs(cAvgErr)/cj); + printf (" AvgRelAbsErr > Epsilon (%e)\n",epsilon); + ierr = 0; + for (j=0; j epsilon) { + ierr++; +#ifdef VERBOSE + if (ierr < 10) { + printf(" array c: index: %ld, expected: %e, observed: %e, relative error: %e\n", + j,cj,c[j],abs((cj-c[j])/cAvgErr)); + } +#endif + } + } + printf(" For array c[], %d errors were found.\n",ierr); + } + if (err == 0) { + printf ("Solution Validates: avg error less than %e on all three arrays\n",epsilon); + } +#ifdef VERBOSE + printf ("Results Validation Verbose Results: \n"); + printf (" Expected a(1), b(1), c(1): %f %f %f \n",aj,bj,cj); + printf (" Observed a(1), b(1), c(1): %f %f %f \n",a[1],b[1],c[1]); + printf (" Rel Errors on a, b, c: %e %e %e \n",abs(aAvgErr/aj),abs(bAvgErr/bj),abs(cAvgErr/cj)); +#endif +} + +#ifdef TUNED +/* stubs for "tuned" versions of the kernels */ +void tuned_STREAM_Copy() +{ + ssize_t j; +#pragma omp parallel for + for (j=0; j/dev/null) || return 1 + awk ' + $1 == "Principals:" { principals = 1; next } + principals && $1 == "Critical" && $2 == "Options:" { exit } + principals { + line = $0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line != "") { count++; value = line } + } + END { if (count == 1) print value; else exit 1 } + ' <<< "${details}" +} + +user=${CSCS_USER:-} +ssh_key=${CSCS_SSH_KEY:-${HOME:?HOME is not set}/.ssh/cscs-key} +ela_host=${CSCS_ELA_HOST:-ela.cscs.ch} +daint_host=${CSCS_DAINT_HOST:-daint.alps.cscs.ch} +jupyter_local_port=${ACH_JUPYTER_LOCAL_PORT:-8888} + +while [ "$#" -gt 0 ]; do + case "$1" in + --user) user=${2:?--user requires a value}; shift 2 ;; + --key) ssh_key=${2:?--key requires a value}; shift 2 ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;; + *) break ;; + esac +done + +if [ "$#" -eq 0 ]; then + echo "Error: NODE is required." >&2 + usage >&2 + exit 2 +fi +node=$1 +shift +if [ "$#" -ne 0 ]; then + echo "Error: unexpected argument after NODE: $1" >&2 + usage >&2 + exit 2 +fi +case "${node}" in + ''|-*|*[!A-Za-z0-9._-]*) echo "Error: invalid compute-node name: ${node}" >&2; exit 2 ;; +esac +for host in "${ela_host}" "${daint_host}"; do + case "${host}" in + ''|-*|*[!A-Za-z0-9._-]*) echo "Error: invalid CSCS hostname: ${host}" >&2; exit 2 ;; + esac +done +case "${jupyter_local_port}" in + ''|*[!0-9]*|??????*) echo "Error: ACH_JUPYTER_LOCAL_PORT must be a port number." >&2; exit 2 ;; +esac +if [ "${jupyter_local_port}" -lt 1 ] || [ "${jupyter_local_port}" -gt 65535 ]; then + echo "Error: ACH_JUPYTER_LOCAL_PORT is outside 1-65535." >&2 + exit 2 +fi +case "${jupyter_local_port}" in + 8080|8081|3478|3479) + echo "Error: ACH_JUPYTER_LOCAL_PORT conflicts with a fixed Streamer port." >&2 + exit 2 + ;; +esac + +case "${ssh_key}" in + *$'\n'*|*$'\r'*|*'"'*) echo "Error: invalid CSCS key path." >&2; exit 2 ;; +esac +if [ ! -f "${ssh_key}" ]; then + echo "Error: CSCS private key not found: ${ssh_key}" >&2 + exit 2 +fi +if [ -z "${user}" ]; then + user=$(discover_cscs_user "${ssh_key}" || true) +fi +case "${user}" in + ''|-*|*[!A-Za-z0-9._-]*) + echo "Error: could not read one CSCS username from ${ssh_key}-cert.pub." >&2 + echo "Run 'cscs-key sign --file ${ssh_key}' or use --user." >&2 + exit 2 + ;; +esac + +config_dir= +ssh_config=${ACH_CSCS_SSH_CONFIG:-} +cleanup() { + local status=$? + trap - EXIT INT TERM + if [ -n "${config_dir}" ]; then + rm -f "${ssh_config}" + rmdir "${config_dir}" >/dev/null 2>&1 || true + fi + exit "${status}" +} +trap cleanup EXIT INT TERM + +if [ -z "${ssh_config}" ]; then + config_dir=$(mktemp -d "${TMPDIR:-/tmp}/ach-cscs-config.XXXXXX") + ssh_config="${config_dir}/ssh_config" + cat > "${ssh_config}" <&2 + exit 1 +fi + +ssh_args=( + -F "${ssh_config}" + -i "${ssh_key}" + -o IdentitiesOnly=yes + -o ExitOnForwardFailure=yes + -o StrictHostKeyChecking=accept-new + -o ServerAliveInterval=30 + -o ServerAliveCountMax=6 + -L "127.0.0.1:${jupyter_local_port}:127.0.0.1:8888" + -L 127.0.0.1:8080:127.0.0.1:8080 + -L 127.0.0.1:3478:127.0.0.1:3478 + -L 127.0.0.1:8081:127.0.0.1:8081 + -L 127.0.0.1:3479:127.0.0.1:3479 +) + +if [ -n "${ACH_CSCS_CONTROL_PATH:-}" ]; then + printf -v ssh_config_q '%q' "${ssh_config}" + printf -v control_path_q '%q' "${ACH_CSCS_CONTROL_PATH}" + ssh_args+=( + -o "ProxyCommand=ssh -F ${ssh_config_q} -S ${control_path_q} -W %h:%p ach-daint" + ) +else + ssh_args+=(-J ach-daint) +fi + +cat </dev/null 2>&1; then + echo "Error: invalid branch name: ${requested_branch}" >&2 + return 2 + fi + + if [ ! -e "${repo}" ]; then + mkdir -p "$(dirname "${repo}")" + echo "Cloning ${requested_branch} into ${repo}..." + git clone --branch "${requested_branch}" \ + https://github.com/NVIDIA/accelerated-computing-hub.git "${repo}" + elif ! git -C "${repo}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: --repo exists but is not a Git checkout: ${repo}" >&2 + return 1 + else + local branch + branch=$(git -C "${repo}" branch --show-current) + if [ -z "${branch}" ]; then + echo "Error: the existing checkout has a detached HEAD: ${repo}" >&2 + return 1 + fi + + local status + if ! status=$(git -C "${repo}" status --porcelain=v1 --untracked-files=no); then + echo "Error: could not inspect the existing checkout: ${repo}" >&2 + return 1 + fi + + if [ -z "${status}" ]; then + local remote_ref="refs/remotes/origin/${requested_branch}" + echo "Updating the clean checkout to ${requested_branch} in ${repo} (current=${branch})..." + git -C "${repo}" fetch origin \ + "+refs/heads/${requested_branch}:${remote_ref}" + if git -C "${repo}" show-ref --verify --quiet \ + "refs/heads/${requested_branch}"; then + if git -C "${repo}" merge-base --is-ancestor \ + "refs/heads/${requested_branch}" "${remote_ref}"; then + if [ "${branch}" != "${requested_branch}" ]; then + git -C "${repo}" switch "${requested_branch}" + fi + git -C "${repo}" merge --ff-only "${remote_ref}" + elif git -C "${repo}" merge-base --is-ancestor \ + "${remote_ref}" "refs/heads/${requested_branch}"; then + if [ "${branch}" != "${requested_branch}" ]; then + git -C "${repo}" switch "${requested_branch}" + fi + else + echo "Error: local ${requested_branch} cannot fast-forward to origin/${requested_branch}." >&2 + echo "The checkout remains on ${branch}; resolve it manually or use a different --repo path." >&2 + return 1 + fi + else + git -C "${repo}" switch -c "${requested_branch}" "${remote_ref}" + fi + else + echo "Leaving the dirty checkout unchanged (branch=${branch})." + fi + fi + + CHECKOUT_BRANCH=$(git -C "${repo}" branch --show-current) + if [ -z "${CHECKOUT_BRANCH}" ]; then + echo "Error: the checkout must be on a branch: ${repo}" >&2 + return 1 + fi +} + +queue_state() { + squeue --noheader --jobs "$1" --format='%T' 2>/dev/null | sed -n '1p' +} + +stop_job() { + local job_id=$1 + local state= + state=$(queue_state "${job_id}" || true) + case "${state}" in + PENDING*|CONFIGURING*|REQUEUED*|RESV_DEL_HOLD*) + scancel "${job_id}" || true + ;; + *) + scancel --full --signal=TERM "${job_id}" || true + ;; + esac + + for _ in {1..30}; do + if state=$(queue_state "${job_id}"); then + [ -n "${state}" ] || return 0 + fi + sleep 1 + done + echo "Job ${job_id} did not stop after TERM; requesting cancellation." >&2 + scancel "${job_id}" || true + for _ in {1..30}; do + if state=$(queue_state "${job_id}"); then + [ -n "${state}" ] || return 0 + fi + sleep 1 + done + echo "Warning: job ${job_id} is still visible in squeue after cancellation." >&2 + return 1 +} + +wait_until_ready() { + local job_id=$1 + local log=$2 + local timeout=$3 + local deadline=0 + local last_state= + + while true; do + local ready_line= + if [ -f "${log}" ]; then + ready_line=$(grep -m1 '^READY node=' "${log}" || true) + fi + + local queue_line= + if ! queue_line=$(squeue --noheader --jobs "${job_id}" \ + --format='%T|%N' 2>/dev/null); then + queue_line= + fi + queue_line=${queue_line%%$'\n'*} + local state= + if [ -n "${queue_line}" ]; then + state=${queue_line%%|*} + else + state=$(sacct --noheader --allocations --jobs "${job_id}" \ + --format=State --parsable2 2>/dev/null | head -n1 | cut -d'|' -f1 || true) + fi + + if [ -n "${state}" ] && [ "${state}" != "${last_state}" ]; then + echo "Job ${job_id}: ${state}" + last_state=${state} + fi + case "${state}" in + RUNNING) + if [ "${deadline}" -eq 0 ]; then + deadline=$((SECONDS + timeout)) + fi + if [ -n "${ready_line}" ]; then + local node=${ready_line#READY node=} + printf 'CSCS_WEB_NODE=%s\n' "${node}" + printf 'CSCS_WEB_LOG=%s\n' "${log}" + return 0 + fi + ;; + PENDING*|CONFIGURING*|REQUEUED*|RESV_DEL_HOLD*) + deadline=0 + ;; + COMPLETED*|FAILED*|CANCELLED*|TIMEOUT*|NODE_FAIL*|OUT_OF_MEMORY*|PREEMPTED*|BOOT_FAIL*|DEADLINE*) + echo "Error: job ${job_id} entered ${state} before the services became ready." >&2 + [ ! -f "${log}" ] || tail -n 40 "${log}" >&2 + return 1 + ;; + esac + if [ "${deadline}" -ne 0 ] && [ "${SECONDS}" -ge "${deadline}" ]; then + echo "Error: services did not become ready within ${timeout}s after job ${job_id} started." >&2 + echo "Stopping the job so a retry cannot create a duplicate deployment." >&2 + stop_job "${job_id}" || true + return 1 + fi + sleep 2 + done +} + +ACTIVE_JOB_ID= +cancel_active_job() { + trap - HUP INT TERM + echo "Launcher interrupted; stopping job ${ACTIVE_JOB_ID}." >&2 + stop_job "${ACTIVE_JOB_ID}" || true + exit 130 +} + +login_main() { + if [ "${1:-}" = -h ] || [ "${1:-}" = --help ]; then + usage + return 0 + fi + + local account=${CSCS_ACCOUNT:-} + local repo=${ACH_REPO:-${SCRATCH:?SCRATCH is not set}/accelerated-computing-hub} + local branch=${ACH_BRANCH:-event/2026-07-cscs-summer-school} + local state_dir=${ACH_STATE:-${SCRATCH}/ach-pyhpc-web} + local partition=${CSCS_PARTITION:-normal} + local reservation=${CSCS_RESERVATION:-} + local duration=${CSCS_TIME:-10:00:00} + local start_timeout=${ACH_START_TIMEOUT:-1800} + + while [ "$#" -gt 0 ]; do + case "$1" in + --account) account=${2:?--account requires a value}; shift 2 ;; + --repo) repo=${2:?--repo requires a value}; shift 2 ;; + --branch) branch=${2:?--branch requires a value}; shift 2 ;; + --state) state_dir=${2:?--state requires a value}; shift 2 ;; + --partition) partition=${2:?--partition requires a value}; shift 2 ;; + --reservation) reservation=${2:?--reservation requires a value}; shift 2 ;; + --time) duration=${2:?--time requires a value}; shift 2 ;; + --start-timeout) start_timeout=${2:?--start-timeout requires a value}; shift 2 ;; + -h|--help) usage; return 0 ;; + *) echo "Error: unknown argument: $1" >&2; usage >&2; return 2 ;; + esac + done + + if [ -n "${SLURM_JOB_ID:-}" ]; then + echo "Error: run the launcher on a Daint login node, not inside a Slurm job." >&2 + return 1 + fi + case "${start_timeout}" in + ''|*[!0-9]*) echo "Error: --start-timeout must be an integer." >&2; return 2 ;; + esac + if [ -z "${account}" ]; then + if ! account=$(id -gn "${USER}") || [ -z "${account}" ]; then + echo "Error: could not discover the primary CSCS project; use --account." >&2 + return 2 + fi + fi + echo "Using CSCS Slurm account ${account}." + + prepare_checkout "${repo}" "${branch}" + echo "Using checkout branch ${CHECKOUT_BRANCH} with release assets from ${branch}." + + mkdir -p "${state_dir}" + chmod 700 "${state_dir}" + + local batch_script + batch_script=$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd -P)/$(basename "${BASH_SOURCE[0]}") + local prepare_source + prepare_source=$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd -P)/prepare-podman-compose.py + if [ ! -x "${batch_script}" ]; then + echo "Error: run an executable launcher file, not a pipe or process substitution." >&2 + return 1 + fi + if [ ! -f "${prepare_source}" ]; then + echo "Error: missing compose adapter: ${prepare_source}" >&2 + return 1 + fi + + local -a sbatch_args=( + --parsable + "--partition=${partition}" + "--time=${duration}" + --nodes=1 + --ntasks=1 + --gpus=1 + --signal=B:TERM@60 + --job-name=ach-pyhpc-web + "--chdir=${repo}" + "--output=${state_dir}/slurm-%j.log" + "--export=ALL,ACH_REPO=${repo},ACH_STATE=${state_dir},ACH_RELEASE_BRANCH=${branch},ACH_PREPARE_SOURCE=${prepare_source}" + ) + sbatch_args+=(--account="${account}") + if [ -n "${reservation}" ]; then + sbatch_args+=(--reservation="${reservation}") + fi + + local job_id + job_id=$(sbatch "${sbatch_args[@]}" "${batch_script}" --batch) + job_id=${job_id%%;*} + case "${job_id}" in + ''|*[!0-9]*) echo "Error: sbatch returned an invalid job ID: ${job_id}" >&2; return 1 ;; + esac + local log="${state_dir}/slurm-${job_id}.log" + + printf 'CSCS_WEB_JOB_ID=%s\n' "${job_id}" + echo "Waiting for JupyterLab and both Nsight Streamers to become ready..." + ACTIVE_JOB_ID=${job_id} + trap cancel_active_job HUP INT TERM + local wait_status=0 + wait_until_ready "${job_id}" "${log}" "${start_timeout}" || wait_status=$? + trap - HUP INT TERM + ACTIVE_JOB_ID= + if [ "${wait_status}" -ne 0 ]; then + return "${wait_status}" + fi + printf 'CSCS_WEB_REPO=%s\n' "${repo}" +} + +batch_main() { +if [ -z "${SLURM_JOB_ID:-}" ]; then + echo "Error: --batch is for the Slurm job started by this launcher." >&2 + exit 1 +fi + +ACH_REPO=${ACH_REPO:?ACH_REPO is not set} +ACH_STATE=${ACH_STATE:-${SCRATCH:?SCRATCH is not set}/ach-pyhpc-web} +ACH_RELEASE_BRANCH=${ACH_RELEASE_BRANCH:?ACH_RELEASE_BRANCH is not set} +COMPOSE_URL=${ACH_COMPOSE_URL:-https://raw.githubusercontent.com/NVIDIA/accelerated-computing-hub/generated/${ACH_RELEASE_BRANCH}/tutorials/pyhpc/brev/docker-compose.yml} +ACH_PREPARE_SOURCE=${ACH_PREPARE_SOURCE:?ACH_PREPARE_SOURCE is not set} + +if [ ! -d "${ACH_REPO}/tutorials/pyhpc/notebooks" ]; then + echo "Error: student checkout has no PyHPC notebooks: ${ACH_REPO}" >&2 + exit 1 +fi + +RUN_STATE="${ACH_STATE}/${SLURM_JOB_ID}" +mkdir -p "${RUN_STATE}" +chmod 700 "${ACH_STATE}" "${RUN_STATE}" +SERVICE_EVENTS="${RUN_STATE}/service-events" + +SOURCE_COMPOSE="${RUN_STATE}/docker-compose.yml" +PODMAN_COMPOSE="${RUN_STATE}/docker-compose.podman.yml" +PREPARE_SCRIPT="${RUN_STATE}/prepare-podman-compose.py" +curl --fail --location --retry 3 --silent --show-error \ + "${COMPOSE_URL}" --output "${SOURCE_COMPOSE}" +if [ ! -f "${ACH_PREPARE_SOURCE}" ]; then + echo "Error: missing compose adapter: ${ACH_PREPARE_SOURCE}" >&2 + exit 1 +fi +cp "${ACH_PREPARE_SOURCE}" "${PREPARE_SCRIPT}" + +PYTHON=$(command -v python3.11 || command -v python3) +VENV="${RUN_STATE}/venv" +COMPOSE="${VENV}/bin/podman-compose" +if [ ! -x "${COMPOSE}" ]; then + "${PYTHON}" -m venv "${VENV}" + "${VENV}/bin/pip" install --disable-pip-version-check --quiet \ + 'podman-compose==1.6.0' +fi + +TURN_USERNAME="turn_$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 16)" +TURN_PASSWORD="$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 32)" + +export ACH_PODMAN_HOST_NETWORK=1 +export JUPYTER_HOST=127.0.0.1 +export NSYS_HTTP_URL=http://127.0.0.1:8080 +export SELKIES_ENABLE_HTTPS=false + +"${VENV}/bin/python" "${PREPARE_SCRIPT}" \ + "${SOURCE_COMPOSE}" "${PODMAN_COMPOSE}" "${ACH_REPO}" 1 + +JOB_ROOT="/dev/shm/${USER}/ach-pyhpc-web-${SLURM_JOB_ID}" +mkdir -p "${JOB_ROOT}" +chmod 700 "${JOB_ROOT}" + +store_conf() { + echo "${RUN_STATE}/storage-${1}.conf" +} + +store_root() { + echo "${JOB_ROOT}/${1}" +} + +for store in main nsys ncu; do + root=$(store_root "${store}") + mkdir -p "${root}/runtime" + chmod 700 "${root}" "${root}/runtime" + cat > "$(store_conf "${store}")" </dev/null 2>&1 || true + with_store "${store}" podman pod rm --all --force >/dev/null 2>&1 || true + with_store "${store}" podman system migrate >/dev/null 2>&1 || true +} + +pids=() +stop_services() { + if [ "${#pids[@]}" -gt 0 ]; then + kill "${pids[@]}" 2>/dev/null || true + fi + for store in main nsys ncu; do + clean_store "${store}" + done + for pid in "${pids[@]}"; do + wait "${pid}" 2>/dev/null || true + done + pids=() +} + +cleanup() { + local status=$? + trap - EXIT INT TERM + stop_services + podman unshare rm -rf "${JOB_ROOT}" >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT INT TERM + +# Daint's short-lived user systemd manager can terminate rootless Podman +# processes placed in its transient scope. Keep Podman in the Slurm job cgroup. +unset DBUS_SESSION_BUS_ADDRESS + +for store in main nsys ncu; do + clean_store "${store}" +done + +echo "Pulling the GitHub CI image and NVIDIA Streamer images (no local builds)..." +with_store main "${COMPOSE}" --podman-run-args=--cgroups=disabled \ + -f "${PODMAN_COMPOSE}" pull jupyter +with_store nsys "${COMPOSE}" --podman-run-args=--cgroups=disabled \ + -f "${PODMAN_COMPOSE}" pull nsys +with_store ncu "${COMPOSE}" --podman-run-args=--cgroups=disabled \ + -f "${PODMAN_COMPOSE}" pull ncu + +with_store main "${COMPOSE}" --podman-run-args=--cgroups=disabled \ + -f "${PODMAN_COMPOSE}" \ + run --rm --no-deps -T \ + -e "TURN_USERNAME=${TURN_USERNAME}" -e "TURN_PASSWORD=${TURN_PASSWORD}" \ + base + +start_service() { + local service=$1 + local store=$2 + local url=$3 + local log="${RUN_STATE}/${service}.log" + + ( + export CONTAINERS_STORAGE_CONF + CONTAINERS_STORAGE_CONF=$(store_conf "${store}") + export XDG_RUNTIME_DIR + XDG_RUNTIME_DIR="$(store_root "${store}")/runtime" + service_pid="" + terminating=0 + terminate_service() { + terminating=1 + if [ -n "${service_pid}" ] && kill -0 "${service_pid}" 2>/dev/null; then + kill -TERM "${service_pid}" 2>/dev/null || true + fi + } + trap terminate_service TERM INT + + "${COMPOSE}" --podman-run-args=--cgroups=disabled \ + -f "${PODMAN_COMPOSE}" \ + run --rm --no-deps -T \ + -e "TURN_USERNAME=${TURN_USERNAME}" -e "TURN_PASSWORD=${TURN_PASSWORD}" \ + "${service}" & + service_pid=$! + service_status=0 + wait "${service_pid}" || service_status=$? + if [ "${terminating}" -eq 1 ] && kill -0 "${service_pid}" 2>/dev/null; then + wait "${service_pid}" || service_status=$? + fi + printf '%s %s\n' "${service}" "${service_status}" >>"${SERVICE_EVENTS}" + exit "${service_status}" + ) >>"${log}" 2>&1 & + local pid=$! + chmod 600 "${log}" + pids+=("${pid}") + + local deadline=$((SECONDS + 300)) + while [ "${SECONDS}" -lt "${deadline}" ]; do + if [ -s "${SERVICE_EVENTS}" ]; then + echo "Error: a web service exited while waiting for ${service}; see ${RUN_STATE}/*.log" >&2 + return 1 + fi + if curl --fail --silent \ + --connect-timeout 1 --max-time 2 "${url}" >/dev/null 2>&1; then + echo "${service} is ready" + return 0 + fi + sleep 1 + done + + echo "Error: timed out waiting for ${service}; see ${log}" >&2 + return 1 +} + +generation=0 +ready_announced=0 +while true; do + generation=$((generation + 1)) + pids=() + : >"${SERVICE_EVENTS}" + chmod 600 "${SERVICE_EVENTS}" + for service in jupyter nsys ncu; do + printf '\n=== Starting service generation %s ===\n' "${generation}" \ + >>"${RUN_STATE}/${service}.log" + done + + if ! start_service jupyter main http://127.0.0.1:8888/api/status || \ + ! start_service nsys nsys http://127.0.0.1:8080/health || \ + ! start_service ncu ncu http://127.0.0.1:8081/health; then + echo "Web service generation ${generation} failed during startup; restarting all services." >&2 + stop_services + sleep 5 + continue + fi + if [ -s "${SERVICE_EVENTS}" ]; then + echo "A web service exited immediately after startup; restarting all services." >&2 + stop_services + sleep 5 + continue + fi + + if [ "${ready_announced}" -eq 0 ]; then + echo "READY node=$(hostname -s)" + echo "Logs: ${RUN_STATE}/{jupyter,nsys,ncu}.log" + ready_announced=1 + else + echo "Web service generation ${generation} is ready." + fi + + while [ ! -s "${SERVICE_EVENTS}" ]; do + sleep 1 + done + read -r exited_service exited_status <"${SERVICE_EVENTS}" + echo "${exited_service} exited with status ${exited_status}; restarting JupyterLab and both Nsight Streamers." >&2 + stop_services + sleep 1 +done +} + +if [ "${1:-}" = --batch ]; then + shift + batch_main "$@" +else + login_main "$@" +fi diff --git a/tutorials/pyhpc/brev/cscs-run-tutorial.bash b/tutorials/pyhpc/brev/cscs-run-tutorial.bash new file mode 100755 index 00000000..a13843c5 --- /dev/null +++ b/tutorials/pyhpc/brev/cscs-run-tutorial.bash @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# Launch on Daint and connect from the workstation using one authenticated SSH session. + +set -euo pipefail +umask 077 + +usage() { + cat <<'EOF' +Usage: cscs-run-tutorial.bash [OPTIONS] + +Run this script on the workstation with the web browser. It opens one +authenticated SSH control connection to Daint, invokes cscs-launch-tutorial.bash +there, waits for the services, and then invokes cscs-connect-tutorial.bash locally. + +Workstation options: + --user USER Override the username read from the SSH certificate + --key PATH CSCS private key (default: ~/.ssh/cscs-key) + +Other options are passed to cscs-launch-tutorial.bash, including --repo, --branch, +--account, --state, --partition, --time, and --start-timeout. +EOF +} + +discover_cscs_user() { + local certificate="${1}-cert.pub" + [ -f "${certificate}" ] || return 1 + local details + details=$(ssh-keygen -L -f "${certificate}" 2>/dev/null) || return 1 + awk ' + $1 == "Principals:" { principals = 1; next } + principals && $1 == "Critical" && $2 == "Options:" { exit } + principals { + line = $0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line != "") { count++; value = line } + } + END { if (count == 1) print value; else exit 1 } + ' <<< "${details}" +} + +if [ "${1:-}" = -h ] || [ "${1:-}" = --help ]; then + usage + exit 0 +fi + +bootstrap_streamed_helpers() { + local branch=${ACH_BRANCH:-event/2026-07-cscs-summer-school} + local base_url=${ACH_CSCS_HELPER_BASE_URL:-https://raw.githubusercontent.com/NVIDIA/accelerated-computing-hub/${branch}/tutorials/pyhpc/brev} + local prepare_url=${ACH_CSCS_PREPARE_URL:-https://raw.githubusercontent.com/NVIDIA/accelerated-computing-hub/${branch}/brev/prepare-podman-compose.py} + local helper_dir + helper_dir=$(mktemp -d "${TMPDIR:-/tmp}/ach-cscs-helpers.XXXXXX") + + cleanup_downloads() { + rm -f "${helper_dir}/cscs-run-tutorial.bash" \ + "${helper_dir}/cscs-launch-tutorial.bash" \ + "${helper_dir}/cscs-connect-tutorial.bash" \ + "${helper_dir}/prepare-podman-compose.py" + rmdir "${helper_dir}" >/dev/null 2>&1 || true + } + trap cleanup_downloads EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + [ -t 0 ] || cat >/dev/null + local helper + for helper in cscs-run-tutorial.bash cscs-launch-tutorial.bash \ + cscs-connect-tutorial.bash; do + curl --fail --location --retry 3 --silent --show-error \ + "${base_url}/${helper}" --output "${helper_dir}/${helper}" + chmod 700 "${helper_dir}/${helper}" + done + curl --fail --location --retry 3 --silent --show-error \ + "${prepare_url}" --output "${helper_dir}/prepare-podman-compose.py" + chmod 700 "${helper_dir}/prepare-podman-compose.py" + if ! { exec 3/dev/null; then + echo "Error: run this command from an interactive terminal." >&2 + exit 1 + fi + local status=0 + "${helper_dir}/cscs-run-tutorial.bash" "$@" <&3 3<&- || status=$? + exit "${status}" +} + +if [ ! -f "${BASH_SOURCE[0]:-}" ]; then + bootstrap_streamed_helpers "$@" +fi + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd -P) +launch_script="${script_dir}/cscs-launch-tutorial.bash" +connect_script="${script_dir}/cscs-connect-tutorial.bash" +prepare_script="${script_dir}/prepare-podman-compose.py" +for script in "${launch_script}" "${connect_script}"; do + if [ ! -x "${script}" ]; then + echo "Error: missing executable sibling script: ${script}" >&2 + exit 1 + fi +done +if [ ! -x "${prepare_script}" ]; then + echo "Error: missing executable sibling script: ${prepare_script}" >&2 + exit 1 +fi + +user=${CSCS_USER:-} +ssh_key=${CSCS_SSH_KEY:-${HOME:?HOME is not set}/.ssh/cscs-key} +ela_host=${CSCS_ELA_HOST:-ela.cscs.ch} +daint_host=${CSCS_DAINT_HOST:-daint.alps.cscs.ch} +launch_args=() +while [ "$#" -gt 0 ]; do + case "$1" in + --user) user=${2:?--user requires a value}; shift 2 ;; + --key) ssh_key=${2:?--key requires a value}; shift 2 ;; + *) launch_args+=("$1"); shift ;; + esac +done +for host in "${ela_host}" "${daint_host}"; do + case "${host}" in + ''|-*|*[!A-Za-z0-9._-]*) echo "Error: invalid CSCS hostname: ${host}" >&2; exit 2 ;; + esac +done +case "${ssh_key}" in + *$'\n'*|*$'\r'*|*'"'*) echo "Error: invalid CSCS key path." >&2; exit 2 ;; +esac +if [ ! -f "${ssh_key}" ]; then + echo "Error: CSCS private key not found: ${ssh_key}" >&2 + exit 2 +fi +if [ -z "${user}" ]; then + user=$(discover_cscs_user "${ssh_key}" || true) +fi +case "${user}" in + ''|-*|*[!A-Za-z0-9._-]*) + echo "Error: could not read one CSCS username from ${ssh_key}-cert.pub." >&2 + echo "Run 'cscs-key sign --file ${ssh_key}' or use --user." >&2 + exit 2 + ;; +esac + +control_dir=$(mktemp -d "${TMPDIR:-/tmp}/ach-cscs-ssh.XXXXXX") +control_path="${control_dir}/master" +launch_output="${control_dir}/launch.out" +auth_log="${control_dir}/auth.log" +ssh_config="${control_dir}/ssh_config" +cat > "${ssh_config}" </dev/null 2>&1 + rm -f "${launch_output}" "${auth_log}" "${control_path}" "${ssh_config}" + rmdir "${control_dir}" >/dev/null 2>&1 + exit "${status}" +} +trap cleanup EXIT INT TERM + +echo "Opening one authenticated connection to Daint through Ela." +open_master() { + : > "${auth_log}" + local status=0 + ssh -F "${ssh_config}" -M -S "${control_path}" -fN \ + -o ControlMaster=yes -o ControlPersist=no \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=6 \ + ach-daint 2> "${auth_log}" || status=$? + if [ "${status}" -ne 0 ]; then + cat "${auth_log}" >&2 + fi + return "${status}" +} + +if ! open_master; then + if grep -q 'Permission denied (publickey)' "${auth_log}" && \ + command -v cscs-key >/dev/null 2>&1; then + echo "SSH authentication was rejected; renewing the CSCS certificate once." + cscs-key sign --file "${ssh_key}" + rm -f "${control_path}" + : > "${auth_log}" + open_master + else + echo "Error: could not authenticate to Daint through Ela." >&2 + echo "Run 'cscs-key sign' if the CSCS certificate has expired, then retry." >&2 + exit 1 + fi +fi + +upload_command="umask 077; mkdir -p \"\$HOME/.local/share/accelerated-computing-hub\"; cat > \"\$HOME/.local/share/accelerated-computing-hub/cscs-launch-tutorial.bash\"; chmod 700 \"\$HOME/.local/share/accelerated-computing-hub/cscs-launch-tutorial.bash\"" +ssh -F "${ssh_config}" -S "${control_path}" ach-daint "${upload_command}" < "${launch_script}" +upload_command="umask 077; cat > \"\$HOME/.local/share/accelerated-computing-hub/prepare-podman-compose.py\"; chmod 700 \"\$HOME/.local/share/accelerated-computing-hub/prepare-podman-compose.py\"" +ssh -F "${ssh_config}" -S "${control_path}" ach-daint "${upload_command}" < "${prepare_script}" + +remote_command="\"\$HOME/.local/share/accelerated-computing-hub/cscs-launch-tutorial.bash\"" +for arg in ${launch_args[@]+"${launch_args[@]}"}; do + printf -v quoted_arg '%q' "${arg}" + remote_command+=" ${quoted_arg}" +done + +set +e +ssh -F "${ssh_config}" -S "${control_path}" ach-daint "${remote_command}" \ + 2>&1 | tee "${launch_output}" +launch_status=${PIPESTATUS[0]} +set -e +if [ "${launch_status}" -ne 0 ]; then + exit "${launch_status}" +fi + +node=$(sed -n 's/^CSCS_WEB_NODE=//p' "${launch_output}" | tail -n1) +job_id=$(sed -n 's/^CSCS_WEB_JOB_ID=//p' "${launch_output}" | tail -n1) +if [ -z "${node}" ] || [ -z "${job_id}" ]; then + echo "Error: the Daint launcher did not report a job and node ID." >&2 + exit 1 +fi +node_number=${node#nid} +case "${node_number}" in + ''|*[!0-9]*) echo "Error: invalid node ID from the Daint launcher: ${node}" >&2; exit 1 ;; +esac +if [ "${node_number}" = "${node}" ]; then + echo "Error: invalid node ID from the Daint launcher: ${node}" >&2 + exit 1 +fi +case "${job_id}" in + ''|*[!0-9]*) echo "Error: invalid job ID from the Daint launcher: ${job_id}" >&2; exit 1 ;; +esac + +echo "Job ${job_id} is ready on ${node}. Opening the forwarded compute-node shell." +echo "The job continues after that shell exits; while connected, stop it with: scancel --full --signal=TERM ${job_id}" +ACH_CSCS_CONTROL_PATH="${control_path}" ACH_CSCS_SSH_CONFIG="${ssh_config}" \ + "${connect_script}" --user "${user}" --key "${ssh_key}" "${node}" diff --git a/tutorials/pyhpc/brev/docker-compose.yml b/tutorials/pyhpc/brev/docker-compose.yml new file mode 100644 index 00000000..2b4c49c6 --- /dev/null +++ b/tutorials/pyhpc/brev/docker-compose.yml @@ -0,0 +1,87 @@ +name: &tutorial-name pyhpc + +x-config: + dockerfile: &dockerfile tutorials/pyhpc/brev/dockerfile + image: &image ghcr.io/nvidia/pyhpc-tutorial:latest + working-dir: &working-dir /accelerated-computing-hub/tutorials/pyhpc/notebooks + large: &large true + architectures: [amd64, arm64] + default-jupyter-url: &default-jupyter-url "/lab/tree/accelerated-computing-hub/tutorials/pyhpc/notebooks/syllabi/pyhpc__cupy_kernels_mpi_jax_omp_interop__2_days.ipynb?file-browser-path=/accelerated-computing-hub/tutorials/pyhpc/notebooks" + gpu-config: &gpu-config + privileged: true + ulimits: + memlock: -1 + stack: 67108864 + shm_size: 1g + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + common-env: &common-env + BREV_ENV_ID: ${BREV_ENV_ID:-} + ACH_TUTORIAL: *tutorial-name + ACH_RUN_TESTS: ${ACH_RUN_TESTS:-} + ACH_TEST_ARGS: ${ACH_TEST_ARGS:-} + ACH_RESTART_COMPOSE_SERVICES: "1" + ACH_USER: ${ACH_USER:-ach} + ACH_UID: ${ACH_UID:-1000} + ACH_GID: ${ACH_GID:-1000} + common-service: &common-service + pull_policy: missing + volumes: + - accelerated-computing-hub:/accelerated-computing-hub + - /var/run/docker.sock:/var/run/docker.sock + environment: *common-env + user: root + working_dir: *working-dir + persistent-service: &persistent-service + depends_on: + base: + condition: service_completed_successfully + restart: unless-stopped + +services: + base: + <<: [*gpu-config, *common-service] + image: *image + entrypoint: ["/accelerated-computing-hub/brev/entrypoint.bash", "base"] + build: + context: ../../.. + dockerfile: *dockerfile + args: + CUDA_BASE_IMAGE: ${CUDA_BASE_IMAGE:-ghcr.io/nvidia/mirrors/nvidia-cuda-13.1.0-devel-ubuntu22.04} + restart: "no" + jupyter: + <<: [*gpu-config, *common-service, *persistent-service] + image: *image + entrypoint: ["/accelerated-computing-hub/brev/entrypoint.bash", "jupyter"] + command: *default-jupyter-url + environment: + <<: *common-env + ACH_PORT_FORWARDS: "8080:nsys:8080 8081:ncu:8081" + ports: + - "0.0.0.0:8888:8888" # JupyterLab + nsys: + <<: [*gpu-config, *common-service, *persistent-service] + image: nvcr.io/nvidia/devtools/nsight-streamer-nsys:2026.1.1 + entrypoint: ["/accelerated-computing-hub/brev/entrypoint.bash", "nsight"] + ports: + - "0.0.0.0:8080:8080" # HTTP + - "0.0.0.0:3478:3478" # TURN + ncu: + <<: [*gpu-config, *common-service, *persistent-service] + image: nvcr.io/nvidia/devtools/nsight-streamer-ncu:2025.4.1 + entrypoint: ["/accelerated-computing-hub/brev/entrypoint.bash", "nsight"] + environment: + <<: *common-env + HTTP_PORT: "8081" + TURN_PORT: "3479" + ports: + - "0.0.0.0:8081:8081" # HTTP + - "0.0.0.0:3479:3479" # TURN + +volumes: + accelerated-computing-hub: diff --git a/tutorials/pyhpc/brev/dockerfile b/tutorials/pyhpc/brev/dockerfile new file mode 100644 index 00000000..d012dc81 --- /dev/null +++ b/tutorials/pyhpc/brev/dockerfile @@ -0,0 +1,219 @@ +# Stage 1: LLVM builder +ARG CUDA_BASE_IMAGE=docker.io/nvidia/cuda:13.1.0-devel-ubuntu22.04 +FROM ${CUDA_BASE_IMAGE} AS llvm-builder +ARG LLVM_REPO=https://github.com/aaronj0/llvm-project.git +ARG LLVM_TAG=isc2026 +ENV DEBIAN_FRONTEND=noninteractive +RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|https://ports.ubuntu.com/ubuntu-ports|g' /etc/apt/sources.list \ + && printf 'Acquire::Retries "5";\nAcquire::https::Timeout "30";\nAcquire::http::Timeout "30";\n' > /etc/apt/apt.conf.d/80-ach-retries \ + && apt-get update -y \ + && apt-get install -y --no-install-recommends \ + git cmake ninja-build lld build-essential ca-certificates python3 python3-dev \ + && rm -rf /var/lib/apt/lists/* +RUN case "$(dpkg --print-architecture)" in \ + amd64) native_llvm_target=X86; default_link_jobs=2 ;; \ + arm64) native_llvm_target=AArch64; default_link_jobs=1 ;; \ + *) echo "Unsupported LLVM build architecture" >&2; exit 1 ;; \ + esac \ + && llvm_targets="${native_llvm_target};NVPTX" \ + && link_jobs="${default_link_jobs}" \ + && git clone --depth 1 --branch ${LLVM_TAG} ${LLVM_REPO} /src/llvm \ + && cmake -S /src/llvm/llvm -B /src/llvm-build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DLLVM_ENABLE_PROJECTS="clang;lld" \ + -DLLVM_TARGETS_TO_BUILD="${llvm_targets}" \ + -DLLVM_ENABLE_ASSERTIONS=OFF \ + -DLLVM_USE_LINKER=lld \ + -DLLVM_INSTALL_UTILS=ON \ + -DLLVM_INCLUDE_TESTS=OFF \ + -DCLANG_ENABLE_STATIC_ANALYZER=OFF \ + -DCLANG_ENABLE_ARCMT=OFF \ + -DLLVM_PARALLEL_LINK_JOBS="${link_jobs}" \ + -DCMAKE_INSTALL_PREFIX=/opt/llvm \ + && cmake --build /src/llvm-build --target install \ + && rm -rf /src/llvm /src/llvm-build + +# Stage 2: Tutorial image +FROM ${CUDA_BASE_IMAGE} + +ENV ACH_TUTORIAL=pyhpc \ + PYTHON_VERSION=3.12 \ + NSIGHT_SYSTEMS_VERSION=2026.1.1 \ + NSIGHT_SYSTEMS_AMD64_DEB_FILE=2026_1/NsightSystems-linux-cli-public-2026.1.1.204-3717666.deb \ + NSIGHT_SYSTEMS_ARM64_DEB_FILE=2026_1/nsight-systems-cli-2026.1.1_2026.1.1.204-1_arm64.deb \ + NSIGHT_COMPUTE_VERSION=2025.4.1 \ + NSIGHT_COMPUTE_DEB_VERSION=2025.4.1.2-1 \ + OMPI_ALLOW_RUN_AS_ROOT=1 \ + OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1 \ + PIP_ROOT_USER_ACTION=ignore \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + CUPY_CACHE_DIR=/tmp/cupy_cache \ + MPLCONFIGDIR=/tmp/matplotlib_cache \ + VIRTUAL_ENV_DISABLE_PROMPT=1 \ + PATH=/accelerated-computing-hub/brev/wrappers:/usr/local/nvidia/bin:/usr/local/cuda/bin:${PATH} + +ENV ACH_NSYS_PATH=/opt/nvidia/nsight-systems-cli/${NSIGHT_SYSTEMS_VERSION}/bin/nsys \ + ACH_NCU_PATH=/opt/nvidia/nsight-compute/${NSIGHT_COMPUTE_VERSION}/ncu + +RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|https://ports.ubuntu.com/ubuntu-ports|g' /etc/apt/sources.list \ + && printf 'Acquire::Retries "5";\nAcquire::https::Timeout "30";\nAcquire::http::Timeout "30";\n' > /etc/apt/apt.conf.d/80-ach-retries + +# Install system packages (needed before pip install for git-based packages) +RUN apt-get update -y \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + git \ + git-lfs \ + apt-transport-https \ + ca-certificates \ + curl \ + gnupg \ + lsb-release \ + gosu \ + libglib2.0-0 \ + socat \ + sudo \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* + +# Enable passwordless sudo for all users and pass through environment and path +RUN echo 'ALL ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers \ + && sed -i -e 's/^Defaults\s*env_reset/Defaults !env_reset/' -e 's/^Defaults\s*secure_path=/#&/' /etc/sudoers + +# Install Python +RUN curl -fsSL https://keyserver.ubuntu.com/pks/lookup?op=get\&search=0xBA6932366A755776 | gpg --dearmor -o /usr/share/keyrings/deadsnakes.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/deadsnakes.gpg] https://ppa.launchpadcontent.net/deadsnakes/ppa/ubuntu jammy main" > /etc/apt/sources.list.d/deadsnakes-ppa.list \ + && apt-get update -y \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + python${PYTHON_VERSION} \ + python${PYTHON_VERSION}-dev \ + python${PYTHON_VERSION}-venv \ + && curl -sS https://bootstrap.pypa.io/get-pip.py | python${PYTHON_VERSION} \ + && ln -sf /usr/bin/python${PYTHON_VERSION} /usr/bin/python3 \ + && ln -sf /usr/bin/python3 /usr/bin/python \ + && ln -sf /usr/bin/python${PYTHON_VERSION}-config /usr/bin/python3-config \ + && ln -s /usr/local/bin/pip /usr/bin/pip \ + && rm -f /usr/lib/python3.*/EXTERNALLY-MANAGED \ + && rm -rf /var/lib/apt/lists/* + +# cpp +RUN apt-get update -y \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + ninja-build \ + mpich \ + libmpich-dev \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* + +# Standard memory-bandwidth benchmarks: STREAM (McCalpin) for the CPU and +# BabelStream v5.0 (CUDA model) for the GPU. Used to record the machine's +# practical ceilings in machine.json for the SWE tutorial. +COPY tutorials/${ACH_TUTORIAL}/brev/benchmarks/stream.c /opt/benchmarks/stream.c +RUN gcc -O3 -fopenmp -DSTREAM_ARRAY_SIZE=50000000 /opt/benchmarks/stream.c \ + -o /usr/local/bin/stream_c \ + && git clone --depth 1 --branch v5.0 https://github.com/UoB-HPC/BabelStream \ + /opt/benchmarks/BabelStream \ + && cmake -S /opt/benchmarks/BabelStream -B /opt/benchmarks/BabelStream/build \ + -DMODEL=cuda -DCMAKE_CUDA_COMPILER=/usr/local/cuda/bin/nvcc \ + -DCUDA_ARCH=all-major \ + && cmake --build /opt/benchmarks/BabelStream/build -j \ + && install -m 0755 /opt/benchmarks/BabelStream/build/cuda-stream /usr/local/bin/cuda-stream \ + && rm -rf /opt/benchmarks/BabelStream + +# Copy only requirements.txt first for better Docker layer caching. +COPY tutorials/${ACH_TUTORIAL}/brev/requirements.txt /opt/requirements.txt +COPY tutorials/${ACH_TUTORIAL}/brev/ipython-startup-matplotlib-theme.py /usr/local/etc/ipython/startup/10-matplotlib-theme.py + +# Install Python packages. Build mpi4py against MPICH so notebook-local MPI +# launches work inside CSCS Slurm Container Engine steps. +RUN MPICC=/usr/bin/mpicc.mpich \ + pip install --no-cache-dir --root-user-action=ignore --no-binary=mpi4py -r /opt/requirements.txt \ + && rm -f /opt/requirements.txt + +# Install Nsight Systems and Nsight Compute. +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "${arch}" in \ + amd64) \ + nsys_file="${NSIGHT_SYSTEMS_AMD64_DEB_FILE}"; \ + ncu_repo_arch="ubuntu2204/x86_64"; \ + ncu_deb="nsight-compute-${NSIGHT_COMPUTE_VERSION}_${NSIGHT_COMPUTE_DEB_VERSION}_amd64.deb"; \ + ;; \ + arm64) \ + nsys_file="${NSIGHT_SYSTEMS_ARM64_DEB_FILE}"; \ + ncu_repo_arch="ubuntu2204/sbsa"; \ + ncu_deb="nsight-compute-${NSIGHT_COMPUTE_VERSION}_${NSIGHT_COMPUTE_DEB_VERSION}_arm64.deb"; \ + ;; \ + *) \ + echo "Unsupported architecture for Nsight packages: ${arch}" >&2; \ + exit 1; \ + ;; \ + esac; \ + nsys_url="https://developer.nvidia.com/downloads/assets/tools/secure/nsight-systems/${nsys_file}"; \ + nsys_deb="${nsys_file##*/}"; \ + curl -fL -o "${nsys_deb}" "${nsys_url}"; \ + curl -fL -o "${ncu_deb}" "https://developer.download.nvidia.com/compute/cuda/repos/${ncu_repo_arch}/${ncu_deb}"; \ + apt-get update -y; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "./${nsys_deb}" "./${ncu_deb}"; \ + rm -f "${nsys_deb}" "${ncu_deb}"; \ + test -x "${ACH_NSYS_PATH}"; \ + test -x "${ACH_NCU_PATH}"; \ + apt-get clean -y; \ + rm -rf /var/lib/apt/lists/* + +# Install profiler-backed kernels for in-place notebook cell profiling. +RUN nsightful-ncu install --sys-prefix '--profiler-args=--set full --clock-control none' \ + && nsightful-nsys install --sys-prefix '--profiler-args=--trace=cuda,nvtx,osrt' + +# Disable unnecessary default Jupyter extensions. +RUN python -m jupyter labextension disable "@jupyterlab/apputils-extension:announcements" \ + && python -m jupyter labextension disable "@jupyterlab/console-extension:tracker" + +# LLVM, CppInterOP, CppJIT +ARG CPPJIT_REPO=https://github.com/aaronj0/cppjit-compres.git +ARG CPPJIT_TAG=isc2026 +ENV CUDA=/usr/local/cuda-13.1 \ + CPPJIT=/usr/local/lib/python${PYTHON_VERSION}/dist-packages/cppjit + +# Patched LLVM 21.1.8 from the builder stage (install only). +COPY --from=llvm-builder /opt/llvm /opt/llvm + +RUN set -eux; \ + case "$(dpkg --print-architecture)" in \ + amd64) cuda_target="x86_64-linux" ;; \ + arm64) cuda_target="sbsa-linux" ;; \ + *) echo "Unsupported CUDA target architecture: $(dpkg --print-architecture)" >&2; exit 1 ;; \ + esac; \ + test -d "${CUDA}/targets/${cuda_target}"; \ + ln -sfn "${cuda_target}" "${CUDA}/targets/native-linux" + +RUN apt-get update -y \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + zlib1g-dev libzstd-dev \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone --depth 1 --branch ${CPPJIT_TAG} ${CPPJIT_REPO} /opt/cppjit-src \ + && pip install --no-cache-dir --root-user-action=ignore /opt/cppjit-src \ + --config-settings=cmake.define.LLVM_DIR=/opt/llvm/lib/cmake/llvm \ + --config-settings=cmake.define.Clang_DIR=/opt/llvm/lib/cmake/clang \ + && rm -rf /opt/cppjit-src + +ENV CPPJIT_ENABLE_CUDA=1 +ENV CPLUS_INCLUDE_PATH=/usr/include/python${PYTHON_VERSION}:${CPPJIT}/cppyy_backend/include:${CUDA}/include:${CUDA}/targets/native-linux/include/cccl:${CUDA}/targets/native-linux/include +ENV LD_LIBRARY_PATH=${CUDA}/lib64:${CUDA}/targets/native-linux/lib:${CPPJIT}/cppyy_backend/lib:/usr/local/nvidia/lib:/usr/local/nvidia/lib64:${LD_LIBRARY_PATH} + +# Build-time sanity check +RUN test -f ${CUDA}/nvvm/libdevice/libdevice.10.bc \ + && /opt/llvm/bin/clang-repl --version >/dev/null \ + && CPPJIT_ENABLE_CUDA=0 python -c "import cppjit, os; p=os.path.dirname(cppjit.__file__); \ +assert p=='${CPPJIT}', p; \ +assert os.path.exists(p+'/cppyy_backend/lib/libclangCppInterOp.so'), 'CppInterOp .so missing'; \ +print('cppjit layer OK at', p)" + +COPY --chmod=0777 . /accelerated-computing-hub + +WORKDIR /accelerated-computing-hub/tutorials/${ACH_TUTORIAL}/notebooks + +ENTRYPOINT ["/accelerated-computing-hub/brev/entrypoint.bash", "jupyter"] diff --git a/tutorials/pyhpc/brev/ipython-startup-matplotlib-theme.py b/tutorials/pyhpc/brev/ipython-startup-matplotlib-theme.py new file mode 100644 index 00000000..bd3a0087 --- /dev/null +++ b/tutorials/pyhpc/brev/ipython-startup-matplotlib-theme.py @@ -0,0 +1,49 @@ +"""Apply the Jupyter theme lazily when Matplotlib is first imported.""" + +import importlib.abc as _importlib_abc +import importlib.machinery as _importlib_machinery +import sys as _sys + + +def _apply_matplotlib_theme(): + from jupyter_dark_detect import is_dark + from matplotlib import style + + style.use("dark_background" if is_dark() else "default") + + +class _MatplotlibThemeLoader(_importlib_abc.Loader): + def __init__(self, loader, finder): + self._loader = loader + self._finder = finder + + def create_module(self, spec): + if hasattr(self._loader, "create_module"): + return self._loader.create_module(spec) + return None + + def exec_module(self, module): + try: + self._loader.exec_module(module) + finally: + _sys.meta_path.remove(self._finder) + module.__loader__ = self._loader + module.__spec__.loader = self._loader + _apply_matplotlib_theme() + + +class _MatplotlibThemeFinder(_importlib_abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname != "matplotlib": + return None + + spec = _importlib_machinery.PathFinder.find_spec(fullname, path, target) + if spec is not None and spec.loader is not None: + spec.loader = _MatplotlibThemeLoader(spec.loader, self) + return spec + + +if "matplotlib" in _sys.modules: + _apply_matplotlib_theme() +else: + _sys.meta_path.insert(0, _MatplotlibThemeFinder()) diff --git a/tutorials/pyhpc/brev/requirements.txt b/tutorials/pyhpc/brev/requirements.txt new file mode 100644 index 00000000..4e6616cf --- /dev/null +++ b/tutorials/pyhpc/brev/requirements.txt @@ -0,0 +1,37 @@ +numpy == 2.3.5 +matplotlib == 3.10.9 +psutil == 7.2.2 + +# Jupyter +jupyter == 1.1.1 +jupyter-server-proxy == 4.4.0 +jupyterlab == 4.5.4 +jupyterlab-execute-time == 3.3.0 +jupyter-dark-detect == 0.1.0 +nbformat == 5.10.4 +nbconvert == 7.17.1 + +# Programming models and interop (notebooks 08-14: the SWE ladder) +jax[cuda13] == 0.10.0 +pyomp == 0.5.1 +nanobind == 2.12.0 +pybind11 == 3.0.4 + +# CuPy and CUDA kernels (notebooks 00-05: ported accelerated-python exercises) +cupy-cuda13x == 13.6.0 +numba-cuda[cu13] == 0.28.2 +cuda-cccl[test-cu13] == 0.4.5 + +# NVIDIA developer tools (notebooks 03-05: Nsight Systems / Nsight Compute profiling) +nvtx == 0.2.14 +nsightful[notebook] @ git+https://github.com/brycelelbach/nsightful.git@a41989403430168e02ac3cfdc4060bca4ebb8040 + +# Distributed computing (notebooks 06 and 13: mpi4py) +mpi4py == 4.1.1 + +# Python/C++ Interop (notebook 07) +cffi == 2.0.0 +memory_profiler == 0.61.0 + +# Testing +pytest == 9.0.3 diff --git a/tutorials/pyhpc/brev/test-cscs.bash b/tutorials/pyhpc/brev/test-cscs.bash new file mode 100755 index 00000000..fc727a41 --- /dev/null +++ b/tutorials/pyhpc/brev/test-cscs.bash @@ -0,0 +1,86 @@ +#! /bin/bash +# +# Run the PyHPC tutorial validation suite on CSCS Slurm Container Engine. +# +# This script is intended to run on a CSCS login node using an EDF published +# to the generated branch by GitHub CI. + +set -euo pipefail + +if [ -z "${CSCS_ACCOUNT:-}" ]; then + echo "Error: set CSCS_ACCOUNT to the CSCS project/account for srun." >&2 + exit 2 +fi + +if [ -z "${CSCS_EDF:-}" ]; then + echo "Error: set CSCS_EDF to a generated-branch EDF file." >&2 + exit 2 +fi + +if [ ! -f "${CSCS_EDF}" ]; then + echo "Error: CSCS_EDF does not exist: ${CSCS_EDF}" >&2 + exit 2 +fi + +CSCS_PARTITION="${CSCS_PARTITION:-normal}" +CSCS_NOTEBOOK_TIME="${CSCS_NOTEBOOK_TIME:-02:00:00}" +CSCS_PACKAGE_TIME="${CSCS_PACKAGE_TIME:-00:20:00}" +CSCS_PROFILE_TIME="${CSCS_PROFILE_TIME:-00:20:00}" + +run_step() { + local name=$1 + shift + + echo "" + echo "==========================================" + echo "${name}" + echo "==========================================" + "$@" +} + +run_container_tests() { + local time_limit=$1 + local args=$2 + + srun -A "${CSCS_ACCOUNT}" -p "${CSCS_PARTITION}" -t "${time_limit}" -N1 -n1 \ + --environment="${CSCS_EDF}" \ + env ACH_RUN_TESTS=1 ACH_TEST_ARGS="${args}" \ + /accelerated-computing-hub/brev/entrypoint.bash base +} + +run_step "Package smoke tests" \ + run_container_tests "${CSCS_PACKAGE_TIME}" "test/test_packages.py" + +run_step "Notebook ladder" \ + run_container_tests "${CSCS_NOTEBOOK_TIME}" "test/test_notebooks.py" + +# Expand the profiling script inside the allocated container, not here. +# shellcheck disable=SC2016 +run_step "Manual nsys/ncu profiler validation" \ + srun -A "${CSCS_ACCOUNT}" -p "${CSCS_PARTITION}" -t "${CSCS_PROFILE_TIME}" -N1 -n1 \ + --environment="${CSCS_EDF}" bash -lc ' + set -euo pipefail + workdir=$(mktemp -d /tmp/pyhpc-profile.XXXXXX) + cd "${workdir}" + cat > profile_smoke.py << "PY" +import cupy as cp +x = cp.arange(1 << 20, dtype=cp.float32) +y = cp.sin(x) + cp.cos(x) +print(float(y.sum())) +cp.cuda.runtime.deviceSynchronize() +PY + python profile_smoke.py + nsys profile --stats=false --cuda-event-trace=false \ + --force-overwrite true -o profile_smoke python profile_smoke.py + test -s profile_smoke.nsys-rep + nsys export --type sqlite --quiet true --force-overwrite true \ + -o profile_smoke.sqlite profile_smoke.nsys-rep + test -s profile_smoke.sqlite + ncu -f --kernel-name regex:.* --set full \ + -o profile_smoke python profile_smoke.py + test -s profile_smoke.ncu-rep + ncu --import profile_smoke.ncu-rep --csv | sed -n "1,20p" + ' + +echo "" +echo "CSCS PyHPC validation completed successfully." diff --git a/tutorials/pyhpc/brev/test.bash b/tutorials/pyhpc/brev/test.bash new file mode 100755 index 00000000..7babb9ea --- /dev/null +++ b/tutorials/pyhpc/brev/test.bash @@ -0,0 +1,79 @@ +#! /bin/bash +# +# Run tests for the pyhpc tutorial. +# +# When called with no arguments, runs both test suites (packages, notebooks). +# When called with arguments: +# - Bare words (e.g. "06") are treated as a pytest -k filter for notebook tests. +# - Paths or flags (e.g. "test/test_packages.py", "-k foo") are forwarded to +# pytest directly. +# +# Usage: +# ./test.bash # run all suites +# ./test.bash 06 # run notebook tests matching "06" +# ./test.bash "06 or 07" # run notebook tests matching "06 or 07" +# ./test.bash test/test_packages.py # run only package tests +# ./test.bash -k "cupy" # forward raw pytest flags + +START_TIME=$(date +%s.%N) + +if command -v nvidia-smi >/dev/null 2>&1; then + nvidia-smi || exit 1 +else + NVIDIA_GPU_DEVICE=$(find /dev -maxdepth 1 -type c \ + -name 'nvidia[0-9]*' -print -quit 2>/dev/null) + if [ -n "${NVIDIA_GPU_DEVICE}" ]; then + echo "NVIDIA GPU device ${NVIDIA_GPU_DEVICE} is available; nvidia-smi is not installed" + else + echo "Error: no NVIDIA GPU is available" >&2 + exit 1 + fi +fi + +TUTORIAL_ROOT=/accelerated-computing-hub/tutorials/pyhpc + +if [ $# -gt 0 ]; then + if [[ "$1" == -* ]] || [[ "$1" == */* ]] || [[ "$1" == *.py ]]; then + PYTEST_ARGS=() + for ARG in "$@"; do + if [[ "${ARG}" != -* ]] && [[ "${ARG}" == */* || "${ARG}" == *.py ]] && [ ! -e "${ARG}" ] && [ -e "${TUTORIAL_ROOT}/${ARG}" ]; then + PYTEST_ARGS+=("${TUTORIAL_ROOT}/${ARG}") + else + PYTEST_ARGS+=("${ARG}") + fi + done + echo "Running: pytest ${PYTEST_ARGS[*]}" + pytest "${PYTEST_ARGS[@]}" + else + echo "Running: pytest ${TUTORIAL_ROOT}/test/test_notebooks.py -k \"$*\"" + pytest "${TUTORIAL_ROOT}/test/test_notebooks.py" -k "$*" + fi + EXIT_CODE=$? +else + # Run package tests. + echo "Running package tests..." + pytest "${TUTORIAL_ROOT}/test/test_packages.py" + EXIT_CODE_PACKAGES=$? + + # Test notebooks (solutions where they exist, exercises otherwise). + echo "" + echo "Running notebook tests..." + pytest "${TUTORIAL_ROOT}/test/test_notebooks.py" + EXIT_CODE_NOTEBOOKS=$? + + # Overall exit code is non-zero if any test suite failed. + EXIT_CODE=$((EXIT_CODE_PACKAGES || EXIT_CODE_NOTEBOOKS)) +fi + +END_TIME=$(date +%s.%N) +ELAPSED=$(awk "BEGIN {print $END_TIME - $START_TIME}") + +echo "" +awk -v elapsed="$ELAPSED" 'BEGIN { + hours = int(elapsed / 3600) + minutes = int((elapsed % 3600) / 60) + seconds = elapsed % 60 + printf "Elapsed time: %dh %dm %.3fs\n", hours, minutes, seconds +}' + +exit $EXIT_CODE diff --git a/tutorials/pyhpc/notebooks/00__numpy.ipynb b/tutorials/pyhpc/notebooks/00__numpy.ipynb new file mode 100644 index 00000000..c0c3ee7e --- /dev/null +++ b/tutorials/pyhpc/notebooks/00__numpy.ipynb @@ -0,0 +1,372 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d2e341ff-0c1e-40e8-8c33-9e3039de8013", + "metadata": { + "id": "d2e341ff-0c1e-40e8-8c33-9e3039de8013" + }, + "source": [ + "## NumPy" + ] + }, + { + "cell_type": "markdown", + "id": "a5ba6a1c", + "metadata": {}, + "source": [ + "### Table of Contents\n", + "\n", + "1. [The De Facto Standard for Array Data](#1.-The-De-Facto-Standard-for-Array-Data)\n", + "2. [Anatomy of an `ndarray`: Structure and Memory](#2.-Anatomy-of-an-`ndarray`:-Structure-and-Memory)\n", + "3. [Array Creation and Logical Views (Views vs. Copies)](#3.-Array-Creation-and-Logical-Views-(Views-vs.-Copies))\n", + "4. [Aggregations and Axes](#4.-Aggregations-and-Axes)\n", + "5. [Broadcasting: The \"Stretch\" Rule](#5.-Broadcasting:-The-\"Stretch\"-Rule)\n", + "6. [Why Vectorize? The Speed Advantage](#6.-Why-Vectorize?-The-Speed-Advantage)" + ] + }, + { + "cell_type": "markdown", + "id": "b30427de", + "metadata": {}, + "source": [ + "### 1. The De Facto Standard for Array Data\n", + "\n", + "NumPy is the foundational library for High Performance Computing (HPC) and Machine Learning (ML) in Python. Libraries like PyTorch, Pandas, and Scikit-learn are built upon or mirror the NumPy API. Learning NumPy is essential for mastering the Array Programming paradigm.\n", + "\n", + "NumPy provides the `ndarray` (N-dimensional array), a powerful, high-performance, and uniform container that enables highly efficient memory management, indexing, slicing, and, most importantly, vectorized arithmetic." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc4596d8-d9ff-4c66-8822-246c0fc830c7", + "metadata": { + "id": "cc4596d8-d9ff-4c66-8822-246c0fc830c7" + }, + "outputs": [], + "source": [ + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "id": "c59fce80", + "metadata": {}, + "source": [ + "### 2. Anatomy of an `ndarray`: Structure and Memory\n", + "\n", + "Unlike a standard Python list, an `ndarray` is a fixed-size, structured block of contiguous memory. Its efficiency comes from these four key, immutable properties:\n", + "\n", + "- **Data**: A pointer to the memory location holding the elements.\n", + "- **dtype**: The data type (e.g., `int32`, `float64`) which is uniform across all elements.\n", + "- **Shape**: A tuple defining the size along each dimension (e.g., `(100, 50)` for 100 rows and 50 columns).\n", + "- **Strides**: The number of bytes to step in memory to reach the next element along each dimension. This is how NumPy efficiently handles different shapes and views.\n", + "\n", + "Let's explore these properties by creating a large dataset.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "- `np.arange(start, stop, step)`: Returns evenly spaced values in the half-open interval $[\\text{start}, \\text{stop})$.\n", + "- `arr.nbytes`: Total bytes of storage for the array's elements.\n", + "- `arr.ndim`: The number of array dimensions (integer).\n", + "- `arr.size`: The total number of elements in the array (integer).\n", + "- `arr.shape`: The tuple of array dimensions.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "465e35bd", + "metadata": {}, + "outputs": [], + "source": [ + "# Use a large number to clearly demonstrate the memory density of ndarrays\n", + "N = 50_000_000" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5f1a613f-bc87-4950-b195-a66bb5bc05d3", + "metadata": { + "id": "5f1a613f-bc87-4950-b195-a66bb5bc05d3" + }, + "outputs": [], + "source": [ + "# TODO: Create the input data array with the numbers 1 to 50_000_000 (inclusive).\n", + "# Hint: np.arange generates values within a half-open interval [start, stop)\n", + "arr = ...\n", + "arr" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50530f2c-29bf-4061-8f84-bc5be00a5622", + "metadata": { + "id": "50530f2c-29bf-4061-8f84-bc5be00a5622" + }, + "outputs": [], + "source": [ + "# TODO: Calculate how large the array is in GB with nbytes.\n", + "# Hint: GB is 2**30 bytes. The .nbytes attribute returns the total bytes consumed by the elements.\n", + "# Note: This demonstrates that arrays are dense memory blocks, unlike pointer-heavy Python lists.\n", + "arr..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ffc15dad-e2fd-4b96-8b39-3496519d0656", + "metadata": { + "id": "ffc15dad-e2fd-4b96-8b39-3496519d0656" + }, + "outputs": [], + "source": [ + "# TODO: How many dimensions does the array have?\n", + "arr..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b15cdf25-eb35-4926-b306-90ffd62b3d28", + "metadata": { + "id": "b15cdf25-eb35-4926-b306-90ffd62b3d28" + }, + "outputs": [], + "source": [ + "# TODO: How many elements does the array have?\n", + "arr..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63887722-c9d7-405e-a019-e75646115541", + "metadata": { + "id": "63887722-c9d7-405e-a019-e75646115541" + }, + "outputs": [], + "source": [ + "# TODO: What is the shape of the array?\n", + "arr..." + ] + }, + { + "cell_type": "markdown", + "id": "f5e58ee4", + "metadata": {}, + "source": [ + "### 3. Array Creation and Logical Views (Views vs. Copies)\n", + "\n", + "Arrays can logically represent data in many ways (e.g., 1D signal, 2D image, 4D video batch) independent of the underlying physical memory block.\n", + "\n", + "Most operations, like adding two arrays together, returns a **Copy**, which requires allocating a new array, which can negatively impact performance.\n", + "\n", + "Some operations, like transposing or `reshape()` often return a **View** instead of a **Copy**. A View only changes the metadata (`shape` and `strides`) without duplicating the physical data, making these operations nearly instantaneous.\n", + "\n", + "Most Copy operations take an `out` parameter that takes an array; if it provided, the result is written to that array instead of allocating a new one. For example, `A + B` or `np.add(A, B)` will return a new array with the result, but `np.add(A + B, out=A)` will place the result in `A` without an allocation.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "- `np.linspace(start, stop, num)`: Returns `num` evenly spaced samples, calculated over the interval $[\\text{start}, \\text{stop}]$.\n", + "- `np.random.default_rng().random(size)`: Returns random floats in $[0.0, 1.0)$. `size` can be a tuple.\n", + "- `arr.sort()`: Sorts an array in-place (modifies the original data). Use `np.sort(arr)` to return a sorted copy.\n", + "- `arr.reshape(new_shape)`: Returns a View with a new shape. One dimension can be -1, instructing NumPy to calculate the size automatically.\n", + "- `np.resize(arr, new_shape)`: Returns a new array with the specified shape. If the new shape is larger, it fills the new elements by repeating the original array.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1527b4f6-5d75-47d4-97e0-d0e78bbc59f9", + "metadata": { + "id": "1527b4f6-5d75-47d4-97e0-d0e78bbc59f9" + }, + "outputs": [], + "source": [ + "# TODO: Create a new array with 5_000_000 elements containing equally spaced values between 0 to 1000 (inclusive).\n", + "arr = ...\n", + "arr" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2f51aa2e-b994-4a91-aed6-4a4632eb7050", + "metadata": { + "id": "2f51aa2e-b994-4a91-aed6-4a4632eb7050" + }, + "outputs": [], + "source": [ + "# TODO: Create a random array that is 10_000 rows by 5_000 columns.\n", + "arr = ...\n", + "arr" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ec06270-6e08-4cce-9385-9dc8b53e95fd", + "metadata": { + "id": "4ec06270-6e08-4cce-9385-9dc8b53e95fd" + }, + "outputs": [], + "source": [ + "# TODO: Sort that array (in-place).\n", + "# Note: arr.sort() modifies the array directly, which is typically faster than creating a copy.\n", + "arr..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cdde560b-5ba6-484c-a601-00b7ef71273d", + "metadata": { + "id": "cdde560b-5ba6-484c-a601-00b7ef71273d" + }, + "outputs": [], + "source": [ + "# TODO: Reshape the array to have the last dimension of length 5.\n", + "# Ensure that the operation only changes the logical view without duplicating the physical data pointer.\n", + "# Hint: You can use -1 for one dimension to let NumPy automatically calculate the size based on the total elements.\n", + "arr_new = ...\n", + "arr_new" + ] + }, + { + "cell_type": "markdown", + "id": "54982876", + "metadata": {}, + "source": [ + "### 4. Aggregations and Axes\n", + "\n", + "When performing aggregations (like `sum`, `mean`, `max`), you must specify the **Axis** you want to collapse (or reduce) the array along.\n", + "\n", + "- **Axis 0**: The first dimension (often rows in 2D). Aggregating across Axis 0 produces a result for each column.\n", + "- **Axis 1**: The second dimension (often columns in 2D). Aggregating across Axis 1 produces a result for each row.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "- `np.sum(a, axis=None)`: Sum of array elements over a given axis.\n", + " - `axis=0`: Collapse the rows (sum vertical columns).\n", + " - `axis=1`: Collapse the columns (sum horizontal rows).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44dd3ac2-c9b7-4327-ba63-860b074c0583", + "metadata": { + "id": "44dd3ac2-c9b7-4327-ba63-860b074c0583" + }, + "outputs": [], + "source": [ + "# TODO: Find the sum of each row in the reshaped array (arr_new) above.\n", + "# Hint: To sum the row's content, we must reduce across the columns.\n", + "arr_sum = ...\n", + "arr_sum" + ] + }, + { + "cell_type": "markdown", + "id": "ed072cee", + "metadata": {}, + "source": [ + "### 5. Broadcasting: The \"Stretch\" Rule\n", + "\n", + "Broadcasting is NumPy's mechanism for performing arithmetic between arrays of different shapes. If dimensions don't match, NumPy attempts to \"stretch\" the smaller array to match the larger one.\n", + "\n", + "**The Compatibility Rule:** Two dimensions are compatible when:\n", + "1. They are equal, or\n", + "2. One of them is 1.\n", + "\n", + "If a dimension is 1, NumPy logically copies that single value across the dimension to match the other array's shape **without allocating any new memory**.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "- **Arithmetic Operators** (`/`, `*`, `+`, `-`): These operate element-wise. Broadcasting occurs if shapes are different but compatible.\n", + "- `np.allclose(a, b)`: Returns `True` if two floating-point arrays are element-wise equal within a tolerance. Essential for comparisons instead of using `==`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b15342af-2916-481a-9724-9874acf4ed24", + "metadata": { + "id": "b15342af-2916-481a-9724-9874acf4ed24" + }, + "outputs": [], + "source": [ + "# TODO: Normalize each row of the 2D array (arr_new) by dividing by the sum you just computed (arr_sum).\n", + "# Hint: 'arr_new' is (M, N) and 'arr_sum' is (M,). To successfully divide, you may need to reshape 'arr_sum' to (M, 1)\n", + "# so that broadcasting can stretch it across the N columns.\n", + "arr_normalized = ...\n", + "arr_normalized" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b04622b8-c6de-4756-8a56-e3d2835a5eaf", + "metadata": { + "id": "b04622b8-c6de-4756-8a56-e3d2835a5eaf" + }, + "outputs": [], + "source": [ + "# EXTRA CREDIT: Prove that your normalized array is actually normalized.\n", + "# Hint: If normalized correctly, the sum of every row should now be 1.0.\n", + "# Check if the new row sums are close to 1.0 using np.allclose." + ] + }, + { + "cell_type": "markdown", + "id": "31657dd2", + "metadata": {}, + "source": [ + "### 6. Why Vectorize? The Speed Advantage\n", + "\n", + "The entire Array Programming paradigm hinges on **Vectorization**.\n", + "\n", + "Why use complex shapes and broadcasting instead of simple Python `for` loops?\n", + "\n", + "NumPy's array functions are implemented in highly optimized native code (C/C++, Fortran). An operation like `A + A**2`, where `A` is a massive `ndarray`, is often $\\mathbf{100\\times}$ faster than performing the equivalent element-wise operation using explicit Python loops.\n", + "\n", + "**Always choose a vectorized NumPy function or operator over a manual Python loop.**" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/01__cupy.ipynb b/tutorials/pyhpc/notebooks/01__cupy.ipynb new file mode 100644 index 00000000..583bea16 --- /dev/null +++ b/tutorials/pyhpc/notebooks/01__cupy.ipynb @@ -0,0 +1,528 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f966f67f", + "metadata": {}, + "source": [ + "## CuPy\n", + "\n", + "### Table of Contents\n", + "1. [Creating Arrays: CPU vs. GPU](#1.-Creating-Arrays:-CPU-vs.-GPU)\n", + "2. [Basic Operations](#2.-Basic-Operations)\n", + " - [Sequential Operations & Memory](#Sequential-Operations-&-Memory)\n", + "3. [Complex Operations (Linear Algebra)](#3.-Complex-Operations-(Linear-Algebra))\n", + " - [Agnostic Code (NumPy Dispatch)](#Agnostic-Code-(NumPy-Dispatch))\n", + "4. [Device Management](#4.-Device-Management)\n", + "5. [Exercise - NumPy to CuPy](#Exercise---NumPy-to-CuPy)\n", + " - [Part 1](#Part-1)\n", + " - [Part 2](#Part-2)\n", + "\n", + "---\n", + "\n", + "Let's shift gears to high-level array functionality using **[CuPy](https://cupy.dev/)**.\n", + "\n", + "#### What is CuPy?\n", + "CuPy is a library that implements the familiar **NumPy API** but runs on the GPU (using CUDA C++ in the backend). \n", + "\n", + "**Why use it?**\n", + "* **Zero Friction:** If you know NumPy, you already know CuPy.\n", + "* **Speed:** It provides out-of-the-box GPU acceleration for array operations.\n", + "* **Ease of use:** You can often port CPU code to GPU simply by changing `import numpy as np` to `import cupy as cp`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d369bcdc", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import cupy as cp\n", + "import cupyx as cpx\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# Helper to display benchmark results concisely.\n", + "def print_benchmark(result):\n", + " \"\"\"Print benchmark result using wall-clock (cpu_times) for fair comparison.\"\"\"\n", + " avg_ms = result.cpu_times.mean() * 1000\n", + " std_ms = result.cpu_times.std() * 1000\n", + " print(f\"{result.name}: {avg_ms:.3f} ms +/- {std_ms:.3f} ms\")" + ] + }, + { + "cell_type": "markdown", + "id": "15fc304c", + "metadata": {}, + "source": [ + "### 1. Creating Arrays: CPU vs. GPU\n", + "\n", + "Let's compare the performance of creating a large 3D array (approx. 100 MB in size) on the CPU versus the GPU.\n", + "\n", + "We will use `np.ones()` for the CPU and `cp.ones()` for the GPU.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0f8b002", + "metadata": {}, + "outputs": [], + "source": [ + "# CPU creation\n", + "print_benchmark(cpx.profiler.benchmark(np.ones, ((50, 500, 500),), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19309ca7", + "metadata": {}, + "outputs": [], + "source": [ + "# GPU creation\n", + "print_benchmark(cpx.profiler.benchmark(cp.ones, ((50, 500, 500),), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "markdown", + "id": "ae637eaf", + "metadata": {}, + "source": [ + "We can see here that creating this array on the GPU is much faster than doing so on the CPU!\n", + "\n", + "**About `cupyx.profiler.benchmark()`:**\n", + "\n", + "We use CuPy's built-in `benchmark()` utility for timing GPU operations. This is important because GPU operations are **asynchronous** - when you call a CuPy function, the CPU places a task in the GPU's \"to-do list\" (stream) and immediately moves on without waiting.\n", + "\n", + "The `benchmark()` function handles all the complexity of proper GPU timing for us:\n", + "- It automatically synchronizes GPU streams to get accurate measurements.\n", + "- It runs warm-up iterations to avoid cold-start overhead.\n", + "- It reports both CPU wall-clock times (`cpu_times`) and GPU kernel times (`gpu_times`). We use `cpu_times` for all comparisons because it measures end-to-end wall-clock time, giving a fair apples-to-apples comparison between CPU and GPU code.\n", + "\n", + "This makes it the recommended way to time CuPy code, as it's both accurate and convenient." + ] + }, + { + "cell_type": "markdown", + "id": "6d179e9b", + "metadata": {}, + "source": [ + "### 2. Basic Operations\n", + "\n", + "The syntax for mathematical operations is identical. Let's multiply every value in our arrays by `5`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9ce1a260", + "metadata": {}, + "outputs": [], + "source": [ + "multiply_shape = (50, 500, 500)\n", + "\n", + "def multiply(x):\n", + " return x * 5" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de5bdefb", + "metadata": {}, + "outputs": [], + "source": [ + "# CPU Operation\n", + "x_cpu = np.ones(multiply_shape)\n", + "print_benchmark(cpx.profiler.benchmark(multiply, (x_cpu,), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6a7f32b8", + "metadata": {}, + "outputs": [], + "source": [ + "# GPU Operation\n", + "x_gpu = cp.ones(multiply_shape)\n", + "print_benchmark(cpx.profiler.benchmark(multiply, (x_gpu,), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "markdown", + "id": "bc24579f", + "metadata": {}, + "source": [ + "The GPU completes this operation notably faster, with the code staying the same." + ] + }, + { + "cell_type": "markdown", + "id": "83c69334", + "metadata": {}, + "source": [ + "#### Sequential Operations & Memory\n", + "\n", + "Now let's do a couple of operations sequentially." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e40c724", + "metadata": {}, + "outputs": [], + "source": [ + "sequential_math_shape = (50, 500, 500)\n", + "\n", + "def sequential_math(x):\n", + " x = x * 5\n", + " x = x * x\n", + " x = x + x\n", + " return x" + ] + }, + { + "cell_type": "markdown", + "id": "8bb2eba8", + "metadata": {}, + "source": [ + "Remember, each of these operations will return a **Copy**, not a **View**. This can be a common performance pitfall with NumPy and CuPy!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0294dbc", + "metadata": {}, + "outputs": [], + "source": [ + "# CPU: Sequential math\n", + "x_cpu = np.ones(sequential_math_shape)\n", + "print_benchmark(cpx.profiler.benchmark(sequential_math, (x_cpu,), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "acafdbe7", + "metadata": {}, + "outputs": [], + "source": [ + "# GPU: Sequential math\n", + "x_gpu = cp.ones(sequential_math_shape)\n", + "print_benchmark(cpx.profiler.benchmark(sequential_math, (x_gpu,), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "markdown", + "id": "0f250bbb", + "metadata": {}, + "source": [ + "But even with the copies, the GPU ran that much faster. The copies stay on the GPU; CuPy only transfers data from GPU to CPU when necessary or explicitly requested." + ] + }, + { + "cell_type": "markdown", + "id": "84221268", + "metadata": {}, + "source": [ + "### 3. Complex Operations (Linear Algebra)\n", + "\n", + "GPUs excel at Linear Algebra. Let's look at **Singular Value Decomposition (SVD)**, a computationally heavy $O(N^3)$ operation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49a665a4", + "metadata": {}, + "outputs": [], + "source": [ + "svd_shape = (3000, 1000)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "978af795", + "metadata": {}, + "outputs": [], + "source": [ + "# CPU SVD\n", + "x_cpu = np.random.random(svd_shape)\n", + "print_benchmark(cpx.profiler.benchmark(np.linalg.svd, (x_cpu,), n_repeat=5, n_warmup=1))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e0bc855b", + "metadata": {}, + "outputs": [], + "source": [ + "# GPU SVD\n", + "x_gpu = cp.random.random(svd_shape)\n", + "print_benchmark(cpx.profiler.benchmark(cp.linalg.svd, (x_gpu,), n_repeat=5, n_warmup=1))" + ] + }, + { + "cell_type": "markdown", + "id": "e298f0ea", + "metadata": {}, + "source": [ + "The GPU outperforms the CPU again with exactly the same API!" + ] + }, + { + "cell_type": "markdown", + "id": "4a0870d0", + "metadata": {}, + "source": [ + "#### Agnostic Code (NumPy Dispatch)\n", + "\n", + "A key feature of CuPy is that many **NumPy functions work on CuPy arrays without changing your code**.\n", + "\n", + "When you pass a CuPy GPU array (`x_gpu`) into a NumPy function that supports the `__array_function__` protocol (e.g., `np.linalg.svd()`), NumPy detects the CuPy input and **delegates the operation to CuPy’s own implementation**, which runs on the GPU.\n", + "\n", + "This allows you to write code using standard `np.*` syntax and have it run on either CPU or GPU seamlessly - **as long as CuPy implements an override for that function.**\n", + "\n", + "One common source of hidden performance penalties is **implicit transfers between CPU and GPU**. In some cases, CuPy guards against this: for example, when NumPy tries to convert a `cupy.ndarray` into a `numpy.ndarray` via the `__array__` protocol (e.g. `np.asarray(gpu_array)`), CuPy raises a `TypeError` instead of silently copying data to the host. \n", + "\n", + "However, CuPy **does** perform implicit GPU → CPU transfers in other cases, such as printing a GPU array, converting to a Python scalar (e.g. `float`, `.item()`), or evaluating a GPU scalar in a boolean context. We will explore these implicit transfers in a later notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba4f2863", + "metadata": {}, + "outputs": [], + "source": [ + "# We create the data on the GPU...\n", + "x_gpu = cp.random.random(svd_shape)\n", + "\n", + "# BUT we call the standard NumPy function - CuPy dispatches it to the GPU!\n", + "print_benchmark(cpx.profiler.benchmark(np.linalg.svd, (x_gpu,), n_repeat=5, n_warmup=1))" + ] + }, + { + "cell_type": "markdown", + "id": "6e37faae", + "metadata": {}, + "source": [ + "### 4. Device Management\n", + "\n", + "If you have multiple GPUs, you can use a `with` statement to ensure specific arrays are created on specific devices (e.g., GPU 0 vs GPU 1)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26aa4f57", + "metadata": {}, + "outputs": [], + "source": [ + "with cp.cuda.Device(0):\n", + " x_on_gpu0 = cp.random.random((100000, 1000))\n", + "\n", + "print(f\"Array is on device: {x_on_gpu0.device}\")" + ] + }, + { + "cell_type": "markdown", + "id": "32f7226a", + "metadata": {}, + "source": [ + "**Note:** CuPy functions generally expect all input arrays to be on the **same** device. Passing an array stored on a non-current device may work depending on the hardware configuration but is generally discouraged as it may not be performant.\n" + ] + }, + { + "cell_type": "markdown", + "id": "2e0a4a03", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "id": "d2e341ff-0c1e-40e8-8c33-9e3039de8013", + "metadata": { + "id": "d2e341ff-0c1e-40e8-8c33-9e3039de8013" + }, + "source": [ + "### Exercise - NumPy to CuPy\n", + "\n", + "#### Part 1\n", + "Let's put the \"Drop-in Replacement\" philosophy to the test with the same data pipeline as the previous notebook. Specifically, the single block of code below performs the following steps:\n", + "1) Generate a massive dataset (50 million elements).\n", + "2) Process it using a heavy operation (Sorting).\n", + "3) Manipulate the shape and normalize the data (Broadcasting).\n", + "4) Verify the integrity of the result.\n", + "\n", + "**TODO:**\n", + "1. Run the cell below with `xp = np` (CPU Mode). Note the benchmark output.\n", + "2. Change the setup line to `xp = cp` (GPU Mode). Run it again.\n", + "3. Observe how the exact same logic runs significantly faster on the GPU with CuPy while retaining the implementation properties of NumPy.\n", + "\n", + "Note: We use `cupyx.profiler.benchmark()` for timing, which automatically handles GPU synchronization." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc4596d8-d9ff-4c66-8822-246c0fc830c7", + "metadata": { + "id": "cc4596d8-d9ff-4c66-8822-246c0fc830c7" + }, + "outputs": [], + "source": [ + "# Step 1.) Setup: Choose Your Target\n", + "xp = np # Toggle this to 'cp' for GPU acceleration\n", + "\n", + "print(f\"Running on: {xp.__name__.upper()}\")\n", + "\n", + "# Step 2.) Data Generation\n", + "N = 50_000_000\n", + "print(f\"Generating {N:,} random elements ({N*8/2**30:.2f} GB)...\")\n", + "arr = xp.random.rand(N)\n", + "\n", + "# Step 3.) Heavy Computation (Timed)\n", + "print(\"Sorting data...\")\n", + "# cpx.profiler.benchmark() handles GPU synchronization automatically\n", + "result = cpx.profiler.benchmark(xp.sort, (arr,), n_repeat=5, n_warmup=1)\n", + "print_benchmark(result)\n", + "\n", + "# Step 4.) Manipulation & Broadcasting\n", + "# Purpose: Demonstrate that CuPy supports complex reshaping and broadcasting rules exactly like NumPy.\n", + "# This shows you don't need to rewrite your data processing logic.\n", + "\n", + "# Reshape to a matrix with 5 columns\n", + "arr_new = arr.reshape((-1, 5))\n", + "\n", + "# Normalize: Divide every row by its sum using broadcasting\n", + "row_sums = arr_new.sum(axis=1)\n", + "normalized_matrix = arr_new / row_sums[:, xp.newaxis]\n", + "\n", + "# Step 5.) Verification\n", + "# Purpose: Verify mathematical correctness/integrity of the result.\n", + "check_sums = xp.sum(normalized_matrix, axis=1)\n", + "xp.testing.assert_allclose(check_sums, 1.0)\n", + "\n", + "print(\"Verification: PASSED (All rows sum to 1.0)\")" + ] + }, + { + "cell_type": "markdown", + "id": "077b7589", + "metadata": {}, + "source": [ + "**TODO: When working with CuPy arrays, try changing `xp.testing.assert_allclose()` to `np.testing.assert_allclose()`. What happens and why?**" + ] + }, + { + "cell_type": "markdown", + "id": "AxU_hG5M-LKS", + "metadata": { + "id": "AxU_hG5M-LKS" + }, + "source": [ + "#### Part 2\n", + "We will now create a massive dataset (50 million points) representing a sine wave and see how fast the GPU can sort it compared to the CPU. \n", + "\n", + "**TODO:** \n", + "1) **Generate Data:** Create a NumPy array (`y_cpu`) and a CuPy array (`y_gpu`) representing $\\sin(x)$ from $0$ to $2\\pi$ with `50,000,000` points.\n", + "2) **Benchmark CPU and GPU:** Use `benchmark()` from `cupyx.profiler` to measure both `np.sort()` and `cp.sort()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "EKwfS_iM9Yps", + "metadata": { + "id": "EKwfS_iM9Yps" + }, + "outputs": [], + "source": [ + "# Step 1.) Generate Data\n", + "N = 50_000_000\n", + "print(f\"Generating {N} points...\")\n", + "\n", + "# TODO: Create x_cpu using np.linspace from 0 to 2*pi\n", + "# TODO: Create y_cpu by taking np.sin(x_cpu)\n", + "\n", + "# TODO: Create x_gpu using cp.linspace from 0 to 2*pi\n", + "# TODO: Create y_gpu by taking cp.sin(x_gpu)\n", + "\n", + "\n", + "# Step 2.) Benchmark NumPy (CPU)\n", + "print(\"Benchmarking NumPy Sort (this may take a few seconds)...\")\n", + "# TODO: Use cpx.profiler.benchmark(function, (args,), n_repeat=5, n_warmup=1)\n", + "# Hint: Pass the function `np.sort()` and the argument `(y_cpu,)`\n", + "# Note: The comma in (y_cpu,) is required to make it a tuple!\n", + "\n", + "\n", + "# Step 3.) Benchmark CuPy (GPU)\n", + "print(\"Benchmarking CuPy Sort...\")\n", + "# TODO: Use cpx.profiler.benchmark(function, (args,), n_repeat=5, n_warmup=1)\n", + "# Hint: Pass the function `cp.sort()` and the argument `(y_gpu,)`\n", + "# Note: The comma in (y_gpu,) is required to make it a tuple!" + ] + }, + { + "cell_type": "markdown", + "id": "qnAvEk5QFAA8", + "metadata": { + "id": "qnAvEk5QFAA8" + }, + "source": [ + "**EXTRA CREDIT: Benchmark with different array sizes and find the size at which CuPy and NumPy take the same amount of time. Try to extract the timing data from `cupyx.profiler.benchmark()`'s return value and customize how the output is displayed. You could even make a graph.**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42YwwyrJFTyV", + "metadata": { + "id": "42YwwyrJFTyV" + }, + "outputs": [], + "source": [ + "sizes = [5, 50, 500, 5_000, 50_000, 500_000, 5_000_000, 50_000_000]\n", + "\n", + "# TODO" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/02__power_iteration__cupy__memory_spaces.ipynb b/tutorials/pyhpc/notebooks/02__power_iteration__cupy__memory_spaces.ipynb new file mode 100644 index 00000000..3a0645d9 --- /dev/null +++ b/tutorials/pyhpc/notebooks/02__power_iteration__cupy__memory_spaces.ipynb @@ -0,0 +1,532 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5650dae9", + "metadata": {}, + "source": [ + "## Power Iteration - CuPy - Memory Spaces\n", + "\n", + "### Table of Contents\n", + "1. [Introduction to Memory Spaces](#1-introduction-to-memory-spaces)\n", + "2. [The CPU Baseline (NumPy)](#2-the-cpu-baseline-numpy)\n", + "3. [The GPU Port (CuPy)](#3-the-gpu-port-cupy)\n", + "4. [Optimizing Data Generation](#4-optimizing-data-generation)\n", + "5. [Verification and Benchmarking](#5-verification-and-benchmarking)\n", + "6. [Extra Credit](#extra-credit)\n", + "\n", + "---\n", + "\n", + "### 1. Introduction to Memory Spaces\n", + "\n", + "Before we implement algorithms on the GPU, we must understand the hardware architecture. A heterogeneous system (like the one you are using) consists of two memory spaces: \n", + "\n", + "1. **Host Memory:** Accessible by the CPU.\n", + "2. **Device Memory:** Accessible by the GPU.\n", + "\n", + "To ensure data is accessible from a particular processor, we need to explicity transfer it:\n", + "\n", + "* **Host $\\to$ Device:** Move data to the GPU to compute.\n", + " * Syntax: `x_device = cp.asarray(x_host)`\n", + "* **Device $\\to$ Host:** Move results back to the CPU to save to disk, plot with Matplotlib, or print.\n", + " * Syntax: `y_host = cp.asnumpy(y_device)`\n", + "\n", + "#### Implicit Transfers and Synchronization\n", + "\n", + "It is crucial to understand when CuPy interacts with the CPU implicitly. These interactions can kill performance because they force the GPU to pause (synchronize) while data moves.\n", + "\n", + "CuPy silently transfers and synchronizes when you:\n", + "1. **Print** a GPU array (`print(gpu_array)`).\n", + "2. **Convert** to a Python scalar (`float(gpu_array)` or `.item()`).\n", + "3. **Evaluate** a GPU scalar in a boolean context (`if gpu_scalar > 0:`).\n", + "\n", + "#### The Task\n", + "To understand the implications of these concepts, let's experiment with estimating the dominant eigenvalue of a matrix using the **Power Iteration** algorithm.\n", + "\n", + "Before we dive into the code, let's understand the math behind the algorithm we are implementing.\n", + "\n", + "**Power Iteration** is a classic iterative method used to find the dominant eigenvalue (the eigenvalue with the largest absolute value) and its corresponding eigenvector of a square matrix $A$.\n", + "\n", + "##### How It Works\n", + "\n", + "The core idea is simple: if you repeatedly multiply a vector by a matrix $A$, the vector will eventually converge towards the dominant eigenvector of $A$, regardless of the initial vector you started with (provided the initial vector has some component in the direction of the dominant eigenvector).\n", + "\n", + "##### The Mathematical Steps\n", + "\n", + "Given a square matrix $A$ and a random initial vector $x_0$, the algorithm proceeds as follows for each step $k$:\n", + "\n", + "**1. Matrix-Vector Multiplication:**\n", + "\n", + "We calculate the next approximation of the vector:\n", + "\n", + "$$y = A x_k$$\n", + "\n", + "**2. Eigenvalue Estimation (Rayleigh Quotient):**\n", + "\n", + "We estimate the eigenvalue $\\lambda$ using the current vector. This is essentially projecting $y$ onto $x$:\n", + "\n", + "$$\\lambda_k = \\frac{x_k^T y}{x_k^T x_k} = \\frac{x_k^T A x_k}{x_k^T x_k}$$\n", + "\n", + "**3. Residual Calculation (Error Check):**\n", + "\n", + "We check how close we are to the true definition of an eigenvector ($Ax = \\lambda x$) by calculating the \"residual\" (error):\n", + "\n", + "$$r = ||y - \\lambda_k x_k||$$\n", + "\n", + "If $r$ is close to 0, we have converged.\n", + "\n", + "**4. Normalization:**\n", + "\n", + "To prevent the numbers from exploding (overflow) or vanishing (underflow), we normalize the vector for the next iteration:\n", + "\n", + "$$x_{k+1} = \\frac{y}{||y||}$$\n", + "\n", + "We will start with a standard CPU implementation, port it to the GPU using CuPy, and analyze the performance impact of memory transfers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89b85f06", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import cupy as cp\n", + "import cupyx as cpx\n", + "import time\n", + "from dataclasses import dataclass\n", + "\n", + "# Configuration for the algorithm\n", + "@dataclass\n", + "class PowerIterationConfig:\n", + " dim: int = 10000 # Matrix size (dim x dim)\n", + " dominance: float = 0.05 # How much larger the top eigenvalue is (controls convergence, higher == faster)\n", + " max_steps: int = 1000 # Maximum iterations\n", + " check_frequency: int = 25 # Check for convergence every N steps\n", + " progress: bool = True # Print progress logs\n", + " residual_threshold: float = 1e-10 # Stop if error is below this" + ] + }, + { + "cell_type": "markdown", + "id": "7c644ecf", + "metadata": {}, + "source": [ + "### 2. The CPU Baseline (NumPy)\n", + "\n", + "We generate a random dense matrix that is diagonalizable. This data is generated on the **Host (CPU)** and resides in **Host Memory**.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6d40fdc4", + "metadata": {}, + "outputs": [], + "source": [ + "def generate_host(cfg=PowerIterationConfig()):\n", + " \"\"\"Generates a random diagonalizable matrix on the CPU.\"\"\"\n", + " np.random.seed(42)\n", + "\n", + " # Create eigenvalues: One large one (1.0), the rest smaller\n", + " weak_lam = np.random.random(cfg.dim - 1) * (1.0 - cfg.dominance)\n", + " lam = np.random.permutation(np.concatenate(([1.0], weak_lam)))\n", + "\n", + " # Construct matrix A = P * D * P^-1\n", + " P = np.random.random((cfg.dim, cfg.dim)) # Random invertible matrix\n", + " D = np.diag(np.random.permutation(lam)) # Diagonal matrix of eigenvalues\n", + " A = ((P @ D) @ np.linalg.inv(P)) # The final matrix\n", + " return A\n", + "\n", + "# Generate the data on Host\n", + "print(\"Generating Host Data...\")\n", + "A_host = generate_host()\n", + "print(f\"Host Matrix Shape: {A_host.shape}\")\n", + "print(f\"Data Type: {A_host.dtype}\")" + ] + }, + { + "cell_type": "markdown", + "id": "978870e1", + "metadata": {}, + "source": [ + "#### Implementing Power Iteration (CPU)\n", + "\n", + "As described above, the Power Iteration algorithm repeatedly multiplies a vector $x$ by matrix $A$ ($y = Ax$) and normalizes the result. We initialize this algorithm with a vector of 1s ($x_0$) as our initial guess." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "466a1228", + "metadata": {}, + "outputs": [], + "source": [ + "def estimate_host(A, cfg=PowerIterationConfig()) -> np.ndarray:\n", + " \"\"\"\n", + " Performs power iteration using purely NumPy (CPU).\n", + " \"\"\"\n", + " # Initialize solution vector.\n", + " x = np.ones(A.shape[0], dtype=np.float64)\n", + "\n", + " for i in range(0, cfg.max_steps, cfg.check_frequency):\n", + " # Matrix-Vector multiplication.\n", + " y = A @ x\n", + "\n", + " # Rayleigh quotient.\n", + " lam = (x @ y) / (x @ x)\n", + "\n", + " # Calculate residual (error).\n", + " res = np.linalg.norm(y - lam * x)\n", + "\n", + " # Normalize vector for next step.\n", + " x = y / np.linalg.norm(y)\n", + "\n", + " if cfg.progress:\n", + " print(f\"Step {i}: residual = {res:.3e}\")\n", + "\n", + " # Save a checkpoint.\n", + " np.savetxt(f\"/tmp/host_{i}.txt\", x)\n", + "\n", + " # Convergence check.\n", + " if res < cfg.residual_threshold:\n", + " break\n", + "\n", + " # Run intermediate steps without checking residual to save compute.\n", + " for _ in range(i + 1, min(i + cfg.check_frequency, cfg.max_steps)):\n", + " y = A @ x\n", + " x = y / np.linalg.norm(y)\n", + "\n", + " return (x.T @ (A @ x)) / (x.T @ x)\n", + "\n", + "lam_est_host = estimate_host(A_host)\n", + "\n", + "assert isinstance(lam_est_host, (np.ndarray, np.generic)), \"Must return a NumPy array or NumPy scalar\"\n", + "np.testing.assert_allclose(lam_est_host, 1, atol=1e-4)\n", + "\n", + "print()\n", + "print(\"Dominant Eigenvalue:\", lam_est_host)" + ] + }, + { + "cell_type": "markdown", + "id": "2a6f4be0", + "metadata": {}, + "source": [ + "### 3. The GPU Port (CuPy)\n", + "\n", + "#### Exercise: Port the CPU Implementation to GPU\n", + "\n", + "Now it's your turn! Your task is to convert the `estimate_host` function to run on the GPU using CuPy.\n", + "\n", + "**Remember the rules of Memory Spaces:**\n", + "1. **Transfer:** Move `A_host` from CPU to GPU using `cp.asarray()`.\n", + "2. **Compute:** Perform math using `cp` functions on the GPU.\n", + "3. **Retrieve:** Move result back to CPU using `cp.asnumpy()`.\n", + "\n", + "**Hint:** CuPy tries to replicate the NumPy API. In many cases, you can simply change `np.` to `cp.`. However, CuPy operations *must* run on data present in Device Memory.\n", + "\n", + "**The code below starts as a copy of the CPU implementation. Modify it to run on the GPU:**\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b696ad3", + "metadata": {}, + "outputs": [], + "source": [ + "def estimate_device(A, cfg=PowerIterationConfig()) -> np.ndarray:\n", + " \"\"\"\n", + " TODO: Port the power iteration algorithm to the GPU using CuPy.\n", + "\n", + " Steps to complete:\n", + " 1. Transfer the input matrix A to the GPU\n", + " 2. Initialize the vector x on the GPU\n", + " 3. Replace np operations with cp operations\n", + " 4. Copy x from device to host and save a checkpoint\n", + " 5. Return the result as a NumPy array\n", + " \"\"\"\n", + " # Initialize solution vector\n", + " x = np.ones(A.shape[0], dtype=np.float64)\n", + "\n", + " for i in range(0, cfg.max_steps, cfg.check_frequency):\n", + " # Matrix-Vector multiplication\n", + " y = A @ x\n", + "\n", + " # Rayleigh quotient: (x . y) / (x . x)\n", + " lam = (x @ y) / (x @ x)\n", + "\n", + " # Calculate residual (error)\n", + " res = np.linalg.norm(y - lam * x)\n", + "\n", + " # Normalize vector for next step\n", + " x = y / np.linalg.norm(y)\n", + "\n", + " if cfg.progress:\n", + " print(f\"Step {i}: residual = {res:.3e}\")\n", + "\n", + " np.savetxt(f\"/tmp/host_{i}.txt\", x) # Save a checkpoint.\n", + "\n", + " # Convergence check\n", + " if res < cfg.residual_threshold:\n", + " break\n", + "\n", + " # Run intermediate steps without checking residual to save compute\n", + " for _ in range(i + 1, min(i + cfg.check_frequency, cfg.max_steps)):\n", + " y = A @ x\n", + " x = y / np.linalg.norm(y)\n", + "\n", + "lam_est_device = estimate_device(A_host)\n", + "\n", + "assert isinstance(lam_est_device, (np.ndarray, np.generic)), \"Must return a NumPy array or NumPy scalar\"\n", + "np.testing.assert_allclose(lam_est_device, 1, atol=1e-4)\n", + "\n", + "print()\n", + "print(\"Dominant Eigenvalue:\", lam_est_device)" + ] + }, + { + "cell_type": "markdown", + "id": "0f1199c7", + "metadata": {}, + "source": [ + "### 4. Optimizing Data Generation\n", + "\n", + "In the previous step, we generated data on the CPU and copied it to the GPU. For large datasets, the transfer time from host to device can be a bottleneck. \n", + "\n", + "It is almost always faster to **generate** the data directly on the GPU if possible.\n", + "\n", + "#### Exercise: Generate Data Directly on the GPU\n", + "\n", + "Your task is to convert the `generate_host` function to generate the matrix directly on the GPU using CuPy's random functions.\n", + "\n", + "**Hints:**\n", + "- Use `cp.random.seed()` instead of `np.random.seed()`\n", + "- Use `cp.random.random()` instead of `np.random.random()`\n", + "- Use `cp.random.permutation()` instead of `np.random.permutation()`\n", + "- Use `cp.concatenate()`, `cp.array()`, `cp.diag()`, and `cp.linalg.inv()`\n", + "\n", + "**The code below starts as a copy of the CPU implementation. Modify it to generate data directly on the GPU:**\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1f9fc26", + "metadata": {}, + "outputs": [], + "source": [ + "def generate_device(cfg=PowerIterationConfig()):\n", + " \"\"\"\n", + " TODO: Port this CPU implementation to generate data directly on the GPU.\n", + " Replace all np operations with their cp equivalents:\n", + " - np.random.seed -> cp.random.seed\n", + " - np.random.random -> cp.random.random\n", + " - np.random.permutation -> cp.random.permutation\n", + " - np.concatenate -> cp.concatenate\n", + " - np.diag -> cp.diag\n", + " - np.linalg.inv -> cp.linalg.inv\n", + " \"\"\"\n", + " np.random.seed(42)\n", + "\n", + " # Create eigenvalues: One large one (1.0), the rest smaller\n", + " weak_lam = np.random.random(cfg.dim - 1) * (1.0 - cfg.dominance)\n", + " lam = np.random.permutation(np.concatenate(([1.0], weak_lam)))\n", + "\n", + " # Construct matrix A = P * D * P^-1\n", + " P = np.random.random((cfg.dim, cfg.dim))\n", + " D = np.diag(np.random.permutation(lam))\n", + " A = ((P @ D) @ np.linalg.inv(P))\n", + " return A\n", + "\n", + "A_device = generate_device()\n", + "\n", + "lam_est_device_gen = estimate_device(A_device)\n", + "\n", + "np.testing.assert_allclose(lam_est_device_gen, 1, atol=1e-4)\n", + "\n", + "print()\n", + "print(\"Dominant Eigenvalue:\", lam_est_device_gen)" + ] + }, + { + "cell_type": "markdown", + "id": "2bd664cb", + "metadata": {}, + "source": [ + "#### Think About It\n", + "\n", + "Both functions use `seed(42)`. Are `A_host` and `A_device` identical? Try comparing them:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f58b60c3", + "metadata": {}, + "outputs": [], + "source": [ + "with np.printoptions(precision=4):\n", + " print(\"A_host:\")\n", + " print(A_host)\n", + " print()\n", + " print(\"A_device:\")\n", + " print(A_device)\n", + " print()" + ] + }, + { + "cell_type": "markdown", + "id": "7bfa9d9d", + "metadata": {}, + "source": [ + "What does this reveal about `np.random` vs `cp.random`?" + ] + }, + { + "cell_type": "markdown", + "id": "a4ae6f53", + "metadata": {}, + "source": [ + "### 5. Verification and Benchmarking\n", + "\n", + "Finally, let's verify our accuracy against a reference implementation (`numpy.linalg.eigvals()`) and benchmark the speedup.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e05d69c7", + "metadata": {}, + "outputs": [], + "source": [ + "start = time.perf_counter()\n", + "lam_ref = np.linalg.eigvals(A_host).real.max()\n", + "T_ref = time.perf_counter() - start" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7d72dd8b", + "metadata": {}, + "outputs": [], + "source": [ + "print(f\"Power Iteration (Host) = {lam_est_host}\")\n", + "print(f\"Power Iteration (Device) = {lam_est_device}\")\n", + "print(f\"`eigvals` Reference = {lam_ref}\")\n", + "\n", + "rel_err_host = abs(lam_est_host - lam_ref) / abs(lam_ref)\n", + "rel_err_device = abs(lam_est_device - lam_ref) / abs(lam_ref)\n", + "print()\n", + "print(f\"Relative Error (Host) = {rel_err_host:.3e}\")\n", + "print(f\"Relative Error (Device) = {rel_err_device:.3e}\")\n", + "\n", + "np.testing.assert_allclose(lam_est_host, lam_ref, rtol=1e-4)\n", + "np.testing.assert_allclose(lam_est_device, lam_ref, rtol=1e-4)" + ] + }, + { + "cell_type": "markdown", + "id": "f092af24", + "metadata": {}, + "source": [ + "#### Benchmarking with `cupyx.profiler.benchmark()`\n", + "\n", + "We use CuPy's built-in benchmarking utility for accurate GPU timing. This handles warmup and synchronization automatically.\n", + "\n", + "We intentionally use `A_host` for both benchmarks, not `A_device`, because they're not the same matrices due to differences in NumPy and CuPy's random facilities. Different matrices converge at different rates, so it's only valid to benchmark on the same inputs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e362437c", + "metadata": {}, + "outputs": [], + "source": [ + "cfg = PowerIterationConfig(progress=False)\n", + "\n", + "print(\"Timing Host...\")\n", + "T_host = cpx.profiler.benchmark(estimate_host, args=(A_host, cfg), n_repeat=10, n_warmup=1).cpu_times\n", + "\n", + "print(\"Timing Device...\")\n", + "T_device = cpx.profiler.benchmark(estimate_device, args=(A_host, cfg), n_repeat=10, n_warmup=1).cpu_times\n", + "\n", + "print()\n", + "print(f\"Power Iteration (Host) = {T_host.mean() * 1000:.6g} ms ± {(T_host.std() / T_host.mean()):.2%} (mean ± relative stdev of {T_host.size} runs)\")\n", + "print(f\"Power Iteration (Device) = {T_device.mean() * 1000:.6g} ms ± {(T_device.std() / T_device.mean()):.2%} (mean ± relative stdev of {T_device.size} runs)\")\n", + "print(f\"`eigvals` Reference = {T_ref * 1000:.6g} ms\")\n", + "\n", + "speedup = T_host.mean() / T_device.mean()\n", + "print()\n", + "print(f\"Speedup (Device over Host) = {speedup:.1f}x\")" + ] + }, + { + "cell_type": "markdown", + "id": "6c965808", + "metadata": {}, + "source": [ + "---\n", + "\n", + "### Extra Credit\n", + "\n", + "**Explore the impact of changing the following parameters:**\n", + "\n", + "1. **Problem Size (`dim`):** How does the GPU speedup change as you decrease the matrix dimensions? Try values like 1024, 2048, 4096, 8192.\n", + "\n", + "2. **Compute Workload (`max_steps` and `dominance`):** The `dominance` parameter controls how quickly the algorithm converges. A smaller dominance means eigenvalues are closer together, requiring more iterations. How does this affect the CPU vs GPU comparison?\n", + "\n", + "3. **Check Frequency (`check_frequency`):** This controls how often we check for convergence (and trigger implicit CPU synchronization via the print statement). What happens to GPU performance when you check every step (`check_frequency=1`) vs. less frequently (`check_frequency=50`)?\n", + "\n", + "**Experiment below:**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "da43cde6", + "metadata": {}, + "outputs": [], + "source": [ + "# Try different configurations here!\n", + "# Example:\n", + "# cfg_smaller = PowerIterationConfig(dim=8192, progress=False)\n", + "# cfg_slow_converge = PowerIterationConfig(dominance=0.01, progress=False)\n", + "# cfg_frequent_check = PowerIterationConfig(check_frequency=1, progress=True)\n", + "\n", + "# Your experiments:" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/03__power_iteration__cupy__asynchrony.ipynb b/tutorials/pyhpc/notebooks/03__power_iteration__cupy__asynchrony.ipynb new file mode 100644 index 00000000..c89a87c9 --- /dev/null +++ b/tutorials/pyhpc/notebooks/03__power_iteration__cupy__asynchrony.ipynb @@ -0,0 +1,458 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0a33e74a", + "metadata": { + "id": "8KP1pYqmtXdr" + }, + "source": [ + "## Power Iteration - CuPy - Asynchrony\n", + "\n", + "### Table of Contents\n", + "1. [Introduction and Setup](#1-Introduction-and-Setup)\n", + "2. [Theory: Streams and Synchronization](#2-Theory:-Streams-and-Synchronization)\n", + "3. [The Baseline Implementation](#3-The-Baseline-Implementation)\n", + "4. [Profiling the Baseline](#4-Profiling-the-Baseline)\n", + "5. [Better Visibility with NVTX](#5-Better-Visibility-with-NVTX)\n", + "6. [Implementing Asynchrony](#6-Implementing-Asynchrony)\n", + "7. [Performance Analysis](#7-Performance-Analysis)\n", + "8. [Balancing CPU I/O and GPU Compute](#8-Balancing-CPU-I/O-and-GPU-Compute)\n", + "\n", + "### 1. Introduction and Setup\n", + "\n", + "GPU programming is inherently asynchronous. In this exercise, we will explore the implications of this behavior when using CuPy and learn how to analyze the flow of execution using profiling tools.\n", + "\n", + "We will revisit the Power Iteration algorithm. Our goal is to take a standard implementation, profile it to identify bottlenecks caused by implicit synchronization, and then optimize it using CUDA streams and asynchronous memory transfers.\n", + "\n", + "First, we need to ensure NVIDIA's developer tools are installed and available and do all of our imports." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aec097b1", + "metadata": { + "id": "rO4kOPuP_0JG" + }, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# Install necessary packages if running in Google Colab.\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not os.path.exists(\"/accelerated-computing-hub-installed\"):\n", + " print(\"Downloading Nsight Systems package.\")\n", + " !curl -s -L -O https://developer.nvidia.com/downloads/assets/tools/secure/nsight-systems/2026_1/NsightSystems-linux-cli-public-2026.1.1.204-3717666.deb\n", + " print(\"Installing Nsight Systems package.\")\n", + " !dpkg -i NsightSystems-linux-cli-public-2026.1.1.204-3717666.deb > /dev/null\n", + " print(\"Installing PIP packages.\")\n", + " !pip install \"nvtx\" \"nsightful[notebook] @ git+https://github.com/brycelelbach/nsightful.git@fa9ee4d81441ac62379c5306f2f2d8b0894d06ec\" > /dev/null 2>&1\n", + " open(\"/accelerated-computing-hub-installed\", \"a\").close()\n", + " print(\"All packages installed.\")\n", + "\n", + "import numpy as np\n", + "import cupy as cp\n", + "import cupyx as cpx\n", + "import nvtx\n", + "from dataclasses import dataclass\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "markdown", + "id": "8e078245", + "metadata": { + "id": "6sUvjAtMxI3h" + }, + "source": [ + "### 2. Theory: Streams and Synchronization\n", + "\n", + "All GPU work is launched asynchronously on a stream. The work items in a stream are executed in order. If you launch `f` on a stream and later launch `g` on that same stream, then `f` will be executed before `g`. But if `f` and `g` are launched on different streams, then their execution might overlap.\n", + "\n", + "**How CuPy handles this:**\n", + "\n", + "- **Default Stream:** Unless specified, CuPy launches work on the default CUDA stream.\n", + "- **Sequential Device Execution:** By default, CuPy work executes sequentially on the GPU.\n", + "- **Asynchronous Host Execution:** From the Python (Host) perspective, the code often returns immediately after launching the GPU kernel, before the work is actually finished.\n", + "\n", + "**TODO:** Even though CuPy is asynchronous, certain operations force the CPU to wait for the GPU to finish. What operations do you think implicitly synchronize the host and device?\n", + "\n", + "### 3. The Baseline Implementation\n", + "\n", + "We will start with a baseline implementation of the Power Iteration algorithm.\n", + "\n", + "The setup below mirrors the previous memory-spaces notebook: one configuration, one generated matrix, and one estimator function. The Nsight Systems kernel lets us profile these ordinary notebook cells directly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7dd38317", + "metadata": {}, + "outputs": [], + "source": [ + "@dataclass\n", + "class PowerIterationConfig:\n", + " dim: int = 19000\n", + " dominance: float = 0.05\n", + " max_steps: int = 400\n", + " check_frequency: int = 25\n", + " progress: bool = True\n", + " residual_threshold: float = 1e-10" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "81b3f4c6", + "metadata": {}, + "outputs": [], + "source": [ + "def generate_device(cfg=PowerIterationConfig()):\n", + " cp.random.seed(42)\n", + " weak_lam = cp.random.random(cfg.dim - 1) * (1.0 - cfg.dominance)\n", + " lam = cp.random.permutation(cp.concatenate((cp.asarray([1.0]), weak_lam)))\n", + " P = cp.random.random((cfg.dim, cfg.dim))\n", + " D = cp.diag(cp.random.permutation(lam))\n", + " return (P @ D) @ cp.linalg.inv(P)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f083598a", + "metadata": {}, + "outputs": [], + "source": [ + "A_device = generate_device()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d8cdd65d", + "metadata": {}, + "outputs": [], + "source": [ + "def estimate_device_baseline(A, cfg=PowerIterationConfig()) -> np.ndarray:\n", + " A_gpu = cp.asarray(A)\n", + " x = cp.ones(A_gpu.shape[0], dtype=np.float64)\n", + "\n", + " for i in range(0, cfg.max_steps, cfg.check_frequency):\n", + " y = A_gpu @ x\n", + " lam = (x @ y) / (x @ x)\n", + " res = cp.linalg.norm(y - lam * x)\n", + " x = y / cp.linalg.norm(y)\n", + "\n", + " if cfg.progress:\n", + " print(f\"step {i}: residual = {res:.3e}\")\n", + "\n", + " np.savetxt(f\"/tmp/device_{i}.txt\", cp.asnumpy(x))\n", + " if res < cfg.residual_threshold:\n", + " break\n", + "\n", + " for _ in range(i + 1, min(i + cfg.check_frequency, cfg.max_steps)):\n", + " y = A_gpu @ x\n", + " x = y / cp.linalg.norm(y)\n", + "\n", + " return cp.asnumpy((x.T @ (A_gpu @ x)) / (x.T @ x))" + ] + }, + { + "cell_type": "markdown", + "id": "3bcee9e1", + "metadata": {}, + "source": [ + "Now let's make sure it works:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a3c12c49", + "metadata": {}, + "outputs": [], + "source": [ + "lam_est_baseline = estimate_device_baseline(A_device)\n", + "\n", + "assert isinstance(lam_est_baseline, (np.ndarray, np.generic)), \"Must return a NumPy array or NumPy scalar\"\n", + "np.testing.assert_allclose(lam_est_baseline, 1, atol=1e-4)\n", + "\n", + "print()\n", + "print(\"Dominant Eigenvalue:\", lam_est_baseline)" + ] + }, + { + "cell_type": "markdown", + "id": "b06f4af6", + "metadata": { + "id": "1lyhHnzrdXzI" + }, + "source": [ + "### 4. Profiling the Baseline\n", + "\n", + "The `%%nsys` cell magic profiles the code in its cell with Nsight Systems and saves the native report to the path given by `-o`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2a269421", + "metadata": { + "id": "1HU5p1IhAkTA" + }, + "outputs": [], + "source": [ + "%%nsys -o power_iteration__baseline.nsys-rep\n", + "lam_est_baseline = estimate_device_baseline(A_device)" + ] + }, + { + "cell_type": "markdown", + "id": "a9229929", + "metadata": { + "id": "IlGIAIEPe3SV" + }, + "source": [ + "Explore the profile in Perfetto by clicking the button below the profiled cell. For richer analysis, click the **+** in the JupyterLab tab bar and open Nsight Systems. You can also install the Nsight Systems GUI on your local system, download the `.nsys-rep` report, and open it there." + ] + }, + { + "cell_type": "markdown", + "id": "9d36568d", + "metadata": { + "id": "bGxz6-spplcU" + }, + "source": [ + "### 5. Better Visibility with NVTX\n", + "\n", + "Nsight Systems shows us a lot of information—sometimes too much, and not all of it is relevant. We can annotate specific regions of our code so they stand out in the timeline. These regions can have categories, domains, and colors, and they can be nested. To add them, use the `nvtx.annotate()` context manager:\n", + "\n", + "```\n", + "with nvtx.annotate(\"Loop\"):\n", + " for i in range(20):\n", + " with nvtx.annotate(f\"Step {i}\"):\n", + " pass\n", + "```\n", + "\n", + "**TODO:** Go back to the baseline estimator and add nested `nvtx.annotate()` regions around its setup, loop, compute, copy, and I/O phases.\n", + "\n", + "Then, capture another profile and see if you can identify how we can improve the code. Specifically, think about how we could add more asynchrony." + ] + }, + { + "cell_type": "markdown", + "id": "df5729ba", + "metadata": { + "id": "PF7PUALVfX3A" + }, + "source": [ + "### 6. Implementing Asynchrony\n", + "\n", + "Remember what we've learned about streams and how to use them with CuPy:\n", + "\n", + "- By default, all CuPy operations within a single thread run on the same stream. You can access this stream with `cp.cuda.get_current_stream()`.\n", + "- You can create a new stream with `cp.cuda.Stream(non_blocking=True)`. Use `with` statements to use the stream for all CuPy operations within a block.\n", + "- You can record an event on a stream by calling `.record()` on it.\n", + "- You can synchronize on an event (or an entire stream) by calling `.synchronize()` on it.\n", + "- Memory transfers will block by default. You can launch them asynchronously with `cp.asarray(..., blocking=False)` (for host to device transfers) and `cp.asnumpy(..., blocking=False)` (for device to host transfers).\n", + "\n", + "**TODO:** Adapt the baseline estimator in the cell below to improve performance by overlapping checkpoint copies and CPU I/O with GPU compute." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7791292f", + "metadata": {}, + "outputs": [], + "source": [ + "def estimate_device_async(A, cfg=PowerIterationConfig()) -> np.ndarray:\n", + " raise NotImplementedError(\"TODO: Adapt the baseline estimator to overlap checkpoint copies and I/O with GPU compute!\")" + ] + }, + { + "cell_type": "markdown", + "id": "ce13fae0", + "metadata": {}, + "source": [ + "Now let's make sure it works:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bf843886", + "metadata": { + "id": "pszz-k8cDfqy" + }, + "outputs": [], + "source": [ + "lam_est_async = estimate_device_async(A_device)\n", + "\n", + "assert isinstance(lam_est_async, (np.ndarray, np.generic)), \"Must return a NumPy array or NumPy scalar\"\n", + "np.testing.assert_allclose(lam_est_async, 1, atol=1e-4)\n", + "\n", + "print()\n", + "print(\"Dominant Eigenvalue:\", lam_est_baseline)" + ] + }, + { + "cell_type": "markdown", + "id": "dbd87f34", + "metadata": { + "id": "VFCYIqwaKYqy" + }, + "source": [ + "### 7. Performance Analysis\n", + "\n", + "Before we profile the improved code, let's compare the execution times of both." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a72c7f0d", + "metadata": { + "id": "uSPFNIb9KcPb" + }, + "outputs": [], + "source": [ + "quiet_cfg = PowerIterationConfig(progress=False)\n", + "power_iteration_baseline_duration = cpx.profiler.benchmark(\n", + " estimate_device_baseline, (A_device, quiet_cfg), n_repeat=5, n_warmup=1\n", + ").cpu_times.mean() * 1000\n", + "power_iteration_async_duration = cpx.profiler.benchmark(\n", + " estimate_device_async, (A_device, quiet_cfg), n_repeat=5, n_warmup=1\n", + ").cpu_times.mean() * 1000\n", + "speedup = power_iteration_baseline_duration / power_iteration_async_duration\n", + "\n", + "print(f\"power_iteration_baseline: {power_iteration_baseline_duration:.3f} ms\")\n", + "print(f\"power_iteration_async: {power_iteration_async_duration:.3f} ms\")\n", + "print(f\"power_iteration_async speedup over power_iteration_baseline: {speedup:.2f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "f370fa8f", + "metadata": { + "id": "o4WJVFBkkRaN" + }, + "source": [ + "Next, let's capture a profile report of our improved code with the same Nsight Systems kernel." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb744ac1", + "metadata": { + "id": "BtQR4CHikWFK" + }, + "outputs": [], + "source": [ + "%%nsys -o power_iteration__async.nsys-rep\n", + "lam_est_async = estimate_device_async(A_device)" + ] + }, + { + "cell_type": "markdown", + "id": "cc281fda", + "metadata": { + "id": "Pnvne_F4jYTh" + }, + "source": [ + "Finally, let's look at the profile in Perfetto and confirm we've gotten rid of the idling." + ] + }, + { + "cell_type": "markdown", + "id": "b8a12d4e", + "metadata": {}, + "source": [ + "### 8. Balancing CPU I/O and GPU Compute\n", + "\n", + "Our asynchronous implementation overlaps checkpoint I/O on the CPU with power-iteration steps on the GPU. `check_frequency` controls how many GPU steps run between residual checks and checkpoints. A smaller value produces output more frequently, but may not provide enough GPU work to hide the I/O. A larger value provides more GPU work to overlap with the I/O, but delays output and convergence checks.\n", + "\n", + "**TODO:** Sweep check frequencies from 20 through 35 in increments of 1 and determine the output frequency with the lowest execution time. Use `cupyx.profiler.benchmark` with progress disabled, then plot the results." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c6f42a91", + "metadata": {}, + "outputs": [], + "source": [ + "check_frequencies = list(range(20, 36))\n", + "async_durations = []\n", + "\n", + "for check_frequency in check_frequencies:\n", + " cfg = PowerIterationConfig(check_frequency=check_frequency, progress=False)\n", + " timing = cpx.profiler.benchmark(\n", + " estimate_device_async, (A_device, cfg), n_repeat=5, n_warmup=1\n", + " )\n", + " async_durations.append(timing.cpu_times.mean() * 1000)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3e7b580", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO: Find the check frequency with the shortest execution time and\n", + "# display a performance graph. Highlight the optimal point." + ] + }, + { + "cell_type": "markdown", + "id": "e91c4a27", + "metadata": {}, + "source": [ + "Finally, profile the optimal check frequency. In the trace, compare the CPU I/O region with the GPU compute between checks. Does the GPU compute fully hide the I/O?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f24b6c39", + "metadata": {}, + "outputs": [], + "source": [ + "%%nsys -o power_iteration__async__optimal.nsys-rep\n", + "optimal_cfg = PowerIterationConfig(check_frequency=optimal_check_frequency)\n", + "lam_est_optimal = estimate_device_async(A_device, cfg=optimal_cfg)\n", + "np.testing.assert_allclose(lam_est_optimal, 1, atol=1e-4)" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (Nsight Systems)", + "language": "python", + "name": "nsightful-nsys" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/04__copy__kernel_authoring.ipynb b/tutorials/pyhpc/notebooks/04__copy__kernel_authoring.ipynb new file mode 100644 index 00000000..62af4a5d --- /dev/null +++ b/tutorials/pyhpc/notebooks/04__copy__kernel_authoring.ipynb @@ -0,0 +1,370 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "eedec65d", + "metadata": { + "id": "-JpGaP7-D_5W" + }, + "source": [ + "## Copy - Kernel Authoring\n", + "\n", + "### Table of Contents\n", + "1. [Environment Setup](#1-environment-setup)\n", + "2. [The Baseline Kernel: Blocked Copy](#2-the-baseline-kernel-blocked-copy)\n", + "3. [Profiling the Baseline](#3-profiling-the-baseline)\n", + "4. [Optimization Challenge: Improved Memory Access](#4-optimization-challenge-improved-memory-access)\n", + "5. [Verification & Benchmarking](#5-verification--benchmarking)\n", + "6. [Profiling the Optimized Kernel](#6-profiling-the-optimized-kernel)\n", + "7. [Further Exploration](#7-further-exploration)\n", + "\n", + "---\n", + "\n", + "### 1. Environment Setup\n", + "\n", + "In this exercise, we'll learn how to analyze and reason about the performance of CUDA kernels using the NVIDIA Nsight Compute profiler. We'll look at a few different ways of writing a simple kernel that copies items from one array to another.\n", + "\n", + "First, we need to ensure NVIDIA's developer tools are installed and available and do all of our imports." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cad89755", + "metadata": { + "collapsed": true, + "id": "AoHkvSPMC5Fs" + }, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# Install necessary packages if running in Google Colab.\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not os.path.exists(\"/accelerated-computing-hub-installed\"):\n", + " print(\"Downloading NCU package.\")\n", + " !curl -s -L -O https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/nsight-compute-2025.2.1_2025.2.1.3-1_amd64.deb\n", + " print(\"Installing NCU package.\")\n", + " !dpkg -i nsight-compute-2025.2.1_2025.2.1.3-1_amd64.deb > /dev/null\n", + " !update-alternatives --install /opt/bin/ncu ncu /opt/nvidia/nsight-compute/2025.2.1/ncu 20250201 > /dev/null\n", + " print(\"Uninstalling PIP packages.\")\n", + " !pip uninstall \"cuda-python\" --yes > /dev/null\n", + " print(\"Installing PIP packages.\")\n", + " !pip install \"numba-cuda\" \"cuda-cccl[test-cu12]\" \"nvtx\" \"nsightful[notebook] @ git+https://github.com/brycelelbach/nsightful.git@a41989403430168e02ac3cfdc4060bca4ebb8040\" > /dev/null 2>&1\n", + " open(\"/accelerated-computing-hub-installed\", \"a\").close()\n", + " print(\"All packages installed.\")\n", + "\n", + "from numba import cuda\n", + "import cupy as cp\n", + "import cupyx as cpx" + ] + }, + { + "cell_type": "markdown", + "id": "d8b8df44", + "metadata": { + "id": "A1SfTQk0EwUl" + }, + "source": [ + "### 2. The Baseline Kernel: Blocked Copy\n", + "\n", + "Now, we'll write our first kernel. Each thread will copy `items_per_thread` items from the `src` array to the `dst` array. We'll set the number of threads per block to a constant, `threads_per_block`. We'll calculate how many blocks to launch based on `items_per_thread` and `threads_per_block`. We use `cuda.grid(1)` to get the unique global 1D index of each thread.\n", + "\n", + "Each thread will copy a contiguous set of items, e.g. the items with indices `[base, base + items_per_thread)`:\n", + "\n", + "![image.png](data:image/png;base64,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)\n", + "\n", + "**NOTE: The next cell won't actually run any code; it writes its contents to a file used for verification and benchmarking. We will load the same definitions into this kernel before profiling them.**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0aede02e", + "metadata": { + "id": "I9Tz2hG-_tBj" + }, + "outputs": [], + "source": [ + "threads_per_block = 256\n", + "items_per_thread = 64\n", + "total_items = 2**28\n", + "blocks = total_items // (threads_per_block * items_per_thread)\n", + "\n", + "src = cp.arange(total_items)\n", + "dst = cp.empty_like(src)\n", + "\n", + "@cuda.jit\n", + "def copy_blocked(src, dst, items_per_thread):\n", + " base = cuda.grid(1) * items_per_thread\n", + " for i in range(items_per_thread):\n", + " dst[base + i] = src[base + i]" + ] + }, + { + "cell_type": "markdown", + "id": "199c70c6", + "metadata": { + "id": "TuR4yDV4H6IB" + }, + "source": [ + "Let's make sure it runs correctly:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "894639d2", + "metadata": {}, + "outputs": [], + "source": [ + "dst[:] = 0\n", + "copy_blocked[blocks, threads_per_block](src, dst, items_per_thread)\n", + "cp.testing.assert_array_equal(src, dst)\n", + "print(f\"Problem size: {total_items * src.dtype.itemsize / 2**30:.2f} GB, dtype: {src.dtype}\")" + ] + }, + { + "cell_type": "markdown", + "id": "b2ddd21d", + "metadata": {}, + "source": [ + "### 3. Profiling the Baseline\n", + "\n", + "This notebook uses the **Python 3 (Nsight Compute)** kernel, which was started under `ncu`. The `%%ncu` cell magic profiles only the code in that cell while preserving the imports, arrays, and kernel definitions loaded above.\n", + "\n", + "There is an overhead to running code under the profiler. Your program may execute noticeably slower.\n", + "\n", + "The profiler's collection settings were selected when the custom kernel started, so no enable step or kernel restart is needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a04bda79", + "metadata": { + "id": "5pyHvJtxVnDB" + }, + "outputs": [], + "source": [ + "%%ncu -o copy_blocked.ncu-rep --kernel-name regex:copy_blocked\n", + "dst[:] = 0\n", + "copy_blocked[blocks, threads_per_block](src, dst, items_per_thread)" + ] + }, + { + "cell_type": "markdown", + "id": "d8f2e9f9", + "metadata": { + "id": "ijEtNGHhpLPu" + }, + "source": [ + "The profiling report is displayed directly below the cell above. The first tab summarizes Nsight recommendations and advisories; subsequent tabs contain detailed sections.\n", + "\n", + "**TODO:** Spend a few minutes reviewing the report. What stands out to you? Based on the information in the report, how can the kernel be improved?\n", + "\n", + "The Nsight Compute GUI provides richer information, charts, and diagrams. Click the **+** in the JupyterLab tab bar and open Nsight Compute, or install the GUI on your local system and download the `.ncu-rep` report." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ba084c5", + "metadata": { + "id": "40w07iG5k6Vl" + }, + "outputs": [], + "source": [ + "cp.testing.assert_array_equal(src, dst)" + ] + }, + { + "cell_type": "markdown", + "id": "f5e1372f", + "metadata": { + "id": "mL_9xT44qbMA" + }, + "source": [ + "### 4. Optimization Challenge: Improved Memory Access\n", + "\n", + "**TODO:** Now try to write a better version of our copy kernel.\n", + "\n", + "As a hint, given that this kernel does no compute and just moves data, our memory access patterns are probably important!\n", + "\n", + "Instead of using the `cuda.grid()` utility, you may want to use the hierarchical coordinates of our thread to calculate the index:\n", + "\n", + "- `cuda.blockDim.x`: The number of threads per block.\n", + "- `cuda.blockIdx.x`: The global index of the current thread block.\n", + "- `cuda.threadIdx.x`: The local index of the current thread within this block." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "276f6fbb", + "metadata": { + "id": "B5PBpaY2HnE0" + }, + "outputs": [], + "source": [ + "@cuda.jit\n", + "def copy_optimized(src, dst, items_per_thread):\n", + " TODO() # TODO: You need to implement this kernel! DELETE THIS LINE." + ] + }, + { + "cell_type": "markdown", + "id": "33760b8e", + "metadata": { + "id": "qco9XOsTkPEJ" + }, + "source": [ + "### 5. Verification & Benchmarking\n", + "\n", + "Now, let's make sure our code works:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7dcd2b87", + "metadata": { + "id": "thgSpsCQkN2-" + }, + "outputs": [], + "source": [ + "dst[:] = 0\n", + "copy_optimized[blocks, threads_per_block](src, dst, items_per_thread)\n", + "cp.testing.assert_array_equal(src, dst)" + ] + }, + { + "cell_type": "markdown", + "id": "afc9ec83", + "metadata": { + "id": "kC3Moh2m02-q" + }, + "source": [ + "Before we profile the optimized kernel, let's compare the runtimes of both versions:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6833eaa6", + "metadata": { + "id": "kJ7viF-i06qd" + }, + "outputs": [], + "source": [ + "blocked_times = cpx.profiler.benchmark(lambda: copy_blocked[blocks, threads_per_block](src, dst, items_per_thread), n_repeat=15, n_warmup=1).gpu_times[0]\n", + "optimized_times = cpx.profiler.benchmark(lambda: copy_optimized[blocks, threads_per_block](src, dst, items_per_thread), n_repeat=15, n_warmup=1).gpu_times[0]\n", + "copy_blocked_duration = blocked_times.mean() * 1000\n", + "copy_optimized_duration = optimized_times.mean() * 1000\n", + "speedup = copy_blocked_duration / copy_optimized_duration\n", + "\n", + "print(f\"copy_blocked: {copy_blocked_duration:.3g} ms\")\n", + "print(f\"copy_optimized: {copy_optimized_duration:.3g} ms\")\n", + "print(f\"copy_optimized speedup over copy_blocked: {speedup:.2f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "49f3199a", + "metadata": { + "id": "mfrqUdzozGeU" + }, + "source": [ + "### 6. Profiling the Optimized Kernel\n", + "\n", + "That's quite a difference! Now let's profile the optimized variant:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "042fc726", + "metadata": { + "id": "zO_y6ObXV_wX" + }, + "outputs": [], + "source": [ + "%%ncu -o copy_optimized.ncu-rep --kernel-name regex:copy_optimized\n", + "dst[:] = 0\n", + "copy_optimized[blocks, threads_per_block](src, dst, items_per_thread)" + ] + }, + { + "cell_type": "markdown", + "id": "10958fd7", + "metadata": { + "id": "DjPJRzXTD6uF" + }, + "source": [ + "The optimized kernel's report is displayed directly above. Compare its memory workload and scheduler sections with the baseline." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a8ec9d3a", + "metadata": { + "id": "KjE0Vgu_zgs3" + }, + "outputs": [], + "source": [ + "cp.testing.assert_array_equal(src, dst)" + ] + }, + { + "cell_type": "markdown", + "id": "ef6fb6c4", + "metadata": { + "id": "Xn6IPpuxD_kz" + }, + "source": [ + "### 7. Further Exploration\n", + "\n", + "**EXTRA CREDIT:** Experiment with different problem sizes, threads per block, and items per thread by changing the configuration variables above. If you're feeling really ambitious, do a parameter sweep to study the impact these knobs have on performance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a845f694", + "metadata": {}, + "outputs": [], + "source": [ + "# Try changing total_items, threads_per_block, and items_per_thread above.\n", + "# Rerun the kernel definitions, correctness checks, and benchmarks after each change.\n", + "# For a deeper study, sweep several values and plot runtime or memory throughput." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (Nsight Compute)", + "language": "python", + "name": "nsightful-ncu" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/05__book_histogram__kernel_authoring.ipynb b/tutorials/pyhpc/notebooks/05__book_histogram__kernel_authoring.ipynb new file mode 100644 index 00000000..9de6e752 --- /dev/null +++ b/tutorials/pyhpc/notebooks/05__book_histogram__kernel_authoring.ipynb @@ -0,0 +1,389 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f1a8560a-c91b-48db-af1c-18fcd4892448", + "metadata": { + "id": "f1a8560a-c91b-48db-af1c-18fcd4892448" + }, + "source": [ + "## Book Histogram - Kernel Authoring\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Environment Setup & Data Download](#1.-Environment-Setup-&-Data-Download)\n", + "2. [First Attempt: Global Memory Histogram](#2.-First-Attempt:-Global-Memory-Histogram)\n", + "3. [Fixing Data Races with Atomics](#3.-Fixing-Data-Races-with-Atomics)\n", + "4. [Profiling the Naive Solution](#4.-Profiling-the-Naive-Solution)\n", + "5. [Optimization: Shared Memory](#5.-Optimization:-Shared-Memory)\n", + "6. [Performance Comparison](#6.-Performance-Comparison)\n", + "\n", + "### 1. Environment Setup & Data Download\n", + "\n", + "Let's learn to use some advanced CUDA features like shared memory, atomics, and [cuda.cooperative](https://nvidia.github.io/cccl/unstable/python/coop.html) to write an efficient histogram kernel to determine the most frequent characters in a collection of books.\n", + "\n", + "First, let's download our dataset and install the necessary tools." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ce42d5e5-db1e-46da-a64a-831d0f3d59ff", + "metadata": { + "id": "ce42d5e5-db1e-46da-a64a-831d0f3d59ff" + }, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# Install necessary packages if running in Google Colab.\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not os.path.exists(\"/accelerated-computing-hub-installed\"):\n", + " print(\"Downloading NCU package.\")\n", + " !curl -s -L -O https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/nsight-compute-2025.2.1_2025.2.1.3-1_amd64.deb\n", + " print(\"Installing NCU package.\")\n", + " !dpkg -i nsight-compute-2025.2.1_2025.2.1.3-1_amd64.deb > /dev/null\n", + " !update-alternatives --install /opt/bin/ncu ncu /opt/nvidia/nsight-compute/2025.2.1/ncu 20250201 > /dev/null\n", + " print(\"Uninstalling PIP packages.\")\n", + " !pip uninstall \"cuda-python\" --yes > /dev/null\n", + " print(\"Installing PIP packages.\")\n", + " !pip install \"numba-cuda\" \"cuda-cccl[test-cu12]\" \"nvtx\" \"nsightful[notebook] @ git+https://github.com/brycelelbach/nsightful.git@a41989403430168e02ac3cfdc4060bca4ebb8040\" > /dev/null 2>&1\n", + " open(\"/accelerated-computing-hub-installed\", \"a\").close()\n", + " print(\"All packages installed.\")\n", + "\n", + "import numpy as np\n", + "import urllib.request\n", + "import matplotlib.pyplot as plt\n", + "from numba import cuda\n", + "import cupy as cp\n", + "import cupyx as cpx" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e1ae9dc-39f1-4c93-b923-85a96b45a057", + "metadata": { + "id": "6e1ae9dc-39f1-4c93-b923-85a96b45a057" + }, + "outputs": [], + "source": [ + "urllib.request.urlretrieve(\n", + " \"https://drive.usercontent.google.com/download?id=1MW1lPgkTq3YG9ikuq6u3d9sfpt-wKQZ0&export=download\",\n", + " \"books__15m.txt\")" + ] + }, + { + "cell_type": "markdown", + "id": "9109d3c0-e276-44cc-9f36-f8c79eb48b31", + "metadata": { + "id": "9109d3c0-e276-44cc-9f36-f8c79eb48b31" + }, + "source": [ + "### 2. First Attempt: Global Memory Histogram\n", + "\n", + "A histogram kernel counts the number of times a value occurs in a dataset. To implement this, we create an array that is large enough to store all possible values (in the case of counting 1-byte ASCII characters, 256 elements). Then for the value of each element in the dataset, we increment its location in the array.\n", + "\n", + "Let's try a simple way to implement this:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61c12795-b14a-4447-9dcf-9748616cc453", + "metadata": { + "id": "61c12795-b14a-4447-9dcf-9748616cc453" + }, + "outputs": [], + "source": [ + "bins = 256\n", + "\n", + "values = cp.fromfile(\"books__15m.txt\", dtype=cp.uint8)\n", + "histogram = cp.zeros(bins, dtype=cp.int32)\n", + "\n", + "threads_per_block = 512\n", + "items_per_thread = 8\n", + "items_per_block = threads_per_block * items_per_thread\n", + "blocks = len(values) // items_per_block\n", + "assert values.size % items_per_block == 0\n", + "\n", + "@cuda.jit\n", + "def histogram_global(values, histogram):\n", + " for i in range(items_per_thread):\n", + " value = values[cuda.grid(1) * items_per_thread + i]\n", + " old_count = histogram[value]\n", + " new_count = old_count + 1\n", + " histogram[value] = new_count" + ] + }, + { + "cell_type": "markdown", + "id": "27e30efa-3a37-402f-9414-e444214d8ce6", + "metadata": { + "id": "27e30efa-3a37-402f-9414-e444214d8ce6" + }, + "source": [ + "Now let's make sure it runs and check the output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3f32240-ad7b-4717-98b3-82b0298a099a", + "metadata": { + "id": "b3f32240-ad7b-4717-98b3-82b0298a099a" + }, + "outputs": [], + "source": [ + "histogram[:] = 0\n", + "histogram_global[blocks, threads_per_block](values, histogram)\n", + "assert cp.sum(histogram) < len(values)\n", + "\n", + "histogram_host = cp.asnumpy(histogram)\n", + "\n", + "# Print most frequently occurring characters.\n", + "pairs = sorted(((i, c) for i, c in enumerate(histogram_host) if c), key=lambda x: x[1], reverse=True)[:20]\n", + "labels = [('SPACE' if i == 32 else chr(i)) if 32 <= i <= 126 else f'0x{i:02X}' for i, _ in pairs]\n", + "plt.barh(labels[::-1], [c for _, c in pairs][::-1])\n", + "plt.xlabel('count')\n", + "plt.tight_layout()\n", + "plt.title(\"Top 20 Bins\")\n", + "plt.show()\n", + "\n", + "print(f\"Characters in dataset: {values.size / 1e6:.1f} MB\")" + ] + }, + { + "cell_type": "markdown", + "id": "b14fa522-b41b-4538-8c34-ecc355e55116", + "metadata": { + "id": "b14fa522-b41b-4538-8c34-ecc355e55116" + }, + "source": [ + "### 3. Fixing Data Races with Atomics\n", + "\n", + "It looks like something is wrong - our counts are very low, and the most common characters don't make a lot of sense. Many of our increments seem to get lost!\n", + "\n", + "What's happening here is called a data race. Many different threads are trying to access the bins of the histogram at the same time.\n", + "\n", + "Imagine that two threads are trying to update the same bin:\n", + "\n", + "- Thread 0 reads the count of the bin, which is 0, and stores it in its local variable `old_count`.\n", + "- Thread 0 adds 1 to its `old_count`, producing a `new_count` of 1.\n", + "- Thread 1 reads the count of the bin, which is still 0, and stores it in its local variable `old_count`.\n", + "- Thread 1 adds 1 to its `old_count`, producing a `new_count` of 1.\n", + "- Thread 0 stores `new_count` to the bin, setting it to 1.\n", + "- Thread 1 stores `new_count` to the bin, setting it to 1, and losing the increment from thread 0!\n", + "\n", + "To fix this, we need to use atomic operations. `cuda.atomic.add(array, index, value)` will perform `array[index] += value` as a single indivisible operation. This will ensure that no increments get lost.\n", + "\n", + "**TODO: Fix the code above by modifying it to use `cuda.atomic.add()`.**" + ] + }, + { + "cell_type": "markdown", + "id": "08f4dded-26a7-4ef8-b981-e00c569ca4d0", + "metadata": { + "id": "08f4dded-26a7-4ef8-b981-e00c569ca4d0" + }, + "source": [ + "### 4. Profiling the Naive Solution\n", + "\n", + "Select the **Python 3 (Nsight Compute)** kernel, then profile the naive kernel in place. The `%%ncu` magic preserves the arrays and compiled kernel from the earlier cells and displays the report below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8dbd226c-66f2-43df-868a-6b024b1de24c", + "metadata": { + "id": "8dbd226c-66f2-43df-868a-6b024b1de24c" + }, + "outputs": [], + "source": [ + "%%ncu -o histogram_global.ncu-rep --kernel-name regex:histogram_global\n", + "histogram[:] = 0\n", + "histogram_global[blocks, threads_per_block](values, histogram)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad12380e-253b-4410-ab34-9479411fdf81", + "metadata": { + "id": "ad12380e-253b-4410-ab34-9479411fdf81" + }, + "outputs": [], + "source": [ + "assert cp.sum(histogram) < len(values)" + ] + }, + { + "cell_type": "markdown", + "id": "e1f72831-780f-4cf5-8ff1-2092ecb193d9", + "metadata": { + "id": "e1f72831-780f-4cf5-8ff1-2092ecb193d9" + }, + "source": [ + "### 5. Optimization: Shared Memory\n", + "\n", + "Looking at the profile trace, it seems like our code is quite slow - look at the memory workload tab and see how low the throughput is!\n", + "\n", + "One improvement we should make is to separate loading values from the histogram update and to perform striped loads (also known as coalesced access). We'll use [cuda.cooperative](https://nvidia.github.io/cccl/unstable/python/coop.html)'s block load instead of writing this by hand.\n", + "\n", + "**TODO: Rewrite the code below to use `cuda.cooperative` to load from `values` into local memory.**\n", + "- **Create a `coop.block.load(dtype, threads_per_block, items_per_thread, algorithm)` object outside of the kernel.**\n", + "- **Make sure to link the algorithm object to the kernel by adding a `link` parameter to the decorator.**\n", + "- **Create storage for the items we'll load with `cuda.local.array()`.**\n", + "\n", + "**TODO: Look at the profile trace and code and think about how we could improve performance further.**\n", + "\n", + "**HINT:**\n", + "- **What sorts of operations are we performing? Are they expensive? Can we make the code more efficient by reducing the number of expensive operations we perform?**\n", + "- **You can allocate memory accessible by the entire block with `cuda.shared.array()`.**\n", + "- **You can synchronize all threads within a block with `cuda.syncthreads()`.**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cf7c9865-646a-4bbd-9b41-61cadfc5484c", + "metadata": { + "id": "cf7c9865-646a-4bbd-9b41-61cadfc5484c" + }, + "outputs": [], + "source": [ + "items_per_thread = 8\n", + "items_per_block = threads_per_block * items_per_thread\n", + "blocks = len(values) // items_per_block\n", + "assert values.size % items_per_block == 0\n", + "\n", + "@cuda.jit\n", + "def histogram_localized(values, histogram):\n", + " for i in range(items_per_thread):\n", + " value = values[cuda.grid(1) * items_per_thread + i]\n", + " old_count = histogram[value]\n", + " new_count = old_count + 1\n", + " histogram[value] = new_count\n", + "\n", + "def launch_localized():\n", + " histogram_localized[blocks, threads_per_block](values, histogram)" + ] + }, + { + "cell_type": "markdown", + "id": "fd090ee6-a5d3-46f6-a58e-d34e077a99c0", + "metadata": { + "id": "fd090ee6-a5d3-46f6-a58e-d34e077a99c0" + }, + "source": [ + "Now let's run the code, then profile the optimized kernel with the same Nsight Compute kernel." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b30e9b3-5a4c-4181-b642-b7def5e9f258", + "metadata": { + "id": "1b30e9b3-5a4c-4181-b642-b7def5e9f258" + }, + "outputs": [], + "source": [ + "histogram[:] = 0\n", + "launch_localized()\n", + "assert cp.sum(histogram) == len(values)\n", + "\n", + "histogram_host = cp.asnumpy(histogram)\n", + "pairs = sorted(((i, c) for i, c in enumerate(histogram_host) if c), key=lambda x: x[1], reverse=True)[:20]\n", + "labels = [('SPACE' if i == 32 else chr(i)) if 32 <= i <= 126 else f'0x{i:02X}' for i, _ in pairs]\n", + "plt.barh(labels[::-1], [c for _, c in pairs][::-1])\n", + "plt.xlabel('count')\n", + "plt.tight_layout()\n", + "plt.title(\"Top 20 Bins\")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d637b6b1-fb0b-4807-b70b-c80227c0fd6f", + "metadata": { + "id": "d637b6b1-fb0b-4807-b70b-c80227c0fd6f" + }, + "outputs": [], + "source": [ + "%%ncu -o histogram_localized.ncu-rep --kernel-name regex:histogram_localized\n", + "histogram[:] = 0\n", + "histogram_localized[blocks, threads_per_block](values, histogram)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "114e8ff7-b6fb-42ad-abda-f6d53479c052", + "metadata": { + "id": "114e8ff7-b6fb-42ad-abda-f6d53479c052" + }, + "outputs": [], + "source": [ + "assert cp.sum(histogram) == len(values)" + ] + }, + { + "cell_type": "markdown", + "id": "23df6c7a", + "metadata": {}, + "source": [ + "### 6. Performance Comparison\n", + "\n", + "Let's compare the execution time of our naive global memory implementation against our optimized shared memory implementation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2a3f9ca4-b61b-4536-9896-7a41498cc986", + "metadata": { + "id": "2a3f9ca4-b61b-4536-9896-7a41498cc986" + }, + "outputs": [], + "source": [ + "global_times = cpx.profiler.benchmark(lambda: histogram_global[blocks, threads_per_block](values, histogram), n_repeat=15, n_warmup=4).gpu_times[0]\n", + "localized_times = cpx.profiler.benchmark(launch_localized, n_repeat=15, n_warmup=4).gpu_times[0]\n", + "histogram_global_duration = global_times.mean() * 1000\n", + "histogram_localized_duration = localized_times.mean() * 1000\n", + "speedup = histogram_global_duration / histogram_localized_duration\n", + "\n", + "print(f\"histogram_global: {histogram_global_duration:.3g} ms\")\n", + "print(f\"histogram_localized: {histogram_localized_duration:.3g} ms\")\n", + "print(f\"histogram_localized speedup over histogram_global: {speedup:.2f}\")" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (Nsight Compute)", + "language": "python", + "name": "nsightful-ncu" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/06__mpi4py.ipynb b/tutorials/pyhpc/notebooks/06__mpi4py.ipynb new file mode 100644 index 00000000..6230607d --- /dev/null +++ b/tutorials/pyhpc/notebooks/06__mpi4py.ipynb @@ -0,0 +1,623 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "mpi-title", + "metadata": {}, + "source": [ + "## mpi4py\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Environment Setup](#1-environment-setup)\n", + "2. [One Program, Many Processes](#2-one-program-many-processes)\n", + "3. [Exercise 1: Distributed Sum of Squares](#3-exercise-1-distributed-sum-of-squares)\n", + "4. [Domain Decomposition and Halo Exchange](#4-domain-decomposition-and-halo-exchange)\n", + "5. [The Serial Heat Equation Baseline](#5-the-serial-heat-equation-baseline)\n", + "6. [Exercise 2: Distributed Heat Equation](#6-exercise-2-distributed-heat-equation)\n", + "7. [Verification and Visualization](#7-verification-and-visualization)\n", + "8. [Key Takeaways](#8-key-takeaways)\n", + "\n", + "---\n", + "\n", + "### 1. Environment Setup\n", + "\n", + "[MPI](https://www.mpi-forum.org/) (Message Passing Interface) lets independent processes cooperate by sending data to one another. [mpi4py](https://mpi4py.readthedocs.io/en/stable/) exposes MPI in Python while supporting efficient communication directly from NumPy arrays.\n", + "\n", + "In this notebook, we'll learn to:\n", + "\n", + "1. Launch the same Python program as several MPI processes.\n", + "2. Combine distributed results with a collective operation.\n", + "3. Split a two-dimensional grid into row-wise subdomains.\n", + "4. Exchange halo rows between neighboring processes.\n", + "5. Verify a distributed heat-equation stencil against a serial reference.\n", + "\n", + "First, let's make sure the MPI launcher and Python environment are ready. The setup selects MPICH's local `fork` launcher in the CSCS environment and Open MPI's oversubscription mode in Colab or a local tutorial environment. MPI does not use the GPU in this lesson; the standard GPU notebook environment is retained so this notebook composes with the rest of the tutorial." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mpi-setup", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import shutil\n", + "import subprocess\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "# Install Open MPI and mpi4py if running in Google Colab.\n", + "colab_marker = Path(\"/tmp/accelerated-computing-hub-mpi4py-installed\")\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not colab_marker.exists():\n", + " print(\"Installing Open MPI and mpi4py.\")\n", + " subprocess.run([\"apt-get\", \"-qq\", \"update\"], check=True)\n", + " subprocess.run(\n", + " [\"apt-get\", \"-qq\", \"install\", \"-y\", \"openmpi-bin\", \"libopenmpi-dev\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " subprocess.run(\n", + " [sys.executable, \"-m\", \"pip\", \"install\", \"mpi4py\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " colab_marker.touch()\n", + " print(\"Open MPI and mpi4py installed.\")\n", + "\n", + "# Open MPI protects against accidental root launches. Colab and the tutorial\n", + "# container are isolated environments where explicitly allowing this is safe.\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT\", \"1\")\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT_CONFIRM\", \"1\")\n", + "\n", + "# Match the launcher to the MPI library against which mpi4py was built.\n", + "vendor_result = subprocess.run(\n", + " [\n", + " sys.executable,\n", + " \"-c\",\n", + " \"from mpi4py import MPI; print(MPI.get_vendor()[0])\",\n", + " ],\n", + " check=True,\n", + " capture_output=True,\n", + " text=True,\n", + ")\n", + "MPI_VENDOR = vendor_result.stdout.strip()\n", + "\n", + "if MPI_VENDOR == \"MPICH\":\n", + " mpi_launcher = (\n", + " shutil.which(\"mpirun.mpich\")\n", + " or shutil.which(\"mpiexec.mpich\")\n", + " or shutil.which(\"mpiexec\")\n", + " )\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No MPICH launcher was found\")\n", + " # Do not delegate this nested launch back to Slurm on CSCS.\n", + " MPI_LAUNCHER = [mpi_launcher, \"-launcher\", \"fork\"]\n", + "elif MPI_VENDOR == \"Open MPI\":\n", + " mpi_launcher = shutil.which(\"mpirun.openmpi\") or shutil.which(\"mpirun\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No Open MPI launcher was found\")\n", + " MPI_LAUNCHER = [mpi_launcher, \"--oversubscribe\"]\n", + "else:\n", + " mpi_launcher = shutil.which(\"mpiexec\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(f\"No launcher was found for {MPI_VENDOR}\")\n", + " MPI_LAUNCHER = [mpi_launcher]\n", + "\n", + "def run_program(command):\n", + " '''Run a child program, display its output, and fail on errors.'''\n", + " result = subprocess.run(\n", + " command,\n", + " capture_output=True,\n", + " text=True,\n", + " timeout=180,\n", + " )\n", + " print(result.stdout, end=\"\")\n", + " if result.stderr:\n", + " print(result.stderr, end=\"\", file=sys.stderr)\n", + " result.check_returncode()\n", + "\n", + "def run_mpi(rank_count, script):\n", + " '''Run a Python script under the selected MPI implementation.'''\n", + " command = [\n", + " *MPI_LAUNCHER,\n", + " \"-n\",\n", + " str(rank_count),\n", + " sys.executable,\n", + " \"-u\",\n", + " script,\n", + " ]\n", + " run_program(command)\n", + "\n", + "def run_python(script):\n", + " '''Run a serial Python reference with the notebook's interpreter.'''\n", + " run_program([sys.executable, \"-u\", script])" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-model", + "metadata": {}, + "source": [ + "### 2. One Program, Many Processes\n", + "\n", + "MPI follows the **single program, multiple data** model: every process runs the same script, but each process has a different integer **rank**. The processes belong to a **communicator**; `MPI.COMM_WORLD` contains every process launched for the program.\n", + "\n", + "The notebook kernel itself is a single process, so MPI examples need to be written to Python files and launched with `mpirun`. Passing `4` to the `run_mpi` helper below adds `-n 4` to the launcher and creates four independent Python processes. Each process discovers:\n", + "\n", + "- its own rank with `comm.Get_rank()`;\n", + "- the communicator size with `comm.Get_size()`; and\n", + "- its host name with `MPI.Get_processor_name()`.\n", + "\n", + "Calling `gather` is a **collective operation**: every rank participates, and rank 0 receives one value from each rank. Gathering the values before printing makes the rank order deterministic, even though host names depend on the system running the notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mpi-hello-write", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile hello_mpi.py\n", + "\n", + "from mpi4py import MPI\n", + "\n", + "comm = MPI.COMM_WORLD\n", + "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "\n", + "message = f\"rank {rank} of {size} on {MPI.Get_processor_name()}\"\n", + "messages = comm.gather(message, root=0)\n", + "\n", + "if rank == 0:\n", + " print(f\"Launched {size} MPI processes:\")\n", + " for message in messages:\n", + " print(f\" {message}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mpi-hello-run", + "metadata": {}, + "outputs": [], + "source": [ + "run_mpi(4, \"hello_mpi.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-simple-intro", + "metadata": {}, + "source": [ + "### 3. Exercise 1: Distributed Sum of Squares\n", + "\n", + "Our first collective gathered Python strings. Now let's distribute numerical work. Rank 0 creates the integers 1 through 23 and `scatter` sends one NumPy chunk to each rank. The chunks may have different lengths, which the lowercase object-based `scatter` method handles naturally.\n", + "\n", + "**Simple exercise:** Each rank already computes its local sum of squares. Your task is to combine those partial results on rank 0.\n", + "\n", + "**TODO:** Replace the placeholder with one call to `comm.reduce`. Use `op=MPI.SUM` and `root=0`.\n", + "\n", + "The unchanged validation checks both the one-rank and four-rank results independently against NumPy." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mpi-simple-write", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile distributed_sum_squares.py\n", + "\n", + "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", + "comm = MPI.COMM_WORLD\n", + "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "\n", + "values = np.arange(1, 24, dtype=np.int64) if rank == 0 else None\n", + "chunks = np.array_split(values, size) if rank == 0 else None\n", + "local_values = comm.scatter(chunks, root=0)\n", + "\n", + "local_sum_squares = np.dot(local_values, local_values)\n", + "\n", + "# TODO: Sum the partial results on rank 0 with comm.reduce.\n", + "raise NotImplementedError(\"TODO: reduce the local sums\")\n", + "\n", + "if rank == 0:\n", + " expected = np.dot(values, values)\n", + " assert global_sum_squares == expected\n", + " print(f\"sum(i**2 for i in 1..23) = {global_sum_squares}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mpi-simple-run", + "metadata": {}, + "outputs": [], + "source": [ + "run_mpi(1, \"distributed_sum_squares.py\")\n", + "run_mpi(4, \"distributed_sum_squares.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-think", + "metadata": {}, + "source": [ + "#### Think About It\n", + "\n", + "What does `global_sum_squares` contain on ranks other than rank 0? When would `allreduce` be more appropriate than `reduce`?" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-decomposition", + "metadata": {}, + "source": [ + "### 4. Domain Decomposition and Halo Exchange\n", + "\n", + "Collectives move data across an entire communicator. Stencil computations have a more local communication pattern: each grid point depends only on nearby points. We can split a $66 \\times 66$ grid by rows and give each of four ranks 16 of the 64 interior rows:\n", + "\n", + "```text\n", + "global row: 0 | 1 ... 16 | 17 ... 32 | 33 ... 48 | 49 ... 64 | 65\n", + "owner: boundary | rank 0 | rank 1 | rank 2 | rank 3 | boundary\n", + "```\n", + "\n", + "Every rank stores two extra **halo rows** around its owned rows:\n", + "\n", + "```text\n", + " top halo\n", + " +----------------+\n", + " | owned rows |\n", + " +----------------+\n", + " bottom halo\n", + "```\n", + "\n", + "Before each stencil update, adjacent ranks exchange their edge rows. `MPI.PROC_NULL` represents a missing neighbor at a physical boundary: communication with it completes immediately and leaves the receive buffer unchanged. Using `Sendrecv` pairs a send and receive in one operation, avoiding the deadlock risks of separate blocking sends.\n", + "\n", + "For NumPy arrays, mpi4py's uppercase methods such as `Sendrecv` and `Gather` communicate typed buffers directly. The lowercase methods used above can serialize general Python objects; uppercase methods are the natural choice for fixed-shape numerical arrays." + ] + }, + { + "cell_type": "markdown", + "id": "mpi-serial-intro", + "metadata": {}, + "source": [ + "### 5. The Serial Heat Equation Baseline\n", + "\n", + "The two-dimensional heat equation is\n", + "\n", + "$$\n", + "\\frac{\\partial u}{\\partial t}\n", + "= \\kappa \\left(\\frac{\\partial^2 u}{\\partial x^2}\n", + "+ \\frac{\\partial^2 u}{\\partial y^2}\\right).\n", + "$$\n", + "\n", + "For equal grid spacing $h = \\Delta x = \\Delta y$, define the dimensionless diffusion number\n", + "\n", + "$$r = \\frac{\\kappa\\,\\Delta t}{h^2}.$$\n", + "\n", + "A five-point finite-difference stencil then advances one time step:\n", + "\n", + "$$\n", + "u^{n+1}_{i,j} = u^n_{i,j} + r\\left(\n", + "u^n_{i-1,j} + u^n_{i+1,j} + u^n_{i,j-1} + u^n_{i,j+1}\n", + "- 4u^n_{i,j}\\right).\n", + "$$\n", + "\n", + "We use $r=0.2$, below the two-dimensional explicit stability limit $r \\leq 1/4$, and hold the outer boundary at zero. The complete serial implementation below gives us a trusted reference before communication is introduced." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mpi-serial-write", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile heat_reference.py\n", + "\n", + "import numpy as np\n", + "\n", + "NY = 66\n", + "NX = 66\n", + "STEPS = 120\n", + "DIFFUSION_NUMBER = 0.2\n", + "\n", + "def initial_temperature_rows(global_rows, ny=NY, nx=NX):\n", + " \"\"\"Create the initial hot disk for selected global rows.\"\"\"\n", + " y = np.asarray(global_rows)[:, np.newaxis]\n", + " x = np.arange(nx)[np.newaxis, :]\n", + " center_y = (ny - 1) / 2\n", + " center_x = (nx - 1) / 2\n", + " hot = (y - center_y) ** 2 + (x - center_x) ** 2 <= 6**2\n", + " temperature = hot.astype(np.float64)\n", + " temperature[:, 0] = 0.0\n", + " temperature[:, -1] = 0.0\n", + " return temperature\n", + "\n", + "def initial_temperature(ny=NY, nx=NX):\n", + " \"\"\"Create the full initial field with zero-valued boundaries.\"\"\"\n", + " temperature = np.zeros((ny, nx), dtype=np.float64)\n", + " temperature[1:-1] = initial_temperature_rows(range(1, ny - 1), ny, nx)\n", + " return temperature\n", + "\n", + "def advance(temperature, diffusion_number=DIFFUSION_NUMBER):\n", + " \"\"\"Apply one serial five-point stencil update.\"\"\"\n", + " next_temperature = np.zeros_like(temperature)\n", + " center = temperature[1:-1, 1:-1]\n", + " next_temperature[1:-1, 1:-1] = center + diffusion_number * (\n", + " temperature[:-2, 1:-1]\n", + " + temperature[2:, 1:-1]\n", + " + temperature[1:-1, :-2]\n", + " + temperature[1:-1, 2:]\n", + " - 4.0 * center\n", + " )\n", + " return next_temperature\n", + "\n", + "def solve_serial(steps=STEPS):\n", + " \"\"\"Evolve the complete grid on one process.\"\"\"\n", + " temperature = initial_temperature()\n", + " for _ in range(steps):\n", + " temperature = advance(temperature)\n", + " return temperature\n", + "\n", + "if __name__ == \"__main__\":\n", + " temperature = solve_serial()\n", + " assert np.isfinite(temperature).all()\n", + " assert np.all(temperature >= 0.0)\n", + " assert np.all(temperature <= 1.0)\n", + " assert np.all(temperature[[0, -1], :] == 0.0)\n", + " assert np.all(temperature[:, [0, -1]] == 0.0)\n", + " print(\n", + " f\"Serial reference passed: shape={temperature.shape}, \"\n", + " f\"maximum={temperature.max():.6f}\"\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mpi-serial-run", + "metadata": {}, + "outputs": [], + "source": [ + "run_python(\"heat_reference.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-advanced-intro", + "metadata": {}, + "source": [ + "### 6. Exercise 2: Distributed Heat Equation\n", + "\n", + "The distributed solver retains the serial stencil but stores only one row slab per rank.\n", + "\n", + "**Advanced exercise:** Complete its two missing pieces:\n", + "\n", + "1. **Exchange halo rows.** Add two `comm.Sendrecv` calls to `exchange_halos`:\n", + " - send the first owned row (`temperature[1]`) to `above` while receiving the bottom halo (`temperature[-1]`) from `below`, using tag 0;\n", + " - send the last owned row (`temperature[-2]`) to `below` while receiving the top halo (`temperature[0]`) from `above`, using tag 1.\n", + "2. **Advance the local stencil.** Fill `next_temperature[1:-1, 1:-1]` with the same five-point update as the serial baseline. The halo rows make the slice expression identical even at rank boundaries.\n", + "\n", + "**TODO:** Fill both placeholders and remove their `NotImplementedError` lines. The communication calls use this keyword form:\n", + "\n", + "```python\n", + "comm.Sendrecv(\n", + " sendbuf=..., dest=..., sendtag=...,\n", + " recvbuf=..., source=..., recvtag=...,\n", + ")\n", + "```\n", + "\n", + "The decomposition, initialization, gather, and serial-reference check are already complete." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mpi-heat-write", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile heat_mpi.py\n", + "\n", + "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", + "from heat_reference import (\n", + " DIFFUSION_NUMBER,\n", + " NX,\n", + " NY,\n", + " STEPS,\n", + " initial_temperature,\n", + " initial_temperature_rows,\n", + " solve_serial,\n", + ")\n", + "\n", + "def decompose_rows(comm):\n", + " \"\"\"Return the local row count and first owned global row.\"\"\"\n", + " interior_rows = NY - 2\n", + " if interior_rows % comm.Get_size() != 0:\n", + " raise ValueError(\"The interior row count must be divisible by the rank count\")\n", + " local_rows = interior_rows // comm.Get_size()\n", + " first_global_row = 1 + comm.Get_rank() * local_rows\n", + " return local_rows, first_global_row\n", + "\n", + "def exchange_halos(temperature, comm):\n", + " \"\"\"Exchange edge rows with the ranks above and below.\"\"\"\n", + " rank = comm.Get_rank()\n", + " above = rank - 1 if rank > 0 else MPI.PROC_NULL\n", + " below = rank + 1 if rank + 1 < comm.Get_size() else MPI.PROC_NULL\n", + "\n", + " # TODO: Exchange the first owned row for the bottom halo (tag 0),\n", + " # then the last owned row for the top halo (tag 1).\n", + " raise NotImplementedError(\"TODO: exchange halo rows\")\n", + "\n", + "def advance_local(temperature):\n", + " \"\"\"Apply one five-point update to the locally owned rows.\"\"\"\n", + " next_temperature = np.zeros_like(temperature)\n", + "\n", + " # TODO: Apply the stencil to next_temperature[1:-1, 1:-1].\n", + " raise NotImplementedError(\"TODO: implement the local stencil\")\n", + "\n", + " return next_temperature\n", + "\n", + "def gather_field(local_temperature, local_rows, comm):\n", + " \"\"\"Gather equal-sized row slabs and restore the physical boundaries.\"\"\"\n", + " owned_rows = np.ascontiguousarray(local_temperature[1:-1])\n", + " gathered = None\n", + " if comm.Get_rank() == 0:\n", + " gathered = np.empty((comm.Get_size(), local_rows, NX), dtype=np.float64)\n", + "\n", + " comm.Gather(owned_rows, gathered, root=0)\n", + "\n", + " if comm.Get_rank() != 0:\n", + " return None\n", + "\n", + " temperature = np.zeros((NY, NX), dtype=np.float64)\n", + " temperature[1:-1] = gathered.reshape(NY - 2, NX)\n", + " return temperature\n", + "\n", + "def main():\n", + " comm = MPI.COMM_WORLD\n", + " local_rows, first_global_row = decompose_rows(comm)\n", + " global_rows = np.arange(first_global_row, first_global_row + local_rows)\n", + "\n", + " temperature = np.zeros((local_rows + 2, NX), dtype=np.float64)\n", + " temperature[1:-1] = initial_temperature_rows(global_rows)\n", + "\n", + " for _ in range(STEPS):\n", + " exchange_halos(temperature, comm)\n", + " temperature = advance_local(temperature)\n", + "\n", + " distributed = gather_field(temperature, local_rows, comm)\n", + "\n", + " if comm.Get_rank() == 0:\n", + " reference = solve_serial()\n", + " np.testing.assert_allclose(distributed, reference, rtol=1e-13, atol=1e-13)\n", + " assert np.isfinite(distributed).all()\n", + " assert np.all(distributed[[0, -1], :] == 0.0)\n", + " assert np.all(distributed[:, [0, -1]] == 0.0)\n", + " np.savez(\n", + " \"heat_equation_result.npz\",\n", + " initial=initial_temperature(),\n", + " final=distributed,\n", + " steps=STEPS,\n", + " )\n", + " print(\n", + " \"Distributed heat equation matches the serial reference: \"\n", + " f\"shape={distributed.shape}, maximum={distributed.max():.6f}\"\n", + " )\n", + "\n", + "if __name__ == \"__main__\":\n", + " main()" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-verify-intro", + "metadata": {}, + "source": [ + "### 7. Verification and Visualization\n", + "\n", + "Correctness comes before interpretation. The MPI program gathers the distributed row slabs on rank 0, compares every value with `solve_serial()` using `numpy.testing.assert_allclose`, checks the fixed boundaries, and only then saves the initial and final fields for visualization.\n", + "\n", + "Let's run the advanced exercise with the four ranks used by our decomposition." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mpi-heat-run", + "metadata": {}, + "outputs": [], + "source": [ + "result_path = Path(\"heat_equation_result.npz\")\n", + "result_path.unlink(missing_ok=True)\n", + "run_mpi(4, \"heat_mpi.py\")\n", + "assert result_path.exists()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mpi-plot", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "with np.load(\"heat_equation_result.npz\") as result:\n", + " initial = result[\"initial\"]\n", + " final = result[\"final\"]\n", + " steps = int(result[\"steps\"])\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(10, 4), constrained_layout=True)\n", + "for axis, field, title in zip(\n", + " axes,\n", + " (initial, final),\n", + " (\"Initial temperature\", f\"After {steps} steps\"),\n", + "):\n", + " image = axis.imshow(field, origin=\"lower\", cmap=\"inferno\", vmin=0.0, vmax=1.0)\n", + " axis.set_title(title)\n", + " axis.set_xlabel(\"x\")\n", + " axis.set_ylabel(\"y\")\n", + "\n", + "fig.colorbar(image, ax=axes, label=\"temperature\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-takeaways", + "metadata": {}, + "source": [ + "### 8. Key Takeaways\n", + "\n", + "- Every MPI rank runs the same program with private memory and a distinct rank.\n", + "- Collectives such as `scatter`, `reduce`, and `Gather` coordinate all ranks in a communicator.\n", + "- A row-wise decomposition turns a global stencil into local NumPy work plus two neighbor exchanges.\n", + "- Halo rows make the local stencil expression match the serial expression.\n", + "- `Sendrecv` and `MPI.PROC_NULL` express boundary-safe neighbor communication without extra synchronization.\n", + "- Comparing against a small trusted serial implementation catches communication and indexing mistakes.\n", + "\n", + "The same decomposition pattern extends to larger grids, uneven partitions with `Gatherv`, and higher-dimensional process topologies." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/07__cpp_interop.ipynb b/tutorials/pyhpc/notebooks/07__cpp_interop.ipynb new file mode 100644 index 00000000..a642b4f7 --- /dev/null +++ b/tutorials/pyhpc/notebooks/07__cpp_interop.ipynb @@ -0,0 +1,1945 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1622633e-eba8-4fd3-9d24-3487c3fe2188", + "metadata": {}, + "source": [ + "## C++ Interop" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c4cf0d1f-383f-4abe-a9d7-e230af150445", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext memory_profiler" + ] + }, + { + "cell_type": "markdown", + "id": "a74c5756-669b-4375-a405-dfe4bd901d1b", + "metadata": {}, + "source": [ + "Calling into C or C++ functions from Python is fundamental for binding to existing libraries, and for running your most expensive computations without any Python overhead.\n", + "\n", + "In this tutorial, we'll show a few ways to do Python/C++ interoperability in your HPC project:\n", + "\n", + " * Basic C library calls with **ctypes** and **cffi**\n", + " * Generate bindings to C++ code with **nanobind**\n", + " * Use C++ dynamically from Python with automatic bindings provided by **cppjit**" + ] + }, + { + "cell_type": "markdown", + "id": "4e2316c8-bda0-4846-9af7-30e5e7bdfdc1", + "metadata": {}, + "source": [ + "## Calling basic functions with **ctypes**\n", + "\n", + "[ctypes](https://docs.python.org/3.14/library/ctypes.html) is a builtin Python module for foreign function calling:\n", + "* Zero dependencies\n", + "* Shared libraries with C interfaces only\n", + "* No help with creating/managing the SO's." + ] + }, + { + "cell_type": "markdown", + "id": "a77d3d4e-7bfb-4396-a9b9-b462a7cd199b", + "metadata": {}, + "source": [ + "#### Example 1:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af91f560-dacd-418a-b21e-22bdaf31c936", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile simple.c\n", + "\n", + "float square(float x) {\n", + " return x*x;\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "79e3f009-4263-4789-8581-0bf1ba6e43ab", + "metadata": {}, + "source": [ + "Compile this to a shared library that Python knows how to use with `ctypes`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4835d33-0280-4c2e-a600-af01075e0e8f", + "metadata": {}, + "outputs": [], + "source": [ + "!gcc -shared -fPIC -o simple.so simple.c" + ] + }, + { + "cell_type": "markdown", + "id": "f2e4ac3b-e2c8-45cd-a492-fee5d2d7bf45", + "metadata": {}, + "source": [ + "#### Desired usage in Python:\n", + "\n", + "```python\n", + "y = square(x)\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ef48e939-88de-4e1e-b330-fd97b42d95b6", + "metadata": {}, + "outputs": [], + "source": [ + "import ctypes\n", + "\n", + "lib = ctypes.cdll.LoadLibrary(\"./simple.so\")" + ] + }, + { + "cell_type": "markdown", + "id": "05ee90ed-8b5f-45a9-a07d-c1e7cbae861a", + "metadata": {}, + "source": [ + "> Warning: You can only load a file once; future loads just reuse the existing library (much like Python imports). So if you change the file, you need to restart the kernel." + ] + }, + { + "cell_type": "markdown", + "id": "326c0f62-aa7c-444c-b104-4fe9f1aa3f81", + "metadata": {}, + "source": [ + "Now, we need to set the argument types - they can't be inferred from an SO." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "154f9a54-0d42-4b3e-bc5b-257df6c3cb91", + "metadata": {}, + "outputs": [], + "source": [ + "lib.square.argtypes = (ctypes.c_float,)\n", + "lib.square.restype = ctypes.c_float" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7f7e099c-748b-49df-98fa-8a4c55378398", + "metadata": {}, + "outputs": [], + "source": [ + "%%timeit\n", + "lib.square(2.5)" + ] + }, + { + "cell_type": "markdown", + "id": "c283eb97-c0a9-428e-bd4d-1dfd8ffadfa9", + "metadata": {}, + "source": [ + "Doing the same with Python directly:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "983a6c4e-95bd-4042-8f01-1aa9edef294f", + "metadata": {}, + "outputs": [], + "source": [ + "def mul(a):\n", + " return a*a" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d5dd9f35-c86d-4fec-9cf4-3b1bdf5f4f86", + "metadata": {}, + "outputs": [], + "source": [ + "%%timeit\n", + "mul(2.5)" + ] + }, + { + "cell_type": "markdown", + "id": "9a3d759d-8570-4b94-9e1b-9991463462b6", + "metadata": {}, + "source": [ + "*Question: why is using just Python faster in this case?*" + ] + }, + { + "cell_type": "markdown", + "id": "7629f938-e227-4795-897d-20e8e2a7cd71", + "metadata": {}, + "source": [ + "What were the steps taken?\n", + "\n", + "1. Load the library, by passing the `.so` file to the ctypes library loader.\n", + "1. Set the expected arg types and return type for the function call. We had to use the types from ctypes since compiled types are not the same as Python types. SO's do not store signatures!" + ] + }, + { + "cell_type": "markdown", + "id": "14e3a4c6-3e3f-488e-be94-bce5309255ae", + "metadata": {}, + "source": [ + "There is an alternate way to do this:\n", + "\n", + "1. Create a new C Function type, listing the return type, then the argument type(s) if there are any. This is a different order and structure from type hinting in modern Python." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd436422-0a44-44cd-b3d9-a7d7994fa768", + "metadata": {}, + "outputs": [], + "source": [ + "float_float_t = ctypes.CFUNCTYPE(ctypes.c_float, ctypes.c_float)" + ] + }, + { + "cell_type": "markdown", + "id": "756afa59-cfa0-44a2-88c0-2bae1187b638", + "metadata": {}, + "source": [ + "Now \"cast\" the function with your function type." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08e3725d-f35d-4654-b960-d7bb3a48c6bb", + "metadata": {}, + "outputs": [], + "source": [ + "squarefunc = float_float_t(lib.square)" + ] + }, + { + "cell_type": "markdown", + "id": "62952a49-6c76-44ba-87f2-d051a28aefa6", + "metadata": {}, + "source": [ + "And this also works:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "613b8324-930e-426f-b15c-1dd6a97e44bd", + "metadata": {}, + "outputs": [], + "source": [ + "%%timeit\n", + "squarefunc(2.0)" + ] + }, + { + "cell_type": "markdown", + "id": "9edfade8-923f-4752-81f7-cef1fcecb459", + "metadata": {}, + "source": [ + "However, it sets up a little extra machinery, so it is slightly slower. In exchange, it is itself a valid c_type and can be passed to C code. You can even wrap Python functions this way." + ] + }, + { + "cell_type": "markdown", + "id": "75eb3a9e-8a81-4ba7-8fed-cc6c8275b90b", + "metadata": {}, + "source": [ + "Suggestion: wrap your library in a class!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b643f59-0c19-4247-add1-ece61195cbab", + "metadata": {}, + "outputs": [], + "source": [ + "class SimpleLib:\n", + " def __init__(self, file_to_load: str):\n", + " self._lib = ctypes.cdll.LoadLibrary(file_to_load)\n", + " self._lib.square.argtypes = (ctypes.c_float,)\n", + " self._lib.square.restype = ctypes.c_float\n", + "\n", + " def square(self, x: float) -> float:\n", + " return self._lib.square(x)\n", + " \n", + " #... N" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d77a3cad-5ad1-4814-8837-59f38e8dee2d", + "metadata": {}, + "outputs": [], + "source": [ + "simple = SimpleLib(\"./simple.so\")\n", + "simple.square(2)" + ] + }, + { + "cell_type": "markdown", + "id": "f7faf3dd-09c8-440d-9c99-57f3278487d2", + "metadata": {}, + "source": [ + "In practice, you will usually have a Python wrapper module (not just a class) to \"pythonize\" the interface to the shared library." + ] + }, + { + "cell_type": "markdown", + "id": "fa30e335-8e33-4db1-b411-c6c259869ddc", + "metadata": {}, + "source": [ + "### But what about C++ (and all other languages)?\n", + "\n", + "This wasn't language specific; it is a property of compiled libraries. C++ (and other languages) do not export a clean interface by default. In C++, because of features like overloading, the names are \"mangled\" in a compiler specific way. But you can manually export a nice interface:" + ] + }, + { + "cell_type": "markdown", + "id": "a7334a47-93fd-41f6-8ce3-87166504661d", + "metadata": {}, + "source": [ + " Let's try a slightly more advanced example, this time in C++:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6dc4a59e-f02f-46ae-aba6-1151dbbcaaa1", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile templates.cpp\n", + "\n", + "\n", + "template\n", + "T square(T x) {\n", + " return x*x;\n", + "}\n", + "\n", + "\n", + "extern \"C\" {\n", + " int square_int(int x) {\n", + " return square(x);\n", + " }\n", + " \n", + " double square_double(double x) {\n", + " return square(x);\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "4765b3ca-4ce2-4f03-8100-1f356b8c42b8", + "metadata": {}, + "source": [ + "This is not Python specific! If you want to load a shared object (DLL on Windows) from any language, you need to do this:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24abf578-b279-4ec1-a4fb-5572ea018314", + "metadata": {}, + "outputs": [], + "source": [ + "!g++ templates.cpp -shared -fPIC -o templates.so" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "54143525-1ad1-4ea5-9049-9b031ced0704", + "metadata": {}, + "outputs": [], + "source": [ + "from functools import singledispatchmethod\n", + "\n", + "\n", + "class TemplateLib(object):\n", + " def __init__(self, file_to_load: str):\n", + " self._lib = ctypes.cdll.LoadLibrary(file_to_load)\n", + "\n", + " self._lib.square_double.argtypes = (ctypes.c_double,)\n", + " self._lib.square_double.restype = ctypes.c_double\n", + "\n", + " self._lib.square_int.argtypes = (ctypes.c_int,)\n", + " self._lib.square_int.restype = ctypes.c_int\n", + "\n", + " @singledispatchmethod\n", + " def square(self, arg):\n", + " raise NotImplementedError(\"Not a registered type\")\n", + "\n", + " @square.register\n", + " def _(self, x: int):\n", + " return self._lib.square_int(x)\n", + "\n", + " @square.register\n", + " def _(self, x: float):\n", + " return self._lib.square_double(x)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0f08d785-64fb-4546-b603-1749d84324cd", + "metadata": {}, + "outputs": [], + "source": [ + "templates = TemplateLib(\"./templates.so\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "00fa9406-2809-4f7c-9544-d229a48cafa2", + "metadata": {}, + "outputs": [], + "source": [ + "templates.square(2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f3d91601-1b5b-4022-902d-b92b32c25928", + "metadata": {}, + "outputs": [], + "source": [ + "templates.square(2.0)" + ] + }, + { + "cell_type": "markdown", + "id": "249d1c41-62e8-4d20-8883-194d5d28324d", + "metadata": {}, + "source": [ + "## NumPy tools\n", + "\n", + "NumPy has adapters to help you with ctypes, called [numpy.ctypeslib](https://numpy.org/doc/stable/reference/routines.ctypeslib.html?highlight=ctypeslib#module-numpy.ctypeslib). You can convert to/from *any* object providing an `__array_interface__`, like NumPy arrays. It has a loader with more consistent defaults across operating systems. You also have a special pointer that can do bounds checking and such for array arguments." + ] + }, + { + "cell_type": "markdown", + "id": "2e66f698-46a9-446d-9511-3f227868d004", + "metadata": {}, + "source": [ + "## Alternative for foreign function calling - **cffi**\n", + "\n", + "The [CFFI](http://cffi.readthedocs.io/en/latest/overview.html) module is an alternative to the builtin **ctypes** that requires less boilerplate code.\n", + "\n", + "* The *C Foreign Function Interface* for Python\n", + "* C only\n", + "* Developed for PyPy, but available in CPython too\n", + "\n", + "The same example as before:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0dc9197e-e331-41c2-a4a2-dac9bd0b1bd3", + "metadata": {}, + "outputs": [], + "source": [ + "from cffi import FFI\n", + "\n", + "ffi = FFI()\n", + "\n", + "ffi.cdef(\"float square(float);\")\n", + "\n", + "C = ffi.dlopen(\"./simple.so\")\n", + "\n", + "C.square(2.0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55149395-bd94-441e-9e1b-cec39ec3e248", + "metadata": {}, + "outputs": [], + "source": [ + "C.square" + ] + }, + { + "cell_type": "markdown", + "id": "5addb3b1-18f6-44cc-ab51-57f2b338a075", + "metadata": {}, + "source": [ + "Notice we were able to give a C header this time and it's not necessary to bookkeep the C function types by hand." + ] + }, + { + "cell_type": "markdown", + "id": "78356b53-e92d-4694-9d09-08b9f89fc12f", + "metadata": {}, + "source": [ + "### Exercise 1- Try it yourself\n", + "\n", + "Square the elements in this array (for simplicity, do it in-place):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78b5bcac-7a7e-4259-89ba-3def528a1a1b", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile array_square.c\n", + "\n", + "void squares(float* arr, int size) {\n", + " for(int i=0; i\n", + "\n", + "namespace nb = nanobind;\n", + "\n", + "float square(float x) {\n", + " return x*x;\n", + "}\n", + "\n", + "NB_MODULE(pysimple, m) {\n", + " m.def(\"square\", &square);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "e2f4ad3c-59dd-4adf-97bc-939f7ba6e466", + "metadata": {}, + "source": [ + "Compile, import and run. Note that **nanobind requires C++17** (pybind11 only needed C++11), and we compile `{nbsrc}` into the module:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46b5d530-32c2-4667-92b4-50f59cba4151", + "metadata": {}, + "outputs": [], + "source": [ + "!c++ -std=c++17 pysimple.cpp {nbsrc} -shared {nbinc} {robin} {pyinc.s} -o pysimple.so {plat}" + ] + }, + { + "cell_type": "markdown", + "id": "1a720a7f-9377-49ce-b01f-8b81cfd0cb86", + "metadata": {}, + "source": [ + "Now import it and run it:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "022a33cd-5870-4353-a21d-1dfd327db762", + "metadata": {}, + "outputs": [], + "source": [ + "import pysimple\n", + "\n", + "pysimple.square(3)" + ] + }, + { + "cell_type": "markdown", + "id": "1c80b378-671c-48fb-bb25-5dc1e7c0767d", + "metadata": {}, + "source": [ + "nanobind also allows using templated functions:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "410ece9f-b061-4550-a7f1-bb52d2d72ead", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile pytempl.cpp\n", + "\n", + "#include \n", + "\n", + "namespace nb = nanobind;\n", + "\n", + "template\n", + "T add(T x) {\n", + " return x+x;\n", + "}\n", + "\n", + "NB_MODULE(pytempl, m) {\n", + " m.def(\"add\", [](int value) { return add(value); });\n", + " m.def(\"add\", [](double value) { return add(value); });\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "49f9180e-66d4-4753-99e9-e2e6a34e1a72", + "metadata": {}, + "source": [ + "Note: Requires registering one binding per Python entry type; the rest is handled by C++." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4e16d27b-c4e2-4022-a690-a088c7547552", + "metadata": {}, + "outputs": [], + "source": [ + "!c++ pytempl.cpp -std=c++17 {nbsrc} -shared {nbinc} {robin} {pyinc.s} -o pytempl.so {plat}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "65a2bd22-613c-4ec3-bf91-6b765a216b6e", + "metadata": {}, + "outputs": [], + "source": [ + "import pytempl\n", + "\n", + "pytempl.add(3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6a339240-ad2c-4b5e-a135-d2970579f8dc", + "metadata": {}, + "outputs": [], + "source": [ + "pytempl.add(3.0)" + ] + }, + { + "cell_type": "markdown", + "id": "6da2137c-6cd9-4ee1-9ae8-3232130c71bb", + "metadata": {}, + "source": [ + "For production builds you would normally let CMake (via nanobind's `nanobind_add_module`) handle this, and add size/robustness flags such as `-Os -fvisibility=hidden -flto` plus stripping. The manual command above is kept minimal for this tutorial." + ] + }, + { + "cell_type": "markdown", + "id": "59c4f74e-0778-4778-b017-b7d70e91d4cb", + "metadata": {}, + "source": [ + "### nanobind example with classes\n", + "\n", + "What about classes?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fad28162-a6ad-4828-bbea-8eb4ea81e51d", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile VectorClass.hpp\n", + "#pragma once\n", + "\n", + "class Vector2D {\n", + " double x;\n", + " double y;\n", + "\n", + "public:\n", + "\n", + " Vector2D(double x, double y): x(x), y(y) {}\n", + "\n", + " double get_x() const {\n", + " return x;\n", + " }\n", + "\n", + " double get_y() const {\n", + " return y;\n", + " }\n", + "\n", + " void set_x(double val) {\n", + " x = val;\n", + " }\n", + "\n", + " void set_y(double val) {\n", + " y = val;\n", + " }\n", + "\n", + "\n", + " Vector2D& operator+= (const Vector2D& other) {\n", + " x += other.x;\n", + " y += other.y;\n", + " return *this;\n", + " }\n", + "\n", + " Vector2D operator+ (const Vector2D& other) const {\n", + " return Vector2D(x + other.x, y + other.y);\n", + " }\n", + "};" + ] + }, + { + "cell_type": "markdown", + "id": "58c63d76-f203-4415-8c61-be3bd6f91e0f", + "metadata": {}, + "source": [ + "Our binding code shows a couple more features:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1274ac58-b8e7-4e3a-869e-b46898df6d52", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile vectorclass.cpp\n", + "\n", + "#include \n", + "#include \n", + "#include \"VectorClass.hpp\"\n", + "\n", + "namespace nb = nanobind;\n", + "using namespace nanobind::literals;\n", + "\n", + "NB_MODULE(vectorclass, m) {\n", + " nb::class_(m, \"Vector2D\")\n", + " .def(nb::init(), \"x\"_a, \"y\"_a)\n", + " .def_prop_rw(\"x\", &Vector2D::get_x, &Vector2D::set_x)\n", + " .def_prop_rw(\"y\", &Vector2D::get_y, &Vector2D::set_y)\n", + " .def(nb::self += nb::self)\n", + " .def(nb::self + nb::self)\n", + " .def(\"__repr__\", [](nb::object self){\n", + " return nb::str(\"{0.__class__.__name__}({0.x}, {0.y})\").format(self);\n", + " })\n", + " ;\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7692e55-c68c-4c93-a17d-b49fbd131835", + "metadata": {}, + "outputs": [], + "source": [ + "!c++ -std=c++17 vectorclass.cpp {nbsrc} -shared {nbinc} {robin} {pyinc.s} -o vectorclass.so {plat}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb8dffd5-ee55-4473-8c06-128382cd0fb5", + "metadata": {}, + "outputs": [], + "source": [ + "import vectorclass\n", + "\n", + "v = vectorclass.Vector2D(1, 2)\n", + "print(f\"{v.x = }, {v.y = }\")" + ] + }, + { + "cell_type": "markdown", + "id": "982ef30d-25d3-4afb-9a40-931a09cd4ee8", + "metadata": {}, + "source": [ + "### Exercise 2 - NumPy arrays and nanobind\n", + "\n", + "Write `scale(arr, factor)` that multiplies a 1-D float64 NumPy array in place, using a plain C++ core function `void scale(double*, size_t, double)`. Bind it with `nb::ndarray` so the NumPy array is modified without copying." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "01bd7ec6-f5f9-444a-8ce3-4d2de58eb3b4", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile nanobind_numpy.cpp\n", + "\n", + "#include \n", + "#include \n", + "namespace nb = nanobind;\n", + "using namespace nanobind::literals;\n", + "\n", + "void scale(double* data, size_t n, double factor) {\n", + " for (size_t i = 0; i < n; ++i) data[i] *= factor; // the \"C-style\" core\n", + "}\n", + "\n", + "NB_MODULE(nanobind_numpy, m) {\n", + " m.def(\"scale\",\n", + " // How does the \"scale\" function have to be declared here?\n", + " // TODO: write your code here.\n", + " );\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ab85cc5a-d4e5-4e1c-964c-491f6fe84bc9", + "metadata": {}, + "outputs": [], + "source": [ + "!c++ -std=c++17 nanobind_numpy.cpp {nbsrc} -shared {nbinc} {robin} {pyinc.s} -o nanobind_numpy.so {plat}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4bf03a02-de91-483f-8aee-e10661223324", + "metadata": {}, + "outputs": [], + "source": [ + "import nanobind_numpy\n", + "import numpy as np\n", + "\n", + "# What happens if this would be dtype=np.int32 ?\n", + "# Can you ensure that the behavior is still sensible in this case?\n", + "a = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)\n", + "\n", + "# nanobind_numpy.scale(a, 10.0) # uncomment to try it\n", + "print(a) # [10. 20. 30. 40.] -- original mutated, no copy" + ] + }, + { + "cell_type": "markdown", + "id": "6927f65e-d027-48aa-8c21-e00ab6100c90", + "metadata": {}, + "source": [ + "## Automatic bindings with **cppjit**\n", + "\n", + "cppjit combines the convenience of the Python language with the efficiency of C++ implementations. The dynamic C++ bindings are powered by the [clang-repl](https://clang.llvm.org/docs/ClangRepl.html) C++ interpreter and the\n", + "[CppInterOp](https://github.com/compiler-research/CppInterOp) interoperability library, allowing you to use efficient C++ implementations from Python." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2a537eb2-cb49-4248-a84b-12231ba8f1ab", + "metadata": {}, + "outputs": [], + "source": [ + "import swe_core\n", + "import cppjit\n", + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "id": "c7f00b6d-2715-4525-ab35-81f86005265f", + "metadata": {}, + "source": [ + "Just-in-time compilation of C++ functions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76047b24-607b-41b3-8258-789c11f9ea4f", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit.cppdef('''\n", + "\n", + "float smallest_diff(float* v1, float* v2, std::size_t size) {\n", + " float min_diff = std::numeric_limits::max();\n", + " for (std::size_t i1 = 0; i1 < size; i1++) {\n", + " for (std::size_t i2 = 0; i2 < size; i2++) {\n", + " float diff = std::abs(v1[i1] - v2[i2]);\n", + " if (diff < min_diff) {\n", + " min_diff = diff;\n", + " }\n", + " }\n", + " }\n", + " return min_diff;\n", + "}\n", + "''');" + ] + }, + { + "cell_type": "markdown", + "id": "6ef93f6a-4e6f-4545-9966-e70c045b4889", + "metadata": {}, + "source": [ + "As example inputs, we generate two numpy arrays with random numbers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c741469-53b2-44e5-a3d3-34b5f9bc2cc0", + "metadata": {}, + "outputs": [], + "source": [ + "size = 100\n", + "v1 = np.random.randn(size).astype(np.float32)\n", + "v2 = np.random.randn(size).astype(np.float32)" + ] + }, + { + "cell_type": "markdown", + "id": "a8de0868-50b4-4e96-a3c2-0d05e38fd022", + "metadata": {}, + "source": [ + "Use the function directly: cppjit accepts NumPy arrays with no wrapper code." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a0d2ed36-ef52-4f3d-8be5-73255d33c7d0", + "metadata": {}, + "outputs": [], + "source": [ + "%%timeit\n", + "cppjit.gbl.smallest_diff(v1, v2, size)" + ] + }, + { + "cell_type": "markdown", + "id": "0f62a95d", + "metadata": {}, + "source": [ + "How does the C++ kernel compare to a pure Python implementation?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5cb753ad", + "metadata": {}, + "outputs": [], + "source": [ + "def smallest_diff(x1, x2):\n", + " min_diff = float('inf')\n", + " for e1 in x1:\n", + " for e2 in x2:\n", + " diff = abs(e1 - e2)\n", + " if diff < min_diff:\n", + " min_diff = diff\n", + " return min_diff" + ] + }, + { + "cell_type": "markdown", + "id": "2a618b20", + "metadata": {}, + "source": [ + "**The pure-Python implementation is far slower.**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "955a2534", + "metadata": {}, + "outputs": [], + "source": [ + "%%timeit\n", + "smallest_diff(v1, v2)" + ] + }, + { + "cell_type": "markdown", + "id": "e6075329", + "metadata": {}, + "source": [ + "### Loading of precompiled functions\n", + "\n", + "Precompiling the functionality and loading the library into cppjit improves performance further. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5041095e", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile diff_small.hxx\n", + "\n", + "#include \n", + "#include \n", + "#include \n", + "float optimized_smallest_diff(float* v1, float* v2, std::size_t size);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "085838e2", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile diff_small.cxx\n", + "\n", + "# include \"diff_small.hxx\"\n", + "\n", + "float optimized_smallest_diff(float* v1, float* v2, std::size_t size) {\n", + " float min_diff = std::numeric_limits::max();\n", + " for (std::size_t i1 = 0; i1 < size; i1++) {\n", + " for (std::size_t i2 = 0; i2 < size; i2++) {\n", + " float diff = std::abs(v1[i1] - v2[i2]);\n", + " if (diff < min_diff) {\n", + " min_diff = diff;\n", + " }\n", + " }\n", + " }\n", + " return min_diff;\n", + "}\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c8feb94b", + "metadata": {}, + "outputs": [], + "source": [ + "!g++ -Ofast -shared -fPIC -o libanalysis.so diff_small.cxx" + ] + }, + { + "cell_type": "markdown", + "id": "b1a58c13", + "metadata": {}, + "source": [ + "You can interactively include the header and functionality from the shared library." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5fe81028", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit.cppdef('#include \"diff_small.hxx\"')\n", + "cppjit.load_library('libanalysis.so')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c28a558a", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit.gbl.__dict__" + ] + }, + { + "cell_type": "markdown", + "id": "6445dc5f", + "metadata": {}, + "source": [ + "**The loaded library improves the runtime further.**\n", + "\n", + "While this may not make a huge difference for small files like this example, large files benefit since your code is recompiled every time you `cppdef`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9e7ec5a8", + "metadata": {}, + "outputs": [], + "source": [ + "%%timeit\n", + "\n", + "cppjit.gbl.optimized_smallest_diff(v1, v2, size)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b1c20b0e", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit.gbl.__dict__" + ] + }, + { + "cell_type": "markdown", + "id": "033d45ed", + "metadata": {}, + "source": [ + "Finally, we can show that all implementations come to the same result:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7552e0d5", + "metadata": {}, + "outputs": [], + "source": [ + "print('cppjit:', cppjit.gbl.smallest_diff(v1, v2, size))\n", + "print('Native Python:', smallest_diff(v1, v2))\n", + "print('cppjit (loaded):', cppjit.gbl.optimized_smallest_diff(v1, v2, size))" + ] + }, + { + "cell_type": "markdown", + "id": "189c189b-7828-4fe9-ba05-466b6a9cc80a", + "metadata": {}, + "source": [ + "### Let's look at examples of runtime features in cppjit\n", + "\n", + "We can easily do template instantiations *at runtime* with cppjit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d89d302d-9944-4966-8b21-0fda755babc2", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit.cppdef('''template\n", + "T add(T x) {\n", + " return x+x;\n", + "}''')" + ] + }, + { + "cell_type": "markdown", + "id": "1d5fbf19-3ebc-405c-bc0f-56e7b6a9a48f", + "metadata": {}, + "source": [ + "We can automatically call `cppjit.gbl.add` now!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9dc55be-1cb9-4939-bec8-69f9b672bcb1", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit.gbl.add(3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f87f25ac-fed1-4cc2-9ac0-242d09256b6a", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit.gbl.add(3.0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d11ac293-501d-43e4-8ff6-3901c011e4ea", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit.gbl.add('a')" + ] + }, + { + "cell_type": "markdown", + "id": "8add00ae", + "metadata": {}, + "source": [ + "More Runtime Template Instantiations:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "adac071d", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit.cppdef('''\n", + "struct MyClass {\n", + " MyClass(int i) : fData(i) {}\n", + " virtual ~MyClass() {}\n", + " virtual int add(int i) {\n", + " return fData + i;\n", + " }\n", + " int fData;\n", + "};''')" + ] + }, + { + "cell_type": "markdown", + "id": "35dfffc1", + "metadata": {}, + "source": [ + "Creating a std::vector of the C++ class" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5aea9086", + "metadata": {}, + "outputs": [], + "source": [ + "v = cppjit.gbl.std.vector[cppjit.gbl.MyClass]()\n", + "v" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97302198", + "metadata": {}, + "outputs": [], + "source": [ + "for i in range(10):\n", + " v.emplace_back(i)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8d89d251", + "metadata": {}, + "outputs": [], + "source": [ + "len(v)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e478f8eb", + "metadata": {}, + "outputs": [], + "source": [ + "for m in v:\n", + " print(m.fData, end = ' ')" + ] + }, + { + "cell_type": "markdown", + "id": "716b10f7", + "metadata": {}, + "source": [ + "Runtime Callbacks" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2dbe3382", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit.cppdef('''\n", + "typedef std::function F;\n", + "int callFun(const F& f, int i) {\n", + " return f(i);\n", + "}\n", + "''')" + ] + }, + { + "cell_type": "markdown", + "id": "15530eb4-0c55-4cee-b8bf-291075e1c448", + "metadata": {}, + "source": [ + "### Exercise 3 - Using callbacks to Python\n", + "\n", + "The runtime callback mechanism is one of the most powerful features of cppjit. It can be used, for example, to call back into Python ML inference from C++.\n", + "\n", + "Can you write a little example of how `callFun` can be called with a Python callable as the first argument?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9a0c21c-1622-4de5-bc44-f4374b477172", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO: write your code here.\n", + "..." + ] + }, + { + "cell_type": "markdown", + "id": "e2ee77b6-9685-474f-a4d5-c621c817da18", + "metadata": {}, + "source": [ + "## Putting it all together: A final overview\n", + "\n", + "Finally, we compare the C/C++ interop approaches presented here on a single compute-heavy example, along with NumPy." + ] + }, + { + "cell_type": "markdown", + "id": "5901d264-a1c0-49f0-ac1e-43a12a805c70", + "metadata": {}, + "source": [ + "Array-oriented programming (e.g. NumPy) connects Python with compiled code, but it's not the only way: the bindings above do the same." + ] + }, + { + "cell_type": "markdown", + "id": "5a51ef39-b1ea-4b98-a478-930b3a13ff1b", + "metadata": {}, + "source": [ + "Although much faster than pure Python, array-oriented techniques are not as fast as imperative, compiled code." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91e6a4f4-059c-436d-9ec4-b8b7478929f1", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile quadratic_formula_c.c\n", + "\n", + "#include \n", + "\n", + "void run(double* a, double* b, double* c, double* output, int n_elems) {\n", + " for (int i = 0; i < n_elems; i++) {\n", + " output[i] = (-b[i] + sqrt(b[i]*b[i] - 4*a[i]*c[i])) / (2*a[i]);\n", + " }\n", + "}\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79f49e52-794f-45ef-b46b-4c689d2f3bf4", + "metadata": {}, + "outputs": [], + "source": [ + "!cc quadratic_formula_c.c -shared -fPIC -lm -o quadratic_formula_c.so" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a0d965c0-cb0d-4005-ba41-2f96813a8b71", + "metadata": {}, + "outputs": [], + "source": [ + "import ctypes\n", + "import numpy as np\n", + "\n", + "quadratic_formula_c = ctypes.CDLL(\"./quadratic_formula_c.so\")\n", + "quadratic_formula_c.run.argtypes = (ctypes.POINTER(ctypes.c_double),) * 4 + (ctypes.c_int, )\n", + "quadratic_formula_c.run.restype = None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd66ffd4-05f0-4247-bb3b-df5dbf1827b3", + "metadata": {}, + "outputs": [], + "source": [ + "n_elems = 1000000" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5fb26358-17b6-4e2f-84ec-e29880e66274", + "metadata": {}, + "outputs": [], + "source": [ + "a = np.random.uniform(5, 10, n_elems)\n", + "b = np.random.uniform(10, 20, n_elems)\n", + "c = np.random.uniform(-0.1, 0.1, n_elems)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4c86c2e0-bd45-4470-b695-22bd04ffaf49", + "metadata": {}, + "outputs": [], + "source": [ + "output = np.zeros(n_elems, dtype=np.float64)\n", + "quadratic_formula_c.run(*[arg.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) for arg in (a, b, c, output)], n_elems)\n", + "output" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cedbf713-0719-4bbe-984d-35a1f5cae1ae", + "metadata": {}, + "outputs": [], + "source": [ + "ctypes_time = %timeit -o quadratic_formula_c.run(*[arg.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) for arg in (a, b, c, output)], n_elems)" + ] + }, + { + "cell_type": "markdown", + "id": "58b921ba-f5f2-4415-879e-f4fe68fbb4cb", + "metadata": {}, + "source": [ + "In Pure Python it would look like:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d38854c3", + "metadata": {}, + "outputs": [], + "source": [ + "import math\n", + "\n", + "def run(a, b, c):\n", + " output = []\n", + " for i in range(len(a)):\n", + " output.append((-b[i] + math.sqrt(b[i]*b[i] - 4*a[i]*c[i])) / (2*a[i]))\n", + " return output" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2a3cbbbf", + "metadata": {}, + "outputs": [], + "source": [ + "%timeit run(a, b, c)" + ] + }, + { + "cell_type": "markdown", + "id": "7a69d373", + "metadata": {}, + "source": [ + "Let's look at memory:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7fefbfe9", + "metadata": {}, + "outputs": [], + "source": [ + "%%memit\n", + "\n", + "run(a, b, c)" + ] + }, + { + "cell_type": "markdown", + "id": "7b796ddb", + "metadata": {}, + "source": [ + "NumPy is fast but not much better with memory. Measure memory usage first, so that we see the memory increment from NumPy's initial allocation of working memory." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87ae2613", + "metadata": {}, + "outputs": [], + "source": [ + "%%memit\n", + "\n", + "output[:] = (-b + np.sqrt(b**2 - 4*a*c)) / (2*a)" + ] + }, + { + "cell_type": "markdown", + "id": "5f8b21a3-8553-4f36-a9e7-ee912ef5eece", + "metadata": {}, + "source": [ + "Why? NumPy allocates working memory for the intermediate steps.\n", + "\n", + "* Memory allocation is expensive (`malloc` has to search for unused memory).\n", + "* Accessing different memory is expensive (the CPU can't re-use its cache, and accessing RAM is much slower than most mathematical operations).\n", + "\n", + "*Note: the second time you run this cell you might not see memory increments, because NumPy re-uses pre-allocated working buffers.*" + ] + }, + { + "cell_type": "markdown", + "id": "0637b5f4-54e5-455e-94ed-fad7bd951306", + "metadata": {}, + "source": [ + "Now measure the time:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3f3a7315", + "metadata": {}, + "outputs": [], + "source": [ + "np_time = %timeit -o (-b + np.sqrt(b**2 - 4*a*c)) / (2*a)" + ] + }, + { + "cell_type": "markdown", + "id": "1abd3e68", + "metadata": {}, + "source": [ + "### Let's try nanobind now\n", + "\n", + "nanobind builds the same kernel into a compiled extension module." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4085fa67-f986-4056-894a-fa08b37779c6", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile quadratic_formula_nanobind.cpp\n", + "\n", + "#include \n", + "#include \n", + "#include \n", + "namespace nb = nanobind;\n", + "\n", + "void run(nb::ndarray, nb::c_contig> a_numpy,\n", + " nb::ndarray, nb::c_contig> b_numpy,\n", + " nb::ndarray, nb::c_contig> c_numpy,\n", + " nb::ndarray, nb::c_contig> output_numpy) {\n", + " const double* a = a_numpy.data();\n", + " const double* b = b_numpy.data();\n", + " const double* c = c_numpy.data();\n", + " double* output = output_numpy.data();\n", + " for (size_t i = 0; i < output_numpy.size(); i++) {\n", + " output[i] = (-b[i] + std::sqrt(b[i]*b[i] - 4*a[i]*c[i])) / (2*a[i]);\n", + " }\n", + "}\n", + "\n", + "NB_MODULE(quadratic_formula_nanobind, m) {\n", + " m.def(\"run\", &run);\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "264cd1d3-cc54-4413-8cb9-7f69c38d1b58", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "import nanobind\n", + "from nanobind import include_dir, source_dir\n", + "\n", + "nbinc = \"-I \" + include_dir()\n", + "robin = \"-I \" + os.path.join(os.path.dirname(nanobind.__file__), \"ext\", \"robin_map\", \"include\")\n", + "nbsrc = source_dir() + \"/nb_combined.cpp\"\n", + "plat = \"-undefined dynamic_lookup\" if \"darwin\" in sys.platform else \"-fPIC\"\n", + "pyinc = !python3-config --cflags" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37c14e3a-ece8-4984-8b46-a5c8d22c1646", + "metadata": {}, + "outputs": [], + "source": [ + "!c++ -std=c++17 quadratic_formula_nanobind.cpp {nbsrc} -shared {nbinc} {robin} {pyinc.s} -o quadratic_formula_nanobind.so {plat}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "04918033-e6a0-4384-acbb-ebbb2e86a581", + "metadata": {}, + "outputs": [], + "source": [ + "import quadratic_formula_nanobind" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "914a45ab-67db-4ef0-bb5a-20b3f04a80d6", + "metadata": {}, + "outputs": [], + "source": [ + "output = np.zeros(n_elems, dtype=np.float64)\n", + "quadratic_formula_nanobind.run(a, b, c, output)\n", + "output" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f718827-d128-4272-b543-170ccf1cab88", + "metadata": {}, + "outputs": [], + "source": [ + "nanobind_time = %timeit -o quadratic_formula_nanobind.run(a, b, c, output)" + ] + }, + { + "cell_type": "markdown", + "id": "4bf7ce2e-046f-45bb-8b2c-8d9c99a1573e", + "metadata": {}, + "source": [ + "Leaving Python, writing C++ code, and then importing it is fine for a long-term project, like a library that will be used many times. But, it's inconvenient in the middle of a data analysis." + ] + }, + { + "cell_type": "markdown", + "id": "de872785-68be-471c-b89b-90c8430203a2", + "metadata": {}, + "source": [ + "Note: if we change the C++, recompile, and do\n", + "\n", + "```python\n", + "import quadratic_formula_nanobind\n", + "```\n", + "\n", + "again, we will _not_ get the new version. We would still have the old version, with no error messages or warnings!" + ] + }, + { + "cell_type": "markdown", + "id": "f663096e", + "metadata": {}, + "source": [ + "### cppjit" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14d9977e", + "metadata": {}, + "outputs": [], + "source": [ + "import cppjit\n", + "cppjit.cppdef('''\n", + "void run(double* a, double* b, double* c, double* output, int n_elems) {\n", + " for (int i = 0; i < n_elems; i++) {\n", + " output[i] = (-b[i] + sqrt(b[i]*b[i] - 4*a[i]*c[i])) / (2*a[i]);\n", + " }\n", + "}\n", + "''')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0c89979f", + "metadata": {}, + "outputs": [], + "source": [ + "# double * from NumPy\n", + "x = a.ctypes.data_as(ctypes.POINTER(ctypes.c_double))\n", + "y = b.ctypes.data_as(ctypes.POINTER(ctypes.c_double))\n", + "z = c.ctypes.data_as(ctypes.POINTER(ctypes.c_double))\n", + "cppjit_out = output.ctypes.data_as(ctypes.POINTER(ctypes.c_double))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2d5f9214", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit_time = %timeit -o cppjit.gbl.run(x, y, z, cppjit_out, n_elems)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a64b4c10-de0a-4a58-a092-ba28293f9c12", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit_mem = %memit -o cppjit.gbl.run(x, y, z, cppjit_out, n_elems)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a57b52d-40e9-4276-b6b5-f708179e571d", + "metadata": {}, + "outputs": [], + "source": [ + "cppjit_mem" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14664481", + "metadata": {}, + "outputs": [], + "source": [ + "# NumPy from double *\n", + "np.ctypeslib.as_array(cppjit_out, shape = (n_elems,))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0903b85", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "def avg(lst): \n", + " return sum(lst.timings) / len(lst.timings) \n", + "\n", + "test_names = [\n", + " #'Python \"for\" loops: imperative', # commented out because it's much slower anyway\n", + " 'ctypes: builtin interface',\n", + " 'NumPy: array-oriented',\n", + " 'nanobind: imperative in C++',\n", + " \"cppjit: Automatic, C++ JIT with Clang\"\n", + "]\n", + "test_results = np.array([\n", + " #avg(py_time),\n", + " avg(ctypes_time),\n", + " avg(np_time),\n", + " avg(nanobind_time),\n", + " avg(cppjit_time)\n", + "])\n", + "\n", + "# Creating the plot\n", + "plt.figure(figsize=(10, 6))\n", + "plt.barh(test_names, test_results, color='blue')\n", + "plt.xlabel('Average Execution Time (s)')\n", + "plt.ylabel('Benchmark Test')\n", + "plt.title('Benchmark Execution Time for Different Methods')\n", + "plt.gca().invert_yaxis() # Invert y-axis to have the fastest method at the top\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "fad0bb58-251e-4667-ad69-d989db5830fa", + "metadata": {}, + "source": [ + "### Exercise 4\n", + "\n", + "Not all ways to interoperate with C/C++ code gave us the same runtime performance here. Where do the differences come from?" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/08__swe__intro.ipynb b/tutorials/pyhpc/notebooks/08__swe__intro.ipynb new file mode 100644 index 00000000..f2168828 --- /dev/null +++ b/tutorials/pyhpc/notebooks/08__swe__intro.ipynb @@ -0,0 +1,478 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro-title", + "metadata": {}, + "source": [ + "## SWE - Intro\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Motivation](#sec1)\n", + "2. [The problem: a 1D bump pulse](#sec2)\n", + "3. [Why this small problem](#sec3)\n", + "4. [What we're solving](#sec4)\n", + "5. [Hardware](#sec5)\n", + "6. [NumPy baseline](#sec6)\n", + "7. [Profiling where we spend time](#sec7)\n", + "8. [What each later notebook replaces](#sec8)\n", + "9. [Tutorial ladder](#sec9)\n", + "10. [How to read these notebooks](#sec10)\n", + "\n", + "### 1. Motivation\n", + "\n", + "We have seen powerful array-oriented libraries in Python capable of scaling with increasing problem complexity and data sizes. Now let's look at another part of the HPC Python landscape:\n", + "reaching for a different programming model or library, or dropping into a C/C++ kernel to integrate with an existing codebase. Some familiarity with C/C++ helps, but the syntax is not the focus.\n", + "\n", + "### 2. The problem: a 1D bump pulse\n", + "\n", + "During this hour, we solve the same problem with different approaches and compare the throughput each reaches on the same hardware.\n", + "\n", + "![alt text](../assets/Shallow_water_waves.gif)\n", + "\n", + "**1D Shallow Water Equations**\n", + "\n", + "The SWE are a set of hyperbolic partial differential equations that describe the flow below a pressure surface in a fluid. \n", + "\n", + "In its conservative form, the system carries two variables per cell:\n", + "water height `h` and momentum `hu`.\n", + "\n", + "The initial condition is a **Gaussian-bump pulse**: a small mound of\n", + "water (10% above a 1 m baseline) sitting at rest in\n", + "the middle of the channel at $t = 0$.\n", + "\n", + "Gravity makes it split into two\n", + "counter-propagating wave packets travelling outward at $c = \\sqrt{g\\,h_0}\n", + "\\approx 3.13$ m/s.\n", + "\n", + "We simulate what the water surface looks like after a fixed number of time-steps. The reference implementation and full specification live in `swe_core.py` and in the sections\n", + "below.\n", + "\n", + "Let's visualise the initial condition and the solution after 1000 steps with the NumPy reference kernel." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e0e5e34d", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "import swe_core\n", + "\n", + "N, L = 4096, 10.0\n", + "dx = L / N\n", + "h0, hu0 = swe_core.bump_ic(N, L=L, h0=1.0, amplitude=0.1, sigma=0.5)\n", + "h, hu = h0.copy(), hu0.copy()\n", + "dt = swe_core.fixed_dt(1.1, dx)\n", + "for _ in range(1000):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " h, hu = swe_core.step_numpy(h, hu, dx, dt)\n", + "\n", + "xs = (np.arange(N) + 0.5) * dx\n", + "fig, (axL, axR) = plt.subplots(1, 2, figsize=(11, 3.6))\n", + "axL.plot(xs, h0[1:-1], color='#888', label='t=0 (IC)', linewidth=2)\n", + "axL.plot(xs, h[1:-1], color='#27a', label='t after 1000 steps')\n", + "axL.set_xlabel('x [m]'); axL.set_ylabel('h [m]')\n", + "axL.set_title('Water height — bump pulse, 1D'); axL.legend(); axL.grid(alpha=0.3)\n", + "\n", + "axR.plot(xs, hu0[1:-1], color='#888', label='t=0 (IC)', linewidth=2)\n", + "axR.plot(xs, hu[1:-1], color='#c33', label='t after 1000 steps')\n", + "axR.set_xlabel('x [m]'); axR.set_ylabel('hu [m²/s]')\n", + "axR.set_title('Momentum hu'); axR.legend(); axR.grid(alpha=0.3)\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "309950af", + "metadata": {}, + "source": [ + "The plot above shows the initial bump and\n", + "the surface after 1000 steps.\n", + "\n", + "The bump splits into a left-moving and a right-moving wave\n", + "packet, each carrying about half the original amplitude.\n", + "\n", + "Let's now render everything in between as an animation. To keep the embedded animation compact, we render on a\n", + "coarser 256-cell grid over a wider 20 m channel; across the tutorial we will scale to higher resolutions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f17d79b6", + "metadata": {}, + "outputs": [], + "source": [ + "# Animation of the bump-pulse evolution. A coarser grid (N=256) sampled every\n", + "# 40th step keeps the embedded JS animation small.\n", + "swe_core.animate_pulse(n_cells=256, length=20.0, n_steps=2500, skip=40)" + ] + }, + { + "cell_type": "markdown", + "id": "ef85cfa2", + "metadata": {}, + "source": [ + "### 3. Why this small problem\n", + "\n", + "A 1D bump pulse is a relatively small non-trivial PDE with two conserved variables. It fits in ~30 lines of vectorised NumPy, and is still complex enough to surface real performance questions:\n", + "\n", + "- The bottleneck depends on `N`: arithmetic bound when working set fits in cache, memory bandwidth bound when the data resides in DRAM.\n", + "- Per-element nonlinearity (`hu²/h`, `sqrt(g h)`), which goes beyond the classic `saxpy` example.\n", + "\n", + "Every tool's result can be validated against the same reference to a fixed tolerance after a set number of time-steps." + ] + }, + { + "cell_type": "markdown", + "id": "4194f9d4", + "metadata": {}, + "source": [ + "### 4. What we're solving\n", + "\n", + "The 1D shallow-water equations track water height $h$ and momentum $hu$\n", + "along a channel:\n", + "\n", + "$$\n", + "\\frac{\\partial h}{\\partial t} + \\frac{\\partial (hu)}{\\partial x} = 0,\n", + "\\qquad\n", + "\\frac{\\partial (hu)}{\\partial t} + \\frac{\\partial}{\\partial x}\\!\\left( \\tfrac{(hu)^2}{h} + \\tfrac{1}{2} g h^2 \\right) = 0.\n", + "$$\n", + "\n", + "`swe_core.step_numpy` advances both by one timestep using forward Euler\n", + "in time and a Rusanov flux at each cell interface. What matters here is the **shape** of the operation:\n", + "\n", + "- Read the two neighbours of each cell.\n", + "- Compute a flux at each face from the two states it sits between.\n", + "- Update each cell by `(Δt/Δx)` times the difference of its two adjacent fluxes.\n", + "\n", + "We re-implement the function that performs these three steps with different libraries and programming models, and explore their strengths and limitations." + ] + }, + { + "cell_type": "markdown", + "id": "971a84e1", + "metadata": {}, + "source": [ + "#### Function to be replaced\n", + "\n", + "`swe_core.step_numpy(h, hu, dx, dt, g)` is a concise ~20-line vectorised\n", + "NumPy implementation:\n", + "\n", + "```py\n", + "def step_numpy(h, hu, dx, dt, g=g, tol=DRY_TOL):\n", + " \"\"\"One forward-Euler step with Rusanov flux. Returns (h_new, hu_new).\"\"\"\n", + " # Left/right states at every interface i+½ (length N+1).\n", + " hL, hR = h[:-1], h[1:]\n", + " huL, huR = hu[:-1], hu[1:]\n", + "\n", + " # Velocities and wave speeds at the face.\n", + " h_safe_L = np.maximum(hL, tol)\n", + " h_safe_R = np.maximum(hR, tol)\n", + " uL, uR = huL / h_safe_L, huR / h_safe_R\n", + " cL, cR = np.sqrt(g * h_safe_L), np.sqrt(g * h_safe_R)\n", + " a = np.maximum(np.abs(uL) + cL, np.abs(uR) + cR)\n", + "\n", + " # Rusanov interface flux: average of physical fluxes − stabilising diffusion.\n", + " F_h = 0.5 * (huL + huR) - 0.5 * a * (hR - hL)\n", + " F_hu = 0.5 * (huL*uL + 0.5*g*hL*hL + huR*uR + 0.5*g*hR*hR) - 0.5 * a * (huR - huL)\n", + "\n", + " # Divergence: update interior cells, ghost cells unchanged.\n", + " h_new, hu_new = np.empty_like(h), np.empty_like(hu)\n", + " h_new[0], h_new[-1] = h[0], h[-1]\n", + " hu_new[0], hu_new[-1] = hu[0], hu[-1]\n", + " h_new[1:-1] = h[1:-1] - (dt / dx) * (F_h[1:] - F_h[:-1])\n", + " hu_new[1:-1] = hu[1:-1] - (dt / dx) * (F_hu[1:] - F_hu[:-1])\n", + " return h_new, hu_new\n", + "```\n", + "\n", + "The body is the three blocks above, in order: **read** the per-face\n", + "interface states, **compute** the Rusanov flux, **update** each\n", + "interior cell from the divergence of those fluxes.\n", + "\n", + "The rest of the bookkeeping: the initial and boundary conditions, timing loop, and validation functions remain the same.\n" + ] + }, + { + "cell_type": "markdown", + "id": "sec3-machine-md", + "metadata": {}, + "source": [ + "### 5. Hardware\n", + "\n", + "Before timing anything, let's record the machine: versions, device identity and\n", + "clocks. The problem sizes for the whole tutorial are fixed here too, from this\n", + "machine's core count, cache sizes and free memory, and saved to `machine.json`\n", + "so every notebook sweeps the same sizes.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `swe_core.machine_report()`: versions, CPU/GPU identity, clocks, governor.\n", + "- `swe_core.save_sizing()`: fixes this machine's problem sizes and records\n", + " them in `machine.json`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sec3-machine-code", + "metadata": {}, + "outputs": [], + "source": [ + "import swe_core\n", + "\n", + "swe_core.machine_report()\n", + "print()\n", + "swe_core.save_sizing()\n" + ] + }, + { + "cell_type": "markdown", + "id": "844d0d22", + "metadata": {}, + "source": [ + "### 6. NumPy baseline\n", + "\n", + "Let's run the full simulation, time it, and save one row to `timings.json`. Each\n", + "later notebook writes its own row to the same file and reports the same\n", + "acceptance metric.\n", + "\n", + "The initial condition is built once, outside the timed region. A production run\n", + "reads its state from a file or an instrument, so what is measured here is the\n", + "solver." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f6d31987", + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "N, N_STEPS = swe_core.canonical_size()\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "dx = L / N\n", + "DT = swe_core.fixed_dt(H0 + AMP, dx, cfl=CFL, g=G)\n", + "print(f'canonical problem: N = {N:,} cells, {N_STEPS} steps '\n", + " f'({swe_core.to_mib(swe_core.working_set_bytes(N)):.1f} MiB working set)')\n", + "\n", + "IC = swe_core.bump_ic(N, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + "\n", + "def run_numpy():\n", + " h, hu = IC[0].copy(), IC[1].copy()\n", + " for _ in range(N_STEPS):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " h, hu = swe_core.step_numpy(h, hu, dx, DT, g=G)\n", + " return h, hu\n", + "\n", + "result = swe_core.timed_run(run_numpy, warmup=2, repeats=5, label='08_numpy')\n", + "cells_per_s = N * N_STEPS / result['median_s']\n", + "print(f\"NumPy: median {result['median_s']*1000:.1f} ms | {cells_per_s/1e6:.1f} Mcells/s\")\n", + "\n", + "swe_core.save_timing(result, grid_str=f'N={N}', tool='numpy', hardware='cpu',\n", + " dtype='float64', steps=N_STEPS)\n", + "\n", + "# Un-timed pass for the multi-snapshot plot.\n", + "N_PLOT = 4096\n", + "PLOT_STEPS = 2000\n", + "dx_plot = L / N_PLOT\n", + "DT_PLOT = swe_core.fixed_dt(H0 + AMP, dx_plot, cfl=CFL, g=G)\n", + "snap_steps = {0, 250, 500, 1000, 2000}\n", + "h, hu = swe_core.bump_ic(N_PLOT, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + "snaps = {0: h[1:-1].copy()}\n", + "for step in range(1, PLOT_STEPS + 1):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " h, hu = swe_core.step_numpy(h, hu, dx_plot, DT_PLOT, g=G)\n", + " if step in snap_steps:\n", + " snaps[step] = h[1:-1].copy()\n", + "\n", + "xs = (np.arange(N_PLOT) + 0.5) * dx_plot\n", + "fig, ax = plt.subplots(figsize=(9, 3.4))\n", + "colors = plt.cm.viridis(np.linspace(0.15, 0.85, len(snaps)))\n", + "for (step, h_snap), color in zip(sorted(snaps.items()), colors):\n", + " label = f't = {step*DT_PLOT:.3f} s' + (' (IC)' if step == 0 else '')\n", + " ax.plot(xs, h_snap, color=color, label=label, linewidth=1.6)\n", + "ax.set_xlabel('x [m]'); ax.set_ylabel('h [m]')\n", + "ax.set_title('Gaussian bump propagation — water height at successive times')\n", + "ax.legend(loc='upper right', fontsize=9); ax.grid(alpha=0.3)\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sweep-for-synthesis", + "metadata": {}, + "outputs": [], + "source": [ + "# Rates across the shared size range, for the synthesis notebook (14).\n", + "swe_core.save_sweep('08_numpy', swe_core.solve_numpy)" + ] + }, + { + "cell_type": "markdown", + "id": "559e88cb", + "metadata": {}, + "source": [ + "### 7. Profiling where we spend time\n", + "\n", + "Each iteration of `run_numpy` does two pieces of work: apply boundary\n", + "conditions, then compute the flux and update the two variables. How\n", + "does that cost split, and how does the split change with `N`?\n", + "\n", + "The cell below times each component separately at\n", + "`N ∈ {1024, 4096, 16384}`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99fccd1b", + "metadata": {}, + "outputs": [], + "source": [ + "def time_components(N_local, n_steps=200):\n", + " L_local = L\n", + " dx_local = L_local / N_local\n", + " dt_local = swe_core.fixed_dt(H0 + AMP, dx_local, cfl=CFL, g=G)\n", + "\n", + " # Warm run\n", + " h, hu = swe_core.bump_ic(N_local, L=L_local, h0=H0, amplitude=AMP, sigma=SIG)\n", + " for _ in range(2):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " h, hu = swe_core.step_numpy(h, hu, dx_local, dt_local, g=G)\n", + "\n", + " # Measured pass — fresh IC.\n", + " h, hu = swe_core.bump_ic(N_local, L=L_local, h0=H0, amplitude=AMP, sigma=SIG)\n", + " t_bc = t_step = 0.0\n", + " for _ in range(n_steps):\n", + " t0 = time.perf_counter()\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " t1 = time.perf_counter()\n", + " h, hu = swe_core.step_numpy(h, hu, dx_local, dt_local, g=G)\n", + " t2 = time.perf_counter()\n", + " t_bc += t1 - t0\n", + " t_step += t2 - t1\n", + " return t_bc / n_steps, t_step / n_steps\n", + "\n", + "print(f\"{'N':>6} {'BC [µs]':>10} {'step [µs]':>12} {'step/total':>12} {'Mcells/s':>12}\")\n", + "for N_b in [1024, 4096, 16384]:\n", + " bc, step = time_components(N_b)\n", + " pct = step / (bc + step) * 100\n", + " rate = N_b / (bc + step) / 1e6\n", + " print(f\"{N_b:6d} {bc*1e6:10.2f} {step*1e6:12.2f} {pct:11.1f}% {rate:12.1f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "35523c8e", + "metadata": {}, + "source": [ + "Takeaways:\n", + "\n", + "- **Boundary condition roughly constant in `N`**: it touches only ghost cells.\n", + "- **Step time scales linearly in `N`**.\n", + "- **At large `N`, the step dominates entirely**, so later notebooks replace the step kernel, not the boundary handling." + ] + }, + { + "cell_type": "markdown", + "id": "07a126f8", + "metadata": {}, + "source": [ + "### 8. What each later notebook replaces\n", + "\n", + "Each later notebook implements `step_(h, hu, dx, dt) -> (h_new, hu_new)`\n", + "on a different stack, then checks the result against `step_numpy`.\n", + "\n", + "The acceptance gate:\n", + "\n", + "$\\max_i |h_{\\text{tool}}[i] - h_{\\text{numpy}}[i]| < \\mathrm{TOL}$\n", + "\n", + "where $\\mathrm{TOL} = 10^{-12}$ for float64 and $10^{-4}$ for float32." + ] + }, + { + "cell_type": "markdown", + "id": "sec9-going-to-try", + "metadata": {}, + "source": [ + "### 9. Tutorial ladder\n", + "\n", + "The NumPy baseline above is our reference. Each of the following notebooks re-implements the solution with a different tool/library:\n", + "\n", + "| NB | Tool | Programming-model hook |\n", + "|---|---|---|\n", + "| 09 | **JAX** | Tracing JIT compiler, whole-loop fusion offloaded to device |\n", + "| 10 | **PyOMP** | OpenMP parallelisation model in Python |\n", + "| 11 | **nanobind** | Handwritten Python bindings for compiled C++ kernels |\n", + "| 12 | **CppJIT** | Clang-based JIT compiled CUDA C++ with automatic bindings |\n", + "| 13 | **mpi4py** | MPI in Python: Distributed slab decomposition + halo exchange |\n", + "\n", + "We end with `14__swe__synthesis.ipynb`: a comparison of programming models, and the achieved-throughput plot built from `timings.json`." + ] + }, + { + "cell_type": "markdown", + "id": "7a3d3eff", + "metadata": {}, + "source": [ + "### 10. How to read these notebooks\n", + "\n", + "Each numbered exercise (09-13) comes as two files:\n", + "\n", + "- `NN__topic__subtopic.ipynb`: the primary notebook we run live during the tutorial, with `# TODO:` cells whose body is a `...` placeholder.\n", + "- `solutions/NN__topic__subtopic__SOLUTION.ipynb`: notebooks complete with solution, for self-paced review.\n", + "\n", + "Within each exercise notebook:\n", + "\n", + "- A **Quick Docs** sub-block precedes each TODO cell, listing the key APIs you need.\n", + "- Optional **`# EXTRA CREDIT:`** cells explore deeper solutions if you have time.\n", + "\n", + "With the NumPy baseline in hand, let's continue with JAX: `09__swe__jax.ipynb`" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/09__swe__jax.ipynb b/tutorials/pyhpc/notebooks/09__swe__jax.ipynb new file mode 100644 index 00000000..b4a3dc5a --- /dev/null +++ b/tutorials/pyhpc/notebooks/09__swe__jax.ipynb @@ -0,0 +1,613 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8701ec32", + "metadata": {}, + "source": [ + "## SWE - JAX\n", + "\n", + "JAX takes a different angle from NumPy and Numba. You write\n", + "the kernel as a **pure function over whole arrays**, decorate it with\n", + "`@jax.jit`, and the XLA compiler produces a single GPU program for the\n", + "entire time loop, running every step on the device without returning to Python.\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Imports and reference setup](#sec1)\n", + "2. [The step as a pure function](#sec2)\n", + "3. [Fuse the time loop with `lax.scan`](#sec3)\n", + "4. [Acceptance gate](#sec4)\n", + "5. [Limitation: shape-rigidity](#sec5)\n", + "6. [Fixed cost: when does N start to matter?](#sec6)\n", + "7. [Cache residency](#sec7)\n", + "\n", + "### 1. Imports and reference setup\n", + "\n", + "We re-import `swe_core` for the float64 reference field, then load JAX." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5990bfb4", + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "import swe_core\n", + "\n", + "# Shared problem parameters\n", + "N, N_STEPS = swe_core.canonical_size()\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "dx = L / N\n", + "DT = swe_core.fixed_dt(H0 + AMP, dx, cfl=CFL, g=G)\n", + "\n", + "# Float64 NumPy reference for validation\n", + "h_ref, _ = swe_core.solve_numpy(N, N_STEPS)\n", + "\n", + "import jax\n", + "import jax.numpy as jnp\n", + "from jax import lax" + ] + }, + { + "cell_type": "markdown", + "id": "8a50f5d5", + "metadata": {}, + "source": [ + "### 2. The step as a pure function\n", + "\n", + "Let's look at the same operation shape as notebook 08, expressed in JAX:\n", + "\n", + "- **Read**. `h[:-1]`, `h[1:]` for interface states, same as NumPy-style slicing.\n", + "- **Compute**. Rusanov formula unchanged: `F_h = 0.5*(huL+huR) - 0.5*a*(hR-hL)`.\n", + "- **Update**. JAX arrays are immutable, so we produce a new array with `h.at[1:-1].set(...)` instead of `h[1:-1] = ...`.\n", + "\n", + "The function signature `(state, _) -> ((new_state), None)` is the shape `lax.scan` expects in the next section.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `jax.jit(fn)`: just-in-time compile a Python function on its first call\n", + " with a given input shape/dtype. Subsequent calls reuse the compiled code.\n", + "- `jax.numpy` mirrors NumPy with immutable arrays.\n", + "- `jnp.maximum`, `jnp.abs`, `jnp.sqrt`: elementwise math.\n", + "- `jnp.array.at[idx].set(value)`: functional update for an immutable array, returns a *new* array with the slot set.\n", + "- `jax.lax.scan(body, init, xs)`: a structured loop the compiler can fuse.\n", + " Equivalent to a Python `for x in xs: carry, y = body(carry, x)`, but\n", + " inside a single XLA program.\n", + "- `arr.block_until_ready()`: synchronise; required before stopping any GPU\n", + " timer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99c48795", + "metadata": {}, + "outputs": [], + "source": [ + "@jax.jit\n", + "def step_jax(state, _):\n", + " '''One Rusanov-flux step, fully functional. Returns (new_state, None).\n", + "\n", + " TODO: fill in the functional Rusanov step below.\n", + "\n", + " The shape of the answer:\n", + " - h, hu = state (each shape (N+2,))\n", + " - apply reflective BCs via .at[0].set(...) / .at[-1].set(...)\n", + " - compute interface states at i+1/2 for i = 0..N\n", + " - h_safe = max(h, DRY); u = hu / h_safe; c = sqrt(g h_safe)\n", + " - a = max(|uL|+cL, |uR|+cR)\n", + " - F_h = 0.5(huL+huR) - 0.5 a (hR - hL)\n", + " - F_hu = 0.5(huL*uL + 0.5 g hL^2 + huR*uR + 0.5 g hR^2) - 0.5 a (huR - huL)\n", + " - h_new = h.at[1:-1].set(h[1:-1] - (DT/dx)(F_h[1:] - F_h[:-1]))\n", + " - hu_new = hu.at[1:-1].set(hu[1:-1] - (DT/dx)(F_hu[1:] - F_hu[:-1]))\n", + "\n", + " Return ((h_new, hu_new), None) for use with lax.scan.\n", + " '''\n", + " h, hu = state\n", + " DRY = jnp.float32(swe_core.DRY_TOL)\n", + " # TODO: implement the Rusanov step (see swe_core.step_numpy for the\n", + " # equivalent NumPy version; the JAX version differs only in functional\n", + " # updates with .at[idx].set(...)).\n", + " h_new = ...\n", + " hu_new = ...\n", + " return (h_new, hu_new), None" + ] + }, + { + "cell_type": "markdown", + "id": "3367bcd4", + "metadata": {}, + "source": [ + "### 3. Fuse the time loop with `lax.scan`\n", + "\n", + "`for _ in range(N_STEPS): state = step_jax(state, ...)` would\n", + "*work*, but each call would round-trip through the Python interpreter\n", + "between steps, preventing XLA from fusing them.\n", + "\n", + "`jax.lax.scan(body, init, xs)` collapses the entire loop into one device\n", + "program. The cost is that `step_jax` must already be jit-able, and\n", + "the scan body must accept a `(state, x)` signature.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `jax.lax.scan(body, init, xs)`: walks the leading axis of `xs`,\n", + " threading `state` through `body`. We do not need the per-step `x` value\n", + " (we use a fixed `DT`), so we pass `jnp.arange(N_STEPS)` and ignore it\n", + " inside the body.\n", + "- `jax.tree_util.tree_map(lambda a: a.block_until_ready(), pytree)`:\n", + " synchronise every leaf in a pytree of arrays. We use it as the sync\n", + " before stopping a timer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3adecd2b", + "metadata": {}, + "outputs": [], + "source": [ + "setup_ic = swe_core.by_size(lambda n: tuple(\n", + " jnp.asarray(a, jnp.float32)\n", + " for a in swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)))\n", + "\n", + "\n", + "def run_jax(N_local: int):\n", + " '''One full N_STEPS simulation at a given grid size, returns (h, hu) on device.\n", + "\n", + " TODO: time-step the prepared device state `setup_ic(N_local)` through\n", + " N_STEPS with jax.lax.scan, then return the two arrays.\n", + " Hint: one jax.lax.scan call replaces the whole Python `for` loop and lets\n", + " XLA fuse every step into a single device program. Synchronise with\n", + " jax.tree_util.tree_map(lambda a: a.block_until_ready(), final) before\n", + " returning, or the timer stops before the GPU does. Leave the result on the\n", + " device; a host copy inside the call would be timed too.\n", + " '''\n", + " final, _ = ...\n", + " return ..." + ] + }, + { + "cell_type": "markdown", + "id": "b28a5d77", + "metadata": {}, + "source": [ + "### 4. Acceptance gate\n", + "\n", + "Cold time captures the first call (XLA compile + execute). Warm time is\n", + "the steady-state, after a couple of warmups. We also check the JAX\n", + "float32 result against the NumPy float64 reference.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- A float32 trajectory accumulates round-off of order `n·ε` over the run,\n", + " which the cell below reports. The acceptance tolerance is `1e-4` here.\n", + "- `swe_core.report_and_verify(warm, diff, tol, ...)`: verify the result is\n", + " within the tolerance and emit one timing and acceptance record." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2503813a", + "metadata": {}, + "outputs": [], + "source": [ + "# Cold capture.\n", + "t0 = time.perf_counter()\n", + "h_f, hu_f = run_jax(N)\n", + "cold_s = time.perf_counter() - t0\n", + "\n", + "# Warm timing\n", + "warm = swe_core.timed_run(run_jax, N, warmup=2, repeats=5, label='09_jax')\n", + "\n", + "# Acceptance.\n", + "diff = swe_core.max_diff(h_ref, np.asarray(h_f))\n", + "swe_core.report_and_verify(warm, diff, tol=1e-4, cold_s=cold_s,\n", + " n=N, steps=N_STEPS, cold_note='incl. XLA compile')\n", + "\n", + "swe_core.save_timing(\n", + " warm, grid_str=f'N={N}', tool='jax', hardware='gpu',\n", + " dtype='float32', steps=N_STEPS,\n", + " cold_s=cold_s, max_diff_vs_numpy=diff,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "70205dfa", + "metadata": {}, + "source": [ + "### 5. Limitation: shape-rigidity\n", + "\n", + "The XLA compile happens **per input shape**.\n", + "\n", + "The chart runs each shape once and splits that first (cold) call into the\n", + "one-time compile and the recurring execution.\n", + "\n", + "What makes the warm runs faster, and why does a new `N` require repaying the compilation cost?\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- JAX requires the same shape and dtype for a warm run." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95e3b113", + "metadata": {}, + "outputs": [], + "source": [ + "# Fresh shapes: each pays one XLA compile on its cold call.\n", + "shape_data = []\n", + "for N_local in [n for n, _ in swe_core.sweep_points()]:\n", + " t0 = time.perf_counter()\n", + " run_jax(N_local)\n", + " cold = time.perf_counter() - t0\n", + " warm = swe_core.timed_run(run_jax, N_local, warmup=2, repeats=3)['median_s']\n", + " shape_data.append((N_local, cold, warm))\n", + " print(f' N={N_local:>10,}: cold {cold*1e3:8.1f} ms warm {warm*1e3:8.1f} ms '\n", + " f'compile ~ cold-warm = {(cold - warm)*1e3:6.0f} ms')\n", + "\n", + "swe_core.plot_compile_share([f'N={n:,}' for n, _, _ in shape_data],\n", + " [c for _, c, _ in shape_data],\n", + " [w for _, _, w in shape_data],\n", + " 'Breakdown of runtime with growing N')" + ] + }, + { + "cell_type": "markdown", + "id": "sec6-fixed-cost-md", + "metadata": {}, + "source": [ + "### 6. Fixed cost: when does N start to matter?\n", + "\n", + "Looking at the warm bars in Sec. 5: at the smallest sweep size the run is\n", + "dominated by a fixed per-step cost, the kernel launch and scan-step dispatch in\n", + "the XLA program. As `N` grows that cost is amortised and the time becomes\n", + "proportional to the cells.\n", + "\n", + "We can explain the curve with a two-term model:\n", + "\n", + "$$t_\\mathrm{warm}(N) \\approx n_\\mathrm{steps} \\cdot (t_0 + N/B)$$\n", + "\n", + "where $t_0$ is the fixed per-step overhead and $B$ is the streaming rate.\n", + "Below the crossover $N^* = t_0 \\cdot B$ the linear term is invisible; above\n", + "it, doubling `N` doubles the time. On GPUs with a large L2 cache there is a third\n", + "regime in between (Sec. 7).\n", + "\n", + "**Q:** at what `N` do you expect the linear term to take over on this GPU?\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sec6-fixed-cost-code", + "metadata": {}, + "outputs": [], + "source": [ + "# Warm sweep over a wide range of N.\n", + "SWEEP_N = [n for n, _ in swe_core.sweep_points()]\n", + "\n", + "sweep_t = []\n", + "for N_local in SWEEP_N:\n", + " r = swe_core.timed_run(run_jax, N_local, warmup=2, repeats=3)\n", + " sweep_t.append(r['median_s'])\n", + " print(f' N={N_local:>9,}: warm {r[\"median_s\"]*1e3:8.1f} ms '\n", + " f'{N_local * N_STEPS / r[\"median_s\"] / 1e6:8.0f} Mcells/s')\n", + "\n", + "Ns = np.array(SWEEP_N, dtype=float)\n", + "ts = np.array(sweep_t)\n", + "\n", + "# Two-term fit: t0 from the small-N plateau, slope from the two largest sizes.\n", + "t0_step = ts[0] / N_STEPS\n", + "slope = (ts[-1] - ts[-2]) / (Ns[-1] - Ns[-2]) / N_STEPS\n", + "n_star = t0_step / slope # crossover N* = t0 / slope\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 4))\n", + "ax.loglog(Ns, ts * 1e3, 'o-', label='warm median')\n", + "ax.loglog(Ns, (t0_step + slope * Ns) * N_STEPS * 1e3, '--', color='#888',\n", + " label=r'model $n_\\mathrm{steps}\\,(t_0 + N/B)$')\n", + "ax.axvline(n_star, color='#c33', ls=':', label=f'crossover N* ~ {n_star:,.0f}')\n", + "ax.set(xlabel='N (cells)', ylabel=f'time per {N_STEPS}-step run [ms]',\n", + " title='Fixed per-step cost dominates until N is large enough')\n", + "ax.legend(); ax.grid(alpha=0.3, which='both')\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "print(f't0 = {t0_step*1e6:.1f} us/step crossover N* ~ {n_star:,.0f} cells')\n" + ] + }, + { + "cell_type": "markdown", + "id": "l2wall-md", + "metadata": {}, + "source": [ + "### 7. Cache residency\n", + "\n", + "Throughput in the sweep above rises with `N`, then flattens. What sets the ceiling?\n", + "\n", + "Query the L2 size, read XLA's working-set footprint from\n", + "`compiled.memory_analysis()`, and plot throughput against it.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `jax.jit(fn).lower(x).compile().memory_analysis()`: static buffer sizes of the\n", + " compiled program; `temp_size_in_bytes` is the intermediate working set.\n", + "- `cupy.cuda.runtime.getDeviceProperties(0)['l2CacheSize']`: this GPU's L2 size." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "l2wall-measure", + "metadata": {}, + "outputs": [], + "source": [ + "import cupy\n", + "\n", + "# Query L2 size. Sweep grid sizes with increasing XLA buffer footprint timing a fixed-length scan on each.\n", + "L2_BYTES = int(cupy.cuda.runtime.getDeviceProperties(0)['l2CacheSize'])\n", + "EC_STEPS = 400\n", + "scan_solve = jax.jit(lambda s: lax.scan(step_jax, s, jnp.arange(EC_STEPS))[0])\n", + "\n", + "# TODO: return (footprint_MB, throughput_Gcells_s) for grid size N_local.\n", + "# - footprint: inferrable by calling memory_analysis() on function returned by compile()\n", + "# - throughput: use timed_run with N_local * EC_STEPS, \"min_s\" - best of the repeats\n", + "def measure(N_local):\n", + " ...\n", + "\n", + "# Footprint is ~24 bytes/cell of XLA buffers. The sweep spans ~0.5x - ~6x the L2 size.\n", + "L2_SWEEP = [int(m * L2_BYTES / 24) for m in (0.5, 0.75, 1, 1.5, 2, 3, 4.5, 6)]\n", + "foot_mb, gcs = [], []\n", + "for N_local in L2_SWEEP:\n", + " foot, gcell = measure(N_local)\n", + " foot_mb.append(foot)\n", + " gcs.append(gcell)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "l2wall-plot", + "metadata": {}, + "outputs": [], + "source": [ + "L2_MB = swe_core.to_mib(L2_BYTES)\n", + "pk = int(np.argmax(gcs))\n", + "peak, floor = gcs[pk], gcs[-1]\n", + "fig, ax = plt.subplots(figsize=(7, 4))\n", + "ax.semilogx(foot_mb, gcs, 'o-', color='#27a')\n", + "ax.axvline(L2_MB, color='#c33', ls='--', label=f'L2 = {L2_MB:.0f} MB')\n", + "ax.axvspan(foot_mb[0], L2_MB, alpha=0.05, color='green')\n", + "ax.axvspan(L2_MB, foot_mb[-1], alpha=0.06, color='#c33')\n", + "ax.set_ylim(0, peak * 1.28)\n", + "ax.annotate(f'{peak:.0f} Gcells/s', xy=(foot_mb[pk], peak), xytext=(0, 8),\n", + " textcoords='offset points', ha='center', va='bottom', fontsize=9, color='#27a',\n", + " bbox=dict(boxstyle='round,pad=0.3', fc='white', ec='#27a', lw=0.8))\n", + "ax.annotate(f'~{floor:.0f} Gcells/s\\nDRAM-bound, {peak/floor:.1f}x lower', xy=(foot_mb[-1], floor),\n", + " xytext=(foot_mb[-1], floor + peak * 0.2), color='#c33', fontsize=9, ha='right')\n", + "ax.set_xlabel('working-set footprint [MB]')\n", + "ax.set_ylabel('throughput [Gcells/s]')\n", + "ax.set_title('Throughput peak')\n", + "ax.legend(loc='center right', fontsize=8)\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "# Device-only DRAM bandwidth\n", + "print(f'DRAM plateau: {gcs[-1]:.1f} Gcells/s -> {gcs[-1] * 16:.0f} GB/s of useful traffic')" + ] + }, + { + "cell_type": "markdown", + "id": "357c92e0", + "metadata": {}, + "source": [ + "**EXTRA CREDIT: inspecting intermediate states**\n", + "\n", + "`lax.scan` runs the whole loop as one compiled program, so there is no Python\n", + "loop to `print` from. Inside it `h` is a placeholder (a *tracer*), not real\n", + "numbers, so `float(h[i])` fails. To watch the trajectory, have the scan body\n", + "return a value each step, and `scan` collects them into an array you get back\n", + "at the end.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `lax.scan(body, init, xs)`: the second element of `body`'s return is\n", + " stacked across steps and returned as the scan's second output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2c3309e8", + "metadata": {}, + "outputs": [], + "source": [ + "# EXTRA CREDIT: collect the trajectory by returning h from the scan body.\n", + "state0 = (jnp.asarray(swe_core.bump_ic(N, L=L, h0=H0, amplitude=AMP, sigma=SIG)[0], jnp.float32),\n", + " jnp.zeros(N + 2, jnp.float32))\n", + "\n", + "# A mid-loop h is a tracer, so float(h[i]) inside the scan body raises. The fix\n", + "# is to return a per-step diagnostic from the body, which scan stacks into ys.\n", + "# TODO: run a scan whose body returns (new_state, per-step total water volume,\n", + "# jnp.sum(new_state[0][1:-1])), then block_until_ready and confirm you get one\n", + "# sample per step.\n", + "..." + ] + }, + { + "cell_type": "markdown", + "id": "sec-fp64-sweep", + "metadata": {}, + "source": [ + "**Float64 for the synthesis comparison.** Notebook 14 compares every tool at\n", + "matched precision. The cell below runs the same scanned solve in float64 and\n", + "records rates across the shared size range." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "jax-step-file", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile swe_jax_step.py\n", + "# The float64 step lives in a file so this notebook and the profiler's\n", + "# subprocess run the same code. Re-run this cell and the next one after\n", + "# any edit here, or the imported copy stays stale.\n", + "from functools import partial\n", + "\n", + "import jax\n", + "import jax.numpy as jnp\n", + "import swe_core\n", + "\n", + "jax.config.update('jax_enable_x64', True)\n", + "G = 9.81\n", + "\n", + "\n", + "@jax.jit\n", + "def step64(state, _, dx_, dt_):\n", + " h, hu = state\n", + " h = h.at[0].set(h[1]).at[-1].set(h[-2])\n", + " hu = hu.at[0].set(-hu[1]).at[-1].set(-hu[-2])\n", + " hL, hR = h[:-1], h[1:]\n", + " huL, huR = hu[:-1], hu[1:]\n", + " hsL = jnp.maximum(hL, swe_core.DRY_TOL)\n", + " hsR = jnp.maximum(hR, swe_core.DRY_TOL)\n", + " uL, uR = huL / hsL, huR / hsR\n", + " cL, cR = jnp.sqrt(G * hsL), jnp.sqrt(G * hsR)\n", + " a = jnp.maximum(jnp.abs(uL) + cL, jnp.abs(uR) + cR)\n", + " F_h = 0.5 * (huL + huR) - 0.5 * a * (hR - hL)\n", + " F_hu = 0.5 * (huL*uL + 0.5*G*hL*hL + huR*uR + 0.5*G*hR*hR) - 0.5 * a * (huR - huL)\n", + " h_new = h.at[1:-1].set(h[1:-1] - (dt_/dx_) * (F_h[1:] - F_h[:-1]))\n", + " hu_new = hu.at[1:-1].set(hu[1:-1] - (dt_/dx_) * (F_hu[1:] - F_hu[:-1]))\n", + " return (h_new, hu_new), None\n", + "\n", + "\n", + "# Wrapping the scan in jit traces the body once. Calling lax.scan directly\n", + "# re-traces it on every call.\n", + "@partial(jax.jit, static_argnames=('n_steps',))\n", + "def solve64(state, dx_, dt_, n_steps):\n", + " final, _ = jax.lax.scan(lambda s, x: step64(s, x, dx_, dt_), state,\n", + " jnp.arange(n_steps))\n", + " return final[0]\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sweep-for-synthesis", + "metadata": {}, + "outputs": [], + "source": [ + "# Float64 rates across the shared size range, for the synthesis notebook (14).\n", + "jax.config.update('jax_enable_x64', True)\n", + "\n", + "# Written by the cell above; reload so an edit there takes effect here.\n", + "import importlib\n", + "import swe_jax_step\n", + "solve64 = importlib.reload(swe_jax_step).solve64\n", + "\n", + "_setup64 = swe_core.by_size(lambda n: tuple(\n", + " jnp.asarray(a) for a in swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)))\n", + "_setup32 = swe_core.by_size(lambda n: tuple(\n", + " jnp.asarray(a, jnp.float32)\n", + " for a in swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)))\n", + "\n", + "\n", + "def run_jax64(n_cells, n_steps):\n", + " dx_ = L / n_cells\n", + " dt_ = swe_core.fixed_dt(H0 + AMP, dx_, cfl=CFL, g=G)\n", + " return jax.block_until_ready(solve64(_setup64(n_cells), dx_, dt_, n_steps))\n", + "\n", + "warm64 = swe_core.timed_run(run_jax64, N, N_STEPS, label='09_jax_fp64')\n", + "diff64 = swe_core.max_diff(h_ref, np.asarray(run_jax64(N, N_STEPS)))\n", + "swe_core.report_and_verify(warm64, diff64, tol=1e-12, n=N, steps=N_STEPS)\n", + "swe_core.save_timing(warm64, grid_str=f'N={N}', tool='jax_fp64', hardware='gpu',\n", + " dtype='float64', steps=N_STEPS, max_diff_vs_numpy=diff64)\n", + "swe_core.save_sweep('09_jax_fp64', run_jax64)\n", + "\n", + "# Matched fp32 point at the largest size: the DRAM-resident precision gap.\n", + "def run_jax32(n_cells, n_steps):\n", + " dx_ = L / n_cells\n", + " dt_ = swe_core.fixed_dt(H0 + AMP, dx_, cfl=CFL, g=G)\n", + " return jax.block_until_ready(solve64(_setup32(n_cells), dx_, dt_, n_steps))\n", + "\n", + "BN, BSTEPS = swe_core.SWEEP_SIZES[-1]\n", + "t64 = swe_core.timed_run(run_jax64, BN, BSTEPS, warmup=1, repeats=3)\n", + "t32 = swe_core.timed_run(run_jax32, BN, BSTEPS, warmup=1, repeats=3)\n", + "print(f\"DRAM-resident fp64/fp32: {t64['median_s'] / t32['median_s']:.1f}x\")" + ] + }, + { + "cell_type": "markdown", + "id": "8f09f703", + "metadata": {}, + "source": [ + "**Recap.**\n", + "\n", + "- We expressed our computation as a **pure function** of `(h, hu)` and let\n", + " `@jax.jit + lax.scan` fuse the whole time loop into one device program.\n", + "- **Shape-rigidity**: changing `N` is not free.\n", + "- Below the crossover **N\\*** (Sec. 6) a fixed per-step cost dominates:\n", + " the grid is too small for the GPU to matter. Above it, time scales with `N`.\n", + "- JAX is float32-first: the docs call single precision 'the desired\n", + " behavior for many machine-learning applications'; float64 is opt-in\n", + " (`jax_enable_x64`) and pays the throughput hit measured above.\n", + "\n", + "Next: `10__swe__pyomp.ipynb` uses OpenMP's standard pragma-based API for shared-memory parallelism from Python." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/10__swe__pyomp.ipynb b/tutorials/pyhpc/notebooks/10__swe__pyomp.ipynb new file mode 100644 index 00000000..084c9d55 --- /dev/null +++ b/tutorials/pyhpc/notebooks/10__swe__pyomp.ipynb @@ -0,0 +1,482 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a8f93a1b", + "metadata": {}, + "source": [ + "## SWE - PyOMP\n", + "\n", + "PyOMP brings the **OpenMP programming model** into Python. Inside a\n", + "`@numba.openmp.njit` function you write `with openmp(\"parallel for\"):`\n", + "and the compiler emits the same kind of parallel-region IR that\n", + "`#pragma omp parallel for` produces in C.\n", + "\n", + "This differs from `@njit(parallel=True)`, which is **Numba's own parallel\n", + "mode**. PyOMP is OpenMP itself, exposed as a\n", + "Python decorator. The directives port to\n", + "C / C++ / Fortran without translation.\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Imports and the OpenMP runtime](#sec1)\n", + "2. [The threading probe](#sec2)\n", + "3. [The step kernel with `openmp(\"parallel for\")`](#sec3)\n", + "4. [Acceptance and timing](#sec4)\n", + "5. [Thread scaling](#sec5)\n", + "6. [Scaling with data size](#sec6)\n", + "\n", + "### 1. Imports and the OpenMP runtime\n", + "\n", + "`numba.openmp` exposes both the decorator and the runtime helpers. Set\n", + "the thread count once with `omp_set_num_threads(N)` before the first\n", + "parallel region.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- Use the PyOMP-aware decorator:\n", + " ```python\n", + " from numba.openmp import (\n", + " njit,\n", + " openmp_context as openmp,\n", + " omp_set_num_threads,\n", + " omp_get_thread_num, omp_get_num_threads,\n", + " )\n", + " ```\n", + "- `with openmp(\"...\"):` accepts any OpenMP construct (`parallel for`,\n", + " `reduction(+:x)`, `target teams distribute`, …)\n", + " at JIT time.\n", + "- `omp_set_num_threads(N)` sets the thread team size. This is equivalent to\n", + " `OMP_NUM_THREADS=N` in C." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fa81a40e", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# Pin OpenMP threads to cores before the runtime starts\n", + "os.environ.setdefault(\"OMP_PROC_BIND\", \"close\")\n", + "os.environ.setdefault(\"OMP_PLACES\", \"cores\")\n", + "\n", + "import time\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import numba\n", + "import psutil\n", + "\n", + "# PyOMP exposes its own njit decorator at numba.openmp.njit\n", + "from numba.openmp import njit\n", + "from numba.openmp import openmp_context as openmp\n", + "from numba.openmp import (\n", + " omp_get_thread_num,\n", + " omp_get_num_threads,\n", + " omp_set_num_threads,\n", + ")\n", + "\n", + "import swe_core\n", + "\n", + "\n", + "# Shared problem parameters\n", + "N, N_STEPS = swe_core.canonical_size()\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "dx = L / N\n", + "DT = swe_core.fixed_dt(H0 + AMP, dx, cfl=CFL, g=G)\n", + "\n", + "# Float64 NumPy reference for validation\n", + "h_ref, _ = swe_core.solve_numpy(N, N_STEPS)" + ] + }, + { + "cell_type": "markdown", + "id": "c282cc1b", + "metadata": {}, + "source": [ + "### 2. The threading probe\n", + "\n", + "Confirm we got the requested team size by asking the OpenMP runtime\n", + "inside a `parallel` region. We request one thread per physical core and\n", + "expect to observe that same count inside the region.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `omp_get_thread_num()` / `omp_get_num_threads()`: this thread's id and\n", + " the team size, valid inside a `parallel` region." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1ed7e81b", + "metadata": {}, + "outputs": [], + "source": [ + "N_THREADS = psutil.cpu_count(logical=False) or os.cpu_count()\n", + "omp_set_num_threads(N_THREADS)\n", + "\n", + "@njit\n", + "def probe_threading():\n", + " '''Inside an `openmp(\"parallel\")` region, ask how many threads we got.\n", + "\n", + " TODO: fill in the kernel below. We want to:\n", + " - declare an integer n_seen = 0 outside the region\n", + " - enter a `with openmp(\"parallel\"):` block\n", + " - from thread 0, store omp_get_num_threads() into n_seen\n", + " - return n_seen\n", + "\n", + " Hint: omp_get_thread_num() and omp_get_num_threads() are exposed by\n", + " `from numba.openmp import omp_get_thread_num, omp_get_num_threads`.\n", + " '''\n", + " n_seen = ... # TODO\n", + " return n_seen\n", + "\n", + "n_observed = probe_threading()\n", + "print(f'threads: requested {N_THREADS}, observed inside parallel: {n_observed}')" + ] + }, + { + "cell_type": "markdown", + "id": "00669ddf", + "metadata": {}, + "source": [ + "### 3. The step kernel with `openmp(\"parallel for\")`\n", + "\n", + "Now let's implement the same **Read / Compute / Update** shape in PyOMP:\n", + "\n", + "- **Read**. Inside a `parallel for`, each thread reads `h[i-1]`, `h[i]`, `h[i+1]` for its slice of cells.\n", + "- **Compute**. `rusanov_face(...)` is called twice per cell, for the west and east face.\n", + "- **Update**. `h_new[i] = h[i] - (dt/dx) * (Fh_e - Fh_w)`. Each thread writes its own range of `h_new`, so no synchronisation is needed.\n", + "\n", + "The per-face flux lives in its own `@njit` helper so the parallel-loop body stays compact. The code shape mirrors what you would write in C-OpenMP.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `with openmp(\"parallel for\"):` parallelises the immediately-following `for` loop. Iterations are distributed across the thread team.\n", + "- `with openmp(\"parallel for reduction(+:total)\"):` would parallelise a reduction. We don't use one here: the SWE step writes one output per cell, with no cross-cell accumulation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dfc3a14f", + "metadata": {}, + "outputs": [], + "source": [ + "DRY_TOL_F = 1e-6\n", + "\n", + "@njit\n", + "def rusanov_face(hL, hR, huL, huR, g):\n", + " '''Scalar Rusanov face flux. Pulled into its own @njit function so the\n", + " parallel-loop body stays compact.'''\n", + " hL_s = hL if hL > DRY_TOL_F else DRY_TOL_F\n", + " hR_s = hR if hR > DRY_TOL_F else DRY_TOL_F\n", + " uL = huL / hL_s\n", + " uR = huR / hR_s\n", + " cL = np.sqrt(g * hL_s)\n", + " cR = np.sqrt(g * hR_s)\n", + " a = max(abs(uL) + cL, abs(uR) + cR)\n", + " Fh = 0.5 * (huL + huR) - 0.5 * a * (hR - hL)\n", + " Fhu = 0.5 * (huL * uL + 0.5*g*hL*hL +\n", + " huR * uR + 0.5*g*hR*hR) - 0.5 * a * (huR - huL)\n", + " return Fh, Fhu\n", + "\n", + "@njit\n", + "def step_pyomp(h, hu, h_new, hu_new, dx, dt, g):\n", + " '''One Rusanov-flux step. The per-cell loop is wrapped in\n", + " `with openmp(\"parallel for\")`.\n", + "\n", + " TODO: fill in the parallel-loop body.\n", + " - h_new[0]/h_new[-1] and hu_new[0]/hu_new[-1] are already populated\n", + " for you (ghost cells carry through; caller re-applies BC).\n", + " - Compute the WEST face flux (between cells i-1 and i) and EAST\n", + " face flux (between cells i and i+1) via `rusanov_face(...)`.\n", + " - Write h_new[i] = h[i] - (dt/dx)(Fh_e - Fh_w) and similarly\n", + " hu_new[i].\n", + " Hint: `rusanov_face` already returns (Fh, Fhu).\n", + " '''\n", + " Np2 = h.shape[0]\n", + " N = Np2 - 2\n", + " # Carry ghost cells through (the caller reapplies BCs between steps).\n", + " h_new[0] = h[0]; hu_new[0] = hu[0]\n", + " h_new[-1] = h[-1]; hu_new[-1] = hu[-1]\n", + " inv = dt / dx\n", + " with openmp(\"parallel for\"):\n", + " for i in range(1, N + 1):\n", + " # TODO: call rusanov_face twice; write h_new[i] and hu_new[i].\n", + " ...\n", + "\n", + "@njit\n", + "def _fill(dst_h, dst_hu, src_h, src_hu, s1, s2, n):\n", + " with openmp(\"parallel for\"):\n", + " for i in range(n):\n", + " dst_h[i] = src_h[i]\n", + " dst_hu[i] = src_hu[i]\n", + " s1[i] = 0.0\n", + " s2[i] = 0.0\n", + "\n", + "\n", + "def _setup_ic(n):\n", + " src_h, src_hu = swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + " m = n + 2\n", + " h, hu, h2, hu2 = (np.empty(m) for _ in range(4))\n", + " _fill(h, hu, src_h, src_hu, h2, hu2, m)\n", + " return h, hu, h2, hu2, src_h, src_hu\n", + "\n", + "\n", + "setup_ic = swe_core.by_size(_setup_ic)\n" + ] + }, + { + "cell_type": "markdown", + "id": "efb0af0a", + "metadata": {}, + "source": [ + "### 4. Acceptance and timing\n", + "\n", + "We use two pre-allocated buffers, swapped after each step so the loop allocates nothing. Wall-\n", + "clock is measured by `swe_core.timed_run`. Correctness against the\n", + "float64 NumPy reference uses `swe_core.max_diff`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3f1997cf", + "metadata": {}, + "outputs": [], + "source": [ + "def run_pyomp() -> tuple[np.ndarray, np.ndarray]:\n", + " h, hu, h2, hu2, src_h, src_hu = setup_ic(N)\n", + " _fill(h, hu, src_h, src_hu, h2, hu2, N + 2) # reset, in parallel\n", + " for _ in range(N_STEPS):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " step_pyomp(h, hu, h2, hu2, dx, DT, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " return h, hu\n", + "\n", + "# Cold capture (includes Numba + PyOMP compile).\n", + "t0 = time.perf_counter()\n", + "h_pyomp, _ = run_pyomp()\n", + "cold_s = time.perf_counter() - t0\n", + "\n", + "# Check now, because the timed runs below overwrite this buffer.\n", + "diff = swe_core.max_diff(h_ref, h_pyomp)\n", + "\n", + "warm = swe_core.timed_run(run_pyomp, warmup=2, repeats=5, label='10_pyomp')\n", + "swe_core.report_and_verify(warm, diff, tol=1e-12, cold_s=cold_s,\n", + " n=N, steps=N_STEPS,\n", + " cold_note='incl. JIT + OMP region lowering')\n", + "\n", + "swe_core.save_timing(\n", + " warm, grid_str=f'N={N}', tool='pyomp', hardware='cpu',\n", + " dtype='float64', steps=N_STEPS,\n", + " cold_s=cold_s, max_diff_vs_numpy=diff,\n", + " threads_observed=int(n_observed),\n", + " numba_version=numba.__version__,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "68cda77c", + "metadata": {}, + "source": [ + "### 5. Thread scaling\n", + "\n", + "The same kernel scales with `omp_set_num_threads(n)`, with no recompile and no\n", + "Python-level thread orchestration. The sweep below runs up to the logical-core count.\n", + "The dotted line in the plot marks the physical-core count.\n", + "\n", + "**Predict before run:** sketch speedup against thread count.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8acef27f", + "metadata": {}, + "outputs": [], + "source": [ + "N_LOGICAL = os.cpu_count()\n", + "THREAD_COUNTS = [1]\n", + "while THREAD_COUNTS[-1] * 2 <= N_LOGICAL:\n", + " THREAD_COUNTS.append(THREAD_COUNTS[-1] * 2)\n", + "\n", + "sweep = []\n", + "for nthreads in THREAD_COUNTS:\n", + " omp_set_num_threads(nthreads)\n", + " r = swe_core.timed_run(run_pyomp, warmup=2, repeats=3)\n", + " rate = N * N_STEPS / r['median_s'] / 1e6\n", + " sweep.append((nthreads, r['median_s'], rate))\n", + " print(f' threads = {nthreads:>2}: {r[\"median_s\"]*1000:6.1f} ms '\n", + " f'{rate:7.1f} Mcells/s')\n", + "\n", + "ns = [s[0] for s in sweep]\n", + "ts = [s[1] for s in sweep]\n", + "speedups = [ts[0] / t for t in ts]\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 3.4))\n", + "ax.plot(ns, speedups, 'o-', linewidth=2, markersize=10, label='measured')\n", + "ax.plot(ns, ns, '--', color='#aaa', label='ideal (linear)')\n", + "ax.axvline(N_THREADS, color='#c33', linestyle=':',\n", + " label=f'physical cores ({N_THREADS})')\n", + "ax.set_xscale('log', base=2)\n", + "ax.set_xticks(ns); ax.set_xticklabels([str(n) for n in ns])\n", + "ax.set_xlabel('OMP_NUM_THREADS'); ax.set_ylabel('speedup vs 1 thread')\n", + "ax.set_title('Thread scaling')\n", + "ax.legend(); ax.grid(alpha=0.3); plt.tight_layout(); plt.show()\n", + "\n", + "omp_set_num_threads(N_THREADS) # restore the notebook default for later cells\n" + ] + }, + { + "cell_type": "markdown", + "id": "sec5-reveal-md", + "metadata": {}, + "source": [ + "Speedup is near-linear while each new thread lands on an idle core." + ] + }, + { + "cell_type": "markdown", + "id": "ed4dfcc7", + "metadata": {}, + "source": [ + "### 6. Scaling with data size\n", + "\n", + "Let's now look at throughput against working-set size. Here one fused loop runs per step, so the working set is just the four\n", + "`float64` state arrays. The last-level cache boundary is marked in the plot.\n", + "\n", + "A new `N` triggers no recompile here, unlike JAX. Numba specialises on `dtype` and rank, not shape, so there is no cold spike to chart.\n", + "\n", + "**Q:** throughput is flat while the working set is cache-resident, from L2\n", + "through L3. What limits the kernel there? (Measured in notebook 14)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6256f3b6", + "metadata": {}, + "outputs": [], + "source": [ + "# Sweep across cache regimes. Few steps at large N keeps runtime bounded, throughput is step count-invariant.\n", + "N_MAX = max(n for n, _ in swe_core.sweep_points())\n", + "SWEEP = [(n, max(20, min(1000, 200_000_000 // n)))\n", + " for n in (1 << k for k in range(14, N_MAX.bit_length()))]\n", + "\n", + "grid_data = []\n", + "for N_local, steps_local in SWEEP:\n", + " L_local = 10.0\n", + " dx_local = L_local / N_local\n", + " DT_local = swe_core.fixed_dt(H0 + AMP, dx_local, cfl=CFL, g=G)\n", + "\n", + " # Run the IC outside timed function: only time update step.\n", + " h0, hu0 = swe_core.bump_ic(N_local, L=L_local, h0=H0, amplitude=AMP, sigma=SIG)\n", + " s0, s1 = np.empty_like(h0), np.empty_like(hu0)\n", + "\n", + " def run_at(h=h0, hu=hu0, h2=s0, hu2=s1, dx_=dx_local, dt_=DT_local, s_=steps_local):\n", + " for _ in range(s_):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " step_pyomp(h, hu, h2, hu2, dx_, dt_, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + "\n", + " r = swe_core.timed_run(run_at, warmup=2, repeats=5)\n", + " cells_per_s = N_local * steps_local / r['median_s']\n", + " ws_mb = swe_core.to_mib(swe_core.working_set_bytes(N_local + 2, n_arrays=4)) # The fused kernel stores 4 float64 arrays\n", + " grid_data.append((N_local, cells_per_s, ws_mb))\n", + " print(f' N={N_local:>9,}: working set {ws_mb:8.1f} MiB '\n", + " f'{cells_per_s/1e6:6.1f} Mcells/s')\n", + "\n", + "LLC_MIB = swe_core.llc_mib() # CPU last-level cache, the wall to watch\n", + "swe_core.plot_rate_sweep([d[2] for d in grid_data],\n", + " {f'PyOMP warm @ {N_THREADS} threads': [d[1] / 1e6 for d in grid_data]},\n", + " 'working-set footprint [MiB]',\n", + " 'Throughput across cache and DRAM',\n", + " boundaries=[(LLC_MIB, f'last-level cache = {LLC_MIB:.0f} MiB')] if LLC_MIB else (),\n", + " logy=False, from_zero=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sweep-for-synthesis", + "metadata": {}, + "outputs": [], + "source": [ + "# Rates across the shared size range, for the synthesis notebook (14).\n", + "def run_pyomp_at(n_cells, n_steps):\n", + " dx_ = 10.0 / n_cells\n", + " dt_ = swe_core.fixed_dt(H0 + AMP, dx_, cfl=CFL, g=G)\n", + " h, hu, h2, hu2, src_h, src_hu = setup_ic(n_cells)\n", + " _fill(h, hu, src_h, src_hu, h2, hu2, n_cells + 2)\n", + " for _ in range(n_steps):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " step_pyomp(h, hu, h2, hu2, dx_, dt_, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " return h\n", + "\n", + "swe_core.save_sweep('10_pyomp', run_pyomp_at)" + ] + }, + { + "cell_type": "markdown", + "id": "ecd0b992", + "metadata": {}, + "source": [ + "**Recap.**\n", + "\n", + "- Same `#pragma omp parallel for` directives as C, exposed via a\n", + " Python context manager.\n", + "- Thread scaling is near-linear while each thread has a core of its own\n", + " (Sec. 5); the kernel handles any `N` with no recompile (Sec. 6).\n", + "\n", + "Next: `11__swe__nanobind.ipynb` drops into C++. We compile a\n", + "hand-written kernel and call it from Python through a binding library.\n" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/11__swe__nanobind.ipynb b/tutorials/pyhpc/notebooks/11__swe__nanobind.ipynb new file mode 100644 index 00000000..35ee8177 --- /dev/null +++ b/tutorials/pyhpc/notebooks/11__swe__nanobind.ipynb @@ -0,0 +1,460 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "84637cd3", + "metadata": {}, + "source": [ + "## SWE - nanobind\n", + "\n", + "One way to get more performance is to write the kernel in C++ and bind it back to Python. This notebook takes the binding-library and FFI (foreign function interface) approach with **nanobind**, the modern successor to pybind11.\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Imports and source layout](#sec1)\n", + "2. [The C++ source](#sec2)\n", + "3. [Build with nanobind + CMake](#sec3)\n", + "4. [Acceptance and timing](#sec4)\n", + "5. [nanobind vs pybind11](#sec5)\n", + "6. [Limitation: binding and build glue](#sec6)\n", + "\n", + "### 1. Imports and source layout\n", + "\n", + "The C++ source lives at `notebooks/swe_step.cpp`. We invoke `cmake` and\n", + "the system C++ compiler via `subprocess`. The resulting shared library is\n", + "imported back into Python.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `nanobind.cmake_dir()`: path to nanobind's CMake config files. We pass\n", + " it via `-Dnanobind_DIR=` so `find_package(nanobind CONFIG REQUIRED)`\n", + " succeeds.\n", + "- `nanobind_add_module(target source)`: nanobind's CMake helper that\n", + " creates a Python extension target with the right include paths,\n", + " link flags, and visibility settings.\n", + "- `nb::ndarray>` (on the C++ side): a typed NumPy view (`double*` + `shape`).\n", + " No copy unless the layout demands one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2f5d84c4", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "import numpy as np\n", + "import nanobind\n", + "import psutil\n", + "\n", + "import swe_core\n", + "\n", + "# One OpenMP thread per physical core, pinned, as in NB 10.\n", + "os.environ.setdefault('OMP_NUM_THREADS', str(psutil.cpu_count(logical=False)))\n", + "os.environ.setdefault('OMP_PROC_BIND', 'close')\n", + "os.environ.setdefault('OMP_PLACES', 'cores')\n", + "\n", + "# Shared problem parameters.\n", + "N, N_STEPS = swe_core.canonical_size()\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "dx = L / N\n", + "DT = swe_core.fixed_dt(H0 + AMP, dx, cfl=CFL, g=G)\n", + "\n", + "# CMakeLists.txt + swe_step.cpp live next to swe_core.py; build artifacts go in build/.\n", + "CPP_DIR = Path(swe_core.SWE_STEP_CPP).parent\n", + "BUILD_DIR = CPP_DIR / 'build'\n", + "\n", + "# Float64 NumPy reference for validation\n", + "h_ref, _ = swe_core.solve_numpy(N, N_STEPS)\n", + "\n", + "def _setup_ic(n):\n", + " h, hu = swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + " return (h, hu, np.empty_like(h), np.empty_like(hu),\n", + " np.empty(n + 1), np.empty(n + 1))\n", + "\n", + "setup_ic = swe_core.by_size(_setup_ic)\n" + ] + }, + { + "cell_type": "markdown", + "id": "ff37df55", + "metadata": {}, + "source": [ + "### 2. The C++ source\n", + "\n", + "Same **Read / Compute / Update** shape as NB 08, expressed in C++ via nanobind:\n", + "\n", + "- **Read**. The `double*` pointer nanobind hands us, a zero-copy view of the NumPy buffer.\n", + "- **Compute**. One flux per interface, written into a caller-provided face buffer, no temporaries.\n", + "- **Update**. Writes into `double*`. No Python objects on the hot path.\n", + "\n", + "From `swe_step.cpp`:\n", + "\n", + "```cpp\n", + "#include \n", + "#include \n", + "#include \n", + "#include \n", + "\n", + "namespace nb = nanobind;\n", + "\n", + "inline void rusanov_face(double hL, double hR, double huL, double huR,\n", + " double g, double& Fh, double& Fhu) {\n", + " /* ...same arithmetic as swe_core.step_numpy... */\n", + "}\n", + "\n", + "// Each interface flux is computed exactly once, as in swe_core.step_numpy.\n", + "void cpp_step(\n", + " nb::ndarray, nb::c_contig> h_in,\n", + " nb::ndarray, nb::c_contig> hu_in,\n", + " nb::ndarray, nb::c_contig> h_out,\n", + " nb::ndarray, nb::c_contig> hu_out,\n", + " nb::ndarray, nb::c_contig> Fh_buf,\n", + " nb::ndarray, nb::c_contig> Fhu_buf,\n", + " double dx, double dt, double g)\n", + "{\n", + " const double* h = h_in.data();\n", + " /* ... */\n", + " // Pass 1: one flux per interface i+1/2, i = 0..N.\n", + " #pragma omp parallel for\n", + " for (size_t f = 0; f <= N; ++f)\n", + " rusanov_face(h[f], h[f + 1], hu[f], hu[f + 1], g, Fh[f], Fhu[f]);\n", + "\n", + " // Pass 2: difference the stored fluxes over the interior.\n", + " #pragma omp parallel for\n", + " for (size_t i = 1; i <= N; ++i) { /* ... */ }\n", + "}\n", + "\n", + "NB_MODULE(swe_step, m) {\n", + " m.def(\"cpp_step\", &cpp_step,\n", + " nb::arg(\"h\"), nb::arg(\"hu\"), nb::arg(\"h_new\"), nb::arg(\"hu_new\"),\n", + " nb::arg(\"Fh\"), nb::arg(\"Fhu\"),\n", + " nb::arg(\"dx\"), nb::arg(\"dt\"), nb::arg(\"g\") = 9.81);\n", + "}\n", + "```\n", + "\n", + "Things to note:\n", + "- `nb::ndarray, nb::c_contig>` is a zero-copy typed view into the NumPy buffer; `nb::c_contig` rejects strided views.\n", + "- The face-flux buffers are pre-allocated by NumPy and passed in, so the kernel allocates nothing.\n", + "- `NB_MODULE`'s name must match the compiled library filename (`swe_step.cpython-...so`).\n", + "- `nb::arg` carries Python keyword names and defaults into the generated signature.\n", + "- Both passes run under `#pragma omp parallel for`, the same directives used with PyOMP. Each thread writes its own index range, no synchronisation needed." + ] + }, + { + "cell_type": "markdown", + "id": "6c713090", + "metadata": {}, + "source": [ + "### 3. Build with nanobind + CMake\n", + "\n", + "nanobind ships with a CMake helper, `nanobind_add_module(...)`, that\n", + "sets up include paths, link flags, RPATH, and visibility for you. Let's\n", + "write a small `CMakeLists.txt` and then configure and build the library.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `cmake -B build -S .` configures (out-of-source build directory).\n", + "- `-DCMAKE_BUILD_TYPE=Release` is essential for the compiler to emit optimisation flags (`-O3 -DNDEBUG`).\n", + "- `-DPython_EXECUTABLE=` ensures we correctly link against the Python executable in our venv.\n", + "- `cmake --build build --config Release -j` compiles our code into a shared library." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "613ae7b5", + "metadata": {}, + "outputs": [], + "source": [ + "# locate nanobind's CMake config\n", + "NANOBIND_CMAKE_DIR = nanobind.cmake_dir()\n", + "\n", + "CMAKELISTS = '''\n", + "cmake_minimum_required(VERSION 3.20)\n", + "project(swe_step LANGUAGES CXX)\n", + "set(CMAKE_CXX_STANDARD 17)\n", + "find_package(Python 3.12 REQUIRED COMPONENTS Interpreter Development.Module)\n", + "find_package(nanobind CONFIG REQUIRED)\n", + "find_package(OpenMP REQUIRED)\n", + "nanobind_add_module(swe_step swe_step.cpp)\n", + "target_compile_options(swe_step PRIVATE -O3)\n", + "target_link_libraries(swe_step PRIVATE OpenMP::OpenMP_CXX)\n", + "'''\n", + "(CPP_DIR / 'CMakeLists.txt').write_text(CMAKELISTS)\n", + "\n", + "# TODO: configure + build the C++ extension with swe_core.run_cmd(..., cwd=CPP_DIR).\n", + "# - cmake -B build -S . with these flags:\n", + "# -DCMAKE_BUILD_TYPE=Release (so g++ gets -O3)\n", + "# -DPython_EXECUTABLE= (the venv interpreter)\n", + "# -Dnanobind_DIR=NANOBIND_CMAKE_DIR (so find_package(nanobind) works)\n", + "# - cmake --build build --config Release -j\n", + "..." + ] + }, + { + "cell_type": "markdown", + "id": "e82729d0", + "metadata": {}, + "source": [ + "Now that we have our shared library, let's import it on the Python side:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "930ea37e", + "metadata": {}, + "outputs": [], + "source": [ + "sys.path.insert(0, str(BUILD_DIR))\n", + "import swe_step as cpp_swe" + ] + }, + { + "cell_type": "markdown", + "id": "4822549e", + "metadata": {}, + "source": [ + "### 4. Acceptance and timing\n", + "\n", + "The time loop reuses pre-allocated state and face-flux buffers, swapping\n", + "input and output each step (double buffering), so the timing measures the\n", + "kernel call, not per-step allocation. Boundary conditions are re-applied on\n", + "the Python side (`swe_core.apply_bc_reflective`) between steps, so the C++\n", + "kernel does only the step." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac0a257d", + "metadata": {}, + "outputs": [], + "source": [ + "# Buffers are allocated once, so the timed region is the step loop.\n", + "_IC = swe_core.bump_ic(N, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + "_STATE = (np.empty_like(_IC[0]), np.empty_like(_IC[1]))\n", + "_BUF = (np.empty_like(_IC[0]), np.empty_like(_IC[1]),\n", + " np.empty(N + 1), np.empty(N + 1)) # one flux slot per interface\n", + "\n", + "def run_nanobind() -> tuple[np.ndarray, np.ndarray]:\n", + " h, hu = _STATE\n", + " np.copyto(h, _IC[0])\n", + " np.copyto(hu, _IC[1])\n", + " h2, hu2, Fh, Fhu = _BUF\n", + " for _ in range(N_STEPS):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " cpp_swe.cpp_step(h, hu, h2, hu2, Fh, Fhu, dx, DT, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " return h, hu\n", + "\n", + "# No cold/warm split here: compile cost was paid at build time and the dlopen\n", + "# at import, so every function call runs warm.\n", + "h_nb, _ = run_nanobind()\n", + "\n", + "# Check now, because the timed runs below overwrite this buffer.\n", + "diff = swe_core.max_diff(h_ref, h_nb)\n", + "\n", + "warm = swe_core.timed_run(run_nanobind, warmup=2, repeats=5, label='11_nanobind')\n", + "swe_core.report_and_verify(warm, diff, tol=1e-12, n=N, steps=N_STEPS)\n", + "\n", + "npy_ms = 1e3 * next(r['median_s'] for r in swe_core.load_timings() if r['tool'] == 'numpy')\n", + "print(f\"NumPy baseline {npy_ms:.1f} ms -> {npy_ms / (1e3 * warm['median_s']):.2f}x\")\n", + "\n", + "swe_core.save_timing(\n", + " warm, grid_str=f'N={N}', tool='nanobind', hardware='cpu',\n", + " dtype='float64', steps=N_STEPS,\n", + " max_diff_vs_numpy=diff, binding='nanobind',\n", + " flags='-O3',\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "97b5f1bb", + "metadata": {}, + "source": [ + "### 5. nanobind vs pybind11\n", + "\n", + "pybind11 is nanobind's predecessor and remains the most widely used C++↔Python binding library (PyTorch, SciPy, and much of the HPC ecosystem). The ergonomics are nearly identical: in pybind11 the array type is `py::array_t` and the entry point is `PYBIND11_MODULE`. nanobind's published benchmarks report up to ~10× lower per-call overhead, ~4× faster compilation, and ~5× smaller binaries ([nanobind](https://github.com/wjakob/nanobind))." + ] + }, + { + "cell_type": "markdown", + "id": "a8b1306d", + "metadata": {}, + "source": [ + "### 6. Limitation: binding and build glue\n", + "\n", + "The trade-off is everything around the kernel:\n", + "\n", + "- **The bindings track the C++ API.** `NB_MODULE`, `m.def`, and `nb::arg` are a separate layer that has to be hand-edited whenever the C++ source evolves. The kernel is also no longer regular C++, it carries nanobind types like `nb::ndarray`.\n", + "- **Build system.** Building the module requires working with CMake and a C++ toolchain.\n", + "- **Recompile-on-edit cycle.** Editing the source leads to recompilation, restarting the kernel and re-importing the module.\n", + "- **FFI boundary cost.** Each call from Python into C++ pays a small fixed overhead for argument conversion. At ~1000 calls per run it stays well under a millisecond, negligible against the kernel time.\n", + "\n", + "With `-O3` the compiler still emits scalar code for this\n", + "loop, yet Sec. 4 lands well ahead of single-threaded NumPy: no Python\n", + "overhead, no temporaries, and both passes are multi-threaded.\n", + "\n", + "**EXTRA CREDIT:** make the kernel even faster by changing only compile flags. The cell\n", + "below rebuilds the same source as module `swe_step_fast`. Edit\n", + "its `target_compile_options` line and re-run. The first build imports cleanly; a loaded C extension cannot be\n", + "reloaded without a kernel restart. Run `swe_core.report_and_verify` to ensure correctness. What is the largest gain you can reach, and which\n", + "flag does the work?\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `target_compile_options(swe_step_fast PRIVATE )`: the line to\n", + " extend. Candidates: `-ffast-math`, `-fno-math-errno`, `-funroll-loops`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ec-flags", + "metadata": {}, + "outputs": [], + "source": [ + "# EXTRA CREDIT: rebuild with extra flags and measure the gain vs -O3.\n", + "assert 'swe_step_fast' not in sys.modules, 'restart the kernel before rebuilding with new flags'\n", + "\n", + "# Same source, second module name, extra flags.\n", + "(CPP_DIR / 'swe_step_fast.cpp').write_text(\n", + " open(swe_core.SWE_STEP_CPP).read().replace('NB_MODULE(swe_step,', 'NB_MODULE(swe_step_fast,'))\n", + "\n", + "CMAKELISTS = '''\n", + "cmake_minimum_required(VERSION 3.20)\n", + "project(swe_step_fast LANGUAGES CXX)\n", + "set(CMAKE_CXX_STANDARD 17)\n", + "find_package(Python 3.12 REQUIRED COMPONENTS Interpreter Development.Module)\n", + "find_package(nanobind CONFIG REQUIRED)\n", + "find_package(OpenMP REQUIRED)\n", + "nanobind_add_module(swe_step_fast swe_step_fast.cpp)\n", + "# TODO: extend the line below with flags that tune for this CPU and\n", + "# vectorise; keep the gate green\n", + "target_compile_options(swe_step_fast PRIVATE -O3)\n", + "target_link_libraries(swe_step_fast PRIVATE OpenMP::OpenMP_CXX)\n", + "'''\n", + "(CPP_DIR / 'CMakeLists.txt').write_text(CMAKELISTS)\n", + "\n", + "swe_core.run_cmd(['cmake', '-B', 'build', '-S', '.',\n", + " '-DCMAKE_BUILD_TYPE=Release',\n", + " f'-DPython_EXECUTABLE={sys.executable}',\n", + " f'-Dnanobind_DIR={NANOBIND_CMAKE_DIR}'], cwd=CPP_DIR)\n", + "swe_core.run_cmd(['cmake', '--build', 'build', '--config', 'Release', '-j'], cwd=CPP_DIR)\n", + "\n", + "import swe_step_fast as cpp_fast\n", + "\n", + "def run_fast(n_cells=N, n_steps=N_STEPS):\n", + " dx_ = L / n_cells\n", + " dt_ = swe_core.fixed_dt(H0 + AMP, dx_, cfl=CFL, g=G)\n", + " ic_h, ic_hu, h2, hu2, Fh, Fhu = setup_ic(n_cells)\n", + " h, hu = ic_h.copy(), ic_hu.copy()\n", + " for _ in range(n_steps):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " cpp_fast.cpp_step(h, hu, h2, hu2, Fh, Fhu, dx_, dt_, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " return h, hu\n", + "\n", + "h_fast, _ = run_fast()\n", + "warm_fast = swe_core.timed_run(run_fast, warmup=2, repeats=5, label='11_nanobind')\n", + "diff_fast = swe_core.max_diff(h_ref, h_fast)\n", + "swe_core.report_and_verify(warm_fast, diff_fast, tol=1e-12, n=N, steps=N_STEPS)\n", + "print(f\"{warm['median_s'] / warm_fast['median_s']:.1f}x vs the -O3 build\")\n", + "\n", + "# The synthesis notebook compares each tool at its best: replace the row.\n", + "if diff_fast < 1e-12:\n", + " swe_core.save_timing(\n", + " warm_fast, grid_str=f'N={N}', tool='nanobind', hardware='cpu',\n", + " dtype='float64', steps=N_STEPS, max_diff_vs_numpy=diff_fast,\n", + " binding='nanobind', flags='-O3')\n", + " swe_core.save_sweep('11_nanobind', run_fast)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sweep-for-synthesis", + "metadata": {}, + "outputs": [], + "source": [ + "# Rates across the shared size range, for the synthesis notebook (14).\n", + "# Pick up faster implementation if EXTRA CREDIT was done.\n", + "_mod = cpp_fast if 'cpp_fast' in globals() else cpp_swe\n", + "\n", + "def run_nanobind_at(n_cells, n_steps):\n", + " dx_ = L / n_cells\n", + " dt_ = swe_core.fixed_dt(H0 + AMP, dx_, cfl=CFL, g=G)\n", + " ic_h, ic_hu, h2, hu2, Fh, Fhu = setup_ic(n_cells)\n", + " h, hu = ic_h.copy(), ic_hu.copy()\n", + " for _ in range(n_steps):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " _mod.cpp_step(h, hu, h2, hu2, Fh, Fhu, dx_, dt_, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " return h\n", + "\n", + "swe_core.save_sweep('11_nanobind', run_nanobind_at)" + ] + }, + { + "cell_type": "markdown", + "id": "recap-07", + "metadata": {}, + "source": [ + "---\n", + "\n", + "**Recap.**\n", + "\n", + "- A short `CMakeLists.txt` + `nb::ndarray<...>` exposes a C++ kernel to Python with zero-copy NumPy arrays.\n", + "- The cost is the glue around the kernel: a bindings layer to maintain as the C++ evolves, plus build and FFI overhead.\n", + "- The kernel computes each face once, as `step_numpy` does, with both passes under `#pragma omp parallel for`. `-O3 -mcpu=native` is still scalar yet well ahead of NumPy; one more flag vectorises it further (EXTRA CREDIT).\n", + "\n", + "Next: `12__swe__cppjit__cub.ipynb` uses JIT-compiled C++/CUDA, with no CMake, recompilation, or separate shared library. It provides automatic Python bindings and runs the whole solve on the GPU using the CUB library." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/12__swe__cppjit__cub.ipynb b/tutorials/pyhpc/notebooks/12__swe__cppjit__cub.ipynb new file mode 100644 index 00000000..56197f9d --- /dev/null +++ b/tutorials/pyhpc/notebooks/12__swe__cppjit__cub.ipynb @@ -0,0 +1,709 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "nb05-title", + "metadata": {}, + "source": [ + "## SWE - CppJIT - CUB\n", + "\n", + "NumPy, JAX, PyOMP, and nanobind each rewrote the same step in a different way,\n", + "but nanobind required a separate build: edit the C++, run CMake, restart the\n", + "kernel, re-import. CppJIT removes that round-trip: hand it C++ or CUDA source\n", + "with `cppjit.cppdef`, and the declared functions are callable from Python\n", + "straight away. NumPy arrays can be passed as pointers with no wrapper code.\n", + "In this notebook we write the step as a CUB kernel and run the whole solve on the GPU.\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Imports and activation](#sec1)\n", + "2. [Solve on the GPU with CUB in C++](#sec2)\n", + "3. [Acceptance and timing](#sec3)\n", + "4. [CuPy baseline](#sec4)\n", + "5. [Fusing the CuPy step](#sec5)\n", + "6. [CUDA C++ kernel](#sec6)\n", + "7. [Scaling: fused vs drop-in vs CPU](#sec7)\n", + "8. [Limitations and recap](#sec8)\n", + "\n", + "### 1. Imports and activation\n", + "\n", + "`import cppjit` starts a live CUDA-enabled clang-repl interpreter, so the kernels below run on the GPU.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `cppjit.cppdef(src)`: compile a block of C++ or CUDA source and link it into\n", + " the session. The functions it declares become callable from Python automatically through proxies.\n", + "- `cppjit.gbl.(...)`: call a declared C++ function. A contiguous `float64`\n", + " array can be passed through as a `double*`, zero-copy.\n", + "- `cppjit.CUDA_ENABLED`: returns `True` if the C++ interpreter was created with CUDA support.\n", + "\n", + "---\n", + "\n", + "**Note.** CppJIT is the successor project to the [cppyy](https://cppyy.readthedocs.io/en/latest/) Python/C++ bindings generator that originated from the field of high-energy physics.\n", + "Based on the LLVM compiler infrastructure and the [CppInterOp](https://github.com/compiler-research/CppInterOp) library, CppJIT enables automatic interoperability paradigms and advanced features such as GPU and C++23 support.\n", + "This tutorial image bootstraps an alpha version of this package, built from source. A beta release on PyPI is planned for late August 2026." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nb05-imports", + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import swe_core\n", + "import cppjit\n", + "\n", + "assert cppjit.CUDA_ENABLED, \\\n", + " 'This notebook needs the CUDA-enabled tutorial image.'\n", + "\n", + "# Shared problem parameters.\n", + "N, N_STEPS = swe_core.canonical_size()\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "\n", + "# Float64 NumPy reference for validation.\n", + "h_ref, _ = swe_core.solve_numpy(N, N_STEPS)\n", + "\n", + "# Host-side setup is built once per size, so the timed region is the solve.\n", + "def _setup_ic(n):\n", + " h, hu = swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + " return np.ascontiguousarray(h), np.ascontiguousarray(hu)\n", + "\n", + "setup_ic = swe_core.by_size(_setup_ic)\n" + ] + }, + { + "cell_type": "markdown", + "id": "nb05-sec2", + "metadata": {}, + "source": [ + "### 2. Solve on the GPU with CUB in C++\n", + "\n", + "Let's solve the same Rusanov step with the CUB library in C++. CUB is a library of architecture-tuned CUDA C++ building blocks: warp-, block-,\n", + "and device-level algorithm primitives, shipped with the CUDA Toolkit as part\n", + "of CCCL.\n", + "The kernel lives in `swe_cub_solver.cpp`. `cppjit.cppdef` compiles it into the session.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `cub::DeviceFor::Bulk(n, op)`: run `op` on the GPU for each index in\n", + " `0..n-1`, so we walk the cells without building an index array.\n", + "- `[=] __device__ (long i) { ... }`: a CUDA lambda. It captures the field\n", + " pointers by value and runs on the device.\n", + "- `cudaMalloc` / `cudaMemcpy`: explicit device allocation and host-device\n", + " copies." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nb05-gpu", + "metadata": {}, + "outputs": [], + "source": [ + "# TODO: compile the kernel into the session. Read swe_cub_solver.cpp\n", + "# (swe_core.SWE_CUB_CPP) and hand its contents to cppjit.cppdef in one call;\n", + "# its functions are then callable as cppjit.gbl..\n", + "...\n", + "\n", + "def run_swe_gpu(n_cells=N, n_steps=N_STEPS):\n", + " \"\"\"Time-step on the GPU. The state stays in device memory.\"\"\"\n", + " cell_dx = L / n_cells\n", + " step_dt = swe_core.fixed_dt(H0 + AMP, cell_dx, cfl=CFL, g=G)\n", + " h, hu = setup_ic(n_cells)\n", + " cppjit.gbl.gpu_swe_init(h, hu, h.shape[0]) # no-op once resident\n", + " cppjit.gbl.gpu_swe_steps(cell_dx, step_dt, G, n_steps)\n", + "\n", + "\n", + "def fetch_swe_gpu(n_cells=N):\n", + " \"\"\"Final (h, hu) of the last solve, copied to the host.\"\"\"\n", + " h_out, hu_out = np.empty(n_cells + 2), np.empty(n_cells + 2)\n", + " cppjit.gbl.gpu_swe_fetch(h_out, hu_out)\n", + " return h_out, hu_out" + ] + }, + { + "cell_type": "markdown", + "id": "nb05-sec3", + "metadata": {}, + "source": [ + "### 3. Acceptance and timing\n", + "\n", + "We run the full solve, check it against the float64 NumPy reference, then\n", + "report a time. Cold call cost: clang-repl emits PTX, and the CUDA driver compiles PTX to SASS for this GPU at first launch. Both the cold call and the warm median are saved to `timings.json`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nb05-timing", + "metadata": {}, + "outputs": [], + "source": [ + "t0 = time.perf_counter()\n", + "run_swe_gpu()\n", + "cold_s = time.perf_counter() - t0\n", + "h_gpu, _ = fetch_swe_gpu()\n", + "\n", + "warm = swe_core.timed_run(run_swe_gpu, warmup=2, repeats=5, label='12_cppjit_gpu_cub')\n", + "diff = swe_core.max_diff(h_ref, h_gpu)\n", + "swe_core.report_and_verify(warm, diff, tol=1e-12, cold_s=cold_s,\n", + " n=N, steps=N_STEPS,\n", + " cold_note='incl. JIT + first PTX->SASS')\n", + "\n", + "swe_core.save_timing(\n", + " warm, grid_str=f'N={N}', tool='cppjit_gpu_cub', hardware='gpu',\n", + " dtype='float64', steps=N_STEPS, cold_s=cold_s, max_diff_vs_numpy=diff)" + ] + }, + { + "cell_type": "markdown", + "id": "sec4-cupy-md", + "metadata": {}, + "source": [ + "### 4. CuPy baseline\n", + "\n", + "Since we are now running on GPU, let's use CuPy as a baseline. It runs the same vectorised step as `swe_core.step_numpy`, but on the device, as a drop-in replacement (`cp.op` for `np.op`).\n", + "\n", + "**Q:** the warm time below lands far behind the CUB solve, on the same GPU\n", + "with the same arithmetic. Where does the time go? Count the kernel launches\n", + "per step with `nsys` below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sec4-cupy-code", + "metadata": {}, + "outputs": [], + "source": [ + "import cupy as cp\n", + "\n", + "def step_cupy(h, hu, cell_dx, step_dt, g=G, tol=swe_core.DRY_TOL):\n", + " \"\"\"swe_core.step_numpy, slice for slice, on the device.\"\"\"\n", + " hL, hR = h[:-1], h[1:]\n", + " huL, huR = hu[:-1], hu[1:]\n", + " h_safe_L = cp.maximum(hL, tol)\n", + " h_safe_R = cp.maximum(hR, tol)\n", + " uL, uR = huL / h_safe_L, huR / h_safe_R\n", + " cL, cR = cp.sqrt(g * h_safe_L), cp.sqrt(g * h_safe_R)\n", + " a = cp.maximum(cp.abs(uL) + cL, cp.abs(uR) + cR)\n", + " F_h = 0.5 * (huL + huR) - 0.5 * a * (hR - hL)\n", + " F_hu = 0.5 * (huL*uL + 0.5*g*hL*hL\n", + " + huR*uR + 0.5*g*hR*hR) - 0.5 * a * (huR - huL)\n", + " h_new, hu_new = h.copy(), hu.copy()\n", + " h_new[1:-1] = h[1:-1] - (step_dt / cell_dx) * (F_h[1:] - F_h[:-1])\n", + " hu_new[1:-1] = hu[1:-1] - (step_dt / cell_dx) * (F_hu[1:] - F_hu[:-1])\n", + " return h_new, hu_new\n", + "\n", + "setup_ic_dev = swe_core.by_size(lambda n: tuple(cp.asarray(a) for a in setup_ic(n)))\n", + "\n", + "\n", + "def run_swe_cupy(n_cells=N, n_steps=N_STEPS):\n", + " \"\"\"Full solve with CuPy. The state is already on the device.\"\"\"\n", + " cell_dx = L / n_cells\n", + " step_dt = swe_core.fixed_dt(H0 + AMP, cell_dx, cfl=CFL, g=G)\n", + " h0, hu0 = setup_ic_dev(n_cells)\n", + " h, hu = h0.copy(), hu0.copy()\n", + " for _ in range(n_steps):\n", + " h[0] = h[1]; h[-1] = h[-2]\n", + " hu[0] = -hu[1]; hu[-1] = -hu[-2]\n", + " h, hu = step_cupy(h, hu, cell_dx, step_dt)\n", + " cp.cuda.Device().synchronize()\n", + " return h, hu\n", + "\n", + "t0 = time.perf_counter()\n", + "h_cupy, _ = run_swe_cupy()\n", + "cold_cupy_s = time.perf_counter() - t0\n", + "\n", + "warm_cupy = swe_core.timed_run(run_swe_cupy, warmup=2, repeats=5, label='12_cupy')\n", + "diff_cupy = swe_core.max_diff(h_ref, cp.asnumpy(h_cupy))\n", + "swe_core.report_and_verify(warm_cupy, diff_cupy, tol=1e-12, cold_s=cold_cupy_s,\n", + " n=N, steps=N_STEPS,\n", + " cold_note='incl. NVRTC kernel compiles')\n", + "\n", + "swe_core.save_timing(\n", + " warm_cupy, grid_str=f'N={N}', tool='cupy', hardware='gpu',\n", + " dtype='float64', steps=N_STEPS, cold_s=cold_cupy_s,\n", + " max_diff_vs_numpy=diff_cupy)\n" + ] + }, + { + "cell_type": "markdown", + "id": "nvrtc-cache-note", + "metadata": {}, + "source": [ + "**Note.** The cold call doesn't actually compile anything. NVRTC builds CuPy's\n", + "own operator kernels (such as `cupy_sqrt__float64...`), and the cubins land\n", + "in an on-disk cache (`CUPY_CACHE_DIR`) that persists across sessions, so a\n", + "warm cache hides the compile cost. To record it, point `CUPY_CACHE_DIR` at an\n", + "empty directory before the `cupy` import and re-run." + ] + }, + { + "cell_type": "markdown", + "id": "ec-nsys-md", + "metadata": {}, + "source": [ + "**Count the kernel launches.** This tutorial image ships Nsight Systems. The cell\n", + "below profiles a short solve of each GPU path and sums kernel instances\n", + "per step.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `nsys profile -o python script.py`: record a timeline;\n", + " `--python-sampling=true` adds interpreter backtraces.\n", + "- `nsys stats --report cuda_gpu_kern_sum .nsys-rep`: kernel\n", + " instance counts per name." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nsys-writefile", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile swe_launch_probe.py\n", + "import sys\n", + "import time\n", + "\n", + "import cupy as cp\n", + "import swe_core\n", + "\n", + "mode, N, steps = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])\n", + "L = 10.0; dx = L / N; dt = swe_core.fixed_dt(1.1, dx)\n", + "h, hu = swe_core.bump_ic(N, L=L)\n", + "\n", + "if mode in ('cub', 'raw'):\n", + " import cppjit\n", + " assert cppjit.CUDA_ENABLED\n", + " cppjit.cppdef(open(swe_core.SWE_CUB_CPP if mode == 'cub'\n", + " else swe_core.SWE_RAW_CPP).read())\n", + " tag = '' if mode == 'cub' else '_raw'\n", + " getattr(cppjit.gbl, 'gpu_swe_init' + tag)(h, hu, h.shape[0])\n", + " step = getattr(cppjit.gbl, 'gpu_swe_steps' + tag)\n", + " solve = lambda: step(dx, dt, 9.81, steps)\n", + "elif mode == 'jax':\n", + " import jax\n", + " import jax.numpy as jnp\n", + " from swe_jax_step import solve64\n", + " st = tuple(jnp.asarray(a) for a in (h, hu))\n", + "\n", + " def solve():\n", + " jax.block_until_ready(solve64(st, dx, dt, steps))\n", + "elif mode == 'fused':\n", + " import swe_fused_kernel as k\n", + " step = cp.ElementwiseKernel(\n", + " 'raw float64 h, raw float64 hu, int64 n, float64 inv, float64 g',\n", + " 'raw float64 h_new, raw float64 hu_new',\n", + " k.BODY, 'swe_step_fused', preamble=k.PREAMBLE)\n", + " a0, b0 = cp.asarray(h), cp.asarray(hu)\n", + " a, b = cp.empty_like(a0), cp.empty_like(b0)\n", + " c, d = cp.empty_like(a0), cp.empty_like(b0)\n", + " inv = dt / dx\n", + "\n", + " def solve():\n", + " p, q, r, s = a, b, c, d\n", + " cp.copyto(p, a0); cp.copyto(q, b0)\n", + " for _ in range(steps):\n", + " step(p, q, N, inv, 9.81, r, s, size=N)\n", + " p, q, r, s = r, s, p, q\n", + " cp.cuda.Device().synchronize()\n", + "else:\n", + " gh0, ghu0 = cp.asarray(h), cp.asarray(hu)\n", + "\n", + " def solve():\n", + " gh, ghu = gh0.copy(), ghu0.copy()\n", + " for _ in range(steps):\n", + " gh[0] = gh[1]; gh[-1] = gh[-2]; ghu[0] = -ghu[1]; ghu[-1] = -ghu[-2]\n", + " hL, hR = gh[:-1], gh[1:]; huL, huR = ghu[:-1], ghu[1:]\n", + " hsL = cp.maximum(hL, 1e-6); hsR = cp.maximum(hR, 1e-6)\n", + " uL, uR = huL / hsL, huR / hsR\n", + " cL, cR = cp.sqrt(9.81 * hsL), cp.sqrt(9.81 * hsR)\n", + " a = cp.maximum(cp.abs(uL) + cL, cp.abs(uR) + cR)\n", + " F_h = 0.5 * (huL + huR) - 0.5 * a * (hR - hL)\n", + " F_hu = (0.5 * (huL*uL + 0.5*9.81*hL*hL + huR*uR + 0.5*9.81*hR*hR)\n", + " - 0.5 * a * (huR - huL))\n", + " gh2, ghu2 = gh.copy(), ghu.copy()\n", + " gh2[1:-1] = gh[1:-1] - (dt/dx) * (F_h[1:] - F_h[:-1])\n", + " ghu2[1:-1] = ghu[1:-1] - (dt/dx) * (F_hu[1:] - F_hu[:-1])\n", + " gh, ghu = gh2, ghu2\n", + " cp.cuda.Device().synchronize()\n", + "\n", + "# One warm call pays the compile and the allocation, then profile the next one.\n", + "solve()\n", + "cp.cuda.profiler.start()\n", + "t0 = time.perf_counter()\n", + "solve()\n", + "cp.cuda.Device().synchronize()\n", + "print(f'WALL_S {time.perf_counter() - t0:.6f}')\n", + "cp.cuda.profiler.stop()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nsys-profile", + "metadata": {}, + "outputs": [], + "source": [ + "PROF_STEPS = 20\n", + "MODES = ('cub', 'raw', 'cupy')\n", + "STAGE = {'cub': '12_cppjit_gpu_cub', 'raw': '12_cppjit_gpu_raw',\n", + " 'cupy': '12_cupy', 'fused': '12_cupy_fused',\n", + " 'jax': '09_jax_fp64'}\n", + "walls = {}\n", + "\n", + "# Node tracing records kernels inside CUDA graphs, which XLA uses.\n", + "# TODO: profile each mode with Nsight Systems, and read the WALL_S line the\n", + "# probe prints. `-c cudaProfilerApi` limits the report to the profiled call.\n", + "# out = !nsys profile -c cudaProfilerApi --capture-range-end=stop --cuda-graph-trace=node --force-overwrite true -o launch_{mode} python swe_launch_probe.py {mode} {N} {PROF_STEPS}\n", + "...\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nsys-count", + "metadata": {}, + "outputs": [], + "source": [ + "for mode in MODES:\n", + " rows = !nsys stats --force-export=true --report cuda_gpu_kern_sum launch_{mode}.nsys-rep\n", + " kern = [ln for ln in rows if ln.strip()[:1].isdigit()]\n", + " total = sum(int(ln.split()[2]) for ln in kern)\n", + " if total == 0:\n", + " raise RuntimeError(f'no kernels recorded for {mode}; re-run the profile cell')\n", + " print(f' {mode:>6}: {total:>4} kernel launches over {PROF_STEPS} steps '\n", + " f'= {total / PROF_STEPS:5.1f} kernels/step')\n" + ] + }, + { + "cell_type": "markdown", + "id": "sec5-fused-md", + "metadata": {}, + "source": [ + "### 5. Fusing the CuPy step\n", + "\n", + "`cupy.ElementwiseKernel` fuses the whole step into one CUDA C kernel,\n", + "one thread per cell. Each thread derives its ghost values, computes\n", + "its two face fluxes, and writes its update. This gives us one launch\n", + "per step, with no Python round-trip. Since neighbouring cells share faces,\n", + "every interior flux is computed twice. Storing them instead would require a\n", + "second pass and launch, as in the earlier CUB case.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `cp.ElementwiseKernel(in_params, out_params, operation, name, preamble=...)`:\n", + " compiles a CUDA C elementwise kernel on first call (NVRTC). `raw float64 x`\n", + " passes an array with explicit indexing (`x[j]`) instead of an implicit\n", + " per-element view; `size=` sets the launch size.\n", + "- `preamble`: pass CUDA C `__device__` helpers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sec5-fused-code", + "metadata": {}, + "outputs": [], + "source": [ + "_RUSANOV_DEV = r'''\n", + "__device__ void rusanov_face(double hL, double hR, double huL, double huR,\n", + " double g, double& Fh, double& Fhu) {\n", + " const double DRY = 1e-6;\n", + " const double hL_s = hL > DRY ? hL : DRY;\n", + " const double hR_s = hR > DRY ? hR : DRY;\n", + " const double uL = huL / hL_s, uR = huR / hR_s;\n", + " const double cL = sqrt(g * hL_s), cR = sqrt(g * hR_s);\n", + " const double a = fmax(fabs(uL) + cL, fabs(uR) + cR);\n", + " Fh = 0.5 * (huL + huR) - 0.5 * a * (hR - hL);\n", + " Fhu = 0.5 * (huL * uL + 0.5 * g * hL * hL + huR * uR + 0.5 * g * hR * hR)\n", + " - 0.5 * a * (huR - huL);\n", + "}\n", + "'''\n", + "\n", + "_FUSED_BODY = r'''\n", + " const long j = i + 1; // interior cell\n", + " const double hW = (j == 1) ? h[1] : h[j - 1]; // reflective ghosts,\n", + " const double huW = (j == 1) ? -hu[1] : hu[j - 1]; // derived in-kernel\n", + " const double hE = (j == n) ? h[n] : h[j + 1];\n", + " const double huE = (j == n) ? -hu[n] : hu[j + 1];\n", + " double Fw_h, Fw_hu, Fe_h, Fe_hu;\n", + " rusanov_face(hW, h[j], huW, hu[j], g, Fw_h, Fw_hu);\n", + " rusanov_face(h[j], hE, hu[j], huE, g, Fe_h, Fe_hu);\n", + " h_new[j] = h[j] - inv * (Fe_h - Fw_h);\n", + " hu_new[j] = hu[j] - inv * (Fe_hu - Fw_hu);\n", + " if (j == 1) { h_new[0] = h[1]; hu_new[0] = -hu[1]; }\n", + " if (j == n) { h_new[n + 1] = h[n]; hu_new[n + 1] = -hu[n]; }\n", + "'''\n", + "\n", + "step_cupy_fused = cp.ElementwiseKernel(\n", + " 'raw float64 h, raw float64 hu, int64 n, float64 inv, float64 g',\n", + " 'raw float64 h_new, raw float64 hu_new',\n", + " _FUSED_BODY, 'swe_step_fused', preamble=_RUSANOV_DEV)\n", + "\n", + "# The profiler runs this kernel in its own process, so write the two sources\n", + "# out rather than keeping a second copy of them.\n", + "open('swe_fused_kernel.py', 'w').write(\n", + " f\"PREAMBLE = r'''{_RUSANOV_DEV}'''\\nBODY = r'''{_FUSED_BODY}'''\\n\")\n", + "\n", + "# Device buffers are allocated once and reset to the initial condition per call.\n", + "def _setup_ic_fused(n):\n", + " h0, hu0 = setup_ic_dev(n)\n", + " return h0, hu0, *(cp.empty_like(h0) for _ in range(4))\n", + "\n", + "setup_ic_fused = swe_core.by_size(_setup_ic_fused)\n", + "\n", + "\n", + "def run_swe_cupy_fused(n_cells=N, n_steps=N_STEPS):\n", + " \"\"\"Full solve with hand-fused CuPy kernel: one launch per step.\"\"\"\n", + " cell_dx = L / n_cells\n", + " step_dt = swe_core.fixed_dt(H0 + AMP, cell_dx, cfl=CFL, g=G)\n", + " h0, hu0, h, hu, h2, hu2 = setup_ic_fused(n_cells)\n", + " cp.copyto(h, h0)\n", + " cp.copyto(hu, hu0)\n", + " inv = step_dt / cell_dx\n", + " for _ in range(n_steps):\n", + " step_cupy_fused(h, hu, n_cells, inv, G, h2, hu2, size=n_cells)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " cp.cuda.Device().synchronize()\n", + " return h, hu\n", + "\n", + "t0 = time.perf_counter()\n", + "h_fused, _ = run_swe_cupy_fused()\n", + "cold_fused_s = time.perf_counter() - t0\n", + "\n", + "warm_fused = swe_core.timed_run(run_swe_cupy_fused, warmup=2, repeats=5,\n", + " label='12_cupy_fused')\n", + "diff_fused = swe_core.max_diff(h_ref, cp.asnumpy(h_fused))\n", + "swe_core.report_and_verify(warm_fused, diff_fused, tol=1e-12, cold_s=cold_fused_s,\n", + " n=N, steps=N_STEPS,\n", + " cold_note='incl. NVRTC compile of fused kernel')\n", + "\n", + "swe_core.save_timing(\n", + " warm_fused, grid_str=f'N={N}', tool='cupy_fused', hardware='gpu',\n", + " dtype='float64', steps=N_STEPS, cold_s=cold_fused_s,\n", + " max_diff_vs_numpy=diff_fused)\n" + ] + }, + { + "cell_type": "markdown", + "id": "sec6-raw-md", + "metadata": {}, + "source": [ + "### 6. CUDA C++ kernel\n", + "\n", + "CppJIT is not tied to CUB: the same session can declare a `__global__`\n", + "kernel and launch it directly. `swe_raw_cuda_solver.cpp` rewrites our GPU solver in CUDA C++.\n", + "\n", + "The raw launch comes with trade-offs: you have to size the launch yourself, write the\n", + "index guard, and give up CUB's algorithm libraries, autotuning and abstractions.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `__global__ void kernel(...)`: a CUDA C++ kernel; each thread computes its index\n", + " from `blockIdx`/`blockDim`/`threadIdx` and guards the range.\n", + "- `kernel<<>>(args)`: kernel launch syntax. cppjit\n", + " compiles and registers the kernel live (same clang-repl session as CUB).\n", + "- Launch shape: 256-thread blocks, `grid = ceil(N / 256)`, try tuning if time permits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sec6-raw-code", + "metadata": {}, + "outputs": [], + "source": [ + "# Load the CUDA C++ kernel into our Python session.\n", + "cppjit.cppdef(open(swe_core.SWE_RAW_CPP).read())\n", + "\n", + "def run_swe_gpu_raw(n_cells=N, n_steps=N_STEPS):\n", + " \"\"\"Time-step with CUDA C++, two launches per step.\"\"\"\n", + " cell_dx = L / n_cells\n", + " step_dt = swe_core.fixed_dt(H0 + AMP, cell_dx, cfl=CFL, g=G)\n", + " h, hu = setup_ic(n_cells)\n", + " cppjit.gbl.gpu_swe_init_raw(h, hu, h.shape[0])\n", + " cppjit.gbl.gpu_swe_steps_raw(cell_dx, step_dt, G, n_steps)\n", + "\n", + "\n", + "def fetch_swe_gpu_raw(n_cells=N):\n", + " \"\"\"Final (h, hu) of the last raw solve.\"\"\"\n", + " h_out, hu_out = np.empty(n_cells + 2), np.empty(n_cells + 2)\n", + " cppjit.gbl.gpu_swe_fetch_raw(h_out, hu_out)\n", + " return h_out, hu_out\n", + "\n", + "t0 = time.perf_counter()\n", + "run_swe_gpu_raw()\n", + "cold_raw_s = time.perf_counter() - t0\n", + "h_raw, _ = fetch_swe_gpu_raw()\n", + "\n", + "warm_raw = swe_core.timed_run(run_swe_gpu_raw, warmup=2, repeats=5,\n", + " label='12_cppjit_gpu_raw')\n", + "diff_raw = swe_core.max_diff(h_ref, h_raw)\n", + "swe_core.report_and_verify(warm_raw, diff_raw, tol=1e-12, cold_s=cold_raw_s,\n", + " n=N, steps=N_STEPS,\n", + " cold_note='incl. JIT + first PTX->SASS compilation')\n", + "\n", + "swe_core.save_timing(\n", + " warm_raw, grid_str=f'N={N}', tool='cppjit_gpu_raw', hardware='gpu',\n", + " dtype='float64', steps=N_STEPS, cold_s=cold_raw_s,\n", + " max_diff_vs_numpy=diff_raw)\n" + ] + }, + { + "cell_type": "markdown", + "id": "nb05-sec4", + "metadata": {}, + "source": [ + "### 7. Scaling: fused vs drop-in vs CPU\n", + "\n", + "The sweep below times all five paths across grid sizes and plots throughput." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nb05-sweep", + "metadata": {}, + "outputs": [], + "source": [ + "# Sweep the grid size and compare all five paths on throughput.\n", + "rates = swe_core.sweep_table({\n", + " 'CppJIT raw': run_swe_gpu_raw, 'CppJIT CUB': run_swe_gpu,\n", + " 'CuPy fused': run_swe_cupy_fused, 'CuPy drop-in': run_swe_cupy,\n", + " 'NumPy (CPU)': swe_core.solve_numpy})\n", + "\n", + "swe_core.plot_rate_sweep([n for n, _ in swe_core.sweep_points()], rates,\n", + " 'N (cells)', 'Throughput vs grid size (float64)',\n", + " logy=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sweep-for-synthesis", + "metadata": {}, + "outputs": [], + "source": [ + "# Rates across the shared size range, for the synthesis notebook (14).\n", + "for _stage, _fn in (('12_cppjit_gpu_cub', run_swe_gpu),\n", + " ('12_cppjit_gpu_raw', run_swe_gpu_raw),\n", + " ('12_cupy', run_swe_cupy),\n", + " ('12_cupy_fused', run_swe_cupy_fused)):\n", + " swe_core.save_sweep(_stage, _fn)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28420482", + "metadata": {}, + "outputs": [], + "source": [ + "# The fused kernel is defined above, so it is profiled here rather than with\n", + "# the others, then every path's wall clock is split the same way.\n", + "for extra in ('fused', 'jax'):\n", + " out = !nsys profile -c cudaProfilerApi --capture-range-end=stop --cuda-graph-trace=node --force-overwrite true -o launch_{extra} python swe_launch_probe.py {extra} {N} {PROF_STEPS}\n", + " walls[extra] = float(next(l for l in out if 'WALL_S' in l).split()[1])\n", + "\n", + "for mode in (*MODES, 'fused', 'jax'):\n", + " bd = swe_core.nsys_breakdown(f'launch_{mode}.nsys-rep', walls[mode])\n", + " swe_core.save_breakdown(STAGE[mode], bd, N, PROF_STEPS)\n", + " print(f\" {mode:>6}: kernels {100 * bd['kernel_s'] / bd['total_s']:4.1f}% \"\n", + " f\"copies {100 * bd['memcpy_s'] / bd['total_s']:4.1f}% \"\n", + " f\"host {100 * bd['host_s'] / bd['total_s']:4.1f}%\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "sec7-takeaways", + "metadata": {}, + "source": [ + "Takeaways:\n", + "\n", + "- CuPy drop-in beats NumPy at every size and still trails the fused kernel.\n", + " Each of its 43 kernels per step re-reads the arrays from memory; the\n", + " fused kernel reads them once.\n", + "- The raw CUDA dispatch and the CUB path run within a few percent of each\n", + " other. Both trail the fused CuPy kernel, which stores no fluxes." + ] + }, + { + "cell_type": "markdown", + "id": "nb05-sec5", + "metadata": {}, + "source": [ + "### 8. Limitations and recap\n", + "\n", + "CppJIT gives us C++ and CUDA as a notebook language: edit a cell, run it,\n", + "call it from Python. The main cost is compilation at first use: `cppdef`\n", + "parses the source when it is declared, and the first call includes JIT\n", + "compilation of both host and device code.\n", + "\n", + "**Recap.**\n", + "\n", + "- `cppjit.cppdef` compiles C++ and CUDA in the running session; `cppjit.gbl`\n", + " allows function dispatch with NumPy arguments as pointers.\n", + "- The same Rusanov step runs on the GPU with CUB, the data kept resident\n", + " across the time loop.\n", + "- The drop-in CuPy path beats NumPy at every size and still trails the fused\n", + " paths. The win over drop-in is memory traffic: dozens of kernels per step\n", + " each re-read the arrays, against one or two that read them once.\n", + "- The fused CuPy kernel is a source fragment compiled inside CuPy's generated\n", + " wrapper; CppJIT compiles standard C++ translation units.\n", + "\n", + "Next: `13__swe__mpi4py.ipynb` distributes the same solve across MPI ranks:\n", + "slab decomposition and halo exchange." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/13__swe__mpi4py.ipynb b/tutorials/pyhpc/notebooks/13__swe__mpi4py.ipynb new file mode 100644 index 00000000..f27f6ddf --- /dev/null +++ b/tutorials/pyhpc/notebooks/13__swe__mpi4py.ipynb @@ -0,0 +1,746 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "43e247f6", + "metadata": {}, + "source": [ + "## SWE - mpi4py\n", + "\n", + "MPI is a distributed computing model: `mpirun` starts many copies of your\n", + "program as independent processes that share no memory and communicate by\n", + "explicit messages, instead of the single accelerated process we saw with\n", + "JAX, PyOMP, nanobind, and CppJIT. mpi4py brings that model to Python.\n", + "\n", + "Here that means splitting the channel into contiguous **slabs**, one per\n", + "process, each holding its slab plus one **ghost cell** per side; neighbours\n", + "swap edge cells before every step (the **halo exchange**). The kernel is\n", + "untouched: each rank advances its slab with `swe_core.step_numpy`. This\n", + "notebook is about the decomposition and what the communication costs.\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Imports and the launch pattern](#sec1)\n", + "2. [The decomposition probe](#sec2)\n", + "3. [The halo exchange](#sec3)\n", + "4. [Acceptance and timing](#sec4)\n", + "5. [Rank scaling](#sec5)\n", + "6. [Overlapping communication and computation](#sec6)\n", + "\n", + "### 1. Imports and the launch pattern\n", + "\n", + "An MPI program is `size` identical copies of one script started together;\n", + "each copy asks for its **rank** to find its role. The notebook kernel is\n", + "not one of those copies, so every program below is written out with\n", + "`%%writefile` and launched with `run_mpi`.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `MPI.COMM_WORLD`: the communicator spanning every rank of the launch;\n", + " `Get_rank()` / `Get_size()` return this copy's id and the total count.\n", + "- `run_mpi(ranks, script, *args)`: launch under the detected MPI\n", + " implementation. `MPI.get_vendor()` selects the matching launcher, so the\n", + " same cell works against Open MPI and MPICH.\n", + "- `mpi_result(...)`: the same launch, returning the `RESULT` record rank 0\n", + " prints. Timing inside the script keeps launcher and `MPI_Init` startup\n", + " out of the numbers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c59b191", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "import shutil\n", + "import subprocess\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import mpi4py\n", + "import psutil\n", + "\n", + "import swe_core\n", + "\n", + "# Install Open MPI and mpi4py if running in Google Colab.\n", + "colab_marker = Path(\"/tmp/accelerated-computing-hub-mpi4py-installed\")\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not colab_marker.exists():\n", + " print(\"Installing Open MPI and mpi4py.\")\n", + " subprocess.run([\"apt-get\", \"-qq\", \"update\"], check=True)\n", + " subprocess.run(\n", + " [\"apt-get\", \"-qq\", \"install\", \"-y\", \"openmpi-bin\", \"libopenmpi-dev\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " subprocess.run(\n", + " [sys.executable, \"-m\", \"pip\", \"install\", \"mpi4py\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " colab_marker.touch()\n", + " print(\"Open MPI and mpi4py installed.\")\n", + "\n", + "# Open MPI protects against accidental root launches. Colab and the tutorial\n", + "# container are isolated environments where explicitly allowing this is safe.\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT\", \"1\")\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT_CONFIRM\", \"1\")\n", + "\n", + "# Match the launcher to the MPI library against which mpi4py was built.\n", + "vendor_result = subprocess.run(\n", + " [\n", + " sys.executable,\n", + " \"-c\",\n", + " \"from mpi4py import MPI; print(MPI.get_vendor()[0])\",\n", + " ],\n", + " check=True,\n", + " capture_output=True,\n", + " text=True,\n", + ")\n", + "MPI_VENDOR = vendor_result.stdout.strip()\n", + "\n", + "if MPI_VENDOR == \"MPICH\":\n", + " mpi_launcher = (\n", + " shutil.which(\"mpirun.mpich\")\n", + " or shutil.which(\"mpiexec.mpich\")\n", + " or shutil.which(\"mpiexec\")\n", + " )\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No MPICH launcher was found\")\n", + " # Do not delegate this nested launch back to Slurm on CSCS.\n", + " MPI_LAUNCHER = [mpi_launcher, \"-launcher\", \"fork\"]\n", + "elif MPI_VENDOR == \"Open MPI\":\n", + " mpi_launcher = shutil.which(\"mpirun.openmpi\") or shutil.which(\"mpirun\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No Open MPI launcher was found\")\n", + " MPI_LAUNCHER = [mpi_launcher, \"--oversubscribe\"]\n", + "else:\n", + " mpi_launcher = shutil.which(\"mpiexec\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(f\"No launcher was found for {MPI_VENDOR}\")\n", + " MPI_LAUNCHER = [mpi_launcher]\n", + "\n", + "\n", + "def run_program(command):\n", + " '''Run a child program, display its output, and fail on errors.'''\n", + " result = subprocess.run(\n", + " command,\n", + " capture_output=True,\n", + " text=True,\n", + " timeout=180,\n", + " )\n", + " print(result.stdout, end=\"\")\n", + " if result.stderr:\n", + " print(result.stderr, end=\"\", file=sys.stderr)\n", + " result.check_returncode()\n", + "\n", + "\n", + "def run_mpi(rank_count, script):\n", + " '''Run a Python script under the selected MPI implementation.'''\n", + " command = [\n", + " *MPI_LAUNCHER,\n", + " \"-n\",\n", + " str(rank_count),\n", + " sys.executable,\n", + " \"-u\",\n", + " script,\n", + " ]\n", + " run_program(command)\n", + "\n", + "\n", + "def run_python(script):\n", + " '''Run a serial Python reference with the notebook's interpreter.'''\n", + " run_program([sys.executable, \"-u\", script])\n", + "\n", + "\n", + "def mpi_result(rank_count, script, *args):\n", + " '''Launch a script and return the RESULT record rank 0 prints.'''\n", + " result = subprocess.run(\n", + " [*MPI_LAUNCHER, \"-n\", str(rank_count),\n", + " sys.executable, \"-u\", script, *map(str, args)],\n", + " capture_output=True,\n", + " text=True,\n", + " timeout=900,\n", + " )\n", + " if result.stderr:\n", + " print(result.stderr, end=\"\", file=sys.stderr)\n", + " result.check_returncode()\n", + " line = next((l for l in result.stdout.splitlines()\n", + " if l.startswith(\"RESULT \")), None)\n", + " assert line, result.stdout\n", + " return json.loads(line[len(\"RESULT \"):])\n", + "\n", + "\n", + "# Use a teaching-scale rank count; large shared nodes may expose hundreds of cores.\n", + "N_RANKS = min(32, psutil.cpu_count(logical=False) or os.cpu_count())\n", + "\n", + "# Shared problem identity, for reporting. The solver script restates the\n", + "# full configuration: each MPI process is its own interpreter.\n", + "N, N_STEPS = swe_core.canonical_size()" + ] + }, + { + "cell_type": "markdown", + "id": "e05b77ed", + "metadata": {}, + "source": [ + "### 2. The decomposition probe\n", + "\n", + "`N` interior cells split into `size` contiguous slabs, remainder cells\n", + "going to the lowest ranks. Each rank stores `local_N + 2` cells: its slab\n", + "plus one ghost per side. Five ranks does not divide `N = 16384` evenly, so\n", + "the probe shows the uneven split.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `divmod(N, size)`: base slab length and remainder; the first\n", + " `remainder` ranks take one extra cell." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3ca356a", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile swe_mpi_decomp.py\n", + "\"\"\"Slab decomposition probe: every rank reports the cells it owns.\"\"\"\n", + "from mpi4py import MPI\n", + "\n", + "N = 16384 # a fixed, readable grid: this probe illustrates the split\n", + "\n", + "comm = MPI.COMM_WORLD\n", + "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "\n", + "# TODO: split N interior cells into `size` contiguous slabs.\n", + "# - local_N: cells this rank owns; ranks below N % size take one extra\n", + "# - offset : global index of this rank's first interior cell\n", + "# Hint: with base = N // size and rem = N % size, the first `rem` ranks\n", + "# are one cell longer, which shifts each offset by min(rank, rem).\n", + "local_N = ... # TODO\n", + "offset = ... # TODO\n", + "\n", + "rows = comm.gather((rank, offset, local_N), root=0)\n", + "if rank == 0:\n", + " for r, o, n in rows:\n", + " print(f\"rank {r}/{size}: interior cells [{o:>6}, {o + n:>6}) local_N = {n:,}\")\n", + " assert sum(n for _, _, n in rows) == N, \"cells lost or duplicated\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a0e0e594", + "metadata": {}, + "outputs": [], + "source": [ + "run_mpi(5, \"swe_mpi_decomp.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "8d884386", + "metadata": {}, + "source": [ + "### 3. The halo exchange\n", + "\n", + "Each update reads a cell's two neighbours. Interior cells of a slab find\n", + "both locally; the first and last cell each need one cell owned by the\n", + "neighbouring rank, delivered into the ghosts before every step:\n", + "\n", + "- **Exchange**: `Sendrecv` copies each neighbour's edge cell into the\n", + " ghost, in both directions.\n", + "- **Walls**: rank 0 mirrors its left ghost, the last rank its right\n", + " ghost.\n", + "- **Step**: `swe_core.step_numpy` on the local array, unchanged.\n", + "\n", + "Every interior cell then sees the same neighbour values as the serial\n", + "solve, so the gate expects `max_diff` exactly zero.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `comm.Sendrecv(sendbuf, dest=.., sendtag=.., recvbuf=.., source=..,\n", + " recvtag=..)`: one paired send and receive. The runtime schedules the\n", + " pair, so neighbour chains cannot deadlock.\n", + "- `MPI.PROC_NULL`: the no-op peer. A transfer with it completes\n", + " immediately; the wall ranks use it as their missing neighbour.\n", + "- Uppercase methods (`Sendrecv`, `Isend`, `Gatherv`) move NumPy buffers\n", + " zero-copy; any contiguous slice like `h[-2:-1]` is a valid message\n", + " buffer.\n", + "- `comm.Gatherv(sendbuf, (recvbuf, counts), root=0)`: concatenate\n", + " unequal-length slabs on one rank. The acceptance gate uses it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "77c1ea48", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile swe_mpi_solver.py\n", + "\"\"\"Distributed 1D SWE solver: slab decomposition + halo exchange.\n", + "\n", + "Rank 0 prints one result: max-over-ranks wall times and the\n", + "max_diff.\n", + "\"\"\"\n", + "import os\n", + "os.environ.setdefault(\"OMP_NUM_THREADS\", \"1\") # pure MPI: one thread per rank\n", + "\n", + "import json, sys\n", + "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", + "import swe_core\n", + "\n", + "# Shared problem parameters; sweep runs pass N and N_STEPS on the CLI.\n", + "_args = [a for a in sys.argv[1:] if not a.startswith('-')]\n", + "N = int(_args[0]) if _args else 16384\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "N_STEPS = int(_args[1]) if len(_args) > 1 else 1000\n", + "dx = L / N\n", + "DT = swe_core.fixed_dt(H0 + AMP, dx, cfl=CFL, g=G)\n", + "\n", + "comm = MPI.COMM_WORLD\n", + "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "left = rank - 1 if rank > 0 else MPI.PROC_NULL\n", + "right = rank + 1 if rank < size - 1 else MPI.PROC_NULL\n", + "\n", + "# Slab partition (Sec. 2).\n", + "local_N = N // size + (1 if rank < N % size else 0)\n", + "offset = rank * (N // size) + min(rank, N % size)\n", + "\n", + "\n", + "def halo_exchange(h, hu):\n", + " \"\"\"Fill the ghost cells from the neighbours' edge interior cells.\n", + "\n", + " TODO: two Sendrecv shifts, each for h and hu.\n", + " - Shift east: my last interior cell -> right neighbour's left ghost.\n", + " - Shift west: my first interior cell -> left neighbour's right ghost.\n", + " Hint: edges are h[1:2] / h[-2:-1], ghosts are h[:1] / h[-1:];\n", + " comm.Sendrecv(sendbuf, dest=.., sendtag=t, recvbuf=.., source=.., recvtag=t).\n", + " The PROC_NULL ends need no special-casing.\n", + " \"\"\"\n", + " ... # TODO\n", + "\n", + "\n", + "def exchange_and_bc(h, hu):\n", + " \"\"\"Ghost cells from the neighbours. Physical walls on the end ranks.\"\"\"\n", + " halo_exchange(h, hu)\n", + " if rank == 0:\n", + " h[0] = h[1]; hu[0] = -hu[1]\n", + " if rank == size - 1:\n", + " h[-1] = h[-2]; hu[-1] = -hu[-2]\n", + "\n", + "\n", + "# This rank's slab, cut once from the global IC at import.\n", + "_h_g, _hu_g = swe_core.bump_ic(N, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + "IC = (_h_g [offset : offset + local_N + 2].copy(),\n", + " _hu_g[offset : offset + local_N + 2].copy())\n", + "del _h_g, _hu_g\n", + "\n", + "\n", + "def solve_local():\n", + " \"\"\"N_STEPS of exchange / BC / step on the local slab.\"\"\"\n", + " h, hu = IC[0].copy(), IC[1].copy()\n", + " for _ in range(N_STEPS):\n", + " exchange_and_bc(h, hu)\n", + " h, hu = swe_core.step_numpy(h, hu, dx, DT, g=G)\n", + " return h, hu\n", + "\n", + "\n", + "def timed(fn):\n", + " \"\"\"Max-over-ranks wall time of one fn() call: the slowest rank sets it.\"\"\"\n", + " comm.Barrier()\n", + " t0 = MPI.Wtime()\n", + " out = fn()\n", + " return comm.allreduce(MPI.Wtime() - t0, op=MPI.MAX), out\n", + "\n", + "\n", + "def benchmark(solve):\n", + " \"\"\"Cold pass + one warm-up, then 5 timed repeats; gate and report.\"\"\"\n", + " cold_s, _ = timed(solve) # first pass: first-touch + numpy warm-up\n", + " timed(solve)\n", + " ts, h = [], None\n", + " for _ in range(5):\n", + " dt_s, (h, _hu) = timed(solve)\n", + " ts.append(dt_s)\n", + "\n", + " # Acceptance: gather the interior slabs to rank 0, compare to the reference.\n", + " counts = [N // size + (1 if r < N % size else 0) for r in range(size)]\n", + " h_all = np.empty(N) if rank == 0 else None\n", + " comm.Gatherv(h[1:-1], (h_all, counts) if rank == 0 else None, root=0)\n", + " if rank == 0:\n", + " h_ref, _ = swe_core.solve_numpy(N, N_STEPS)\n", + " print(\"RESULT \" + json.dumps({\n", + " \"ranks\": size, \"cold_s\": cold_s,\n", + " \"median_s\": float(np.median(ts)), \"min_s\": float(np.min(ts)),\n", + " \"max_s\": float(np.max(ts)), \"repeats\": len(ts),\n", + " \"max_diff\": swe_core.max_diff(h_ref[1:-1], h_all)}))\n", + "\n", + "\n", + "def halo_cost(reps=20_000, step_reps=500):\n", + " \"\"\"Per-step cost of the exchange alone vs the local compute alone.\"\"\"\n", + " h_g, hu_g = swe_core.bump_ic(N, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + " h = h_g [offset : offset + local_N + 2].copy()\n", + " hu = hu_g[offset : offset + local_N + 2].copy()\n", + "\n", + " def exchanges():\n", + " for _ in range(reps):\n", + " exchange_and_bc(h, hu)\n", + "\n", + " def steps():\n", + " for _ in range(step_reps):\n", + " swe_core.step_numpy(h, hu, dx, DT, g=G)\n", + "\n", + " t_x = min(timed(exchanges)[0] for _ in range(3)) / reps # min of 3: the floor\n", + " t_c = min(timed(steps)[0] for _ in range(3)) / step_reps\n", + " if rank == 0:\n", + " print(f\"halo exchange: {t_x * 1e6:6.2f} us/step (4 messages, 8 B each)\")\n", + " print(f\"local step : {t_c * 1e6:6.2f} us/step (local_N = {local_N:,})\")\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " halo_cost() if \"--halo-cost\" in sys.argv else benchmark(solve_local)" + ] + }, + { + "cell_type": "markdown", + "id": "7895b056", + "metadata": {}, + "source": [ + "### 4. Acceptance and timing\n", + "\n", + "Timing lives inside the script: `Barrier`, then `MPI.Wtime` around a full\n", + "solve, then a max over ranks (the slowest rank sets the wall clock).\n", + "Rank 0 gathers the slabs with `Gatherv`, computes `max_diff` against the\n", + "float64 reference, and prints one `RESULT` line for the notebook to\n", + "record." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ba6b32f", + "metadata": {}, + "outputs": [], + "source": [ + "res = mpi_result(N_RANKS, \"swe_mpi_solver.py\", N, N_STEPS)\n", + "\n", + "warm = {'label': '13_mpi4py', 'median_s': res['median_s'],\n", + " 'min_s': res['min_s'], 'max_s': res['max_s'], 'repeats': res['repeats']}\n", + "swe_core.report_and_verify(warm, res['max_diff'], tol=1e-12,\n", + " cold_s=res['cold_s'], n=N, steps=N_STEPS,\n", + " cold_note='first solve pass; excl. mpirun + MPI_Init')\n", + "\n", + "swe_core.save_timing(\n", + " warm, grid_str=f'N={N}', tool='mpi4py', hardware='cpu',\n", + " dtype='float64', steps=N_STEPS,\n", + " cold_s=res['cold_s'], max_diff_vs_numpy=res['max_diff'],\n", + " ranks=res['ranks'], mpi4py_version=mpi4py.__version__,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "72b883c0", + "metadata": {}, + "source": [ + "### 5. Rank scaling\n", + "\n", + "The same script scales with `mpirun -n`. The sweep\n", + "runs powers of two up to the physical-core count.\n", + "\n", + "**Predict before run:** each rank steps `N / ranks` cells but pays the\n", + "same four messages per step however small the slab gets. Sketch\n", + "throughput against rank count." + ] + }, + { + "cell_type": "markdown", + "id": "0a309bec", + "metadata": {}, + "source": [ + "The halo payload is 8 bytes per message, so the exchange cost is\n", + "latency. Measure it against the local step alone, at 2 ranks:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fe0fb509", + "metadata": {}, + "outputs": [], + "source": [ + "run_program([*MPI_LAUNCHER, \"-n\", \"2\", sys.executable, \"-u\",\n", + " \"swe_mpi_solver.py\", str(N), str(N_STEPS), \"--halo-cost\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d243acdc", + "metadata": {}, + "outputs": [], + "source": [ + "RANK_COUNTS = [1]\n", + "while RANK_COUNTS[-1] * 2 <= N_RANKS:\n", + " RANK_COUNTS.append(RANK_COUNTS[-1] * 2)\n", + "\n", + "sweep = []\n", + "for n_ranks in RANK_COUNTS:\n", + " r = mpi_result(n_ranks, \"swe_mpi_solver.py\", N, N_STEPS)\n", + " assert r['max_diff'] < 1e-12, f'gate failed at {n_ranks} ranks'\n", + " rate = N * N_STEPS / r['median_s'] / 1e6\n", + " sweep.append((n_ranks, r['median_s'], rate))\n", + " print(f' ranks = {n_ranks}: {r[\"median_s\"]*1000:6.1f} ms '\n", + " f'{rate:7.1f} Mcells/s max_diff {r[\"max_diff\"]:.1e}')\n", + "\n", + "ranks = [s[0] for s in sweep]\n", + "rates = [s[2] for s in sweep]\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 3.4))\n", + "ax.plot(ranks, rates, 'o-', linewidth=2, markersize=10, label='measured')\n", + "ax.plot(ranks, [rates[0] * n for n in ranks], '--', color='#aaa', label='ideal (linear)')\n", + "ax.set_xscale('log', base=2)\n", + "ax.set_xticks(ranks); ax.set_xticklabels([str(n) for n in ranks])\n", + "ax.set_xlabel('MPI ranks'); ax.set_ylabel('throughput [Mcells/s]')\n", + "ax.set_title('Rank scaling')\n", + "ax.legend(); ax.grid(alpha=0.3); plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "333244ae", + "metadata": {}, + "source": [ + "**Takeaways:**\n", + "\n", + "- Speedup can exceed the rank count, because adding ranks shrinks each slab\n", + " until it fits in cache.\n", + "- The exchange is cheap next to the step itself. The floor is the NumPy\n", + " kernel's fixed per-step cost, paid by every rank on an ever-smaller slab.\n", + "- Halving the slab halves the compute but not the fixed cost: the\n", + " surface-to-volume effect." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "swpmpi01", + "metadata": {}, + "outputs": [], + "source": [ + "# Rates across the shared size range, for the synthesis notebook (14).\n", + "from pathlib import Path\n", + "\n", + "sweep = []\n", + "for n_, steps_ in swe_core.SWEEP_SIZES:\n", + " r = mpi_result(N_RANKS, \"swe_mpi_solver.py\", n_, steps_)\n", + " assert r['max_diff'] < 1e-12, (n_, r)\n", + " sweep.append({'n': n_, 'steps': steps_, 'median_s': r['median_s']})\n", + " print(f\"N={n_:>10,} steps={steps_:>5}: {n_ * steps_ / r['median_s'] / 1e6:7.0f} Mcells/s\")\n", + "\n", + "records = swe_core.load_timings()\n", + "row = next(rr for rr in records if rr.get('stage') == '13_mpi4py')\n", + "row['sweep'] = sweep\n", + "Path(swe_core.TIMINGS_PATH).write_text(json.dumps(records, indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "8f0fb225", + "metadata": {}, + "source": [ + "### 6. Overlapping communication and computation\n", + "\n", + "The blocking loop serialises exchange and compute, yet only the first\n", + "and last interior cell of a slab read the ghosts. A non-blocking\n", + "pattern like:\n", + "\n", + "- post `Irecv`/`Isend`\n", + "- update the interior\n", + "- `Waitall`\n", + "- update the two edge cells\n", + "\n", + "steps every other cell while the messages are being passed.\n", + "\n", + "Sec. 5 measured the exchange cost. Predict: what fraction of a step can hiding\n", + "it save? The two edge cells are advanced from their own faces. A second\n", + "`step_numpy` call would cost more than the exchange it hides, because an\n", + "array-wide call is priced per call, not per cell.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `comm.Irecv(buf, source=..)` / `comm.Isend(buf, dest=..)`: post\n", + " nonblocking transfers, each returning a `Request`.\n", + "- `MPI.Request.Waitall(reqs)`: block until every request completes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b66cf0a9", + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile swe_mpi_overlap.py\n", + "# EXTRA CREDIT: hide the halo exchange behind the interior computation.\n", + "\"\"\"Non-blocking halo exchange overlapped with the interior update.\n", + "\n", + "Only the first and last interior cell of a slab read the ghosts. Post the\n", + "exchange, step every other cell while the messages fly, wait, then update\n", + "those two. Reuses the decomposition and harness of swe_mpi_solver.\n", + "\"\"\"\n", + "import math\n", + "\n", + "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", + "import swe_core\n", + "import swe_mpi_solver as s\n", + "\n", + "\n", + "def rusanov_face(hL, hR, huL, huR):\n", + " \"\"\"Rusanov flux (Fh, Fhu) at one interface, in scalars.\"\"\"\n", + " hsL, hsR = max(hL, swe_core.DRY_TOL), max(hR, swe_core.DRY_TOL)\n", + " uL, uR = huL / hsL, huR / hsR\n", + " cL, cR = math.sqrt(s.G * hsL), math.sqrt(s.G * hsR)\n", + " a = max(abs(uL) + cL, abs(uR) + cR)\n", + " return (0.5 * (huL + huR) - 0.5 * a * (hR - hL),\n", + " 0.5 * (huL*uL + 0.5*s.G*hL*hL + huR*uR + 0.5*s.G*hR*hR)\n", + " - 0.5 * a * (huR - huL))\n", + "\n", + "\n", + "def edge_cell(h, hu, i):\n", + " \"\"\"Advance cell i from its two faces, without an array-wide kernel call.\n", + "\n", + " Reads through float() because arithmetic on numpy scalars is several\n", + " times slower than on Python floats, and the result is identical.\"\"\"\n", + " hm, h0, hp = float(h[i-1]), float(h[i]), float(h[i+1])\n", + " qm, q0, qp = float(hu[i-1]), float(hu[i]), float(hu[i+1])\n", + " Fh_w, Fhu_w = rusanov_face(hm, h0, qm, q0)\n", + " Fh_e, Fhu_e = rusanov_face(h0, hp, q0, qp)\n", + " r = s.DT / s.dx\n", + " return h0 - r * (Fh_e - Fh_w), q0 - r * (Fhu_e - Fhu_w)\n", + "\n", + "\n", + "def step_overlap(h, hu):\n", + " \"\"\"One step: Isend/Irecv, interior update, Waitall, edge update.\"\"\"\n", + " # TODO: post the halo as four Irecv + four Isend (tags 0-3, matched\n", + " # pairs), same slices as the blocking halo_exchange, into `reqs`.\n", + " # Hint: comm.Irecv(buf, source=.., tag=..) and\n", + " # comm.Isend(buf, dest=.., tag=..) each return a request.\n", + " reqs = ... # TODO\n", + " # Interior cells 2 .. local_N-1 read no ghosts: step them during the exchange.\n", + " h_mid, hu_mid = swe_core.step_numpy(h[1:-1], hu[1:-1], s.dx, s.DT, g=s.G)\n", + " MPI.Request.Waitall(reqs)\n", + " # Ghosts are now valid: physical walls first, then the two edge cells.\n", + " if s.rank == 0:\n", + " h[0] = h[1]; hu[0] = -hu[1]\n", + " if s.rank == s.size - 1:\n", + " h[-1] = h[-2]; hu[-1] = -hu[-2]\n", + "\n", + " h_new, hu_new = np.empty_like(h), np.empty_like(hu)\n", + " h_new[0], hu_new[0] = h[0], hu[0]\n", + " h_new[-1], hu_new[-1] = h[-1], hu[-1]\n", + " h_new[2:-2], hu_new[2:-2] = h_mid[1:-1], hu_mid[1:-1]\n", + " h_new[1], hu_new[1] = edge_cell(h, hu, 1)\n", + " h_new[-2], hu_new[-2] = edge_cell(h, hu, -2)\n", + " return h_new, hu_new\n", + "\n", + "\n", + "def solve_local_overlap():\n", + " h, hu = s.IC[0].copy(), s.IC[1].copy()\n", + " for _ in range(s.N_STEPS):\n", + " h, hu = step_overlap(h, hu)\n", + " return h, hu\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " s.benchmark(solve_local_overlap)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6a0283a5", + "metadata": {}, + "outputs": [], + "source": [ + "r_ov = mpi_result(N_RANKS, \"swe_mpi_overlap.py\", N, N_STEPS)\n", + "assert r_ov['max_diff'] < 1e-12, r_ov\n", + "\n", + "print(f'blocking (Sec. 4): {res[\"median_s\"]*1e3:6.1f} ms')\n", + "print(f'overlapped (Sec. 6): {r_ov[\"median_s\"]*1e3:6.1f} ms max_diff {r_ov[\"max_diff\"]:.1e}')" + ] + }, + { + "cell_type": "markdown", + "id": "f251c0fa", + "metadata": {}, + "source": [ + "**Recap.**\n", + "\n", + "- We decompose our working set across nodes. Halo exchange: a standard pattern\n", + " of distributed stencil codes.\n", + "- One node scales until the per-step fixed cost dominates the shrinking slab.\n", + "- Overlap (Sec. 6) can win at most the exchange time, so the split has to cost\n", + " less than that. Here the extra non-blocking calls alone exceed it, and the\n", + " overlapped loop stays behind the blocking one. The prize grows with rank\n", + " count, since the slab shrinks while the exchange stays latency-bound, and on\n", + " a real network.\n", + "\n", + "Next: `14__swe__synthesis.ipynb` collects every row from `timings.json` and\n", + "compares the tools on compile cost and float64 throughput across the memory\n", + "hierarchy." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/14__swe__synthesis.ipynb b/tutorials/pyhpc/notebooks/14__swe__synthesis.ipynb new file mode 100644 index 00000000..9c5f2638 --- /dev/null +++ b/tutorials/pyhpc/notebooks/14__swe__synthesis.ipynb @@ -0,0 +1,254 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ffe28a76", + "metadata": {}, + "source": [ + "## SWE - Synthesis\n", + "\n", + "We solved the same 1D bump-pulse step with multiple tools: NumPy, JAX, PyOMP,\n", + "nanobind, CuPy, CppJIT, and mpi4py. This notebook reads `timings.json` and compares them on first-call\n", + "compile cost and on a matched-float64 throughput sweep, from cache-resident to DRAM-resident sizes. It\n", + "closes with a summary table of programming models and limitations.\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Read the timings](#sec1)\n", + "2. [First-call compile cost](#sec2)\n", + "3. [Matched-precision comparison across the memory hierarchy](#sec3)\n", + "4. [Programming models and limitations](#sec4)\n", + "\n", + "### 1. Read the timings\n", + "\n", + "Let's load every row from `timings.json` (written by notebooks 08-13). Each row\n", + "carries the warm wall-clock (`median_s`), the first-call cost where the\n", + "tool JIT-compiles (`cold_s`), the hardware and dtype it ran on, and the\n", + "max-diff against the float64 NumPy reference." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18601eeb", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import swe_core\n", + "\n", + "rows = swe_core.load_timings()\n", + "by_tool = {r[\"tool\"]: r for r in rows}\n", + "\n", + "# timings.json is written by notebooks 08 to 13\n", + "expected = [\"numpy\", \"cupy\", \"cupy_fused\", \"jax\", \"jax_fp64\", \"pyomp\",\n", + " \"nanobind\", \"cppjit_gpu_cub\", \"cppjit_gpu_raw\", \"mpi4py\"]\n", + "missing = [t for t in expected if t not in by_tool]\n", + "if missing:\n", + " raise SystemExit(\n", + " \"timings.json has no rows for: \" + \", \".join(missing) + \". \"\n", + " \"Run notebooks 08 through 13 first, then re-run this notebook.\"\n", + " )\n", + "\n", + "hdr = (f'{\"stage\":<24}{\"tool\":>18}{\"hardware\":>9}{\"dtype\":>9}'\n", + " f'{\"median_s\":>11}{\"cold_s\":>9}{\"max_diff\":>12}')\n", + "print(hdr)\n", + "print(\"-\" * len(hdr))\n", + "for r in rows:\n", + " cold = r.get(\"cold_s\", float(\"nan\"))\n", + " md = r.get(\"max_diff_vs_numpy\")\n", + " md_s = f\"{md:.1e}\" if md is not None else (\"ref\" if r[\"tool\"] == \"numpy\" else \"n/a\")\n", + " print(f'{r[\"stage\"]:<24}{r[\"tool\"]:>18}{r[\"hardware\"]:>9}{r[\"dtype\"]:>9}'\n", + " f'{r[\"median_s\"]:>11.4f}{cold:>9.3f}{md_s:>12}')" + ] + }, + { + "cell_type": "markdown", + "id": "114ab6fd", + "metadata": {}, + "source": [ + "### 2. First-call compile cost\n", + "\n", + "Cold time is the first call, where CuPy, JAX, PyOMP, and CppJIT pay a compile\n", + "cost before they execute. Warm is the steady-state median over repeated runs\n", + "at the canonical size from notebook 08. The JAX bars are its native float32 run;\n", + "the rest are float64. nanobind compiles at build time, not at first\n", + "call, so it has no cold bar." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3a21b26e", + "metadata": {}, + "outputs": [], + "source": [ + "# Cold vs warm for the tools that JIT-compile on the first call.\n", + "jit = [\"cupy\", \"jax\", \"pyomp\", \"cppjit_gpu_cub\"]\n", + "jit_labels = {\"cupy\": \"CuPy\", \"jax\": \"JAX\", \"pyomp\": \"PyOMP\",\n", + " \"cppjit_gpu_cub\": \"CppJIT\"}\n", + "swe_core.plot_cold_warm([jit_labels[t] for t in jit],\n", + " [by_tool[t][\"cold_s\"] for t in jit],\n", + " [by_tool[t][\"median_s\"] for t in jit],\n", + " \"First-call compile cost (N = 16384, 1000 steps)\")" + ] + }, + { + "cell_type": "markdown", + "id": "nb06-cmp-md", + "metadata": {}, + "source": [ + "### 3. Matched-precision comparison across the memory hierarchy\n", + "\n", + "This is the primary benchmark: every tool runs float64 over the sizes\n", + "notebook 08 fixed for this machine. The working set is six fp64 arrays,\n", + "48 B per cell, so the sweep runs from cache into DRAM on both devices.\n", + "Throughput is the solve loop for one call from Python; step counts shrink\n", + "with `N`.\n", + "\n", + "The fused GPU kernels are bandwidth-bound at these sizes, so a float32\n", + "solve gains exactly the bytes it saves.\n", + "\n", + "The plot's cache boundaries use this 48 B model. NumPy's temporaries double\n", + "its bytes per cell, so its curve leaves cache earlier. PyOMP's fused kernel\n", + "streams only the four state arrays, so its curve leaves later. The GPU curve\n", + "runs flat through its L2 boundary: a working set that fits in L2 measures no\n", + "faster than one that does not." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nb06-cmp-code", + "metadata": {}, + "outputs": [], + "source": [ + "# Matched-precision (float64) comparison, read from timings.json.\n", + "import cupy as cp\n", + "\n", + "NAMES = {'08_numpy': 'NumPy', '12_cupy': 'CuPy drop-in',\n", + " '12_cupy_fused': 'CuPy fused', '09_jax_fp64': 'JAX',\n", + " '12_cppjit_gpu_cub': 'CppJIT CUB', '12_cppjit_gpu_raw': 'CppJIT raw',\n", + " '10_pyomp': 'PyOMP', '13_mpi4py': 'mpi4py',\n", + " '11_nanobind': 'nanobind'}\n", + "by_stage = {r['stage']: r for r in rows if 'sweep' in r}\n", + "swe_core.require_stages(by_stage, NAMES)\n", + "\n", + "# Each tool carries its own (N, rate) pairs, since a slow tool stops earlier.\n", + "Ns, rate = swe_core.sweep_series(by_stage, NAMES)\n", + "\n", + "# Where the fp64 working set outgrows each device's last-level cache.\n", + "bpc = swe_core.working_set_bytes(1)\n", + "llc = swe_core.llc_total_mib()\n", + "l2 = swe_core.device_l2_mib()\n", + "realms = ([(l2 * 2**20 / bpc, 'GPU L2')] if l2 else []) + \\\n", + " ([(llc * 2**20 / bpc, 'CPU LLC')] if llc else [])\n", + "swe_core.plot_rate_sweep(Ns, rate, 'N (cells)',\n", + " 'fp64 solve across memory hierarchy',\n", + " boundaries=realms)" + ] + }, + { + "cell_type": "markdown", + "id": "80082b9b", + "metadata": {}, + "source": [ + "#### Where end-to-end time is spent\n", + "\n", + "A wall-clock number cannot say where the time went. The bars below come from\n", + "Nsight Systems: one warm call per tool, with a capture range so the report\n", + "covers that call and nothing around it. GPU kernels and memory copies are what\n", + "the profiler measured. The host bar is the remainder, which is Python\n", + "assembling launches and any wait the device did not fill.\n", + "\n", + "Kernels dominate every path here, so what separates them is work on the\n", + "device, not host overhead. JAX is the exception on copies: its scan carries\n", + "state between steps as device-to-device transfers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "06cecd6a", + "metadata": {}, + "outputs": [], + "source": [ + "# Composition of one profiled solve call, recorded by each tool's own\n", + "# notebook (swe_core.nsys_breakdown + save_breakdown).\n", + "BSTAGES = ['12_cupy', '12_cupy_fused', '09_jax_fp64',\n", + " '12_cppjit_gpu_cub', '12_cppjit_gpu_raw']\n", + "swe_core.require_stages(by_stage, BSTAGES, key='breakdown')\n", + "\n", + "PARTS = [('kernel_s', 'GPU kernels', '#27a'),\n", + " ('memcpy_s', 'memory copies', '#e90'),\n", + " ('host_s', 'host and Python', '#c33')]\n", + "fig, ax = plt.subplots(figsize=(8, 3.2))\n", + "ys, left = np.arange(len(BSTAGES)), np.zeros(len(BSTAGES))\n", + "for key, lbl, color in PARTS:\n", + " frac = np.array([by_stage[s]['breakdown'][key]\n", + " / by_stage[s]['breakdown']['total_s']\n", + " for s in BSTAGES]) * 100\n", + " ax.barh(ys, frac, left=left, color=color, label=lbl)\n", + " left += frac\n", + "ax.set_yticks(ys); ax.set_yticklabels([NAMES[s] for s in BSTAGES])\n", + "ax.invert_yaxis(); ax.set_xlim(0, 100)\n", + "ax.set_xlabel('share of one solve call [%]')\n", + "bd0 = by_stage[BSTAGES[0]]['breakdown']\n", + "ax.set_title(f\"Composition of one solve call at N = {bd0['n']:,}, {bd0['steps']} steps\", pad=30)\n", + "for s, y in zip(BSTAGES, ys):\n", + " ax.text(101, y, f\"{by_stage[s]['breakdown']['total_s'] * 1e3:,.0f} ms\",\n", + " va='center', fontsize=8, color='#444')\n", + "ax.legend(ncols=4, fontsize=8, loc='lower center', bbox_to_anchor=(0.5, 1.04))\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "2b9e9456", + "metadata": {}, + "source": [ + "### 4. Programming models and limitations\n", + "\n", + "The plots rank by throughput. The table below is the qualitative counterpart: the programming model each tool exposes, and where that model stops.\n", + "\n", + "| Tool | Programming model | Limitation |\n", + "|---|---|---|\n", + "| **NumPy** | Array programming on the host | Single-threaded, no GPU |\n", + "| **CuPy** | Array programming on the device, NumPy API | One kernel per elementwise op and Python in the loop, unless hand-fused via `ElementwiseKernel` |\n", + "| **JAX** | Pure functions traced and compiled to one device program; autodiff | Shape-rigidity: each new input shape pays a fresh XLA compile; float32 is the default and float64 pays the bandwidth its extra bytes cost |\n", + "| **PyOMP** | OpenMP directives inside a JIT-compiled Python function | Scaling stops at physical cores; scalar per-core rate is arithmetic bound at cache-resident sizes |\n", + "| **mpi4py** | Explicit messages between independent processes | Per-step fixed costs dominate as slabs shrink; one NumPy rank per core on a single node |\n", + "| **nanobind** | Hand-written bindings to a compiled C++ extension | Build-system glue and FFI cost, plus a recompile-on-edit cycle |\n", + "| **CppJIT** | C++ and CUDA compiled into the live session, with bindings generated automatically | First-call compile cost, high memory utilisation and parsing cost (JIT overhead) with large translation units |" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/00__numpy__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/00__numpy__SOLUTION.ipynb new file mode 100644 index 00000000..34040277 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/00__numpy__SOLUTION.ipynb @@ -0,0 +1,631 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d2e341ff-0c1e-40e8-8c33-9e3039de8013", + "metadata": { + "id": "d2e341ff-0c1e-40e8-8c33-9e3039de8013" + }, + "source": [ + "## NumPy - SOLUTION" + ] + }, + { + "cell_type": "markdown", + "id": "a5ba6a1c", + "metadata": {}, + "source": [ + "### Table of Contents\n", + "\n", + "1. [The De Facto Standard for Array Data](#1.-The-De-Facto-Standard-for-Array-Data)\n", + "2. [Anatomy of an `ndarray`: Structure and Memory](#2.-Anatomy-of-an-`ndarray`:-Structure-and-Memory)\n", + "3. [Array Creation and Logical Views (Views vs. Copies)](#3.-Array-Creation-and-Logical-Views-(Views-vs.-Copies))\n", + "4. [Aggregations and Axes](#4.-Aggregations-and-Axes)\n", + "5. [Broadcasting: The \"Stretch\" Rule](#5.-Broadcasting:-The-\"Stretch\"-Rule)\n", + "6. [Why Vectorize? The Speed Advantage](#6.-Why-Vectorize?-The-Speed-Advantage)" + ] + }, + { + "cell_type": "markdown", + "id": "b30427de", + "metadata": {}, + "source": [ + "### 1. The De Facto Standard for Array Data\n", + "\n", + "NumPy is the foundational library for High Performance Computing (HPC) and Machine Learning (ML) in Python. Libraries like PyTorch, Pandas, and Scikit-learn are built upon or mirror the NumPy API. Learning NumPy is essential for mastering the Array Programming paradigm.\n", + "\n", + "NumPy provides the `ndarray` (N-dimensional array), a powerful, high-performance, and uniform container that enables highly efficient memory management, indexing, slicing, and, most importantly, vectorized arithmetic." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cc4596d8-d9ff-4c66-8822-246c0fc830c7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:03.821583Z", + "iopub.status.busy": "2026-03-09T19:52:03.821348Z", + "iopub.status.idle": "2026-03-09T19:52:03.907234Z", + "shell.execute_reply": "2026-03-09T19:52:03.906331Z", + "shell.execute_reply.started": "2026-03-09T19:52:03.821562Z" + }, + "id": "cc4596d8-d9ff-4c66-8822-246c0fc830c7" + }, + "outputs": [], + "source": [ + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "id": "c59fce80", + "metadata": {}, + "source": [ + "### 2. Anatomy of an `ndarray`: Structure and Memory\n", + "\n", + "Unlike a standard Python list, an `ndarray` is a fixed-size, structured block of contiguous memory. Its efficiency comes from these four key, immutable properties:\n", + "\n", + "- **Data**: A pointer to the memory location holding the elements.\n", + "- **dtype**: The data type (e.g., `int32`, `float64`) which is uniform across all elements.\n", + "- **Shape**: A tuple defining the size along each dimension (e.g., `(100, 50)` for 100 rows and 50 columns).\n", + "- **Strides**: The number of bytes to step in memory to reach the next element along each dimension. This is how NumPy efficiently handles different shapes and views.\n", + "\n", + "Let's explore these properties by creating a large dataset.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "- `np.arange(start, stop, step)`: Returns evenly spaced values in the half-open interval $[\\text{start}, \\text{stop})$.\n", + "- `arr.nbytes`: Total bytes of storage for the array's elements.\n", + "- `arr.ndim`: The number of array dimensions (integer).\n", + "- `arr.size`: The total number of elements in the array (integer).\n", + "- `arr.shape`: The tuple of array dimensions.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "465e35bd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:03.908110Z", + "iopub.status.busy": "2026-03-09T19:52:03.907792Z", + "iopub.status.idle": "2026-03-09T19:52:03.913350Z", + "shell.execute_reply": "2026-03-09T19:52:03.912706Z", + "shell.execute_reply.started": "2026-03-09T19:52:03.908084Z" + } + }, + "outputs": [], + "source": [ + "# Use a large number to clearly demonstrate the memory density of ndarrays\n", + "N = 50_000_000" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "5f1a613f-bc87-4950-b195-a66bb5bc05d3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:03.914581Z", + "iopub.status.busy": "2026-03-09T19:52:03.914156Z", + "iopub.status.idle": "2026-03-09T19:52:04.053344Z", + "shell.execute_reply": "2026-03-09T19:52:04.051792Z", + "shell.execute_reply.started": "2026-03-09T19:52:03.914555Z" + }, + "id": "5f1a613f-bc87-4950-b195-a66bb5bc05d3" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([ 1, 2, 3, ..., 49999998, 49999999, 50000000],\n", + " shape=(50000000,))" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION: Create the input data array with the numbers 1 to 50_000_000 (inclusive).\n", + "# np.arange generates values within a half-open interval [start, stop), so we use N + 1 as the stop value.\n", + "arr = np.arange(1, N + 1)\n", + "arr" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "50530f2c-29bf-4061-8f84-bc5be00a5622", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:04.054277Z", + "iopub.status.busy": "2026-03-09T19:52:04.054048Z", + "iopub.status.idle": "2026-03-09T19:52:04.059635Z", + "shell.execute_reply": "2026-03-09T19:52:04.058336Z", + "shell.execute_reply.started": "2026-03-09T19:52:04.054259Z" + }, + "id": "50530f2c-29bf-4061-8f84-bc5be00a5622" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "0.3725290298461914" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION: Calculate how large the array is in GB with nbytes.\n", + "# GB is 2**30 bytes. The .nbytes attribute returns the total bytes consumed by the elements.\n", + "# Note: This demonstrates that arrays are dense memory blocks, unlike pointer-heavy Python lists.\n", + "arr.nbytes / 2**30" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ffc15dad-e2fd-4b96-8b39-3496519d0656", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:04.060841Z", + "iopub.status.busy": "2026-03-09T19:52:04.060613Z", + "iopub.status.idle": "2026-03-09T19:52:04.065950Z", + "shell.execute_reply": "2026-03-09T19:52:04.064662Z", + "shell.execute_reply.started": "2026-03-09T19:52:04.060823Z" + }, + "id": "ffc15dad-e2fd-4b96-8b39-3496519d0656" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION: How many dimensions does the array have?\n", + "arr.ndim" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "b15cdf25-eb35-4926-b306-90ffd62b3d28", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:04.067100Z", + "iopub.status.busy": "2026-03-09T19:52:04.066888Z", + "iopub.status.idle": "2026-03-09T19:52:04.072294Z", + "shell.execute_reply": "2026-03-09T19:52:04.071345Z", + "shell.execute_reply.started": "2026-03-09T19:52:04.067082Z" + }, + "id": "b15cdf25-eb35-4926-b306-90ffd62b3d28" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "50000000" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION: How many elements does the array have?\n", + "arr.size" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "63887722-c9d7-405e-a019-e75646115541", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:04.073589Z", + "iopub.status.busy": "2026-03-09T19:52:04.073261Z", + "iopub.status.idle": "2026-03-09T19:52:04.079056Z", + "shell.execute_reply": "2026-03-09T19:52:04.077552Z", + "shell.execute_reply.started": "2026-03-09T19:52:04.073559Z" + }, + "id": "63887722-c9d7-405e-a019-e75646115541" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "(50000000,)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION: What is the shape of the array?\n", + "arr.shape" + ] + }, + { + "cell_type": "markdown", + "id": "f5e58ee4", + "metadata": {}, + "source": [ + "### 3. Array Creation and Logical Views (Views vs. Copies)\n", + "\n", + "Arrays can logically represent data in many ways (e.g., 1D signal, 2D image, 4D video batch) independent of the underlying physical memory block.\n", + "\n", + "Most operations, like adding two arrays together, returns a **Copy**, which requires allocating a new array, which can negatively impact performance.\n", + "\n", + "Some operations, like transposing or `reshape()` often return a **View** instead of a **Copy**. A View only changes the metadata (`shape` and `strides`) without duplicating the physical data, making these operations nearly instantaneous.\n", + "\n", + "Most Copy operations take an `out` parameter that takes an array; if it provided, the result is written to that array instead of allocating a new one. For example, `A + B` or `np.add(A, B)` will return a new array with the result, but `np.add(A + B, out=A)` will place the result in `A` without an allocation.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "- `np.linspace(start, stop, num)`: Returns `num` evenly spaced samples, calculated over the interval $[\\text{start}, \\text{stop}]$.\n", + "- `np.random.default_rng().random(size)`: Returns random floats in $[0.0, 1.0)$. `size` can be a tuple.\n", + "- `arr.sort()`: Sorts an array in-place (modifies the original data). Use `np.sort(arr)` to return a sorted copy.\n", + "- `arr.reshape(new_shape)`: Returns a View with a new shape. One dimension can be -1, instructing NumPy to calculate the size automatically.\n", + "- `np.resize(arr, new_shape)`: Returns a new array with the specified shape. If the new shape is larger, it fills the new elements by repeating the original array.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1527b4f6-5d75-47d4-97e0-d0e78bbc59f9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:04.079731Z", + "iopub.status.busy": "2026-03-09T19:52:04.079529Z", + "iopub.status.idle": "2026-03-09T19:52:04.107196Z", + "shell.execute_reply": "2026-03-09T19:52:04.105954Z", + "shell.execute_reply.started": "2026-03-09T19:52:04.079714Z" + }, + "id": "1527b4f6-5d75-47d4-97e0-d0e78bbc59f9" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([0.0000000e+00, 2.0000004e-04, 4.0000008e-04, ..., 9.9999960e+02,\n", + " 9.9999980e+02, 1.0000000e+03], shape=(5000000,))" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION: Create a new array with 5_000_000 elements containing equally spaced values between 0 to 1000 (inclusive).\n", + "# np.linspace returns 'num' evenly spaced samples over the closed interval [start, stop].\n", + "arr = np.linspace(0, 1000, 5_000_000)\n", + "arr" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2f51aa2e-b994-4a91-aed6-4a4632eb7050", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:04.108245Z", + "iopub.status.busy": "2026-03-09T19:52:04.108023Z", + "iopub.status.idle": "2026-03-09T19:52:04.717493Z", + "shell.execute_reply": "2026-03-09T19:52:04.716131Z", + "shell.execute_reply.started": "2026-03-09T19:52:04.108227Z" + }, + "id": "2f51aa2e-b994-4a91-aed6-4a4632eb7050" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[0.0400675 , 0.03433849, 0.10189916, ..., 0.80917626, 0.62766635,\n", + " 0.69858587],\n", + " [0.92463309, 0.33014371, 0.97584063, ..., 0.46073024, 0.80102199,\n", + " 0.73499053],\n", + " [0.31035298, 0.098541 , 0.44346722, ..., 0.94924401, 0.89655879,\n", + " 0.84106206],\n", + " ...,\n", + " [0.12849301, 0.00662825, 0.01222947, ..., 0.52379592, 0.07114445,\n", + " 0.39887908],\n", + " [0.45638408, 0.87280363, 0.51137569, ..., 0.04572943, 0.06830721,\n", + " 0.69372701],\n", + " [0.57562788, 0.38413504, 0.65051016, ..., 0.3541537 , 0.33969769,\n", + " 0.92381857]], shape=(10000, 5000))" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION: Create a random array that is 10_000 rows by 5_000 columns.\n", + "# np.random.default_rng().random(size) returns random floats in [0.0, 1.0). size can be a tuple.\n", + "arr = np.random.default_rng().random((10_000, 5_000))\n", + "arr" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "4ec06270-6e08-4cce-9385-9dc8b53e95fd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:04.718321Z", + "iopub.status.busy": "2026-03-09T19:52:04.718028Z", + "iopub.status.idle": "2026-03-09T19:52:05.032781Z", + "shell.execute_reply": "2026-03-09T19:52:05.031590Z", + "shell.execute_reply.started": "2026-03-09T19:52:04.718302Z" + }, + "id": "4ec06270-6e08-4cce-9385-9dc8b53e95fd" + }, + "outputs": [], + "source": [ + "# SOLUTION: Sort that array (in-place).\n", + "# Note: arr.sort() modifies the array directly, which is typically faster than creating a copy.\n", + "arr.sort()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "cdde560b-5ba6-484c-a601-00b7ef71273d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:05.033879Z", + "iopub.status.busy": "2026-03-09T19:52:05.033608Z", + "iopub.status.idle": "2026-03-09T19:52:05.040803Z", + "shell.execute_reply": "2026-03-09T19:52:05.039235Z", + "shell.execute_reply.started": "2026-03-09T19:52:05.033857Z" + }, + "id": "cdde560b-5ba6-484c-a601-00b7ef71273d" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[2.45234896e-04, 4.48883822e-04, 6.00343428e-04, 7.66594983e-04,\n", + " 1.04022200e-03],\n", + " [1.27074932e-03, 1.30087806e-03, 2.29955069e-03, 2.33961878e-03,\n", + " 2.95750178e-03],\n", + " [3.02767082e-03, 3.13463767e-03, 3.29535008e-03, 3.82121766e-03,\n", + " 4.09544080e-03],\n", + " ...,\n", + " [9.97846485e-01, 9.97884396e-01, 9.97997115e-01, 9.98257761e-01,\n", + " 9.98390990e-01],\n", + " [9.98455493e-01, 9.98504022e-01, 9.98518150e-01, 9.98541082e-01,\n", + " 9.98887643e-01],\n", + " [9.99350612e-01, 9.99370475e-01, 9.99601011e-01, 9.99848239e-01,\n", + " 9.99918236e-01]], shape=(10000000, 5))" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION: Reshape the array to have the last dimension of length 5.\n", + "# Using -1 lets NumPy automatically calculate the first dimension.\n", + "# .reshape() returns a View (not a copy) when possible, so no data is duplicated.\n", + "arr_new = arr.reshape(-1, 5)\n", + "arr_new" + ] + }, + { + "cell_type": "markdown", + "id": "54982876", + "metadata": {}, + "source": [ + "### 4. Aggregations and Axes\n", + "\n", + "When performing aggregations (like `sum`, `mean`, `max`), you must specify the **Axis** you want to collapse (or reduce) the array along.\n", + "\n", + "- **Axis 0**: The first dimension (often rows in 2D). Aggregating across Axis 0 produces a result for each column.\n", + "- **Axis 1**: The second dimension (often columns in 2D). Aggregating across Axis 1 produces a result for each row.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "- `np.sum(a, axis=None)`: Sum of array elements over a given axis.\n", + " - `axis=0`: Collapse the rows (sum vertical columns).\n", + " - `axis=1`: Collapse the columns (sum horizontal rows).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "44dd3ac2-c9b7-4327-ba63-860b074c0583", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:05.041814Z", + "iopub.status.busy": "2026-03-09T19:52:05.041582Z", + "iopub.status.idle": "2026-03-09T19:52:05.352312Z", + "shell.execute_reply": "2026-03-09T19:52:05.351284Z", + "shell.execute_reply.started": "2026-03-09T19:52:05.041795Z" + }, + "id": "44dd3ac2-c9b7-4327-ba63-860b074c0583" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([3.10127913e-03, 1.01682986e-02, 1.73743170e-02, ...,\n", + " 4.99037675e+00, 4.99290639e+00, 4.99808857e+00], shape=(10000000,))" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION: Find the sum of each row in the reshaped array (arr_new) above.\n", + "# To sum the row's content, we reduce across the columns (axis=1).\n", + "# axis=1 collapses the second dimension (columns), leaving one sum per row.\n", + "arr_sum = np.sum(arr_new, axis=1)\n", + "arr_sum" + ] + }, + { + "cell_type": "markdown", + "id": "ed072cee", + "metadata": {}, + "source": [ + "### 5. Broadcasting: The \"Stretch\" Rule\n", + "\n", + "Broadcasting is NumPy's mechanism for performing arithmetic between arrays of different shapes. If dimensions don't match, NumPy attempts to \"stretch\" the smaller array to match the larger one.\n", + "\n", + "**The Compatibility Rule:** Two dimensions are compatible when:\n", + "1. They are equal, or\n", + "2. One of them is 1.\n", + "\n", + "If a dimension is 1, NumPy logically copies that single value across the dimension to match the other array's shape **without allocating any new memory**.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "- **Arithmetic Operators** (`/`, `*`, `+`, `-`): These operate element-wise. Broadcasting occurs if shapes are different but compatible.\n", + "- `np.allclose(a, b)`: Returns `True` if two floating-point arrays are element-wise equal within a tolerance. Essential for comparisons instead of using `==`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "b15342af-2916-481a-9724-9874acf4ed24", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:05.353277Z", + "iopub.status.busy": "2026-03-09T19:52:05.353050Z", + "iopub.status.idle": "2026-03-09T19:52:05.885031Z", + "shell.execute_reply": "2026-03-09T19:52:05.883768Z", + "shell.execute_reply.started": "2026-03-09T19:52:05.353258Z" + }, + "id": "b15342af-2916-481a-9724-9874acf4ed24" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[0.0790754 , 0.14474151, 0.1935793 , 0.24718671, 0.33541708],\n", + " [0.12497168, 0.12793468, 0.22614901, 0.2300895 , 0.29085513],\n", + " [0.17426129, 0.18041789, 0.18966789, 0.21993484, 0.23571809],\n", + " ...,\n", + " [0.19995414, 0.19996174, 0.19998432, 0.20003655, 0.20006325],\n", + " [0.19997481, 0.19998453, 0.19998736, 0.19999195, 0.20006136],\n", + " [0.19994656, 0.19995053, 0.19999666, 0.20004612, 0.20006013]],\n", + " shape=(10000000, 5))" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION: Normalize each row of the 2D array (arr_new) by dividing by the sum you just computed (arr_sum).\n", + "# 'arr_new' has shape (M, N) and 'arr_sum' has shape (M,).\n", + "# To successfully divide, we reshape 'arr_sum' to (M, 1) so broadcasting can stretch it across the N columns.\n", + "# Alternative approaches: arr_sum[:, np.newaxis] or arr_sum[:, None] also work.\n", + "arr_normalized = arr_new / arr_sum.reshape(-1, 1)\n", + "arr_normalized" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "b04622b8-c6de-4756-8a56-e3d2835a5eaf", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:52:05.885870Z", + "iopub.status.busy": "2026-03-09T19:52:05.885649Z", + "iopub.status.idle": "2026-03-09T19:52:06.364026Z", + "shell.execute_reply": "2026-03-09T19:52:06.362963Z", + "shell.execute_reply.started": "2026-03-09T19:52:05.885852Z" + }, + "id": "b04622b8-c6de-4756-8a56-e3d2835a5eaf" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SOLUTION (EXTRA CREDIT): Prove that your normalized array is actually normalized.\n", + "# If normalized correctly, the sum of every row should now be 1.0.\n", + "# We compute row sums (axis=1) and check if they are all close to 1.0 using np.allclose.\n", + "np.allclose(np.sum(arr_normalized, axis=1), 1.0)" + ] + }, + { + "cell_type": "markdown", + "id": "31657dd2", + "metadata": {}, + "source": [ + "### 6. Why Vectorize? The Speed Advantage\n", + "\n", + "The entire Array Programming paradigm hinges on **Vectorization**.\n", + "\n", + "Why use complex shapes and broadcasting instead of simple Python `for` loops?\n", + "\n", + "NumPy's array functions are implemented in highly optimized native code (C/C++, Fortran). An operation like `A + A**2`, where `A` is a massive `ndarray`, is often $\\mathbf{100\\times}$ faster than performing the equivalent element-wise operation using explicit Python loops.\n", + "\n", + "**Always choose a vectorized NumPy function or operator over a manual Python loop.**" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/01__cupy__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/01__cupy__SOLUTION.ipynb new file mode 100644 index 00000000..9bc1f46a --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/01__cupy__SOLUTION.ipynb @@ -0,0 +1,944 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f79cdc7b", + "metadata": {}, + "source": [ + "## CuPy - SOLUTION\n", + "\n", + "### Table of Contents\n", + "1. [Creating Arrays: CPU vs. GPU](#1.-Creating-Arrays:-CPU-vs.-GPU)\n", + "2. [Basic Operations](#2.-Basic-Operations)\n", + " - [Sequential Operations & Memory](#Sequential-Operations-&-Memory)\n", + "3. [Complex Operations (Linear Algebra)](#3.-Complex-Operations-(Linear-Algebra))\n", + " - [Agnostic Code (NumPy Dispatch)](#Agnostic-Code-(NumPy-Dispatch))\n", + "4. [Device Management](#4.-Device-Management)\n", + "5. [Exercise - NumPy to CuPy](#Exercise---NumPy-to-CuPy)\n", + " - [Part 1](#Part-1)\n", + " - [Part 2](#Part-2)\n", + "\n", + "---\n", + "\n", + "Let's shift gears to high-level array functionality using **[CuPy](https://cupy.dev/)**.\n", + "\n", + "#### What is CuPy?\n", + "CuPy is a library that implements the familiar **NumPy API** but runs on the GPU (using CUDA C++ in the backend). \n", + "\n", + "**Why use it?**\n", + "* **Zero Friction:** If you know NumPy, you already know CuPy.\n", + "* **Speed:** It provides out-of-the-box GPU acceleration for array operations.\n", + "* **Ease of use:** You can often port CPU code to GPU simply by changing `import numpy as np` to `import cupy as cp`." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "824007e3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:41.631701Z", + "iopub.status.busy": "2026-03-09T19:50:41.631462Z", + "iopub.status.idle": "2026-03-09T19:50:42.740439Z", + "shell.execute_reply": "2026-03-09T19:50:42.739247Z", + "shell.execute_reply.started": "2026-03-09T19:50:41.631680Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import cupy as cp\n", + "import cupyx as cpx\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# Helper to display benchmark results concisely.\n", + "def print_benchmark(result):\n", + " \"\"\"Print benchmark result using wall-clock (cpu_times) for fair comparison.\"\"\"\n", + " avg_ms = result.cpu_times.mean() * 1000\n", + " std_ms = result.cpu_times.std() * 1000\n", + " print(f\"{result.name}: {avg_ms:.3f} ms +/- {std_ms:.3f} ms\")" + ] + }, + { + "cell_type": "markdown", + "id": "daebbaeb", + "metadata": {}, + "source": [ + "### 1. Creating Arrays: CPU vs. GPU\n", + "\n", + "Let's compare the performance of creating a large 3D array (approx. 100 MB in size) on the CPU versus the GPU.\n", + "\n", + "We will use `np.ones()` for the CPU and `cp.ones()` for the GPU." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "60bba049", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:42.741857Z", + "iopub.status.busy": "2026-03-09T19:50:42.741361Z", + "iopub.status.idle": "2026-03-09T19:50:43.154406Z", + "shell.execute_reply": "2026-03-09T19:50:43.152971Z", + "shell.execute_reply.started": "2026-03-09T19:50:42.741821Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ones: 25.258 ms +/- 0.495 ms\n" + ] + } + ], + "source": [ + "# CPU creation\n", + "print_benchmark(cpx.profiler.benchmark(np.ones, ((50, 500, 500),), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "9fc87bfe", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:43.155666Z", + "iopub.status.busy": "2026-03-09T19:50:43.155150Z", + "iopub.status.idle": "2026-03-09T19:50:43.201057Z", + "shell.execute_reply": "2026-03-09T19:50:43.199832Z", + "shell.execute_reply.started": "2026-03-09T19:50:43.155640Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ones: 0.038 ms +/- 0.024 ms\n" + ] + } + ], + "source": [ + "# GPU creation\n", + "print_benchmark(cpx.profiler.benchmark(cp.ones, ((50, 500, 500),), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "markdown", + "id": "e7208e73", + "metadata": {}, + "source": [ + "We can see here that creating this array on the GPU is much faster than doing so on the CPU!\n", + "\n", + "**About `cupyx.profiler.benchmark()`:**\n", + "\n", + "We use CuPy's built-in `benchmark()` utility for timing GPU operations. This is important because GPU operations are **asynchronous** - when you call a CuPy function, the CPU places a task in the GPU's \"to-do list\" (stream) and immediately moves on without waiting.\n", + "\n", + "The `benchmark()` function handles all the complexity of proper GPU timing for us:\n", + "- It automatically synchronizes GPU streams to get accurate measurements.\n", + "- It runs warm-up iterations to avoid cold-start overhead.\n", + "- It reports both CPU wall-clock times (`cpu_times`) and GPU kernel times (`gpu_times`). We use `cpu_times` for all comparisons because it measures end-to-end wall-clock time, giving a fair apples-to-apples comparison between CPU and GPU code.\n", + "\n", + "This makes it the recommended way to time CuPy code, as it's both accurate and convenient." + ] + }, + { + "cell_type": "markdown", + "id": "ed023374", + "metadata": {}, + "source": [ + "### 2. Basic Operations\n", + "\n", + "The syntax for mathematical operations is identical. Let's multiply every value in our arrays by `5`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "e4b2df00", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:43.202011Z", + "iopub.status.busy": "2026-03-09T19:50:43.201773Z", + "iopub.status.idle": "2026-03-09T19:50:43.206209Z", + "shell.execute_reply": "2026-03-09T19:50:43.204910Z", + "shell.execute_reply.started": "2026-03-09T19:50:43.201992Z" + } + }, + "outputs": [], + "source": [ + "multiply_shape = (50, 500, 500)\n", + "\n", + "def multiply(x):\n", + " return x * 5" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "80f6b09d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:43.207336Z", + "iopub.status.busy": "2026-03-09T19:50:43.207113Z", + "iopub.status.idle": "2026-03-09T19:50:43.599432Z", + "shell.execute_reply": "2026-03-09T19:50:43.598191Z", + "shell.execute_reply.started": "2026-03-09T19:50:43.207317Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "multiply: 32.204 ms +/- 0.517 ms\n" + ] + } + ], + "source": [ + "# CPU Operation\n", + "x_cpu = np.ones(multiply_shape)\n", + "print_benchmark(cpx.profiler.benchmark(multiply, (x_cpu,), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a3d3fb89", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:43.600467Z", + "iopub.status.busy": "2026-03-09T19:50:43.600158Z", + "iopub.status.idle": "2026-03-09T19:50:43.617202Z", + "shell.execute_reply": "2026-03-09T19:50:43.616155Z", + "shell.execute_reply.started": "2026-03-09T19:50:43.600434Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "multiply: 0.084 ms +/- 0.033 ms\n" + ] + } + ], + "source": [ + "# GPU Operation\n", + "x_gpu = cp.ones(multiply_shape)\n", + "print_benchmark(cpx.profiler.benchmark(multiply, (x_gpu,), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "markdown", + "id": "e2156810", + "metadata": {}, + "source": [ + "The GPU completes this operation notably faster, with the code staying the same." + ] + }, + { + "cell_type": "markdown", + "id": "111854c2", + "metadata": {}, + "source": [ + "#### Sequential Operations & Memory\n", + "\n", + "Now let's do a couple of operations sequentially." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "6fdd9254", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:43.618191Z", + "iopub.status.busy": "2026-03-09T19:50:43.617879Z", + "iopub.status.idle": "2026-03-09T19:50:43.622640Z", + "shell.execute_reply": "2026-03-09T19:50:43.621303Z", + "shell.execute_reply.started": "2026-03-09T19:50:43.618161Z" + } + }, + "outputs": [], + "source": [ + "sequential_math_shape = (50, 500, 500)\n", + "\n", + "def sequential_math(x):\n", + " x = x * 5\n", + " x = x * x\n", + " x = x + x\n", + " return x" + ] + }, + { + "cell_type": "markdown", + "id": "9e5a7f69", + "metadata": {}, + "source": [ + "Remember, each of these operations will return a **Copy**, not a **View**. This can be a common performance pitfall with NumPy and CuPy!" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "7a9e5dde", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:43.623712Z", + "iopub.status.busy": "2026-03-09T19:50:43.623478Z", + "iopub.status.idle": "2026-03-09T19:50:44.871102Z", + "shell.execute_reply": "2026-03-09T19:50:44.869708Z", + "shell.execute_reply.started": "2026-03-09T19:50:43.623693Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sequential_math: 110.001 ms +/- 0.655 ms\n" + ] + } + ], + "source": [ + "# CPU: Sequential math\n", + "x_cpu = np.ones(sequential_math_shape)\n", + "print_benchmark(cpx.profiler.benchmark(sequential_math, (x_cpu,), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "697c5b75", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:44.872025Z", + "iopub.status.busy": "2026-03-09T19:50:44.871796Z", + "iopub.status.idle": "2026-03-09T19:50:45.019025Z", + "shell.execute_reply": "2026-03-09T19:50:45.017619Z", + "shell.execute_reply.started": "2026-03-09T19:50:44.872006Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sequential_math: 0.157 ms +/- 0.048 ms\n" + ] + } + ], + "source": [ + "# GPU: Sequential math\n", + "x_gpu = cp.ones(sequential_math_shape)\n", + "print_benchmark(cpx.profiler.benchmark(sequential_math, (x_gpu,), n_repeat=10, n_warmup=1))" + ] + }, + { + "cell_type": "markdown", + "id": "eec1185b", + "metadata": {}, + "source": [ + "But even with the copies, the GPU ran that much faster. The copies stay on the GPU; CuPy only transfers data from GPU to CPU when necessary or explicitly requested." + ] + }, + { + "cell_type": "markdown", + "id": "5a166330", + "metadata": {}, + "source": [ + "### 3. Complex Operations (Linear Algebra)\n", + "\n", + "GPUs excel at Linear Algebra. Let's look at **Singular Value Decomposition (SVD)**, a computationally heavy $O(N^3)$ operation." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "bed3783b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:45.020285Z", + "iopub.status.busy": "2026-03-09T19:50:45.019894Z", + "iopub.status.idle": "2026-03-09T19:50:45.024993Z", + "shell.execute_reply": "2026-03-09T19:50:45.023690Z", + "shell.execute_reply.started": "2026-03-09T19:50:45.020254Z" + } + }, + "outputs": [], + "source": [ + "svd_shape = (3000, 1000)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "ec59cfdd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:45.026069Z", + "iopub.status.busy": "2026-03-09T19:50:45.025770Z", + "iopub.status.idle": "2026-03-09T19:50:54.233011Z", + "shell.execute_reply": "2026-03-09T19:50:54.231417Z", + "shell.execute_reply.started": "2026-03-09T19:50:45.026042Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "svd: 1449.570 ms +/- 47.411 ms\n" + ] + } + ], + "source": [ + "# CPU SVD\n", + "x_cpu = np.random.random(svd_shape)\n", + "print_benchmark(cpx.profiler.benchmark(np.linalg.svd, (x_cpu,), n_repeat=5, n_warmup=1))" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "1030dbeb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:54.234203Z", + "iopub.status.busy": "2026-03-09T19:50:54.233626Z", + "iopub.status.idle": "2026-03-09T19:50:57.460338Z", + "shell.execute_reply": "2026-03-09T19:50:57.459124Z", + "shell.execute_reply.started": "2026-03-09T19:50:54.234169Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "svd: 467.642 ms +/- 1.820 ms\n" + ] + } + ], + "source": [ + "# GPU SVD\n", + "x_gpu = cp.random.random(svd_shape)\n", + "print_benchmark(cpx.profiler.benchmark(cp.linalg.svd, (x_gpu,), n_repeat=5, n_warmup=1))" + ] + }, + { + "cell_type": "markdown", + "id": "7d28f130", + "metadata": {}, + "source": [ + "The GPU outperforms the CPU again with exactly the same API!" + ] + }, + { + "cell_type": "markdown", + "id": "20703aab", + "metadata": {}, + "source": [ + "#### Agnostic Code (NumPy Dispatch)\n", + "\n", + "A key feature of CuPy is that many **NumPy functions work on CuPy arrays without changing your code**.\n", + "\n", + "When you pass a CuPy GPU array (`x_gpu`) into a NumPy function that supports the `__array_function__` protocol (e.g., `np.linalg.svd()`), NumPy detects the CuPy input and **delegates the operation to CuPy's own implementation**, which runs on the GPU.\n", + "\n", + "This allows you to write code using standard `np.*` syntax and have it run on either CPU or GPU seamlessly - **as long as CuPy implements an override for that function.**\n", + "\n", + "One common source of hidden performance penalties is **implicit transfers between CPU and GPU**. In some cases, CuPy guards against this: for example, when NumPy tries to convert a `cupy.ndarray` into a `numpy.ndarray` via the `__array__` protocol (e.g. `np.asarray(gpu_array)`), CuPy raises a `TypeError` instead of silently copying data to the host. \n", + "\n", + "However, CuPy **does** perform implicit GPU → CPU transfers in other cases, such as printing a GPU array, converting to a Python scalar (e.g. `float`, `.item()`), or evaluating a GPU scalar in a boolean context. We will explore these implicit transfers in a later notebook." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "12bd8e64", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:50:57.461537Z", + "iopub.status.busy": "2026-03-09T19:50:57.461182Z", + "iopub.status.idle": "2026-03-09T19:51:00.350501Z", + "shell.execute_reply": "2026-03-09T19:51:00.349240Z", + "shell.execute_reply.started": "2026-03-09T19:50:57.461506Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "svd: 463.685 ms +/- 0.124 ms\n" + ] + } + ], + "source": [ + "# We create the data on the GPU...\n", + "x_gpu = cp.random.random(svd_shape)\n", + "\n", + "# BUT we call the standard NumPy function - CuPy dispatches it to the GPU!\n", + "print_benchmark(cpx.profiler.benchmark(np.linalg.svd, (x_gpu,), n_repeat=5, n_warmup=1))" + ] + }, + { + "cell_type": "markdown", + "id": "650f5741", + "metadata": {}, + "source": [ + "### 4. Device Management\n", + "\n", + "If you have multiple GPUs, you can use a `with` statement to ensure specific arrays are created on specific devices (e.g., GPU 0 vs GPU 1)." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "6aa4f888", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:51:00.351518Z", + "iopub.status.busy": "2026-03-09T19:51:00.351250Z", + "iopub.status.idle": "2026-03-09T19:51:00.356925Z", + "shell.execute_reply": "2026-03-09T19:51:00.355691Z", + "shell.execute_reply.started": "2026-03-09T19:51:00.351496Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Array is on device: \n" + ] + } + ], + "source": [ + "with cp.cuda.Device(0):\n", + " x_on_gpu0 = cp.random.random((100000, 1000))\n", + "\n", + "print(f\"Array is on device: {x_on_gpu0.device}\")" + ] + }, + { + "cell_type": "markdown", + "id": "9059a63d", + "metadata": {}, + "source": [ + "**Note:** CuPy functions generally expect all input arrays to be on the **same** device. Passing an array stored on a non-current device may work depending on the hardware configuration but is generally discouraged as it may not be performant." + ] + }, + { + "cell_type": "markdown", + "id": "8f83e93a", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "id": "1c8e1ebe", + "metadata": {}, + "source": [ + "### Exercise - NumPy to CuPy\n", + "\n", + "#### Part 1\n", + "Let's put the \"Drop-in Replacement\" philosophy to the test with the same data pipeline as the previous notebook. Specifically, the single block of code below performs the following steps:\n", + "1) Generate a massive dataset (50 million elements).\n", + "2) Process it using a heavy operation (Sorting).\n", + "3) Manipulate the shape and normalize the data (Broadcasting).\n", + "4) Verify the integrity of the result.\n", + "\n", + "**TODO:**\n", + "1. Run the cell below with `xp = np` (CPU Mode). Note the benchmark output.\n", + "2. Change the setup line to `xp = cp` (GPU Mode). Run it again.\n", + "3. Observe how the exact same logic runs significantly faster on the GPU with CuPy while retaining the implementation properties of NumPy.\n", + "\n", + "Note: We use `cupyx.profiler.benchmark()` for timing, which automatically handles GPU synchronization." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "0ec79976", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:51:00.358081Z", + "iopub.status.busy": "2026-03-09T19:51:00.357810Z", + "iopub.status.idle": "2026-03-09T19:51:01.015830Z", + "shell.execute_reply": "2026-03-09T19:51:01.014668Z", + "shell.execute_reply.started": "2026-03-09T19:51:00.358060Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Running on: CUPY\n", + "Generating 50,000,000 random elements (0.37 GB)...\n", + "Sorting data...\n", + "sort: 33.497 ms +/- 0.343 ms\n", + "Verification: PASSED (All rows sum to 1.0)\n" + ] + } + ], + "source": [ + "# Step 1.) Setup: Choose Your Target\n", + "# Changed from 'np' to 'cp' for GPU acceleration\n", + "xp = cp # Toggle this to 'np' for CPU mode\n", + "\n", + "print(f\"Running on: {xp.__name__.upper()}\")\n", + "\n", + "# Step 2.) Data Generation\n", + "N = 50_000_000\n", + "print(f\"Generating {N:,} random elements ({N*8/2**30:.2f} GB)...\")\n", + "arr = xp.random.rand(N)\n", + "\n", + "# Step 3.) Heavy Computation (Timed)\n", + "print(\"Sorting data...\")\n", + "# cpx.profiler.benchmark() handles GPU synchronization automatically\n", + "result = cpx.profiler.benchmark(xp.sort, (arr,), n_repeat=5, n_warmup=1)\n", + "print_benchmark(result)\n", + "\n", + "# Step 4.) Manipulation & Broadcasting\n", + "# Purpose: Demonstrate that CuPy supports complex reshaping and broadcasting rules exactly like NumPy.\n", + "# This shows you don't need to rewrite your data processing logic.\n", + "\n", + "# Reshape to a matrix with 5 columns\n", + "arr_new = arr.reshape((-1, 5))\n", + "\n", + "# Normalize: Divide every row by its sum using broadcasting\n", + "row_sums = arr_new.sum(axis=1)\n", + "normalized_matrix = arr_new / row_sums[:, xp.newaxis]\n", + "\n", + "# Step 5.) Verification\n", + "# Purpose: Verify mathematical correctness/integrity of the result.\n", + "check_sums = xp.sum(normalized_matrix, axis=1)\n", + "xp.testing.assert_allclose(check_sums, 1.0)\n", + "\n", + "print(\"Verification: PASSED (All rows sum to 1.0)\")" + ] + }, + { + "cell_type": "markdown", + "id": "e411249a", + "metadata": {}, + "source": [ + "**TODO: When working with CuPy arrays, try changing `xp.testing.assert_allclose()` to `np.testing.assert_allclose()`. What happens and why?**" + ] + }, + { + "cell_type": "markdown", + "id": "8f665316", + "metadata": {}, + "source": [ + "**SOLUTION:**\n", + "\n", + "When you change `xp.testing.assert_allclose()` to `np.testing.assert_allclose()` while working with CuPy arrays (`xp = cp`), you will get a **`TypeError`**.\n", + "\n", + "This happens because:\n", + "\n", + "1. `np.testing.assert_allclose()` internally tries to convert its inputs to NumPy arrays via `np.asarray()`.\n", + "2. CuPy arrays live on the GPU, and CuPy **refuses to implicitly convert to NumPy arrays** through the `__array__` protocol.\n", + "3. When NumPy's `assert_allclose` attempts to call `np.asarray()` on the CuPy array, CuPy raises a `TypeError` to prevent a silent (and potentially slow) bulk data copy from GPU to CPU memory.\n", + "\n", + "This is a **safety feature** of CuPy! Note that CuPy does still perform implicit GPU → CPU transfers in some cases (e.g. printing, scalar conversion, boolean evaluation), but it draws the line at bulk array conversion via `np.asarray()`. " + ] + }, + { + "cell_type": "markdown", + "id": "ff6563c7", + "metadata": {}, + "source": [ + "#### Part 2\n", + "We will now create a massive dataset (50 million points) representing a sine wave and see how fast the GPU can sort it compared to the CPU. \n", + "\n", + "**TODO:** \n", + "1) **Generate Data:** Create a NumPy array (`y_cpu`) and a CuPy array (`y_gpu`) representing $\\sin(x)$ from $0$ to $2\\pi$ with `50,000,000` points.\n", + "2) **Benchmark CPU and GPU:** Use `benchmark()` from `cupyx.profiler` to measure both `np.sort()` and `cp.sort()`." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "e65b3b18", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:51:01.017080Z", + "iopub.status.busy": "2026-03-09T19:51:01.016833Z", + "iopub.status.idle": "2026-03-09T19:51:08.277717Z", + "shell.execute_reply": "2026-03-09T19:51:08.276354Z", + "shell.execute_reply.started": "2026-03-09T19:51:01.017061Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Generating 50,000,000 points...\n", + "CPU array shape: (50000000,), dtype: float64\n", + "GPU array shape: (50000000,), dtype: float64\n", + "\n", + "Benchmarking NumPy Sort (this may take a few seconds)...\n", + "NumPy (CPU): 974.833 ms +/- 91.496 ms\n", + "\n", + "Benchmarking CuPy Sort...\n", + "CuPy (GPU): 32.867 ms +/- 0.327 ms\n", + "\n", + "GPU Speedup: 29.7x faster\n" + ] + } + ], + "source": [ + "# Step 1.) Generate Data\n", + "N = 50_000_000\n", + "print(f\"Generating {N:,} points...\")\n", + "\n", + "# SOLUTION: Create x_cpu using np.linspace from 0 to 2*pi\n", + "x_cpu = np.linspace(0, 2 * np.pi, N)\n", + "# SOLUTION: Create y_cpu by taking np.sin(x_cpu)\n", + "y_cpu = np.sin(x_cpu)\n", + "\n", + "# SOLUTION: Create x_gpu using cp.linspace from 0 to 2*pi\n", + "x_gpu = cp.linspace(0, 2 * cp.pi, N)\n", + "# SOLUTION: Create y_gpu by taking cp.sin(x_gpu)\n", + "y_gpu = cp.sin(x_gpu)\n", + "\n", + "print(f\"CPU array shape: {y_cpu.shape}, dtype: {y_cpu.dtype}\")\n", + "print(f\"GPU array shape: {y_gpu.shape}, dtype: {y_gpu.dtype}\")\n", + "\n", + "# Step 2.) Benchmark NumPy (CPU)\n", + "print(\"\\nBenchmarking NumPy Sort (this may take a few seconds)...\")\n", + "# SOLUTION: Use benchmark with np.sort\n", + "cpu_result = cpx.profiler.benchmark(np.sort, (y_cpu,), n_repeat=5, n_warmup=1)\n", + "cpu_avg_ms = cpu_result.cpu_times.mean() * 1000\n", + "cpu_std_ms = cpu_result.cpu_times.std() * 1000\n", + "print(f\"NumPy (CPU): {cpu_avg_ms:.3f} ms +/- {cpu_std_ms:.3f} ms\")\n", + "\n", + "# Step 3.) Benchmark CuPy (GPU)\n", + "print(\"\\nBenchmarking CuPy Sort...\")\n", + "# SOLUTION: Use benchmark with cp.sort\n", + "gpu_result = cpx.profiler.benchmark(cp.sort, (y_gpu,), n_repeat=5, n_warmup=1)\n", + "gpu_avg_ms = gpu_result.cpu_times.mean() * 1000\n", + "gpu_std_ms = gpu_result.cpu_times.std() * 1000\n", + "print(f\"CuPy (GPU): {gpu_avg_ms:.3f} ms +/- {gpu_std_ms:.3f} ms\")\n", + "\n", + "# Summary\n", + "print(f\"\\nGPU Speedup: {cpu_avg_ms / gpu_avg_ms:.1f}x faster\")" + ] + }, + { + "cell_type": "markdown", + "id": "c6acab84", + "metadata": {}, + "source": [ + "**EXTRA CREDIT: Benchmark with different array sizes and find the size at which CuPy and NumPy take the same amount of time. Try to extract the timing data from `cupyx.profiler.benchmark()`'s return value and customize how the output is displayed. You could even make a graph.**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5334aee6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:51:08.278761Z", + "iopub.status.busy": "2026-03-09T19:51:08.278516Z", + "iopub.status.idle": "2026-03-09T19:51:20.948909Z", + "shell.execute_reply": "2026-03-09T19:51:20.947579Z", + "shell.execute_reply.started": "2026-03-09T19:51:08.278741Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Benchmarking different array sizes...\n", + "======================================================================\n", + " Size | NumPy (CPU) | CuPy (GPU) | Winner\n", + "----------------------------------------------------------------------\n", + " 5 | 0.012 ms | 0.164 ms | CPU\n", + " 50 | 0.006 ms | 0.103 ms | CPU\n", + " 500 | 0.005 ms | 0.084 ms | CPU\n", + " 5,000 | 0.031 ms | 0.137 ms | CPU\n", + " 50,000 | 0.373 ms | 0.151 ms | GPU\n", + " 500,000 | 5.265 ms | 0.211 ms | GPU\n", + " 5,000,000 | 76.043 ms | 2.799 ms | GPU\n", + " 50,000,000 | 938.010 ms | 32.682 ms | GPU\n", + "======================================================================\n", + "\n", + "Crossover point: GPU becomes faster between 5,000 and 50,000 elements\n" + ] + } + ], + "source": [ + "# SOLUTION: Extra Credit - Finding the crossover point between CPU and GPU performance\n", + "\n", + "repeat = 10\n", + "warmup = 1\n", + "sizes = [5, 50, 500, 5_000, 50_000, 500_000, 5_000_000, 50_000_000]\n", + "\n", + "cpu_times = []\n", + "gpu_times = []\n", + "\n", + "print(\"Benchmarking different array sizes...\")\n", + "print(\"=\" * 70)\n", + "print(f\"{'Size':>15} | {'NumPy (CPU)':>15} | {'CuPy (GPU)':>15} | {'Winner':>10}\")\n", + "print(\"-\" * 70)\n", + "\n", + "for N in sizes:\n", + " # Generate sine wave data\n", + " y_cpu = np.sin(np.linspace(0, 2 * np.pi, N))\n", + " y_gpu = cp.sin(cp.linspace(0, 2 * cp.pi, N))\n", + "\n", + " # Benchmark CPU\n", + " cpu_result = cpx.profiler.benchmark(np.sort, (y_cpu,), n_repeat=repeat, n_warmup=warmup)\n", + " cpu_time_ms = cpu_result.cpu_times.mean() * 1000\n", + " cpu_times.append(cpu_time_ms)\n", + "\n", + " # Benchmark GPU\n", + " gpu_result = cpx.profiler.benchmark(cp.sort, (y_gpu,), n_repeat=repeat, n_warmup=warmup)\n", + " gpu_time_ms = gpu_result.cpu_times.mean() * 1000\n", + " gpu_times.append(gpu_time_ms)\n", + "\n", + " # Determine winner\n", + " winner = \"GPU\" if gpu_time_ms < cpu_time_ms else \"CPU\"\n", + "\n", + " print(f\"{N:>15,} | {cpu_time_ms:>12.3f} ms | {gpu_time_ms:>12.3f} ms | {winner:>10}\")\n", + "\n", + "print(\"=\" * 70)\n", + "\n", + "# Find approximate crossover point\n", + "crossover_idx = None\n", + "for i in range(len(sizes) - 1):\n", + " # Check if GPU becomes faster between size[i] and size[i+1]\n", + " if cpu_times[i] <= gpu_times[i] and cpu_times[i+1] > gpu_times[i+1]:\n", + " crossover_idx = i\n", + " break\n", + "\n", + "if crossover_idx is not None:\n", + " print(f\"\\nCrossover point: GPU becomes faster between {sizes[crossover_idx]:,} and {sizes[crossover_idx+1]:,} elements\")\n", + "else:\n", + " if gpu_times[0] < cpu_times[0]:\n", + " print(f\"\\nGPU is faster for all tested sizes (even at {sizes[0]:,} elements)\")\n", + " else:\n", + " print(f\"\\nCPU is faster for all tested sizes (even at {sizes[-1]:,} elements)\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8d809375", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:51:20.949865Z", + "iopub.status.busy": "2026-03-09T19:51:20.949630Z", + "iopub.status.idle": "2026-03-09T19:51:21.973158Z", + "shell.execute_reply": "2026-03-09T19:51:21.971781Z", + "shell.execute_reply.started": "2026-03-09T19:51:20.949846Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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358jR46YvL/Zs2czY8YMo/bExEQSEhJeJ1QhhBBCvOVWr15NpUqVaNeuHZcvX+b8+fMMHTqUfPny0bRpU/744w+GDx8OQMuWLbG1tSU4OJjJkyfj6elJ69atU93vkCFDDJb37t3L7du3KVGiRK67ftF9ef6qa1xJ2gohhBBCmMn06dONkmc+Pj789ddf2XI8RVEICAhg+fLlxMTEUK9ePb799lvKlCkDwOHDh2nSpEmq254+fZqaNWtmS1wifdbW1pQuXRqAd999l99//52vvvqKHj16kJSURExMjMHTtvfu3cPDwyPN/U2aNAl/f3/9su6JZjc3tzzxYIIQQgghzOfEiRP4+voCkJycTLly5QgNDeXkyZP07NmT+/fvA9oa/bt37wbgnXfe4cqVKzx8+JBChQqlut+lS5fq/3z//n3WrVsHwCeffEKhQoVYsGABH3/8MVWqVOHUqVNERUVRpUoVHj58yO7du2nRokV2nnam2NraZqifJG2FEEIIIcyoYsWK7N+/X79saWn65dn06dMJCwsjKCgo1fVz5sxh8eLFrF69mhIlSjB16lRatWrF5cuXsbW1pW7duty9e9dgm6lTp3LgwAFq1Khhclwia2k0GhITE3n33XexsrLiwIEDdOnSBYCrV69y69Yt6tSpk+b2NjY22NjYGLWr1Wp5MlwIIYQQr+XFaxC1Wq1/qrRo0aKo1WratWtHpUqV+PPPP+nYsSN2dnZcuXKFatWq0bdv3wxdiyxdupQnT55QuXJl2rRpA8C4cePYv38/e/fu5fPPP+f333/nwYMHTJgwgVatWmXPyZooo9dbkrQVQgghhDAjS0vLNJ+KjImJYfz48Wzbto3ExERq1KjBwoULqVKlSqaPoygKixYtYsqUKXTo0AGANWvW4O7uztatW+nZsyfW1tYGsTx79oxt27YxatSot75EgblMmjSJNm3aULx4ceLi4li3bh2HDx9m7969ODk5MXjwYPz9/SlYsCCOjo6MGjWKOnXqZHgSMiGEEEKI7KDRaBg+fDjh4eFUrFhRX6/WycmJIUOGMGHCBLZv3w5onzzt3LlzmnVrX5SYmMjXX38NwPjx4/XtKpWK1atXU7lyZT777DMURaFmzZrMmjUrG87uzZCv0oUQQgghzOjatWsULlyYkiVL0qdPH27duqVf161bNyIjI9m9ezdnz56levXqNGvWjAcPHmT6OKGhoURERNC8eXN9m5OTE76+voSEhKS6TXBwMPfv32fgwIGZPzGRJSIjI+nXrx8+Pj40a9aM33//nb179+pf8Vu4cCFt27alS5cuNGzYEA8PDzZv3mzmqIUQQgjxNouPj6dTp06sWLGCatWqcfDgQf2kW9u3b2fMmDHY2dnx999/c/fuXYoUKcLUqVNZtmzZK/e9du1a7t27R9GiRenZs6fBOnd3dwYOHKifZHXcuHFYWVll/Qm+IfKkbTZJSUnh2bNn5g4jXRqNhmfPnpGQkCCvwmVSThg7KysrLCwszHJsIYQQWcPX15egoCB8fHy4e/cuM2bMoEGDBly6dIkLFy5w+vRpIiMj9a+yz5s3j61bt7Jp0yaGDRuWqWNFREQA2ovZF7m7u+vXvWzFihW0atWKokWLmnB2IiusWLEi3fW2trYEBgYSGBj4RuLJDde4wvzkOlUIId5e4eHhtGvXjnPnztGuXTvWrVuHvb29fv3Vq1cBKFSokH5ehVKlSnHjxg0uX74MwKNHj7h79y5WVlaUKlVKv62iKCxYsACAsWPHGiVk//vf/7J48WJsbW1JSEhg4sSJtGrVyqD2f24iSdsspigKERERxMTEmDuUV1IUBY1GQ1xcnLzymEk5ZeycnZ3x8PCQvz8hhMildDW4ACpXroyvry9eXl78/PPPJCQk8PjxY1xcXAy2efr0KTdu3ADg2LFjBvtISkpCURQ2bdqkb1u6dCl9+vTJdGx37txh7969/Pzzz5neVuQ9uekaV+QMcp0qhBBvJ19fX+7cuYOjoyPe3t5MmTIFgFq1atG7d28aNGiAWq3m77//pm3bttjb2/Prr78C0KhRIwC2bNnCwIED8fLyIiwsTL/vXbt2ceXKFZycnIweYEhISKBXr148ffqUZcuWcerUKVasWMGwYcNy7fXsW5e0jYmJoXnz5iQnJ5OcnMyYMWMYOnRolu1fdzFbqFAh8uXLl6MvUhRFITk5GUtLyxwdZ05k7rFTFIUnT54QGRkJgKen5xuPQQghRNZzdnambNmyXL9+HWdnZzw9PTl8+HCq/QBq1KjB+fPn9e2LFy/m33//5csvv9S36Z6s1dWqvXfvnsHvjXv37lG1alWjY6xatQoXFxfat2//+icmcr3cdI0rzEuuU4UQ4u12584dAGJjY1myZIm+vX///vTu3RtfX182bNjA3LlzOX78OMnJybzzzjuMGDGCrl27prvv+fPnAzBs2DB9uQWd8ePH8+eff9KmTRuGDh1Kr169OHToEBs3bmTFihUMHjw4i880+711SVsHBweOHj1Kvnz5iI+P55133qFz585GT7GYIiUlRX8xmxX7y27mTjzmZjlh7Ozs7ABtrbtChQrJK2hCCJEHPH78mBs3btC3b1/Kly9PREQElpaWeHt7p9rfzs6O0qVL65cLFixIbGysQZtOiRIl8PDw4MCBA/okbWxsLKdOndJPDKGjKAqrVq2iX79++tfOEhJg40bYuhXu3wcXF+jYEbp1A1vbrDh7kVPltmtcYX5ynSqEEG8vXT3Z9HTt2jXdBO2AAQMYMGCAUfvBgwfT3Obrr7/WT1AGYG9vr387Lbd665K2FhYW5MuXD9DOOKcoSoY+UBmhq++l278Q2U33WXv27JlcDAshRC40fvx42rVrh5eXF+Hh4QQEBGBhYUGvXr1wdXWlTp06dOzYkTlz5lC2bFnCw8PZuXMnnTp1okaNGpk6lkqlYuzYscyaNYsyZcpQokQJpk6dSuHChenYsaNB34MHDxIaGsqQIUMACA6GAQPg4UNQq0Gj0f5782YYMwZWr4Z27bJoUESOI9e4whRynSqEEEK8nlw3+9TRo0dp164dhQsXRqVSsXXrVqM+gYGBeHt7Y2tri6+vL6dPnzZYHxMTQ5UqVShatCgff/wxrq6uWRqjPLUq3hT5rAkhRO52584devXqhY+PD927d8fFxYWTJ0/i5uaGSqVi165dNGzYkIEDB1K2bFl69uzJzZs3jSYTy6gJEyYwatQohg0bRs2aNXn8+DF79uzB9qVHZVesWEHdunUpV64cwcHaJ2p1pUw1GsN/x8RAhw7axK7I2+S6Q2SGfF6EEEKI15PrnrSNj4+nSpUqDBo0iM6dOxut37BhA/7+/nz33Xf4+vqyaNEiWrVqxdWrVylUqBCgrQN34cIF7t27R+fOnenatavJNz9CCCGEEKZav359uusdHBxYvHgxixcvztD+pk+fnu56lUrFzJkzmTlzZrr91q1bB2hLIujeTEvrxSRFAZVK2y88XEolCCGEEEIIkRVy3ZO2bdq0YdasWXTq1CnV9QsWLGDo0KEMHDiQChUq8N1335EvXz5Wrlxp1Nfd3Z0qVapw7NixVPeVmJhIbGyswQ+ARqNJ80dXbiE3/AAG/37xJyAgAJVKRcOGDY3WjRkzBm9v7zca64ABA1CpVPofT09P2rVrx8WLF7PsGLVq1eLrr782aNNoNAQFBdGgQQOcnJywsbHBx8eHcePG8e+//+rH7sXYrK2t8fHxYdKkSTx+/Fi/L29vb/z8/FI9dtWqVRkwYIB+eciQIQwZMiTDsaf3mcxpP7kt3pz0I2MnYydjl7t+8sLYbdig4eHDtBO2OoqiLZ3w889ZP25CZKXp06cbXLfZ2tpSvnx55syZ80Y/b4cPH0alUnHmzJk3dkwhhBBC5C657knb9CQlJXH27FkmTZqkb1Or1TRv3pyQkBBAO0Nyvnz5cHBw4NGjRxw9etRo8g2d2bNnM2PGDKP2qKgoEhISjNqfPXuGRqMhOTmZ5OTk1zqXhATYtElFcLCaBw+gYEFo315D165Klj3BoigKKSkpgPHrS7qL1mPHjnHgwAEaNWpksB3w2ueYGRqNhpIlS7J69WoUReH69evMnDmTxo0bc+HCBf2M2KbaunUrYWFh9OvXT39eiqLQt29fNm3aRP/+/fH398fR0ZErV66wbNkyrl+/zqZNm/Rj5+fnR8+ePUlISODAgQPMnTuXf/75hx9++EF/HEVRUh033Q2qbt24ceOoWrUq/v7+lClTJs24k5OT0Wg03L9/Xz9RTE6m0Wh49OgRiqKgVue674zMSsbOdDJ2ppOxM11eGbuff3ZGrbZBo3n1a85qtcKGDUm0bBlj8vFeHre4uDiT9yVEWuzs7PQTmTx9+pRDhw7xySefoNFo+OSTT8wcnRBCCCGEVp5K2kZHR5OSkmJU6sDd3Z2//voLgJs3bzJs2DD9E4qjRo2iUqVKqe5v0qRJ+Pv765djY2MpVqwYbm5uODo6GvVPSEggLi4OS0tLLC1NH9rgYBg4EB4+VKFWK2g02n9v3WrJuHEKQUFZO9lHask+tVpN/vz5qVixIrNnz6ZZs2b6dbok5eucY2ap1Wrs7OyoV68eAPXr16dUqVI0atSI9evXM378+Nfa/9dff03Pnj1xcHDQt33zzTf8/PPPfP/99wwaNEjf3rRpU4YPH86uXbsMxs7Ly0sfX7Nmzbh37x6rVq3i66+/1tdNVqlUqY6bSqVCrVbr15UrV4569eqxdOlSFi1alGbclpaWqNVqXFxcjOoR5kQajQaVSoWbm1uuTmKYg4yd6WTsTCdjZ7q8Mnbx8aoMJWwBNBoV8fE2+nJUpnh53HLD7zaR+6jVamrXrq1fbtKkCX/++SebN29OM2n79OlT7Ozs3lSIQgghRI4TFRWlfwM9L3J0dMTNzc3cYRjIU0nbjKhVqxbnz5/PUF8bGxtsbGwIDAwkMDBQ/1SqWq1O9QZMrVYbvG5liuBgeLHyg+5GSffvmBgVHTvC1q3Qvr1Jh9DTvdYPxk/a6panTp1Ku3btCAkJoW7dugbrdP8OCgpi4MCBREVFGUzqVrVqVapWrUpQUBAAAwYM4MyZMyxatAh/f3+uXbtGrVq1WL16NY6OjgwfPpw9e/bg5ubG559/To8ePYxifjHOmjVrAhAWFsalS5eoXLky+/bto0WLFvo+KSkpFC9enD59+jBnzpxUxyE0NJRjx44xa9Ysg/0vWLCA6tWrM3jwYKNtLCwsaNOmjdF4vBzfqlWrCAsL0/+H/6rPxovrunXrxrRp05g/f36aCXLd/tL6TOZEuS3enETGznQydqaTsTNdXhg7FxdQq59POpYetRpcXFSo1a83+dCL45abx07kLg4ODjx79gzQXluWKFGCVatW8dtvv7F582YKFy7Mn3/+SWJiIjNmzODHH38kIiKCkiVLMnXqVHr37q3fV0hICLNnz+bMmTM8evSIMmXKMG7cOPr27ZtuDHv27KFz5858/PHHqb7t96KgoCAWLFjA33//jYuLCwMGDGDmzJlYWFjo49+4cSNdu3Y12K5GjRqUKVOGn376CdBOhvjJJ5+wZ88e4uPjqVmzJgsXLuTdd9/Vb+Pt7U3btm31ZSRiYmJo0qQJy5cvz3E3t0IIIbJHVFQUvQf25n7cfXOHkm1cHFxYt2pdjvrdlqeStq6urlhYWHDv3j2D9nv37r3W6/N+fn74+fkRGxuLk5PT64aZppw42Ufbtm2pVq0aM2bMYO/eva+9v4iICMaNG8enn36KlZUVo0ePpk+fPuTLl4+GDRsydOhQli9fzgcffEDt2rXx8vJKc1+hoaEAFC5cmEqVKuHr68vKlSsNkrZ79uwhPDzc4EnZlx04cABLS0tq1aqlb7tz5w7//PMPkydPNvlcX4zPFHXr1iU6Oprz589To0YNk+MQQgghTFWvHmzenLG+Go3hF89C5GS6klS68gi//PKL0XXfpEmTeP/99/npp5/0pcO6d+/O8ePHCQgIoHz58uzatYsPPviAAgUK6L/Qv3nzJvXq1WP48OHY2try22+/MXjwYDQaDf379081ns2bN9O7d29mzZr1yjfIFixYwIQJE/joo4+YP38+V65c4dNPPyUlJYUvvvgCb29vateuzfr16w2StteuXePs2bMEBAQA8PDhQ+rXr4+9vT1LlizBycmJJUuW0LRpU65du2bw1HxwcDDXrl0jMDCQ6OhoPvroI0aNGvXKyRSFEELkDbGxsdyPu49NQxvsXPLemydP7z/l/tH7xMbGStI2u1hbW/Puu+9y4MABOnbsCGhfsztw4AAjR440b3AZsHGjdhKPV9FN9rFpE3zwQfbHNWXKFLp06cLp06cNEpumePDgAUeOHKFixYoAhIeHM2rUKCZOnMjUqVMB7ROqmzdvZuvWrYwZM8Zg++TkZBRF4caNGwwfPhwrKys6dOgAwNChQxk5ciQPHz6kQIECAKxcuZK6detSrly5NGP6/fffKVu2LDY2Nvo23SRjxYsXz/C56WrSJiQksH//fr799lvq1KlDkSJFMryPF1WsWBELCwtOnTolSVshhBBv3G+/weefZ6yvSgXOzvDSQ31C5Ejx8fFG5cF69OhhVBqhatWqfP/99/rlQ4cOERwczN69e2nZsiUALVq04O7duwQEBOiTtj179tRvoygKDRs25M6dOyxdujTVpO3atWsZPHgwixcvZvjw4enGHhcXR0BAABMmTODz//0H2qJFC6ytrfH39+fjjz/GxcWFXr16MXHiROLi4vTlv3766ScKFChAq1atAFi0aBExMTGcPn1an6Bt1qwZZcuWZd68eQZvqSmKQnBwsP56OSwsjM8//xyNRiNPxAsh3krDhg0jJCSEW7duodFoKFu2LOPHj6dXr17A8zeSUxMQEMD06dNTXbdkyRLWrVvH9evXiYuLo1ixYvTp00f/4Ju52bnYkd89v7nDyBaJJJo7BCO5Lmn7+PFjrl+/rl8ODQ3l/PnzFCxYkOLFi+Pv70///v2pUaMGtWrVYtGiRcTHx6f5H8ubUKMGRES8ut/9TD5lPnQoZHSuBA8PMHVy2k6dOvHOO+8wc+ZMduzYYdpO/qdw4cL6hC1A2bJlAWjevLm+zdnZmUKFCnH79m2Dbf/73/8a/E+qcOHC/Pjjj7zzzjuA9gL5o48+Yt26dfj5+REdHc327dv57rvv0o3p7t27aX6TkpkyFxMnTmTixIn65RYtWrBs2bIMb/8yS0tLnJ2duXv3rsn7EEIIIUyxdi0MGQJJSc/bVKrU3wTS/apcvTr73wASOc+CBQtYsGDBK/tVr16d4OBgg7b27dtz7ty5V27r7+9vMM9EXFwcy5cvN2jLDDs7O44ePQpAYmIiZ8+eZdq0aQwdOpSVK1fq+73//vsG2+3bt4+CBQvStGlTg4llW7RowfDhw0lJScHCwoKHDx8SEBDAtm3b+Pfff/Ul1lxcXIxiWbZsGUFBQaxYscKofMLLk9daWlpy4sQJHj9+TLdu3QzWN2/enKdPn3Lp0iUaNWpE9+7d+eijj9i6dat+v+vXr6dLly5YW1vrz6dJkyYULFhQvy8LCwsaNWrE77//bnDsRo0aGTzgUKFCBZ49e0ZkZORrTwgshBC50fLly6levTrdunXj4sWL/P777/Tu3ZsCBQrQunVrKlSoYPAQ2tOnT/X5AV0eJDW//PILERERtG7dmkePHrFjxw5mzJhBYmIis2fPzvbzEjlLrkvanjlzhiZNmuiXdRdr/fv3JygoiB49ehAVFcW0adOIiIigatWq7Nmzx2hyssx4uaZtZkVEwP8e3MxSCQnZs9+XqVQqPv30U3r16pWhC+v0ODs7GyzrLhpTa09ISDBoK1WqFOvXr0elUuHp6Ymnp6dBUjV//vz06tWLFStW4Ofnxw8//ICNjQ3du3dPN6aEhASDi1BA/3TsrVu3MnxuY8aM4YMPPsDGxgZvb2+DSc1Ae6Gd1mcoJSUl1W/NbGxsePr0aYZjEEIIIV6HRgNTpsCL9wTNmsGgQTBypPZNH12NW92/nZ21CdusnCRV5B6xsbH6N5TSU6xYMaO2qKioDG378qQniqK81kQoarXa4C2mevXqkZyczLhx4/D398fe3h7A6P4hOjqaBw8epPmk0927dylatCgDBgzgxIkTTJs2jYoVK+Lo6Mi3337Lhg0bjLb55ZdfKF68uFGCGIwnC1YUhejoaECbBE+N7qEHDw8PmjRpwk8//UTfvn25cOECV65cITAw0OB8Tp48mer5lCpVymA5rWv4l6/XhRDibXHy5El8fX0B7ZdsZcuWJTQ0lN27d9O6dWtq1apl8Kbyt99+C2h/H6aXo5g7dy41atTQ5zr69evH2rVr2bVrF7Nnz+aXX36ha9euFC5cWP9gW5UqVbhx4wbff/99qnPyiNwr1yVtGzdujJJWwdf/GTlyZJaWQ3jdmrYZ/fL5/n1tIjajbG21E4RkZQxp6d69O9OnT+ezzz4zqjOrm9k56cXHcdDWycpKtra2rywTMHToUJYtW8aFCxdYtWoV3bt31194p6VgwYKEhYUZtBUtWpRSpUqxd+9eZs2alaH4ihYtmm58bm5uRKTxyPXdu3dTnW07JiYm1acyhBBCiKwWHw/9+hnWsB0+HBYvBisr6NxZW5ppyxZ48AAKFtTWsO3aVZ6wfZs5OjpmqBRUam81ubm5ZWhbR0dHg2WVSmXU9rrKly8PaN/s0t2Ev/zGVcGCBXFzc2PXrl2p7qNQoUIkJCSwY8cOFixYwKhRo/TrNGnM5rdmzRrGjRtHq1atOHDggMF5vfy0qy4G0NbATS0RXqJECf2fe/XqxYgRI7h//z7r16/H09OTRo0aGeyrdevWfPbZZ0b7efmBBiGEEIZ0vyt0EhO1r9an9ntNo9GwcOFCAMaOHZvmROPwfML1tPbbpUsX/VxAY8eOxc7Ojhs3btC9e3dJ2OZBuS5pmxtltCzB2rXam6WMWr78zdS0Be0TCZ9++in9+/encePGBuuKFi0KwJUrV/STbl25csWovMGbUKNGDapWrcro0aO5ePEi33zzzSu38fHx4dChQ0bt/v7++Pn5sXr1aqP6YxqNhr1796b6VERaGjVqxNKlS3n06JFB8v/YsWPcv3+fhg0bGvSPioriyZMn+Pj4ZPgYQgghhCn+/RfatwfdCzVqNSxcCKNGPS9/YGurve54U9ceInd4uXRBZrxcLiGjHBwcTD5mWi5dugRoJzZOS/PmzZkzZw7W1tZUrlw51T6PHj1Co9Hon0QFbTmHtM7V3d2dAwcO0LBhQ9q0acO+ffvIn19bKzC1hwHq1KlDvnz5uHPnDp1eMfNf586d+c9//sOmTZtYv349PXr0MKg/27x5c3744QfKly+vP6YQQojM0Wg0DB8+nPDwcCpWrMiIESOM+ugmc3RycmLo0KEZ3vfq1avZuHEj9vb2fPHFF/r2RYsWcfz4cVavXg2Al5fXa5VmFDmXVI3PgMDAQCpUqGD0jUdW69YNChR4fnOUFpVK2+9NT/bRu3dvSpYsaZTg9PX1pVixYnz00Ufs3LmTn376iZ49e5rtCdGhQ4dy9OhRfHx8qFev3iv716tXj8jISO7cuWPQPmLECHr27MngwYMZNmwYO3fu5MiRIyxdupQaNWqwfPnyTMU1ZswYLC0tadCgAT/88AMHDx5k8eLFdOzYkQYNGtCiRQuD/mf+l+2vX79+po4jhBBCZMbZs1Cr1vOErYMD7NgBo0e/+ppEiNxIo9Fw8uRJTp48ydGjR1m4cCGzZs2iQoUKRl+iv6hFixa0a9eO1q1bs2jRIg4ePMj27dv54osvGDJkCABOTk7UrFmTL774gk2bNrF161ZatGiR7tt6RYoU4cCBA9y+fZv27dunW3LA2dmZmTNnMmHCBCZOnMju3bvZt28f3333HW3atOHJkyf6vrq6ijNnziQsLIzevXsb7Mvf3x+VSkWjRo1Yu3YtR44cYdOmTXz88cf6J8KEEEKkLT4+nk6dOrFixQqqVavGwYMHjcokAsybNw+A4cOHp7o+NZ999hkDBgygYMGC7N+/3+DLwnz58jF69Gj98ogRI0x6K1zkfJK0zQA/Pz8uX76c6itKWcnWVlsTDtK+STLnZB8WFhZMmjTJqN3KyootW7Zga2tLt27dmD17NgsWLMjQ627ZQffUwaBBgzLUv3Hjxri4uLB7926DdpVKxbp16/j++++5fPkyvXr1omXLlixYsIBmzZrx9ddfZyouT09PTpw4Qbly5Rg7diytWrVi/vz59OvXj507dxrNvLt7924aNGjwWvWYhRBCiPT88gs0aADh4dplb28ICYE2bcwalhDZ6unTp9SpU4c6derQrFkzlixZwgcffMChQ4deOTP3pk2bGD58ON988w1t2rRh8ODB7Nu3z6DswLp16yhdujT9+/dn9OjRdO3alX6veJ3O29ubgwcPcuXKFTp37mxUduxF48aNY9WqVRw6dIguXbrQrVs3li1bRs2aNQ2e8AVtiYTw8HBKlSpl9ACKi4sLJ0+epGrVqkycOJGWLVvy0UcfERYWZvTarxBCCEPh4eE0bNiQ4OBg2rVrx9GjR1MteXjq1Cl+++03rK2tDRKtAE+ePOGvv/7ir7/+0rclJSXRr18/pk2bRtmyZQ1q5+pEREQwbdo0LCwssLKyYvbs2UYlH0XeoFJeVSBW6Olq2j569CjVOloJCQmEhoZSokQJfZ1XUwQHw4ABqU/2UaBA1k32oSgKycnJWFpaGtXsys1WrlzJhx9+yO3btzM8m+24ceP4448/OHjwYIb6Z/fYJScnU7x4cb744ot0L/Kz6jP3pmg0GiIjIylUqJBRklqkT8bOdDJ2ppOxM11OHztF0U429umnz9vq1dPWq02l9Ogb8/K4veraS2SNN3WNK94u8rkRQuRlxYoV486dOzg6OtK/f3/99V6tWrUM3mzo1q0bmzZtYuDAgaxcudJgH4cPH6ZJkyYA+rmb+vbtyw8//IBKpaJv374UKFAA0NYhnzZtGoqi0Lp1a/bt28fkyZOxs7Nj6tSp1KlTh6NHj6ZbL/d13Lhxg26DuuHcyZn87nmvpE78vXhitsSwceVGo8k4s0NGr3Glpm0O1L699okXmewjc8LCwrh27RqfffYZPXr0yHDCFmD8+PGULl2aCxcuUKVKlWyMMmPWrVuHvb290WtsQgghxOtKTIShQ7W19HX69tXWype5h15fdHQ00dHRqFQqXF1dZUJRIYQQIg/SlVeMjY1lyZIl+vb+/fvr7+NDQ0PZsmULKpWKcePGZWi/urmBFEVhzZo1+nYvLy+mTZvG/Pnz2bdvH5UrVyYgIAALCwt27dpFSEgIM2bMSHVySZF7SdI2AwIDAwkMDCQlJeWNHVMm+8i86dOns27dOurWrcv8+fMzta2npydBQUFERUVlU3SZo1arWblyZbZ9SyaEEOLtFBmp/RL4xInnbZ9/Dp98IvVrTRUfH8/GjRvZtm0bJ06cIDo62mC9q6srderUoWPHjnTr1k0mfBJCCCHygIy8tF6iRAmSk5PTXN+4cWOj/Rw+fDjdfY4fP57x48cbtJ148cJO5CmSEcoAPz8//Pz89I8vi5wpKCiIoKAgk7fv1q1b1gXzmj6QbL0QQogsdumStrySruSZnZ32adsuXcwaVq51//59Zs+ezdKlS0lISKBy5cp06NCBkiVLUqBAARRF4eHDh4SGhnL27FmGDh3KqFGj+PDDD/nkk09wdXU19ykIIYQQQogcTJK2QgghhBB53O7d0KMHxMVplwsX1tbQf/dd88aVm3l7e1O6dGnmzp1Lly5dcHtFMeCoqCh++eUXli1bxrJly4iNjX1DkQohhBBCiNxIkrZCCCGEEHmUosDixeDvr53QFLSJ2m3boEgR88aW223atIlWrVpluL+bmxvDhw9n+PDh7N27NxsjE0IIIYQQeUHOm85YCCGEEEK8tmfP4D//gbFjnydsu3SBo0clYZsVMpOwzcptzSkj9fuE0JHPixBCCPF6JGmbAYGBgVSoUIGaNWuaOxQhhBBCiFd6+BDatIHvvnveNnky/Pwz5MtnvrjeFnfv3uXChQvEx8ebO5QsYWVlBcCTJ0/MHInITXSfF93nRwghhBCZI+URMkAmIhNCCCFEbnHtGrRtC3//rV22tobvv4e+fc0b19tg27ZtTJw4kWvXrgHw66+/0rRpU6Kjo2nRogUBAQF07NjRvEGawMLCAmdnZyIjIwHIly8fKpXKzFGJnEpRFJ48eUJkZCTOzs5YWFiYOyQhhMiwqKioPF933tHR8ZW1+EXOIElbIYQQQog84vBh6NxZ+6QtgJsbbNkC9eqZNay3wvbt2+ncuTN16tShd+/eTJ8+Xb/O1dWVIkWKsGrVqlyZtAXw8PAA0CduhXgVZ2dn/edGCCFyg6ioKHoP7M39uPvmDiVbuTi4sG7VOknc5gKStM1Jbt2C6OiM93d1heLFsy8eIYQQQuQa338PI0ZAcrJ2uWJF2LEDvL3NGtZbY+bMmTRs2JBDhw5x//59g6QtQJ06dVi6dKl5gssCKpUKT09PChUqxLNnz8wdjsjhrKys5AlbIUSuExsby/24+9g0tMHOxc7c4WSLp/efcv/ofWJjYyVpmwtI0januHULfHwgISHj29jawtWr2Zq4DQ4O5uuvv+bMmTM8fvyYIkWK0LJlS8aNG0fZsmUzvJ/Dhw/TpEkT/bK9vT2lS5dm1KhRDBw4MEtesQsMDCQoKIjff//doP3y5ct8+eWXHDp0iHv37mFra0vFihXp3LkzH374IQ4ODgAEBQUxcOBA/XZOTk6UL1+eTz75hA4dOhicx+nTp6latarBcc6fP0+1atU4dOgQjRs3Ji4uDi8vL7Zv3049ecRJCCFENklJgYkTYf78521t2sD69eDoaL643jaXLl1iwYIFaa53d3fPE0+pWlhYSDJOCCFEnmbnYkd+9/zmDiPbJJJo7hBEBslEZDlFdHTmErag7Z+ZJ3MzSZesdHJyYvny5ezfv59p06Zx+fJlevToYdI+V61aRUhICBs3bqR06dIMHjyYZcuWvXasT548YdasWXzyyScG7cHBwbz77rv8+eefTJ06lX379vHTTz9Rt25dPvvsMz7//HOjfe3Zs4eQkBDWrl2Lra0tHTt2ZO/evZmOycHBgVGjRjF58mSTz0sIIYRIT1wcdOxomLAdMwaCgyVh+6bly5cv3YnH/vnnH1xcXN5gREIIIYQQIjeTJ20zIDAwkMDAQFJSUswdyhuza9cuvvzyS6ZOncrMmTP17Q0bNmTgwIHs2LHDpP2+88471KhRA4AWLVpQvnx5lixZwocffvha8W7YsIFnz57pn4gFiIiI4IMPPqBBgwbs3LnTYOba9957j/Hjx3Pq1Cmjfb377ru4uroC0LhxY4oVK8aSJUto1apVpuMaNGgQM2fO5MKFC1SpUsWEMxNCCCFSd/MmtGsHf/6pXbawgMBAeM1fqcJETZo0YfXq1YwdO9ZoXUREBMuXL6dt27ZvPjAhhBBCCJEryZO2GeDn58fly5eNXrvPy+bPn4+7uztTp05Ndb3upiMsLAyVSsWmTZsM1o8dOxbvVxTRs7CwoFq1aoSGhgLaZGmfPn2M+k2cOJHChQunmzRfvXo1HTp0wNLy+fcQy5cvJy4ujoULFxokbHU8PDwMkrypcXBwwMfHRx9jZnl5eVGrVi2CgoJM2l4IIYRIzcmTUKvW84StszPs3SsJW3P6v//7P+7cuUPNmjVZunQpKpWKvXv3MmXKFCpVqoSiKAQEBJg7TCGEEEIIkUtI0lYYSU5O5rfffqNZs2apJjuzUmhoKIULFwZg6NChbNmyhUePHunXp6SksHbtWvr3759m/bSnT59y4sQJo7qxhw8fpkiRIlSsWNHk+FJSUrh9+7Y+RlPUrVuXX3/91eTthRBCiBf99BM0bgy68qilS2uTuM2amTWst56Pjw/Hjx/HxcWFqVOnoigKc+fO5fPPP6dSpUocO3bslV9oCyGEEEIIoSPlEd6EGjUgIiL9PklJpu27dWuwtn51Pw8POHMmQ7u8f/8+iYmJFM+GCc5SUlJITk7m0aNHLF26lN9//51JkyYB0Lt3b8aNG8e6desYMWIEoC3TcPfuXQYNGpTmPs+fP8+zZ8+oXLmyQXt4eDjFihUz6p+sm1Yb7UzILyeDdTFGRUUxa9Ys7t69azQDdGZUqVKFr776iri4OP2kZ0IIIURmaTQwYwa8ULWIxo1h0yaQUqk5Q8WKFdm/fz8PHz7k+vXraDQaSpYsKbMzCyGEEEKITJOk7ZsQEQH//ps9+46Kyp79ok1oZrXatWvr/2xpacnw4cOZNm0aAI6OjvTo0YOVK1fqk7arVq2iQYMGlClTJs193r17FyDVG6KXzyE6OtqgX8WKFbl06ZJBHw8PD/2f7ezsmDJlCkOHDs3oKRpxdXVFURTu3bsnSVshhBAmefoUBgyAn39+3jZkiLaGbUa+uxVvVoECBahZs6a5wxBCCCGEELmYJG3fhBeSgGlKSjItAevmlvEnbTPIxcUFW1tbbt26lfl4XmHNmjWUL18eR0dHvL29sX4p9qFDh1K3bl0uXryIp6cnO3bsYNmyZenuMyEhAQAbGxuD9sKFC3Pt2jWDNmdnZ31t4hkzZqRaq3b//v04OTlRoEABvLy8DOrk6v6cWn1dXdvLJSV0cT19+jTd8xBCCCFSc/cudOgAutL6KhXMmwcffaT9s8hZjh49yj///MPDhw9RFMVgnUql4qOPPjJTZEIIIYQQIjeRpO2bkJGyBOfOwbvvZn7fe/ZA9eqZ3y4dlpaW1KtXjwMHDpCcnGyQtHyZra0tAEkvlXd4+PBhqv3Lly9PjRo10txfnTp1qFixIitXrqR48eLY2trSrVu3dOMtWLAgADExMQZPyTZu3JiDBw9y5coVypcvrz833fFdXFxSTdpWqVIFV1fXVI+le0o3IpVyF+Hh4QAUKlTIoD0mJkZ/PCGEECIzzp+Hdu3gzh3tsr09rFunbRM5y/nz5+nRowfXr183StbqSNJWCCFEbjJs2DBCQkK4desWGo2GsmXLMn78eHr16qXv4+3tzc2bNw22s7CwMChLmJqgoCDmzJnDjRs3cHNz44MPPuCzzz7L9nl1hMhNZCKyDAgMDKRChQpv1Wtu/v7+RERE8H//93+prt+1axegTVBaWVlx5coV/bqkpCSOHDli8rGHDh3Kjz/+yIoVK+jRowf58+dPt7+Pjw+AUQJ26NChODg44O/vz7Nnz0yO50VlypTB09OTbdu2Ga3bunUrnp6elC5d2qA9LCwMJycng4SyEEII8SrbtkH9+s8TtsWLw2+/ScI2pxoyZAiRkZF89913nD9/ntDQUKOff/75x9xhCiGEEBm2fPlyrK2t6datG+XLl+fcuXP07t2bPXv2GPUdM2aMwU96tmzZwsCBA7l9+zY9e/bEysqKL7/8kk8++SS7TkWIXEmetM0APz8//Pz8iI2NxcnJydzhvBHvvfceEyZMYPr06Vy+fJmePXvi6upKaGgoK1eu5NGjR7z33nuo1Wo6d+7M119/TenSpXF1deXrr79GURSTa+L27duXiRMnEh0dzYoVK17Zv0SJEnh6enL27FnatGmjb/fw8GDt2rX06NGD2rVrM3z4cHx8fEhISODPP//kwIEDFC1aNFOxqdVqZsyYwbBhw1Cr1XTs2BGVSsW2bdtYuXIly5cvNzrvM2fOULduXdRq+Y5ECCHEqykKzJ0Ln3yi/TNA7dqwdSu4u5s1NJGO//73v8ycOfO16uALIYQQOcnJkyfx9fUFtBN6ly1bltDQUHbv3k3r1q0N+i5atCjD+/3ss88A+Pzzzxk1ahTnz5+nWrVqBAYGMmnSJI4cOULXrl0pXLgw//3vf7GysqJKlSrcuHGD77//nsGDB2fZOQqRk0kWSaTpyy+/ZOvWrTx48IBBgwbRrFkzAgICKFeuHBs3btT3W7JkCY0bN2b06NF8+OGHtG7dmk6dOpl83IIFC9KoUSMqVKhgMHFZerp27cru3buN2jt06MDZs2epWLEiM2fOpHnz5nTr1o1ffvmF0aNHs2/fvkzHN3ToUNatW8fFixfp0aMH3bt35/z586xbt44hQ4YY9H327Bn79++na9eumT6OEEKIt09SEgweDBMnPk/Y9uoFhw5JwjanK1OmTLZM4iqEEEKYiy5hq5OYmAhAkSJFjPoWLFgQR0dH6tatm+qTuDrJyclcvHgRgFq1agFQtWpVbGxsSExM5PLly3Tp0oWhQ4cSHh7O2LFjGT9+PDdu3KB79+6SsBVvFXnSNqdwdQVbW/jfpFoZYmur3S4bdejQgQ4dOqTbx83NjS1bthi1v/hNW+PGjdOs7/ay2NhYTpw4wfTp0zMc55AhQwgMDOTmzZt4eXkZrKtYsSJr1qx55T4GDBjAgAEDMnS8nj170rVrVywtLdO9Qdu3bx/Jycl07949Q/sVQgjx9oqOhi5d4OjR520zZ8KUKTLhWG4wffp0xo0bR69evVK9mRVCCCFyK41Gw/DhwwkPD6dixYqMGDFCv65gwYJUq1YNDw8Pzpw5Q0hICO3atePEiROplpiMjo7WT+Jtb2+vb7e3tycxMZG7d+8C2nzC8ePHWb16NQBeXl6vnKRciLxGkrY5RfHicPWq9o4to1xdtdvlEXFxcVy+fJlvvvkGlUrFwIEDM7xt5cqVad++PV999RULFizIxigzZ/78+YwbN87gl5EQQgjxsitXoG1b0JU8tbWF1atBvvPLPTp37kxCQgI+Pj40a9aMokWLYmFhYdBHpVLx1VdfmSlCIYQQIvPi4+Pp3bs3wcHBVKtWjT179uDg4KBff/bsWf2DTBqNhpo1a3Lu3Dl++eWXVJO2rq6uWFhYkJKSwuPHj/Xtuj97enoCkC9fPkaPHq1PEI8YMeKtKVcphI4kbXOS4sXzVBI2s86ePUuTJk0oVqwYq1evpmDBgpnafs6cOalOEGYujx8/plGjRjJLtBBCiHTt26dNzj56pF328NBOQva/NwZFLnHkyBFGjBjBkydP2L59e6p9JGkrhBAiNwkPD6ddu3acO3eOdu3asW7dOoMHku7fvw+Ai4uLvk33hm3C/94ifvLkCbdu3QKgXLlyWFpaUqlSJc6fP8/p06fx9fXljz/+IDExERsbGypUqABAREQE06ZNw8LCArVazezZs+nRowfe3t5v4tSFyBEkaStyjMyUUEhNmTJlGD9+fBZG9Hrs7e0JCAgwdxhCCCFysMBAGDMG/veWIFWrQnAwFCtm1rCECUaNGoWjoyObNm3C19cXR0dHc4ckhBBCvBZfX1/u3LmDo6Mj3t7eTJkyBdDWou3duzd//vknbdq0oWnTphQrVoyzZ8/yxx9/YGlpSe/evQE4ffo0TZo0AZ4ndKdMmULXrl2ZPHkyZ8+e5ciRI4D2aVpXV1cURaF///5ERUUxefJk7OzsmDp1Kr179+bo0aNYWkoqS7wdZCIyIYQQQog3LDkZRo2CkSOfJ2w7dIBjxyRhm1tdv36djz/+mBYtWkjCVgghRJ5w584dQDvvzJIlS/jqq6/46quv9BN6ly5dmm7dunH58mWCgoK4efMmLVu25NChQ/pJxlLTpUsXvv/+e4oWLcq6detISkri448/5ssvvwS0ZQb37dtH5cqVCQgIYNKkSdSpU4eQkBBmzJiR/ScuRA4hX08IIYQQQrxBjx5Bjx6wd+/ztgkTYPZsUMvX6blWxYoVeaSrcSGEEELkAa96E7Zo0aKvnPQ7rTdqBw8ezODBg1PdZvz48UZv0Z44ceIV0QqR98itQTZ4nVf8hcgM+awJIUTucuMG1KnzPGFrZQWrVsGXX0rCNrebN28eS5cu5fTp0+YORQghhBBC5AHypG0WsrKyArSFtu3s7MwcjXgbPHnyBHj+2RNCCJFzHTsGnTrB/+bswMUFNm+Ghg3NG5fIGvPnz8fBwYE6depQoUIFihcvjoWFhUEflUqV6UlTZ8+ezebNm/nrr7+ws7Ojbt26fPnll/j4+Oj7NG7cWF8PUOfDDz/ku+++M/2EhBBCCCGEWUnSNgMCAwMJDAwkRVd0Lg0WFhY4OzsTGRkJQL58+VCpVG8iRJMoikJycjKWlpY5Os6cyNxjpygKT548ITIyEmdnZ6ObQiGEEDlLUBAMGwbPnmmXy5eH7duhVCmzhiWy0MWLF1GpVBQvXpzHjx9z+fJloz6mXDMcOXIEPz8/atasSXJyMpMnT6Zly5ZcvnyZ/Pnz6/sNHTqUmTNn6pfz5ctn2okIIYQQQogcQZK2GeDn54efnx+xsbE4OTml29fDwwNAn7jNyRRFQaPRoFarJWmbSTll7JydnfWfOSGEEDmPRgOTJ2vLH+i0bAk//wyvuKQQuUxYWFi27HfPnj0Gy0FBQRQqVIizZ8/S8IXHtPPlyyfXBEIIIYQQeYgkbbOYSqXC09OTQoUK8Uz3OE0OpdFouH//Pi4uLqilkF6m5ISxs7KykidshRAiB4uPVzF8uIoX34b384NFi8BSrsCEiXSTnRUsWNCg/ccff+SHH37Aw8ODdu3aMXXqVHnaVgghhBAiF5NbhmxiYWGR4xNqGo0GKysrbG1tJWmbSTJ2Qggh0nPnDnTsWJBLl7RvY1hYwFdfaZO2Im+4desWAMWLFzdYfhVdf1NoNBrGjh1LvXr1eOedd/TtvXv3xsvLi8KFC3Px4kUmTpzI1atX2bx5c6r7SUxMJDExUb8cGxur379GozE5PiGEELlDdHS0/v/9eZWjoyOurq6Z2kZRFFQqFbp/8iIVKlQqlf7t4YzK62Nj6riYKqPHkKStEEIIIUQW+v13aN9eRUSEdpJIR0fYuFFbFkHkHd7e3qhUKp4+fYq1tbV++VVeNUdCevz8/Lh06RLHjx83aB82bJj+z5UqVcLT05NmzZpx48YNSqVSOHn27NnMmDHDqD0qKoqEhAST4xNCCJHzPXr0iHlfzSPuaZy5Q8lWDnYOjB8z/pUlLl8UFxdHmRJlyG+XH1sL22yMznwS7BKILxFPXFxcpsp65vWxMXVcTBUXl7H//iRpK4QQQgiRRX7+Gfr3h4QEbfKuZEmF7dtVVKhg5sBEllu5ciUqlQorKyuD5ewycuRIduzYwdGjRylatGi6fX19fQG4fv16qknbSZMm4e/vr1+OjY2lWLFiuLm54ejomLWBCyGEyFEeP37MucvnsGlgg52LnbnDyRZP7z8l8VgiFhYWFCpUKMPbPX78mGuh13Cu4kx+x/yv3iAXin8aT0xoDA4ODjI2LzB1XExla5uxxLckbYUQQgghXpOiwKxZMG3a8zZf3ySCgy0pVCjvvUImYMCAAekuZxVFURg1ahRbtmzh8OHDlChR4pXbnD9/HgBPT89U19vY2GBjY2PUrlarpeyTEELkcbpXwG1dbMnnnjdrnysoJCgJqFSqTP1e042N7p+8SEHRlzqQsXnO1HExVUaPIVdlQgghhBCvISEBPvjAMGHbv7/Chg0PyGQpNZGLDRo0iFOnTqW5/vTp0wwaNCjT+/Xz8+OHH35g3bp1ODg4EBERQUREBE+fPgXgxo0bfPbZZ5w9e5awsDCCg4Pp168fDRs2pHLlyiafjxBCCCGEMC9J2gohhBBCmOjePWjSBNat0y6rVPDll7BihUIqDzKKPCwoKIgbN26kuT40NJTVq1dner/ffvstjx49onHjxnh6eup/NmzYAIC1tTX79++nZcuWlCtXjnHjxtGlSxe2b99u8rkIIYQQQgjzk/IIQgghhBAm+PNPaNsWbt3SLufLBz/+CB07whuYdFbkMuHh4djZZb52oKKk/wpisWLFOHLkiKlhCSGEEEKIHEqStkIIIYQQmbRjB/TqBY8fa5eLFoXgYKhWzbxxiTdr27ZtbNu2Tb+8bNky9u/fb9QvJiaG/fv3U7NmzTcZnhBCCCGEyMUkaSuEEEIIkUGKAgsXwvjx2j8D1KwJ27ZBGnM+iTzs8uXLbNy4EdBO0HHq1CnOnj1r0EelUpE/f34aNmzIggULzBGmEEIIIYTIhd66mra3b9+mcePGVKhQgcqVK+svtIUQQggh0pOUBB9+COPGPU/Ydu8Ohw9LwvZtNWnSJOLi4oiLi0NRFFasWKFf1v3ExsZy9+5dduzYQdmyZc0dshBCCCGEyCXeuqStpaUlixYt4vLly+zbt4+xY8cSHx9v7rCEEEKIXGv69OmoVCqDn3LlymXb8RRFYdq0aXh6emJnZ0fz5s25du2aUb+dO3fi6+uLnZ0dBQoUoGPHjiYf88EDaN0ali9/3jZtGvz0k7aWrRAajYbevXubOwwhhBBCCJFHvHVJW09PT6pWrQqAh4cHrq6uPHjwwLxBCSGEELlcxYoVuXv3rv7n+PHjJu9r+vTpDBgwIM31c+bMYfHixXz33XecOnWK/Pnz06pVKxISEvR9fvnlF/r27cvAgQO5cOECv/32m8kJtb//htq14dAh7bKNjXbCsRkzQP3WXUkJIYQQQggh3oRcd6tx9OhR2rVrR+HChVGpVGzdutWoT2BgIN7e3tja2uLr68vp06dT3dfZs2dJSUmhWLFi2Ry1EEIIkbdZWlri4eGh/3F1ddWvi4mJYciQIbi5ueHo6EjTpk25cOGCScdRFIVFixYxZcoUOnToQOXKlVmzZg3h4eH6a4Lk5GTGjBnD3LlzGT58OGXLlqVChQp0794908c7cAB8fUH3IG+hQtrkrTxQKYQQQgghhMhOuS5pGx8fT5UqVQgMDEx1/YYNG/D39ycgIIBz585RpUoVWrVqRWRkpEG/Bw8e0K9fP5YtW/YmwhZCCCHytGvXrlG4cGFKlixJnz59uHXrln5dt27diIyMZPfu3Zw9e5bq1avTrFkzk950CQ0NJSIigubNm+vbnJyc8PX1JSQkBIBz587x77//olarqVatGp6enrRp04ZLly5l6ljLlmlLIsTEaJcrVYLTp6FOnUyHLYQQQgghhBCZYmnuADKrTZs2tGnTJs31CxYsYOjQoQwcOBCA7777jp07d7Jy5Uo++eQTABITE+nYsSOffPIJdevWTXNfiYmJJCYm6pdjY2MBbc0yjUaTFadjVhqNBkVR8sS5vGkydqaRcTOdjJ3pZOxMl9Gxq1mzJitXrsTHx4e7d+/y2Wef0aBBAy5evMiFCxc4ffo0ERER2NjYANryBlu3buXnn39m2LBhRvtTFCXN44aHhwPg5uZmsL5QoULcvXsXjUbD9evXAW2ZhXnz5uHt7c2CBQto3Lgxf/31FwULFkz3fFJSYPx4FYsXq/Rt77+v8OOPCg4OkJGPknzuTPPyuMn4CSGEEEKIt1WuS9qmJykpibNnzzJp0iR9m1qtpnnz5vqnbxRFYcCAATRt2pS+ffumu7/Zs2czY8YMo/aoqCiDunm5lUaj4dGjRyiKglqK8mWKjJ1pZNxMJ2NnOhk702V07N599139nz08PFi1ahU1a9ZkxYoVJCYm8vjxY4NyCQAJCQlcunSJyMhITp48SZ8+ffTrnj17hqIobNq0Sd82Z84cunTpwsOHDwGIjo7GwsJCvz4xMRGVSkVkZCQx/3s0duTIkTRo0ACAL774gn379rFy5Ur69euX5rnExakYMcKJAwds9W3Dh8czZUocT5/C06fpjdhz8rkzzcvjFhcXZ+6QhBBCCCGEMIs8lbSNjo4mJSUFd3d3g3Z3d3f++usvAH777Tc2bNhA5cqV9bXv1q5dS6VKlYz2N2nSJPz9/fXLsbGxFCtWTF+TL7fTaDSoVCrc3NzkhjKTZOxMI+NmOhk708nYmc7UsStUqBA+Pj5ERkbi7OyMp6cnBw8eNOrn7OyMq6srLVq04I8//tC3L1myhH///ZcvvvhC3+bu7o6DgwPly5cHICUlhUKFCunXP3r0iCpVquiPDeDr62vQp3Tp0sTExBi0vSgsDDp3VnHpkvYJW0tLhcBAhSFD7AC7DJ8/yOfOVC+Pm62t7as3EkIIIYQQIg/KU0nbjKhfv36GX7WzsbHRv8r5IrVanWduwFQqVZ46nzdJxs40Mm6mk7EznYyd6UwZu8ePH3Pjxg369u1L+fLliYiIwNraGm9v71T758+fn7Jly+qXXVxciIuLM2jTKVWqFB4eHhw6dIjq1asD2i9VT506xYgRI1Cr1dSsWRMbGxuuXbtGw4YNAe3Tu2FhYRQp4s2PP6rZuhXu3wcXF+jYEYoWhR49ICpKe5wCBeCXX1Q0aaIyiiGj5HNnmhfHLaePXdOmTdNcp1KpsLW1xcvLi/fee4+2bdu+wciEEEIIIURul6eStq6urlhYWHDv3j2D9nv37uHh4WHyfgMDAwkMDCQlJeV1QxRCCCHynPHjx9OuXTu8vLwIDw8nICAACwsLevXqhaurK3Xq1KFjx47MmTOHsmXLEh4ezs6dO+nUqRM1atTI1LFUKhVjx45l1qxZlClThhIlSjB16lQKFy5Mx44dAXB0dGT48OEEBARQrFgxvLy8mDt3LomJ8Omn3Xj0CNRqbW1atRo2bzY8RtmysGMHlCmTRQMk8qzIyEhUqrQT+0+ePOHXX39l6dKltGrVim3btmFlZfUGIxRCCCGEELlVnkraWltb8+6773LgwAH9jZtGo+HAgQOMHDnS5P36+fnh5+dHbGwsTk5OWRStEEIIkTfcuXOHXr16cf/+fdzc3Khfvz4nT57Ezc0NgF27dvHpp58ycOBAoqKi8PDwoGHDhkbljDJqwoQJxMfHM2zYMGJiYqhfvz579uwxeJV+7ty5WFpa0rdvX54+fUrJkr48enQQlaoA8HwysZdfvqlUCY4c0T5pK8SrXLp06ZV9nj59ytKlS/H392fOnDl8+umnbyAyIYQQQgiR2+W6pO3jx4/1s0IDhIaGcv78eQoWLEjx4sXx9/enf//+1KhRg1q1arFo0SLi4+MZOHCgGaMWQggh8q7169enu97BwYHFixezePHiDO1v+vTp6a5XqVTMnDmTmTNnptnHysqKefPmMW/ePBISoHBhUKlAUdI/9u3bYJe58rVCpMvOzo6xY8dy+vRp1q1bJ0lbIYQQQgiRIbkuaXvmzBmaNGmiX9ZNFNa/f3+CgoLo0aMHUVFRTJs2jYiICKpWrcqePXtMfpoHpDyCEEIIkZtt3AgPH2asb0wMbNoEH3yQrSGJt1C9evX0k+AKIYQQQgjxKrkuadu4cWOUVzwmM3LkyNcqh/AyKY8ghBBC5F5btz6vYfsqajVs2SJJW5H1njx5gqVlrrv0FkIIIYQQZpKzp+QVQgghhHhN9+9nLGEL2n4PHmRvPOLtoygKwcHBVKpUydyhCCGEEEKIXEK+7hdCCCFEnubikrF6tqB90rZgweyPSeQND16R4X/69ClXr17l22+/5cSJE/zwww9vKDIhhBBCCJHbSdI2A6SmrRBCCJF7OTllLGEL2idtO3XK3nhE3uHq6opKpXplPysrKz777DN69er1BqISQgghhBB5gSRtM0Bq2gohhBC5j6LAjBmwalXG+qtU4OwMXbtma1giD5k2bVq6SVtbW1u8vLxo1qwZbm5ubzAyIYQQQgiR20nSVgghhBB5TnIyjBgB339v2J5WmQRd3m31arC1zf74RN4wffp0c4cghBBCCCHyKEnaCiGEECJPiY+Hnj1hx47nbQsWQKlSMGAAPHyorV2r0Tz/t7OzNmHbrp25oha52alTpwgNDcXFxYUGDRpgK5l/IYQQQgjxmiRpmwFS01YIIYTIHaKjoW1bOHVKu2xtDWvWQI8e2uXwcNi0CbZsgQcPtJOOdeqkLYkgeTaRWXFxcbRp04aQkBB9m4eHBzt37qRq1armC0wIIYQQQuR6krTNAKlpK4QQQuR8oaHQqhVcu6ZddnSErVuhSZPnfWxt4YMPtD9CvK45c+Zw4sQJOnfuTNOmTbl+/Trffvst/fv358KFC+YOTwghhBBC5GKStBVCCCFErvfHH9CmDdy7p1329IQ9e6ByZfPGJfK2zZs307lzZzZt2qRvK1euHCNGjCA0NJQSJUqYMTohhBBCCJGbqc0dgBBCCCHE6/j1V2jY8HnCtnx5CAmRhK3IfmFhYbRs2dKgrVWrViiKwp07d8wUlRBCCCGEyAskaSuEEEKIXOuHH+C99+DxY+1y3bpw/Dh4eZk3LvF2ePr0Kfb29gZtuuVnz56ZIyQhhBBCCJFHSHmEDJCJyIQQQoicRVFg7lyYOPF5W8eOsG4d2NmZLSzxFoqPj+fBgwf6Zd2f4+LiDNp1ChYs+MZiE0IIIYQQuZckbTNAJiITQgghco6UFPD3h8WLn7eNGAFLloCFhfniEm+n4cOHM3z4cKP2zp07p9pfHgIQQgghhBAZIUlbIYQQQuQaCQnQty+8MO8Ts2bB5MmgUpkvLvF2CggIMHcIQgghhBAij5KkrRBCCCFyhZgYbQmEI0e0yxYWsHw5DBxozqjE20yStkIIIYQQIrtI0lYIIYQQOd6dO9CmDVy6pF3Ol0/7tG2bNuaNK8+7dQuiozPe39UVihfPvniEEEIIIYR4S0jSVgghhBA52n//C61baxO3AG5usHMn1Kxp3rjyvFu3wMdHW5Mio2xt4erVtypxe/v2bdRqNUWKFAEgISGBb775xqhf0aJF6d69+5sOTwghhBBC5FKStM2AwMBAAgMDZeIIIYQQ4g07dgzat9eWRgAoWRL27oXSpc0a1tshOjpzCVvQ9o+OfmuStn/++SfVqlVj0aJFjBw5EoD4+HjGjx+PSqVCURR9XwsLC8qXL0+lSpXMFa4QQgghhMhF1OYOIDfw8/Pj8uXL/P777+YORQghhHhr/PILtGjxPGFbowacOCEJW5FzLF26FC8vL/7zn/8Yrfvhhx8IDQ0lNDSUGzduULhwYZYuXWqGKIUQQgghRG4kT9oKIYQQIsf5+msYPRp0Dyq2aqWtYWtvb964hHjRoUOH6Ny5M2q18XMQ7u7ueHl56Zd79+5NcHDwmwxPCCGEEELkYvKkrRBCCCFyDEWByZNh1KjnCdv+/WH7dknYipwnLCyMcuXKGbRZWlpSpUoVHBwcDNpLlCjBzZs332R4QgghhBAiF5MnbYUQQgiRIzx7BkOGwJo1z9smT4ZZs0ClMl9cQqRHo9EYLDs5OfHHH38Y9Xu5xq0QQgghhBDpkaStEEIIIcwuLg66ddNOMgbaJO2SJeDnZ964hEhP0aJFuXDhQob6XrhwgaJFi2ZzREIIIYQQIq+Q8ghCCCGEMKt796BJk+cJWxsbbf1aSdjmbNMB1Us/5dLb4DUpisK0adPw9PTEzs6O5s2bc+3aNf36w4cPo1KpUv3JrslkW7RowY8//khkZGS6/SIjI/nxxx9p0aJFtsQhhBBCCCHyHknaZkBgYCAVKlSgZs2a5g5FCCGEyFOuXYO6deHsWe2yszPs3w+dO5s1LAHaehWvUBG4+8LP8dc43PTp0xk4cGCa6+fMmcPixYv57rvvOHXqFPnz56dVq1YkJCQAULduXe7evWvwM2TIEEqUKEGNGjVeI7K0jR8/nmfPntGsWTPOnDmTap8zZ87QvHlznj17xrhx47IlDiGEEEIIkfdIeYQM8PPzw8/Pj9jYWJycnMwdjhBCCJEnnD4N778P0dHa5WLFYM8eqFDBvHEJICoKRox4ZTdLwCONdTExMYwfP55t27aRmJhIjRo1WLhwIVWqVMl0OIqisGjRIqZMmUKHDh0AWLNmDe7u7mzdupWePXtibW2Nh8fzaJ49e8a2bdsYNWoUqmwqiuzt7c369evp1asXvr6+lC5dmnfeeQd7e3seP37MpUuXuH79OnZ2dqxbt44SJUpkSxxCCCGEECLvkSdthRBCCPHG7dqlLYmgS9i+8w6cOCEJ2xzhwgWoWRNSmUzrZdeAwkBJoA9w64V13bp1IzIykt27d3P27FmqV69Os2bNePDgQaZDCgsLIyIigubNm+vbnJyc8PX1JSQkJNVtgoODuX//frpP72aFtm3bcuHCBYYMGUJ8fDxbtmxh7dq1bNmyhcePHzN48GDOnz9P+/btszUOIYQQQgiRt8iTtkIIIYR4o1auhGHDICVFu9y4MWzZoi2NIMxsyxbo2xfi41/Z1RcIAnzQlkaYATQALsXHc+H4cU6fPk1kZCQ2NjYAzJs3j61bt7Jp0yaGDRuWqbB0NWPd3d0N2t3d3YmIiEh1mxUrVtCqVas3MvlXyZIlWbp0KQBxcXHExsbi4OCAo6Njth9bCCGEEELkTZK0FUIIIcQboSjwf/8HU6c+b+veHdas0U4+JsxIUWDWLJg27Xlb5cpw9SokJqa6SZsX/lwZbRLXC/g5JISE/Pl5/PgxLi4uBts8ffqUGzduAHDs2DHatHm+l6SkJBRFYdOmTfpyBosWLTLpdO7cucPevXv5+eefTdr+dTg4OODg4PDGjyuEEEIIIfIWSdoKIYQQItulpMDIkfDdd8/bxoyBBQtALcWazCs+HgYOhI0bn7f16QPLl2tr2+pqWLyCM1B24ECuP3yIs6Lg6enJ4cOHjfv975HqGjVqcP78eX374sWLuXPnDh9//DEuLi6o1Wrs7OwAKFSoEAD37t3D09NTv829e/eoWrWq0TFWrVqFi4uLlCQQQgghhBC5liRthRBCCJGtnj6FXr1g27bnbXPnwrhxkE3zQ4mMunULOnZ8Xr9WpYIvvoCPP9b+uXhx7U8GPH78mBu3b9PX05Py5csTERGBpaUl3t7eqfa3s7OjdOnS+uWCBQvy6NEjSpQoQaFChVCr1cTGxgLaCb88PDw4cOCAPkkbGxvLqVOnGPHShGmKorBq1Sr69euHlZVVpoZDCCGEEEKInEKStkIIIYTINvfvQ/v22knGAKysYNUq7YOcwsxOnIBOneB/9WJxcIB166Bt2wxtPn78eNq1a4eXlxfh4eEEBARgYWFBr169cHV1pU6dOnTs2JE5c+ZQtmxZwsPD2blzJ506daJGjRqZClWlUjF27FhmzZpFmTJlKFGiBFOnTqVw4cJ07NjRoO/BgwcJDQ1lyJAhmTqGEEIIIYQQOYm8kCiEEEKIbHHzJtSv/zxha28Pu3ZJwjZHWLVKOwOcLmFbqhScPJnhhC1o68b26tULHx8funfvjouLCydPnsTNzQ2VSsWuXbto2LAhAwcOpGzZsvTs2ZObN28aTSaWURMmTGDUqFEMGzaMmjVr8vjxY/bs2YOtra1BvxUrVlC3bl3KlStn0nFymtmzZ1OzZk0cHBwoVKgQHTt25OrVqwZ9EhIS8PPzw8XFBXt7e7p06cK9e/fMFLEQQgghhMgK8qStEEIIIbLchQvQpg3cvatd9vDQJmyrVTNvXG+95GSYMAEWLnze1rQp/PwzvDRp2KusX78+3fUODg4sXryYxYsXZ2h/06dPR6PREKlLJL9EpVIxc+ZMZs6cme5+1q1bl6HjZYWRI0fStm1bmjRpgk02zaZ35MgR/Pz8qFmzJsnJyUyePJmWLVty+fJl8ufPD8BHH33Ezp072bhxI05OTowcOZLOnTvz22+/ZUtMQgghhBAi+0nSVgghhBBZ6uBBbZnUuDjtctmysGcPlChh1rDEw4fQsyfs2/e8beRI7WxwUvvVJCEhIXz77bfY2dnRpEkT3n//fd577z2KZ7AOcEbs2bPHYDkoKIhChQpx9uxZGjZsyKNHj1ixYgXr1q2jadOmgHYitvLly3Py5Elq166dZbEIIYQQQog3R5K2GRAYGEhgYCApKSnmDkUIIYTI0davh3794Nkz7XLt2rB9O7i6mjeut97Vq9riwn//rV22tITAQBg2zLxx5XJnz54lIiKCXbt2sWvXLj755BP8/PyoUKEC77//Pu+//z716tVDrc66imSPHj0CtBO36WJ49uwZzZs31/cpV64cxYsXJyQkJNWkbWJiIomJifpl3YRvGo0GjUaTZbEKIYTIeRRFQaVSofsnL1KhQqVSoShKpn6vydikLa+PjanjYqqMHkOSthng5+eHn58fsbGxODk5mTscIYQQIkdasADGjXu+3LYtbNgA+fKZLyYB7N0LPXrA/5J9uLrCL79Aw4bmjSuP8PDwYNCgQQwaNIjk5GSOHj3K7t27CQ4OZs6cOTg7O9OyZUvatm1L69atcX2NbzA0Gg1jx46lXr16vPPOOwBERERgbW2Ns7OzQV93d3ciIiJS3c/s2bOZMWOGUXtUVBQJCQkmxyeEECLni4uLo0yJMuS3y4+the2rN8iFEuwSiC8RT1xcXJpll1IjY5O2vD42po6LqeJ0ryS+giRthRBCCPFaNBr4+GNt0lZn6FD45hvtA53CTBRFW7v244+1f0kAlSpBcDB4e5s1tLzK0tKSpk2b0rRpU+bOnUtYWBg7duxg9+7dDBs2jKSkJGrUqMGMGTNo1apVpvfv5+fHpUuXOH78+GvFOWnSJPz9/fXLsbGxFCtWDDc3NxwdHV9r30IIIXK2x48fcy30Gs5VnMnvmN/c4WSL+KfxxITG6CfxzCgZm7Tl9bExdVxM9fJEummRWykhhBBCmCwxEQYM0JZF0Jk+HaZNA1Xee3Mq90hMhOHDISjoeVunTrBmDdjbmy2st423tzcjR45k5MiRJCQkcODAAXbt2sXt27czva+RI0eyY8cOjh49StGiRfXtHh4eJCUlERMTY/C07b179/Dw8Eh1XzY2NqlOnKZWq7O0lIMQQoicR/cKuO6fvEhB0b/On5nfazI2acvrY2PquJgqo8eQpK0QQgghTPLokTYPeOiQdlmthu++0z5lK8woIgI6d4aQkOdtU6dqs+mSkDMbW1tbfZ3bzFAUhVGjRrFlyxYOHz5MiZdm9Hv33XexsrLiwIEDdOnSBYCrV69y69Yt6tSpk2XxCyGEEEKIN0uStkIIIYTItPBwaNMGLl7ULtvZwc8/a+vYCjM6dw46dIA7d7TLdnbap227dzdrWMJ0fn5+rFu3jm3btuHg4KCvU+vk5ISdnR1OTk4MHjwYf39/ChYsiKOjI6NGjaJOnTqpTkImhBBCCCFyB0naCiGEECJTrlyB1q3h1i3tsosL7NgBkh8ys59/1taqePpUu1y0KGzbBtWrmzUs8Xq+/fZbABo3bmzQvmrVKgYMGADAwoULUavVdOnShcTERFq1asU333zzhiMVQgghhBBZSZK2QgghhMiwEyegXTt48EC77O0Ne/aAj49Zw3q7aTQQEACzZj1vq1MHtmwBd3fzxSWyhKK8um6cra0tgYGBBAYGvoGIhBBCCCHEmyCFzYQQQgiRIdu2QbNmzxO21appy6ZKwtaMHj+Grl0NE7YDBmgLDUvCVgghhBBCiFxLkrZCCCGEeKWlS7VzWyUkaJebN4fDhyGNyenFmxAWBnXrap+oBe0kYwsWwMqVYGNj1tCEEEIIIYQQr0eStkIIIYRIk6LAtGkqhg/XvoUP0KcP7NwJjo7mje2tduQI1KwJf/6pXXZygl274KOPQKUyb2xvsVu3bjF8+HB8fHwoWLAgR48eBSA6OprRo0fzxx9/mDlCIYQQQgiRW0hNWyGEEEKk6tkzGDfOkZ9+ep4EnDABZs/WPtQpzGTZMvDzg+Rk7XLZshAcLHUqzOzy5cs0aNAAjUaDr68v169fJ/l/f0eurq4cP36c+Ph4VqxYYeZIhRBCCCFEbvBW3nJ16tSJAgUK0LVrV3OHIoQQQuRI8fHQqZOKn37KB2gf3vzqK/jyS0nYms2zZzBqFHz44fOEbatWcPKkJGxzgAkTJuDs7Mzff//NDz/8YDSB2Pvvv8+xY8fMFJ0QQgghhMht3srbrjFjxrBmzRpzhyGEEELkSFFR0KQJ7N6tfcLW2lph/XoYPdrMgb3N7t+H1q3h66+ft/n7w44dUKCA+eISekePHmXEiBG4ubmhSqVERfHixfn333/NEJkQQgghhMiN3sqkbePGjXFwcDB3GEIIIUSOc+OGdm6r33/XLjs6ati9W6F7d/PG9Va7fBl8feHgQe2ytbV2srH588FSKl3lFBqNhnz58qW5PioqChuZIE4IIYQQQmRQrkvaHj16lHbt2lG4cGFUKhVbt2416hMYGIi3tze2trb4+vpy+vTpNx+oEEIIkcucPatN2F6/rl0uUkRh69YHNG5s1rDebjt2QO3a2mw6gLs7HDoEAweaNy5hpHr16uzcuTPVdcnJyaxfv57atWu/4aiEEEIIIURuleuStvHx8VSpUoXAwMBU12/YsAF/f38CAgI4d+4cVapUoVWrVkRGRr7hSIUQQojcY+9eaNQIdL8uK1SA335TKF8+2byBva0URVtAuH17iIvTtlWrpn0Eum5d88YmUjVp0iT27NnDiBEjuHTpEgD37t1j//79tGzZkitXrvDJJ5+YOUohhBBCCJFb5Lp36tq0aUObNm3SXL9gwQKGDh3KwP89gfLdd9+xc+dOVq5cmekL5cTERBITE/XLsbGxgPb1N41GY0L0OYtGo0FRlDxxLm+ajJ1pZNxMJ2NnOhm7V1uzBoYOVZGcrK3DWb++wpYtCs7OGqKiZOxM8Vqfu6dPUQ0bhmrdOn2T0rUrysqVkD8/5OG/j5fHLTd99tq0aUNQUBBjxoxh2bJlAHzwwQcoioKjoyNr1qyhYcOGZo5SCCGEEELkFrkuaZuepKQkzp49y6RJk/RtarWa5s2bExISkun9zZ49mxkzZhi1R0VFkZCQ8Fqx5gQajYZHjx6hKApqmQo8U2TsTCPjZjoZO9PJ2KVNUeDrr/Pz+efP67y/914CgYExJCdDZKSMnalM/dyp797FedAgrM+f17fFTZhA/NixEB+v/cnDXh63ON1TxrlE37596dy5M/v27eP69etoNBpKlSpFq1atZD4FIYQQQgiRKSYlbWNiYjhx4gSXL18mOjoalUqFq6sr5cuXp06dOhQw0yzG0dHRpKSk4O7ubtDu7u7OX3/9pV9u3rw5Fy5cID4+nqJFi7Jx40bq1KljtL9Jkybh7++vX46NjaVYsWK4ubnh6OiYfSfyhmg0GlQqFW5ubnIznkkydqaRcTOdjJ3pZOxSl5ICY8eq+Oab57Pc/+c/CosWWWNhUQiQsXsdJo3d6dOoOndGdfcuAEr+/CirV5O/UyfyZ2OsOcnL42Zra2vukDItf/78dOrUydxhCCGEEEKIXC7DSdukpCTWrVtHUFAQx48fT/N1NbVaTb169Rg4cCC9evXKkbPk7t+/P0P9bGxsUo1frVbnmZtXlUqVp87nTZKxM42Mm+lk7EwnY2coIQE++AB++eV52+zZMHGiCpVKZdBXxs50mRq7H3+EwYNBV5bJywtVcDCqypWzN8gc6MVxy42fu2fPnvHvv//y8OFDFEUxWl+9enUzRCWEEEIIIXKbDCVtv/vuO2bNmkV0dDQtW7Zk4cKFvPvuu5QsWZICBQqgKAoPHz4kNDSUM2fOsH//foYPH86UKVOYOnUqH374YXafBwCurq5YWFhw7949g/Z79+7h4eFh8n4DAwMJDAwkJSXldUMUQgghzOrhQ+jQAY4d0y5bWsKKFdCvn3njemulpMCnn2onHdNp2BA2bQI3N/PFJTItJiaG8ePH8+OPP5KUlGS0XlEUVCqVXE8KIYQQQogMyVDS9vPPP2f8+PEMHDgQJyenVPt4enri6elJ3bp1GT16NLGxsaxcuZLZs2e/saSttbU17777LgcOHKBjx46A9jW7AwcOMHLkSJP36+fnh5+fH7GxsWmevxBCiNxv+vTpRrXMfXx8DErsZCVFUQgICGD58uXExMRQr149vv32W8qUKaPv4+3tzc2bNw22mz17tkmz0N++Da1bw+XL2uX8+bW5wdatX+s0hKliY6FPH9ix43nbsGGwZAlYW5svLmGSAQMGsH37dnr27Imvr69cMwohhBBCiNeSoaTtP//8g6Vl5srfOjo6Mnbs2NdKlqbm8ePHXL9+Xb8cGhrK+fPnKViwIMWLF8ff35/+/ftTo0YNatWqxaJFi4iPj2fgwIFZGocQQoi8qWLFigZldDL7++9F06dPJywsjKCgoFTXz5kzh8WLF7N69WpKlCjB1KlTadWqFZcvXzao5Tlz5kyGDh2qXzZlQqM//4Q2beDff7XLhQrBzp1Qo0amdyWywvXr0L49XLmiXbawgEWLwM8PXipRIXKHffv2MXr0aBYuXGjuUIQQQgghRB6QoTvR17lhfZ1tU3PmzBmaNGmiX9ZNFNa/f3+CgoLo0aMHUVFRTJs2jYiICKpWrcqePXuMJifLDCmPIIQQbw9LS8s0S+roXn/etm0biYmJ1KhRg4ULF1KlSpVMH0dRFBYtWsSUKVPo0KEDAGvWrMHd3Z2tW7fSs2dPfV8HB4fXKvNz5Ii2JMKjR9rl0qVhzx4oVcrkXYrXcfAgdO2qrVUBUKAAbNwIzZqZNy7xWlxcXChdurS5wxBCCCGEEHmESbM7xMXFcfv2bYO28PBwpk2bxsSJEzl9+nSWBJeaxo0boyiK0c+LTzGNHDmSmzdvkpiYyKlTp/D19X2tY/r5+XH58mV+//3314xeCCFETnft2jUKFy5MyZIl6dOnD7du3dKv69atG5GRkezevZuzZ89SvXp1mjVrxoMHDzJ9nNDQUCIiImjevLm+zcnJCV9fX0JCQgz6fvHFF7i4uFCtWjXmzp1LcnJyho+zcSO0bPk8YVuzJvz2myRszUJRIDBQ+xeiS9iWLw+//y4J2zxg2LBhrF+/Ps3JeoUQQgghhMgMkx6DHTZsGKGhoZw8eRKA2NhYateuzZ07d1Cr1Xz11Vfs2bOHxo0bZ2WsQgghRLby9fUlKCgIHx8f7t69y4wZM2jQoAGXLl3iwoULnD59msjISGxsbACYN28eW7duZdOmTQwbNixTx4qIiAAwehPE3d1dvw5g9OjRVK9enYIFC3LixAkmTZrE3bt3WbBgwSuPsXgxjB2rzRWCtjzCzz+DvX2mQhVZISkJRo2CZcuet73/PqxbB46O5otLZJmpU6fqn8Dv27cvRYsWxcLCwqhf586dzRCdEEIIIYTIbUxK2h4/ftxgcrEffviB8PBwTpw4QcWKFWnWrBmzZs2SpK0QQohcpU2bNvo/V65cGV9fX7y8vPj5559JSEjg8ePHuLi4GGzz9OlTbty4AcCxY8cM9pGUlISiKGzatEnftnTpUvr06ZPhmHRlgHQxWVtb8+GHHzJ79mx98vhlGg1MmgRz5jxvGzgQli4FK6sMH1pklagobTmEo0eft02cCP/3f9patiJP+Pfffzl48CDnz5/n/PnzqfZRqVRSbksIIYQQQmSISUnb6OhoihQpol8ODg6mfv361K5dG4B+/foZzb6dm0lNWyGEeDs5OztTtmxZrl+/jrOzM56enhw+fDjVfgA1atQwSNYsXryYf//9ly+//FLfpnuyVlej9t69e3h6eurX37t3j6pVq6YZk6+vL8nJyYSFheHj42O0PikJBg2CH3983jZlCsycKfNbmcXFi9CpE4SFaZdtbGDFCshE4l7kDoMGDeLcuXNMmjQJX19fnJyczB2SEEIIIYTIxUxK2jo7O+tf3Xz69CnHjh3j008/fb5TS0uePHmSNRHmAH5+fv/P3n3HVVn3fxx/nQMIDoYMGQ4kt+VITTRHOcqsNNSWpqkNy7ChmaMcaJa/1MrbIvOuu7SsNC1paFbazpmld96mqbmRIYqMBIVzfn9ccRBZhyNwGO+nj+sB1/dan+vLOn7O9/p8iYyMJCUlRS/ARUSqkbS0NA4ePMiIESNo1aoVcXFxuLq60rhx4wL3r1mzZp6JiHx9fUlJSSlwcqKwsDCCgoLYuHGjLUmbkpLC1q1bGTt2bKEx7dy5E7PZzLff1uPppyEpCfz8ICICbrrJyAV+/bWxr9lslFB9+GFHe0Auh/u6dZgeewzS042G4GCIiYHOnZ0al5SNn376icmTJ1epgQsiIiIi4jwOJW2vvfZaXnvtNVq2bMn69evJyMiwzXwN8Oeff+YZiSsiIlIZTJw4kQEDBhAaGkpsbCwzZ87ExcWFoUOH4u/vT9euXYmIiGDevHk0b96c2NhY1q5dy6BBg+jUqVOJrmUymXjiiSeYM2cOzZo1IywsjOnTpxMSEkJERAQAmzdvZuvWrfTq1QtPT082b95MZOR4XF2HM3ZsXcxmoxSC2Qwff2w8aZ/zUIiHB3zwgZHMlXJmtcJzz1F3xozctmuugTVrQK+PqqygoCB8fX2dHYaIiIiIVBEOJW1feOEFbrzxRoYMGQLAk08+yZVXXglAdnY2q1at4qabbiq9KEVERMrB8ePHGTp0KElJSQQEBNC9e3e2bNlCQEAAAOvWreOZZ55h9OjRJCYmEhQURM+ePfNNJmavSZMmkZ6ezpgxY0hOTqZ79+6sX78eDw8PANzd3VmxYgVRUVFkZmbi7x/G2bPjAaPObc4k9TkfcxK2tWvDl19Ct24Od4U46u+/YfRozB9+mNs2bBi8+SbUrOm8uKTMPfnkkyxevJj777+fOprtT0REREQuk0NJ26ZNm7Jv3z727NmDt7d3nsdE//77b1599VXatWtXWjE6nWraiohUDytWrChyu6enJ4sWLWLRokV2nS8qKqrI7SaTidmzZzN79uwCt3fo0IEtW7YAkJEBISFGXVqrtejrurhAx452hSil6dgxuO02+O03AKwmE9bnn8c8ebIKClcDGRkZuLm50bRpU+68804aNmyIyyUTzZlMJsaPH++kCEVERESkMnEoaQvg5uZWYGLW09MzT6mEqkA1bUVExNlWrYIzZ+zbNyUFVq+G4cPLNia5yObNxoRj8fEAWD09SY6Oxvuee5SwrSYmTpxo+/zVV18tcB8lbUVERETEXg4nbQGOHj3KX3/9xZkzZ7AWMOxn8ODBl3N6ERER+UdMDLYatsUxm43yqUralpOlS+Ghh+D8eWP9iiuwxsSQ+U9ZDakeDh065OwQRERERKQKcShpe/ToUe677z6+/fZbgAITtiaTSeUERERESklSkn0JWzD2O326bOMRICsLJk2Cl1/ObevVyxgWXbcuJCQ4LzYpd6Ghoc4OQURERESqEIeStiNHjmTz5s1MmTKF8PDwKl8yQDVtRUTE2fz8SjbSVpPYl7HkZLj7bmPGtxzjxsFLL4Gbm/0ZdhERERERkQI4lLTdsmULkydPZtasWaUdT4WkmrYiIuJs/frBxx/bt6/FYpRXlTLy558wcCDs22esu7pCdDSMGePcuKRchYWFYTab2bt3L25uboSFhWEqpn6xyWTi4MGD5RShiIiIiFRmDiVtGzRoQN26dUs7FhERESnAyZPw2mv27WsygY8P3H57mYZUfX35Jdx1F5w9a6z7+cFHH8F11zk3Lil31113HSaTCbPZnGddRERERKQ0OJS0nThxIq+++ipjxoyhVq1apR2TiIiI/GPvXrjpJjhyJLfNZIICysmTky9atgw8PMonvmrDaoWFC2HixNzSB23awCefQFiYU0MT51i6dGmR6yIiIiIil8OhpO1DDz1EdnY2zZo14/bbb6dBgwa4uLjk2cdkMjF+/PhSCVJERKQ6+vln4yn8nEnFGjUy5r2aPh3OnMmtcZvz0cfHSNgOGODUsKuezEwYOxbefju3LSIC3nkHPD2dFpZULO+88w49e/akcePGBW4/cuQI33//Pffee2/5BiYiIiIilZJDSdvdu3czb948Tp48ySuvvFLgPkraioiIOO7jj2HYMCNfCNCuHaxbByEhcP/9sHo1rFljJHR9fY0atrffrhG2pS4+HgYPhk2bctumTYNZs4xsucg/Ro8ezbvvvlto0nbLli2MHj1aSVsRERERsYtDSdsxY8Zw9uxZlixZQnh4eJWfnCs6Opro6Giys7OdHYqIiFQDr7wCjz+eWwLhhhuMJK2Xl7Hu4QHDhxuLlKHffjOGOh8/bqzXrGmMtr3rLufGJRWStaCaJRdJT0/H1dWhl94iIiIiUg059Mpx586dzJo1iwcffLC046mQIiMjiYyMJCUlpconqEVExHksFpgyBebPz22791544w2oUcN5cVVLq1bByJFw7pyx3qABxMRAx45ODUsqlv/+97/s3LnTtv7jjz+SlZWVb7/k5GRef/11mjdvXo7RiYiIiEhl5lDSNkwTboiIiJSqzEwYPRo++CC37Zln4NlncycYk3JgsRilD2bPzm3r2tWoVxEU5Ly4pEJas2YNs2bNAozSYEuWLGHJkiUF7uvj48M777xTnuGJiIiISCXmUNJ21qxZTJw4kbvvvpuGDRuWdkwiIiLVSnKyUTb122+NdbMZXnsNHnrIqWFVHUePwqlTxe/3998wY0buFwKM0bZLloC7e9nFJ5XWmDFjuPXWW7FarXTu3JnZs2fTv3//PPuYTCZq165NkyZNVB5BREREROzm0CvHH374AR8fH1q0aEHfvn1p2LAhLi4uefYxmUz861//KpUgRUREqqrjx6F/f9i921ivWRNWroQBA5wbV5Vx9Ci0aAEZGSU7zmSCBQtg/HgNdZZCBQcHExwcDMC3335Lq1atqFevnpOjEhEREZGqwKGk7auvvmr7/PPPPy9wHyVtRUREivb770bC9sQJY93fHz7/HMLDnRtXlXLqVMkTtgCLFsG4caUfj1RZ1113nbNDEBEREZEqxKGkrcViKe04REREqpVvv4WICEhJMdabNIEvvoBmzZwaluS49lpnRyAiIiIiItWY2dkBiIiIVDcffAD9+uUmbK+5BjZtUsJWREREREREDHYlbf/++2+HL3A5x1YU0dHRtG7dmmuuucbZoYiISCVmtcL8+TBsGFy4YLTdcosx6lZlMEVERERERCSHXUnbhg0bMnv2bE6ePGn3iU+cOMGMGTNo1KiRw8FVFJGRkezZs4ft27c7OxQREamksrPh8cdh0qTctgcfhJgYqF3baWGJiIiIiIhIBWRXTdvFixcTFRXF7Nmz6datG3379qVDhw6EhYVRt25drFYrZ86c4dChQ/zyyy9s2LCBLVu20KxZM1577bWyvgcREZEK7dw5GD4cPv44t232bJg2DUwm58UlIiIiIiIiFZNdSds777yT22+/nU8//ZSlS5fy3HPPcf78eUyX/E/TarVSo0YNbrzxRlavXs3AgQMxm1U2V0REqq+kJLjtNvj5Z2PdxQXeeANGj3ZuXGKIAmZd0tYC2FtG17NarcycOZM33niD5ORkunXrxuLFi2l2SUHjtWvXMnv2bP773//i4eHBddddR0xMTBlFJY6aPXt2odtMJhMeHh6EhobSp08f/Pz8yjEyEREREans7EraApjNZiIiIoiIiCAzM5MdO3awd+9ekpKSAPDz86Nly5Z07NgRd3f3MgtYRESksjh8GG66CfbtM9br1IHVq41JyKScWK3F7nIlsOGidbtfHBUgKiqKw4cP89ZbbxW4fd68eSxatIhly5YRFhbG9OnT6devH3v27MHDwwOAjz76iAcffJDnn3+e3r17k5WVxe7duy8jKikrUVFRdu3n7u7OzJkzmTJlStkGJCIiIiJVhkP/L3F3d+faa6/l2muvLe14REREqoTffoObb4a4OGM9KAjWroUOHZwbV7WSlQUvvFDsbq5AUCHbkpOTmThxIp988gmZmZl06tSJl19+mXbt2pU4HKvVysKFC5k2bRq33XYbAO+88w6BgYHExMRw9913k5WVxeOPP878+fO5//77bce2bt26xNeTspeYmFjk9r///pu9e/eyePFinnnmGRo3bszdd99domv88MMPzJ8/nx07dnDy5EnWrFlDRESEbfuoUaNYtmxZnmP69evH+vXrS3QdEREREalYVLtARESklH35JfTsmZuwbdECNm9WwrZcpaVBRASsWlXsrvuBEOAK4B7g6EXb7rjjDhISEvjiiy/YsWMHHTp0oE+fPpw+fbrEIR06dIi4uDj69u1ra/P29iY8PJzNmzcD8Ouvv3LixAnMZjNXX301wcHB9O/fXyNtKyg/P78il4YNG3LDDTfw0Ucf0bVrV1555ZUSXyM9PZ127doRHR1d6D433XQTJ0+etC0ffPDB5dyWiIiIiFQAl/MEoIiIiFxi6VJ48EFjkCfAtdfCp5+CylmWo9hYuPVWY7hzMcKBpRh1bE9i1LftAexOT2fXTz+xbds2EhISbKWfFixYQExMDKtXr2bMmDElCivunyx+YGBgnvbAwEDbtr/++gswHrt/6aWXaNy4MS+++CLXX389f/75J76+viW6plQMJpOJ2267jWeffbbEx/bv35/+/fsXuY+7uztBQYWNFxcRERGRykhJWxERkVJgtcKcOTBjRm7boEHw3ntQs6bz4qp2fv8dbrkFjh0z1j09ITMTzp8vcPeLU2FtMZK4ocCHmzeTUbs2aWlp+SaQOnfuHAcPHgTgxx9/zJNQO3/+PFarldWrV2O1WjGZTCxZsoR77rnH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yclMnDiRTz75hMzMTDp16sTLL79Mu3btSnw7VquVhQsXMm3aNG677TYA3nnnHQIDA4mJickzW72np6ft8W2Rqm7+/PmMGzcuX8IWoFWrVowbN4558+bleSPDHr/88gu9evWyrefUoh05ciRLly5l0qRJpKenM2bMGJKTk+nevTvr16+3u1aaiIiIiFRMStqKiJRUZRwt6gCrFRYsgMmTcycc69QJPvnESNyWWHk/VZCVBbGxcOQIHv/7H6SkGInZi5Oz8fElP+/FatQwErCFjZBt0AD8/MqlfsR+IATwALoCc4GcXrvjjjuoWbMmX3zxBd7e3ixZsoQ+ffrw559/4uvrW6LrHDp0iLi4OPr27Wtr8/b2Jjw8nM2bN+dJ2v7f//0fzz77LI0aNWLYsGGMHz8eV1e99JCq6fjx47i5uRW63c3NjePHj5f4vNdffz1Wa+G140wmE7Nnz2b27NklPreIiIiIVFz6n5OISElUxNGiZSAzEx5+GJYuzW278054+22oVctpYeXKzoa4uKJHyMbFgcWCGfBx5BouLlC/ftEjZAMCoAJM2hMOLMWoY3sSo75tD2B3ejq7fvqJbdu2kZCQYJt4aMGCBcTExLB69WrGjBlTomvFxcUBEBgYmKc9MDDQtg3gscceo0OHDvj6+rJp0yamTp3KyZMneemllxy9TZEK7aqrruK1115j2LBh1K9fP8+248eP89prr9GmTRsnRSciIiIilY2StpXZww9D585GAqllS2Np0EAzAomUpWpQgzox0Zhw7KefctuiomDGDCf9evngA2O5ODkbG2uMpHWU2QzBwUWPkA0KMhK3FYG/v5H8L+R7r/9Fn7fFSOKGAh9u3kxG7dqkpaXh5+eX55hz585x8OBBAH788Uf69889y/nz57Faraxevdo2i+qSJUu455577A455xFugLZt21KjRg0eeugh5s6dW+Cs9SKV3csvv0y/fv1o3rw5gwYNomnTpgDs37+fmJgYrFYry5cvd3KUIiIiIlJZKGlbmW3fbiwXq10bmjfPTeLmLM2aGTMHiYgUYfduGDAADh821j08YNkyY5RtPlarMST33DkjmZiRkffzS9f37XMsqAULSn5MYKAtAWtt0IBUHx/qtGyJOTTUaA8OhiIeY65wGjUy+s/Oshw+QPPRozlw5gw+VivBwcF89913+ffz8QGgU6dO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ajVK6fzzryoyYZQKslqtJfq7pr4pXFXvG0f7xVH2XkNJWxGRi1it8MEHMH48JCTktl99tZHAveaa3LaBAyE2FlavhjVr4PRpY9KxQYOMkggaYSsiUrX17t270G0mkwkPDw9CQ0O5+eabufXWW8ssjvDwcJYuXUqLFi04efIks2bNokePHuzevRtPT898+8+dO5dZs2bla09MTCSjJLNtiohIpZOamkqzsGbUrlkbD5eq+R+WjJoZpIelk5qaSsLF/6krhvqmcFW9bxztF0elpqbatZ+StiIi/zhwAB55BL7+OretTh149lkYNw4KKg/o4QHDhxuLiIhULwkJCZhMhY82+fvvv/n6669ZsmQJ/fr145NPPsHNza3U4+jfv7/t87Zt2xIeHk5oaCgffvgh999/f779p06dyoQJE2zrKSkpNGzYkICAALy8vEo9PhERqTjS0tLYf2g/Pu18qO1V29nhlIn0c+kkH0rG09OTevXq2X2c+qZwVb1vHO0XR3nYOcJLSVsRqfYyM2HePHjuOePzHIMGwaJF0KCB82ITEZGKa/fu3cXuc+7cOZYsWcKECROYN28ezzzzTJnH5ePjQ/PmzTlw4ECB293d3XF3d8/XbjabMZvNZR2eiIg4Uc4j4Dn/qiIrVtvj/CX5u6a+KVxV7xtH+8VR9l5Dr8pEpFr7/nto3x5mzMhN2DZqBJ9+Ch9/rIStiIhcnpo1a/LEE09w99138/7775fLNdPS0jh48CDBwcHlcj0RERERKX1K2opItXTqFIweDddfD3v3Gm0uLjBxIvzvf5pATERESle3bt04dOhQmZx74sSJfP/99xw+fJhNmzYxaNAgXFxcGDp0aJlcT0RERETKnsoj2CE6Opro6Giys7OdHYqIXCarFZYuhaeegqSk3PbwcGOisXbtnBaaiIhUYX///TeuBRVHLwXHjx9n6NChJCUlERAQQPfu3dmyZQsBAQFlcj0RERERKXsaaWuHyMhI9uzZw/bt20v93FFRUZhMpjxLy5YtS/06IgJ79hgja++7Lzdh6+0NixfDpk1K2IqISNmwWq18+umntGnTpkzOv2LFCmJjY8nMzOT48eOsWLGCJk2alMm1RERERKR8aKRtBXDllVeyYcMG23pZjcIQqa7OnYM5c2D+fLhwIbd96FB46SUICnJebCIiUnmdPn26yO3nzp1j3759LF68mE2bNrF8+fJyikxEREREKjtlBysAV1dXgpQ1EikTX34JjzwCf/2V29akCbz2Gtx4o/PiEhGRys/f3x+TyVTsfm5ubjz77LOqMSsiIiIidlPStgLYv38/ISEheHh40LVrV+bOnUujRo2cHZZIpRYXB+PHw4oVuW1ubjB5Mjz9NNSs6bzYRESkapgxY0aRSVsPDw9CQ0Pp06eP6suKiIiISIkoaetk4eHhLF26lBYtWnDy5ElmzZpFjx492L17N56ens4OT6TSsViMCcWmToWzZ3Pbe/aE11+HVq2cF5uIiFQtUVFRzg5BRERERKooJW2drH///rbP27ZtS3h4OKGhoXz44Yfcf//9ToxMpPLZtQseegi2bs1t8/ODBQtg5Eiw4wlWERGREtu6dSuHDh3Cz8+PHj164OHh4eyQRERERKSSU9K2gvHx8aF58+YcOHDA2aGIVBppaRAVBQsXQnZ2bvvo0TBvHvj7OysyERGpylJTU+nfvz+bN2+2tQUFBbF27Vrat2/vvMBEREREpNJT0raCSUtL4+DBg7RsOYIhQyApyRgpGBEBd9wBGrghktenn8K4cXDsWG5bq1aweDFcd53z4hIRkapv3rx5bNq0icGDB9O7d28OHDjA4sWLGTlyJLt27XJ2eCIiIiJSiZmdHUB1N3HiRL7//nsOHz7Mpk2b6N59EMnJLixfPpSYGPj+e4iJgXvvhZAQ+OwzZ0csUjEcOwaDBsFtt+UmbD08YM4c2LlTCVsRESl7H3/8MYMHD2b16tU88sgjvPTSS/zrX/9i9+7dHDp0yNnhiYiIg8aMGUObNm3w9vbG09OTjh078sEHHxR5zOHDhzGZTPmWvn37llPUIlLVaKStkx0/fpyhQ4eSlJSEp2cASUndgS1AABaLsU/Ox+RkI0EVEwMDBzonXhFny8qCV16B6dMhPT23/cYb4bXXoEkT58UmIiLVy+HDh3n88cfztPXr1w+r1crx48cJCwtzUmQiInI53njjDTp06MAdd9zBf//7X7Zv386wYcOoW7cuN910U5HH1q9fn9tvv9223qJFi7IOV0SqKCVtnWzFihUAZGQYI2lNJrBaC97XajW2jxoFsbEqlSDVz7ZtxkRjO3fmtgUGGrVs77pLE42JiEj5OnfuHHXq1MnTlrN+4cIFZ4QkIiKlYMuWLYSHhwOQlZVF8+bNOXToEF988UWxSdumTZuycOHCfO2HDh2iffv2pKWl8fPPP9OlSxfuvvtuVq5cyfDhw3n33XfL4lZEpBKrduURjh07xvXXX0/r1q1p27Ytq1atcnZIAKxaBWfOFJ6wzWG1GvutXl0+cYlUBGfPGnVru3TJTdiaTDB2LOzdC3ffrYStiIg4R3p6OqdPn86zgDFJ2aXtOdtERKRiy0nY5sjMzASMUbTF2bp1K7Vq1SIwMJBBgwaxb98+AMLCwli8eDEWi4XRo0ezbNkyVq5cSdOmTXnttddK/yZEpNKrdiNtXV1dWbhwIe3btycuLo6OHTty8803U7t2bafGFRMDZnNuKYSimEywaBFcey2EhSlZJVWX1Wq8ofHEE3DyZG5727awZImRxBUREXGmhx9+mIcffjhf++DBgwvcPzs7u6xDEhGRUmKxWHj44YeJjY3lyiuvZOzYsUXu37hxY6699lpq167N+vXriYmJYceOHezevRsvLy+GDRvGV199xbJlyxg9ejRubm68//77eHp6ltMdiUhlUu2StsHBwQQHBwMQFBSEv78/p0+fdnrSNinJvoQtGIms7duN2p3+/tC5s7GEh8M114CfX9nGKlIe/voLIiNh/frctlq1YNYsePxxcHNzXmwiIiIAM2fOdHYIIiJSRtLT0xk2bBiffvopV199NevXry8yuRoaGppnEsrExETq16/PsWPH2LRpk62swpQpU1i2bBlWq5U+ffpwzTXXlPm9iEjlVOGStj/88APz589nx44dnDx5kjVr1hAREZFnn+joaObPn09cXBzt2rXjlVdeoXPnziW+1o4dO8jOzqZhw4alFL3j/PzsH2l7sVOnYN06Y8nRtGneRG779qp/K5XH+fPw4oswe7ZR6znHwIHGBGSNGjkvNhERkYspaSsiUjXFxsYyYMAAfv31VwYMGMD777+fr4b53r17AWjUqBG1atXiyJEjhISEUKNGjXzny/jnPzYWi8U2WtfDw4P169ezZs0aBg0aVMZ3JCKVUYWraZuenk67du2Ijo4ucPvKlSuZMGECM2fO5Ndff6Vdu3b069ePhIQE2z7t27fnqquuyrfExsba9jl9+jT33nsv//73v8v8nuwREVGyhO2gQdC/f8Gjag8cgPffNx4p79oVvLyMEbiRkfDOO0YN0JImh0XKw08/QYcO8PTTuQnbBg1gzRr45BMlbEVEREREpOyFh4fz66+/4uXlRePGjZk2bRpPPPEE77//vm2fVq1a0apVK7Zt2wbA0qVLadiwIXfeeSdjxoyhY8eOXLhwgZCQEPr06QPA3Llz+e677+jevTvr1q3DbDbzwAMPcPz4cafcp4hUbBVupG3//v3p379/odtfeuklHnzwQUaPHg3A66+/ztq1a3nrrbeYMmUKADsvnlq+AJmZmURERDBlyhSuvfbaIvfLKTgOkJKSAhjvjllKOes5ZAg89piJs2fBai28SK3JZMXHB5Yvt+LhYZRK+Osv2LYNtm0zsX07/PorZGbmnuPCBfjlF2PJqW/u7W2lUye46qraXHedlS5dLAQGluotXZZZs2Yxe/bsPG0tWrRgz549gPFO5cSJE1m5ciWZmZnceOONREdHE1hON2GxWLBaraX+fVDVFdZvSUkwZYqJt97K/b41m6089hhERVnx9NQbDfqec5z6znHqO8ep7xxzab9Vhv47duwYZrPZNjlNRkZGgRPKNGjQgDvvvLO8wxMREQfkJFFTUlJ45ZVXbO0jR45k2LBhBR7Tp08ftm/fzo8//sjp06cJDAxk5MiRREVF4enpyZYtW4iKiqJ27dosW7aMK664gokTJzJv3jyGDx/ON998g9lc4cbViYgTVbikbVHOnz/Pjh07mDp1qq3NbDbTt29fNm/ebNc5rFYro0aNonfv3owYMaLIfefOncusWbPytScmJtoebyhN//qXO6NG+WAyWQtM3JpMVgAWLkwmJSWTf3LIeHpCnz7GAsbj5X/84cpvv7nx2281+O03N/bvz/ulPnvWxMaNJjZu9ORf/zLa6tfPpkOH81x99QWuvvoCbdtmUauWtdTv0x7p6em0aNGCDz/80Nbm4uJiG1E9efJkNmzYwJIlS/D09OSZZ57htttu49NPPy2X+CwWC2fPnsVqteoPawlc2m/GRGMezJrlxenTud/z7dufZ968FNq0yeLcOTh3zolBVxD6nnOc+s5x6jvHqe8cc2m/paamOjukIv3+++9cffXVLFy4kHHjxgHGa5iJEydiMpmwWnNfR7m4uNCqVSvatGnjrHBFRMROF//+tnefHj160KNHj0L379KlCxcuXMjT9sILL/DCCy84FqSIVHmVKml76tQpsrOz842mDAwMtNWTKc7PP//MypUradu2LTExMQC8++67Bb6Anjp1KhMmTLCtp6Sk0LBhQwICAvDy8nL8RgoxfDh4eVm57z4TZ84YIw0tFpPto48PvP22lQEDvIs9V4MGcMMNuetnz1r45ZfcEbnbtkFcXN7E8IkTLpw4UZPPPqsJgIuLlauuMkordO5spXNnaN0aXFxK864LVrt2bTw8PLjqqqvybTt79iwffPABy5cvt83M3LBhQ6688kr++usvunTpUubxWSwWTCYTAQEB+s94CVzcb/v3m4mMNPHtt7nfh56eVp5/3spDD7ni4uLrxEgrHn3POU595zj1nePUd465tN88KnhR/iVLlhAaGsojjzySb9vy5cttT3RZLBauv/56lixZwquvvlreYYqIiIhIJVSpkraloXv37nY/aufu7o67u3u+drPZXGb/AYuIgJtugtWrYc0aE6dPg6+viUGD4PbbTXh4FF46oSh16xpJ3JxErtUKR49a+Prrs+zb58O2bSZ++QX+/jv3mOxsE7t2wa5d8OabxnVr14ZOnYwJznImOqtfH0yOhVUok8nE/v37adCgAR4eHnTt2pW5c+fSqFEjfvvtNy5cuMCNN95o+zq0bt2aRo0asXXr1iJLXpR2jGX5vVBVZWaamD3bhRdeMHH+fG77HXfAwoUmQkJK+ZupCtH3nOPUd45T3zlOfeeYi/utovfdt99+y+DBgwuMMzAwkNDQUNt6zgzkIiIiIiL2qFRJW39/f1xcXIiPj8/THh8fT1BQUJldNzo6mujoaLKzs8vsGhfz8DBG3Q4fXnbXMJmgYUO49dZM7rvPitlsIisL/vgDtm41RuRu3Qq7d+etJZqeDt9/byw5goPzJnE7dTImP7sc4eHhLF26lBYtWnDy5ElmzZpFjx492L17N3FxcdSoUQMfH588xwQGBhIXF3d5F5YytXEjPPywP3/9lZuYDQuD6GhjYj0REZHK5PDhw7Rs2TJPm6urK+3atcPT0zNPe1hYGEeOHCnP8ERERESkEqtUSdsaNWrQsWNHNm7cSEREBGA8brZx40ZbHbGyEBkZSWRkJCkpKXh7F1+aoLJydYU2bYzlgQeMtvR02LEjN4m7bRscPZr3uJMnISbGWMBICLdsmTeR26YNuLnZH8vFk9G1bduW8PBwQkND+fDDD6lZs+Zl3aeUv4QEmDAB3nvPDBijkVxd4amnYNo0qFXLufGJiIg46tInuLy9vfntt9/y7XdpjVsRERERkaJUuKRtWloaBw4csK0fOnSInTt34uvrS6NGjZgwYQIjR46kU6dOdO7cmYULF5Kens7o0aOdGHXVVbs29OxpLDlOnoTt23OTuNu2YZsUDYzSC3/8YSxLlxptHh7QoUNuErdzZ2OEpb1lFXx8fGjevDkHDhzghhtu4Pz58yQnJ+cZbVvWI66l5CwWePNNmDwZkpNz27t3t/L66yauvNJpoYmIiFy2Bg0asGvXLrv23bVrFw0aNCjjiERERESkqqhwSdtffvmFXr162dZzJgIbOXIkS5cu5a677iIxMZEZM2YQFxdH+/btWb9+fb7JyUpTeZdHqOiCg2HgQGMBIzH35595yyrs2gVZWbnHZGTApk3GksPfP28St3Nn8C1k7qm0tDQOHjzIiBEj6NixI25ubmzcuJEhQ4YAsG/fPo4ePUqHDl15911j1G9SEvj5GXWC77jDSBxL+fn9d3j44bxf87p1rUyblsJjj3ni6qratSIiUrndcMMNvPfee8yYMYN69eoVul9CQgLvvfce99xzTzlGJyIiiYmJpFw8wqiK8fLyIiAgwNlhiEgZqXBJ2+uvv77YR8fGjRtXpuUQLlVdyiM4ymw2yiG0bAkjRxptGRnw2295yyocPJj3uFOnYN06Y8nRtKmRxI2Nncgddwygd+9QkpJimTlzJi4uLgwdOhRvb2/uv/9+JkyYgK+vL15eXjz66KO0aNGVQYO6cOaMEZPFYnz8+GN4/HFYtgwGDCi/fikLUVFRzJo1K09bixYt2Lt3LwAZGRk8+eSTrFixgszMTPr168drr72W502No0ePMnbsWL799lvq1KnDyJEjmTt3Lq6upfPrID0dZs+Gl17Km7gfMQLmzbMC5zCbPQs9XkREpLKYOHEiS5cupU+fPrz99tt06tQp3z6//PIL9913HxcuXODJJ590QpQiItVTYmIiw0YPIyk1ydmhlBk/Tz/ef/t9JW5FqqgKl7SVqsHDA7p2NZYcSUm55RRyErlJl/z9PHDAWOA43347FEjCzS2A0NDuTJq0hdOnA/Dzg5dffhmz2cyQIUPIzMykbdt+7Nv3mq3cQk55uZyPyclw223GCNycEcKV1ZVXXsmGDRts6xcnW8ePH8/atWtZtWoV3t7ejBs3jsGDB/Pzzz8DkJ2dzS233EJQUBCbNm3i5MmT3Hvvvbi5ufH8889fdmxr10JkJFw8z0rz5rB4MfTubXw9EhIu+zIiIiIVQuPGjVmxYgVDhw4lPDycpk2bctVVV1GnTh3S0tLYvXs3Bw4coGbNmrz//vuEhYU5O2QRkWojJSWFpNQk3Hu6U9Ov6s2Lci7pHEk/JJGSkqKkrUgVpaStlBs/P+jf31jAqH371195k7i//gqZmQArbMdduGAkcidNMhYfH7jmGg86d45m2bJo2raFq6826uMWNkjbajW2jxoFsbGVu1SCq6trgbV7z549y3/+8x/ef/99evfuDcDbb79Nq1at2LJlC126dOGrr75iz549bNiwgcDAQNq3b8+zzz7L5MmTiYqKokaNGg7FdOKEMZr5o49y29zd4emnjXq27u4OnVZERKTCu/XWW9m1axcvvPACa9euZc2aNbZtwcHB3H///UyaNImmTZs6MUoRkeqrpl9NagfWdnYYZSKTTGeHICJlSElbO6imbdkwmaBJE2MZOtRoO3/eqIV6cX3cf578t0lOhq+/NpaSsFrhzBlYvRqGDy+VW3CK/fv3ExISgoeHB127dmXu3Lk0atSIHTt2cOHCBfr27Wvbt2XLljRq1IjNmzfTpUsXNm/eTJs2bfKUS+jXrx9jx47lf//7H1dffXWJYsnOhuhomDYNUlNz2/v0MUbXNmt22bcrIiJS4V1xxRUsWbIEgNTUVFJSUvD09MTLy8vJkYmIiIhIZaWkrR1U07b81KgBHTsayyOPGG1nz8L27blJ3K1bIT7esfObTPDqqxAaCoGBxuLlha2sQkUXHh7O0qVLadGiBSdPnmTWrFn06NGD3bt3ExcXR40aNfDx8clzTGBgIHFxcQDExcXlm7QvZz1nH3vt2AEPPWR8zFGvnlHLdtiwytOnIiIipcnT0xNPT9VuFxEREZHLo6StVHje3tC3r7GAMWL22LHc+rj//reR2LWH1WokfXv2zG3z8DCSjTlJ3MBACArKu56z+Pg4NxnZP6e2BNC2bVvCw8MJDQ3lww8/pGbN8qnTlJoK06fDK6/k1gwGGDMG/u//oG7dcglDRERERERERKTKUtJWKh2TCRo1Mpbbb4eDB40Jxi5OIJZERgYcPWosxalRIyfBa8LHx4eGDU2FJnh9fcFsdiwme/n4+NC8eXMOHDjADTfcwPnz50lOTs4z2jY+Pt5WAzcoKIht27blOUf8P8OWC6qTezGrFT7+2Khde+JEbvtVV8Hrr0O3bqVzTyIiIiIiIiIi1Z2StnZQTduKLSLCSCba6667ICTEKLFw8XLqVOETmeU4fx6OH4fjx01A0bOZubpCQEDBCd1LF39/cHGx/x5ypKWlcfDgQUaMGEHHjh1xc3Nj48aNDBkyBIB9+/Zx9OhRunbtCkDXrl157rnnSEhIoF69egB8/fXXeHl5ccUVrXn3XSMBnpRkTBwXEQF33AFxcTBuHKxdm3vtmjVh5kyYMAHc3Eoeu4iIiIiIiIiIFExJWzuopm3FdscdxujP5OSik64mk1HeYOlSoyTCpbKyjMTtpcncgpbERCvZ2UXXScjKgpMnjaU4ZrORuC0uubtkyUTuvHMAV1wRSmxsLDNnzsTFxYWhQ4fi7e3N/fffz4QJE/D19cXLy4tHH32Url270qVLFwBuvPFGWrduzYgRI5g3bx5xcXFMmzaNG2+MJCzMnTNnjFgsFuPjxx8bdWuzs42EdY6bbzZqA4eFFX9vIiIiIiIiIiJSMkraSqXn4QHLlsFttxmJ2YIStzl1aJctKzhhC8bI2KAgYylOVpaVvXsTsFgCSEw0F5vkzcoq+nwWCyQkGMvvvxe153GWLBkKJOHqGoCvb3e6dNnC888HEBgIV1/9MseOmYmIGMKFC5nceGM/Xn/9NdvRLi4ufP7554wdO5auXbtSu3ZtuncfyerVs219lFNmIufjuXO5Vw8JgUWLYPBgTTQmIiIiIiIiIlJWlLSVKmHAAOOx/lGjyDda1GIxRtguW2bsVxqMkbFW6tUrvm6t1WrEVFxiNy7O+HjxiNb8Vtg+y8oykryff37xdg8g+p8FPvkEfvjh0hG7oXTrto7Bg41Jw0aPLjzZfTF3d/jtN6Omr4iIiIiIVH7nz59n+vTpvPfeeyQmJtKkSROmTJnCvffeW+D+X3/9NS+++CK///47p06dIiAggBtuuIHnn3+e4ODgco5eRKRqU9JWqoyBAyE2FlavhjVr4PRpYzKwQYOMCcsKG2Fb1kwmIw5fX2jVquh9rVY4e9a+Eg3x8XlHwRbmzBlj2bv38u4jMxO++gqGD7+884iIiIiISMXw1FNPsWjRIho3bszdd9/NRx99xMiRI6lbty4DChjx8vPPP7Nt2zZ69uyJj48Pq1atYunSpezdu5fNmzc74Q5ERKquMp7bvmqIjo6mdevWXHPNNc4ORYrh4WEkFT/6CL791vg4fLjzErYllVN3t0UL6NnTqNc7bhw8+yz8+9/GyNktW+DQIUhPh5QU2L8ffvrJuNfXXjMmB3v4YSNZfe210KQJ1Klz+bGZzUYyXERERMRZzp8/z+TJk2nQoAHu7u60bt2ad955p9D9z507x+DBg6lfvz4mkwmTycR3331XfgGXI/VN4dQ3BUtMTGTJkiUAfPrppyxbtow5c+YAMGvWrAKPuf322zl+/DgxMTEsXbqUV155BYAtW7Zw5swZDh06hLe3Ny4uLmzZsgWAu+++G5PJxIgRI8rhrkREqg4lbe0QGRnJnj172L59u7NDEbExmcDTE5o2hW7djDqzY8dCVBQsXmxMIvbzz3DgAKSmGknev/6CzZuNUhJLlkDjxvZfz2IxRi+LiIiIOMtTTz3FvHnzcHNz4+677+bo0aOMHDmSzz77rMD9z58/zy+//FItBl+obwqnvinY//73PzIzM/Hw8KBNmzYAtgmMd+3aRXZ2dr5jrrrqKmrVqmVbz8zMBMDb25s6deoQFhbG4sWLsVgsjB49mmXLlrFy5UqaNm3Ka6+9lu98IiJSOCVtRaqJWrUgLAy6dDEmbRszBjp0KL4mbw6z2SjxICIiIuIMjowK9Pb25ujRo6xYsaLA7VVlVKD6pnDqm8LFxcUBUOeix/JyPs/KyuLUqVNFHr9r1y6efvppAF566SXc3NwAGDZsGCNHjmTv3r2MHj0aNzc33n//fTw9PcviNkREqiwlbUWqsYgIYwStPSwWo+SCiIiIiDM4MiqwOFVlVKD6pnDqm8IFBQUBkJaWZmtLTU0FwNXVFX9//0KPXbduHT169CA1NZXXX3+d++67L8/2KVOmAGC1WunTp0+VH7UsIlIWNBGZSDV2xx3w+OOQnGxMglaYnFq7t99eXpGJiIhIeWvZsiXmYh7B6dChA59++mmetoEDB/Lrr78We/4JEyYwYcIE23pqaiqtipul9R+ffPJJnlGBn3/+OQ8//DAXLlwAjFGBDRo0wMXFJc9xderUYe8ls7EuXryY4ZfMrFqrVi327t3LqFGjADh79iwTJ060jdDM0alTJ1scRZk3bx7Dhg2zre/bt48+ffrYda/bt28nODjYtv7vf/+b2bNnF3nM33//DRQ+YrKgvslhLeRFYIMGDYCC++bir9vy5cu5/vrrbevfffddvv4tzPHjx/Osz5o1izfeeKPY46677jree++9PG29e/fmzz//zLdvTt+cP3+eBg0aMGPGDK699lrA6Jv//e9/3HzzzQVe5+K+ueOOO/jpp59o0aIFYIwmff311/nxxx8L7ZscQUFB/PLLL3naHnroIdauXVvsvQ4dOpT58+fnaWvZsmWeRGthXn/9dW699Vbb+o4dO7jtttts6zkJ64yMDAIDA3Fzc7Od96qrrsLFxcX28xMTE8Orr74KGEnes2fPYjKZ8PX15dlnn+XZZ58FjN8RMTExjB07FgAPDw/Wr1+Pn58fNWvWLDLey/0d0bFjR9v6559/zgMPPMDpM6cx7zJjcjEVeJyLuwu9XuyVp23Pe3uI3Rxb7DXrta9H2wfa5mn78ZkfyTybWeyxrYa2on63+rb1tNg0tjy/pdjjALo/2x2PurmTtqxYsYLFixcXe1zz5s355ptv8rT9+uqvnN5bfA28Rr0a0XxI8zxtG8ZtsCve9o+0x7917hsAp/acYudrO+06tu+rffOs//nRnxz99mixx3k39iakTkietsJ+R1wsKyuLGl418MHH1pZxJoOfpv9kV7xdnu5CnZDc38Mnfj7BHx/8Uexx7t7u9HiuR562/775XxJ2JhR7bEjXEFrf0zpP27dPfkt2ZsFvSFmzrVjOWejWrRtvvvlmkb8jLpaVlZXn5+n6+dfjWjM3pfjX2r/464u/io3Xu7E310zM+ybO9gXbOXv4bLHHXtH/Cq645YrcmM5l8d1T3xV7HECnCZ3wucLHth7/azy/v/W7bf3ifnF1zb2vgl5HPPXUU3zwwQfFXvOWW24p9HWExc7Rc0ra2iE6Opro6GiH3oUVqcg8PGDZMqNcgslUcOLW9M/rm2XLKs+EbiIiIlJyJ0+eLHafhg0b5mtLTEzkxIkTxR6bkpKSZ91qtdp1HBgJt4tHBZ47dy7fsQUlUwt6HDstLa3Y6yYmJnK6gGL+cXFxdsWckyjMkZWVZfe9Xvp/DnvivXjfHDkjJqHgvilOYddMTEzMs55T0/TidXvjvdTZs2ftOragx/bj4+OLPNZisXDixAnS0tLyjCb19va2+5pZWVl52m644QZ+/PFH2/qlfVOU06dP23XdM2fO5GuLjY3N8/UtzLlz5/Ksnz9/vtBrJnvjAhoAAFeiSURBVCTkTQ499dRTALak6ciRI/Mda7VaSUpKytPWsGFD5s6dy3fffUf37t2ZPXs2vXv3LvDn6VKX+zviYufOnSM+Pv6fjYUfd3HSKceF9AtknM4o9poX0i7ka8s8m2nXsdnn8/6cWy1Wu47L2fdi6enpdvWTt7d3vrYLqXbe69/579XeeC0XLPnW7T22oDjsObZmQE24ZDLu4n5H5Khfu36e9cv52mSfz3b8XtPs/NqkF/B9mJxJ1rmsAvbOFR8fX6LfEbk7GR8ufcPvwjn74vXwzZ9UyEyx7+fmwrm892q1luBrk2Xf18b2e+MfBb2OOHPmjF3fS5fzOiKHkrZ2iIyMJDIykpSUlAJ/0YlUZgMGGBOTjRoFZ84YtWstltyPPj5GwnbAACcHKiIiImUqODi42JG2AQEBBbbVr1+/gL3z8vLyyrNuMpnsOg6gRo0aNG3alBo1apCRkUF8fDz169e3jfhzc3OjXr16tpG3Li4umM3mPCNPc9SpUyfPda1WK6dOncqT9PH19cW3gGL+OYnj4lw8URMYyUF777Wg0cLFHZudnU18fDwZGRn8/vvvtGnTxlZrtbC+yWG1WomNzT+qsH79+oX2zcUjJt3d3fMc5+7ubve9Xsrb29uuYwt6bD8wMJCzZ/OP1MrOzrYlrevVq0edOnVsfdO2bVtq1KhBvXr1gKL7xt/fP8/oK4vFkm+k1aV9k6Og7xtfX1+77rVu3br52kJCQuwaaXtpLDVq1Mh3TavVSkpKCn///TcWiwVXV1fq1KnDgEte/NeqVYv69euTkpJSaMI4MDAQV1dXoqKiqF27NsuWLeOKK66gSZMmHDx4kBo1auDv74/JVPCo18v9HXHpvQcGBhojA2sWPdL2Um613QpMLOXbr45bvjZ3b/cC9izgujXyXtdkNtl1zZx9L1a7dm27+ikwMDBfm5unnfdaK/+92huv2c2cb93eYwuKw55ja9Suka+tsN8RF8vKysLskjfey/nauNRwsevYgr5v3OrY+bWpXcD3oY97gW9IQO6IUt+6+X9fFfQ7IodtpO0/P0+X/hy71bQvXnev/Pfq7uVu373WzHuvJlMJvjauRX9tLu6XS0faXqpu3bp2/cwV9TrCYrHY9Wa5yVrY8zCST07S9uzZs/n+oFRGFouFhIQE6tWrV+wLdMmrKvZdRgasXg1r1sDp08akY4MGGSURSmuEbVXst/KivnOc+s5x6jvHqe8cc2m/VbXXXhVVZernRx99lFdffZXGjRtz3XXXsXr1atLT01mzZg0RERG2/0R+++23tkf2R40aRXZ2NsuXLwegX79+BAUF8cADD9C9e3eee+45pk2bZhsV2LdvX3x8fNi1a5etREBloL4pnPqmejp48CB33HcHPoN8qB1Y29nhlLr0+HSS1ySz6q1VNGnSxO7jqnq/gPqmKOqbgjnaL46y97WXRtqKCGAkZocPNxYRERGRimjBggV4eHjw3nvv8f7779OkSRMmTZpEREREoccsW7Ysz/qXX34JwPXXX1/gqMCJEycyb948hg8fzjfffFNp3nhR3xROfSMiIpWRkrYiIiIiIlIpuLu7M3/+/HwTM+Uo6CHC4h4szCkbkOOFF17ghRdecDxIJ1HfFE59IyIilZHe/hMRERERERERERGpQJS0FREREREREREREalAlLQVEREREakCoqOjady4MR4eHoSHh7Nt2zZnhyQiIiIiDlJNWztER0cTHR1Ndna2s0MREREREcln5cqVTJgwgddff53w8HAWLlxIv3792LdvH/Xq1XN2eCJShhITE0lJSXF2GGXKy8uLgIAAZ4chIlKulLS1Q2RkJJGRkaSkpODt7e3scERERERE8njppZd48MEHGT16NACvv/46a9eu5a233mLKlClOjk5EykpiYiJjhw0jMynJ2aGUKXc/Pxa//74StyJSrShpKyIiIiJSiZ0/f54dO3YwdepUW5vZbKZv375s3rzZiZEVrqqPDHR0VGBV7xdQ3xTFkb5JSUkhMymJJ93daVizZhlF5lzHzp3jxaQkUlJSlLQVkWpFSdsSsFqtAFXmxYLFYiE1NRUPDw/MZpU3Lgn1nWPUb45T3zlOfec49Z3j1HeOubTfcl5z5bwGk4KdOnWK7OxsAgMD87QHBgayd+/efPtnZmaSmZlpWz979iwAycnJWCyWsg0WSEpK4skHHyTz9Okyv5azuPv68uIbb+Dn52f3MdWhX0B9UxRH+iYlJYUsi4W07GxSqmg5v7TsbLIsFlJSUkhOTrb7uJSUFCzZFtJi08jOqHp9c+70OSzZ6peCqG8Kp74pmKP94ih7X+OarHoVbLfjx4/TsGFDZ4chIiIiUq0cO3aMBg0aODuMCis2Npb69euzadMmunbtamufNGkS33//PVu3bs2zf1RUFLNmzSrvMEVERETkIsW9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UrNmTVOHW6juXYytadOmxgVtchdj++677+jSpQslS5bE0tLS2L+1OChbtiw3btzg7NmzuLm5oZRi4MCBZGdn8/rrr+Pt7U379u0ZOXJknuPMuYii0+mwtLTEycmJI0eOcOfOHUqVKpVnMbYRI0awd+9eWrVqhaWlpbHIr5Qy27xAzvXlFrH/+OMP6tati16vx9LSkpUrVxIYGEhISAhr166levXq+Y4159zkfk3k9t3/+++/qVmzpvGPQF9++SU9e/Zk7dq1DB482Jg3MP/c6PV6bt++bZzND//OnB09ejSXLl1i+vTp1K9fn0qVKlG6dGmcnJyM+5lzbp6YEmYnLi5OAfn+BQcHG/f5/PPP1QsvvKBKlCihGjVqpH766SfTBVyESO6enOTuyUnunozk7clJ7p6c5O7JSe6EeHpOnjypWrVqpWrUqKGSkpKM43v27FEvvviiSkxMNGF0hWv27NnqzJkzxo83bdqkNBqN+vLLL/Pst2bNGuXv76/0en1hh1gk/Pbbb8rNzU3NmzdPKaWUwWBQSiml0+nU0KFD1aRJk4wfm7tbt24ppZTKyMhQSil17do1ValSJdW/f/88+126dEnVr19fbd++vdBjLCrGjBmjqlSpov7++2+l1L/3TUREhGrevLm6ceOGKcMrVGlpaerKlSvq4sWLxrFXXnlFVa9eXV25ciXPvp06dVKLFi0q7BBN5v7vG99++62ytrZWUVFRecYPHDig6tatq06fPl2Y4T3XNEr9358fhRBCCCGEEEI8F/766y/+/PNP2rVrR2ZmJra2tly5coWOHTuyYsUK6tSpY+oQn7ncxdhye/vmWrp0KaNGjWLu3LnGxdg+//xzoqKi2LRpk9n39gU4f/48qamplCxZEk9PT8qUKcOqVasIDg5mxYoVDBkyxLjvBx98wPnz5419Ws3ZgxZjO3HiBC1btiQgIMC4GBtA/fr1ef/9982+ty9AcnIyERERlClTBldXV/r37w9Ax44dSU5OJjY21vgY+19//cWAAQOIiooqNguyDR8+HKUUZcuWxc/Pj9DQUABat27N33//zfr163F3d8fBwYFGjRoxYsQIQkJCTBz5s3fixAnmz5+PtbU1rq6uTJgwATs7Oz788EOmT5+OVquld+/eWFhYoNfradiwIStWrCgWC7I9DdIeQQghhBBCCCGeM56enrzwwgsA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YAEDBw7khRde4IsvvgDMOzcA8fHxxsf9hw4dykcffcSKFSt45513+Pzzz6lRowaJiYkMGTIEf39/goODjYtG/9f7geeZTqfj008/Zdy4caxYsYLIyEjWrVvHTz/9xCuvvIJGo2HLli0EBwdz8OBBFixYQJ06dYy9j839vjE1aY8ghCgS9Ho9ERERtG7dOs8qt40bN+bTTz9l165dtG/fPs8xVatWfeD5CtqWlpbG9OnTiYiI4NKlS3m23dtQvmbNmvj5+aHVao2FRK1WS5MmTahevfp/XkuNGjVYtWoVer2eEydO8P333xMaGkpISAhVq1albdu2Dz3+xo0b9OzZk8qVK7Ny5Urjo1uXL18mPT2dpUuXPrDX1P3XVZD7f7CmpKSglGLq1KlMnTr1geetXLlyvvHk5GQAgoODH/h6169fz1Movr8PVNmyZbG1tTW2J7h3/N5+w8nJyfz+++8PLNTff+0F9ZtycHDI82jcgwQFBfHVV18xYsQIJk2aREBAAD179qR37975VhrOzae5PmInhBBCiKIrPT2dzp074+7uTlRUFJBTYLr/9xVzcf9ibGPGjKFChQrcvHkTS0tLtFot69evp2nTpvlamplzXiBngkru4lEAFStW5NVXXzW2BXNycuK1114jMjKS27dvc+vWLSpWrAiYf24g573FjRs3OHv2LG5ubiilGDhwINnZ2bz++ut4e3vTvn17Y2/bXOaeGysrK6ysrLh165bx/vH19SU+Pp7GjRszePBgVq1axfTp09HpdOj1emxsbADzz01RIEVbIUSRsHv3bs6fP09ERAQRERH5tmu12nxF23tnzt6voG19+/blwIEDvPPOO/j6+lK6dGkMBgMdO3bM99fTIUOGMH78eM6ePUtWVhY//fQTCxcufKxrsrS0pG7dutStW5emTZvSunVrtFrtfxZthw4dyj///MOhQ4fy9EjNjXHQoEEPLJLWq1fvged1dHREo9HkK1rmnnfChAl06NChwGMfVKzOPfbjjz/G19e3wH3u72+U29z/v8Ygb4HZYDBQt25dPvvsswL3rVKlymOf80Hs7OzYt28fcXFxbN68mW3btrF27VratGnDjh078pw7N5/3F52FEEIIIZ61ChUqEBUVZVwE19yLKLmLsU2cOJHw8HA6d+7MN998g16vJzk5mTFjxhhnBeb2aM1lznmBnCcJ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deXKlTz79uvXT5UtW7bAa8yVnZ2tNBqNevvtt/NtCwgIUHXr1lWZmZnGMYPBoF5++WXl5eVlHIuLi1OAiouLM+7j5eWlOnTooAwGg3G/27dvq6pVq6p27doZx3I/NyEhIcYxnU6n3NzclEajUXPmzDGOX7t2TdnZ2ang4GDj2KpVq5SFhYX64Ycf8sS+ZMkSBagff/zROAaoEiVKqJSUFONYYmKiAtTnn39uHPv4448VoE6dOpXnnGFhYQpQly9fzper+61evVoB6uDBg/+5rxDi2ZD2CEIIIYQQQgghxDOi1WpxdXWldevWQM4j7kFBQURERKDX6/Pt36tXL5ydnQs816hRo/KN2dnZGf+fmZnJlStXaNKkCQC//PKLcduQIUPIysriu+++M46tXbsWnU7HoEGDHnoNPj4+JCQkMGjQIFJTU5k/fz6BgYG4urry5ZdfPvTYe504cYJXX32V7t2789577wGglGL9+vV07doVpRRXrlwx/uvQoQPXr1/Pcx33S0tLQymFg4NDvvHdu3fTt29fbt68aTzn1atX6dChA8nJyZw7d67AcyYkJJCcnMyAAQO4evWq8dhbt24REBDAvn37MBgMeY4ZMWKE8f+WlpY0bNgQpRTDhw83jpcrVw5vb2/++usv49i6deuoVasWNWvWzHPtuTOa4+Li8rxO27Zt87SBqFevHvb29nnO+SDlypUDYMOGDfniv19uPq9cufKf5xVCPBtStBVCCCGEEEIIIZ4BvV5PREQErVu35tSpU6SkpJCSkkLjxo25ePEiu3btyndM1apVH3i+gralpaUxfvx4XF1dsbOzw9nZ2bjf9evXjfvVrFkTPz+/PP10tVotTZo0oXr16v95LTVq1GDVqlVcuXKFo0ePMmvWLKysrAgJCWHnzp3/efyNGzfo2bMnlStXZuXKlWg0GgAuX75Meno6S5cuxdnZOc+/YcOGAfkXOyuI+r92EblSUlJQSjF16tR8581dBO5B501OTgYgODg437FfffUVWVlZeXIL8MILL+T5uGzZstja2hrbE9w7fu3atTyvdfz48XyvU6NGjQJjvP91IKfAeu85HyQoKIhmzZoxYsQIXF1d6devH5GRkQUWcHPzmft5EkIUPulpK4QQQgghhBBCPAO7d+/m/PnzREREEBERkW+7Vqulffv2ecbunTl7v4K29e3blwMH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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Analysis Complete\n", + "Maximum GPU speedup: 28.7x at 50,000,000 elements\n" + ] + } + ], + "source": [ + "# SOLUTION: Extra Credit (continued) - Visualization\n", + "\n", + "# Create the plot\n", + "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "# Plot 1: Absolute times (log-log scale)\n", + "ax1.loglog(sizes, cpu_times, 'b-o', label='NumPy (CPU)', linewidth=2, markersize=8)\n", + "ax1.loglog(sizes, gpu_times, 'r-s', label='CuPy (GPU)', linewidth=2, markersize=8)\n", + "for x, y in zip(sizes, cpu_times):\n", + " ax1.text(x, y, f'{x:.3g}', va='bottom', ha='right')\n", + "for x, y in zip(sizes, gpu_times):\n", + " ax1.text(x, y, f'{x:.3g}', va='top', ha='left')\n", + "ax1.set_xlabel('Array Size (elements)', fontsize=12)\n", + "ax1.set_ylabel('Time (ms)', fontsize=12)\n", + "ax1.set_title('Sort Performance: CPU vs GPU', fontsize=14)\n", + "ax1.legend(fontsize=11)\n", + "ax1.grid(True, alpha=0.3)\n", + "\n", + "# Plot 2: Speedup ratio\n", + "speedups = [cpu / gpu for cpu, gpu in zip(cpu_times, gpu_times)]\n", + "colors = ['green' if s > 1 else 'red' for s in speedups]\n", + "COLOR5 = plt.rcParams[\"text.color\"]\n", + "\n", + "ax2.bar(range(len(sizes)), speedups, color=colors, alpha=0.7, edgecolor=COLOR5)\n", + "ax2.axhline(y=1.0, color=COLOR5, linestyle='--', linewidth=2, label='Break-even')\n", + "ax2.set_xticks(range(len(sizes)))\n", + "ax2.set_xticklabels([f'{s:,}' for s in sizes], rotation=45, ha='right', fontsize=9)\n", + "ax2.set_xlabel('Array Size (elements)', fontsize=12)\n", + "ax2.set_ylabel('GPU Speedup (CPU time / GPU time)', fontsize=12)\n", + "ax2.set_title('GPU Speedup Factor', fontsize=14)\n", + "ax2.legend(fontsize=11)\n", + "ax2.grid(True, axis='y', alpha=0.3)\n", + "\n", + "# Add value labels on bars\n", + "for i, (speedup, color) in enumerate(zip(speedups, colors)):\n", + " label = f'{speedup:.1f}x'\n", + " ax2.annotate(label, (i, speedup), textcoords=\"offset points\",\n", + " xytext=(0, 5), ha='center', fontsize=9, fontweight='bold')\n", + "\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "print(\"\\nAnalysis Complete\")\n", + "print(f\"Maximum GPU speedup: {max(speedups):.1f}x at {sizes[speedups.index(max(speedups))]:,} elements\")" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/02__power_iteration__cupy__memory_spaces__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/02__power_iteration__cupy__memory_spaces__SOLUTION.ipynb new file mode 100644 index 00000000..5221bc44 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/02__power_iteration__cupy__memory_spaces__SOLUTION.ipynb @@ -0,0 +1,650 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a8c2d68c", + "metadata": {}, + "source": [ + "## Power Iteration - CuPy - Memory Spaces - SOLUTION\n", + "\n", + "### Table of Contents\n", + "1. [Introduction to Memory Spaces](#1-introduction-to-memory-spaces)\n", + "2. [The CPU Baseline (NumPy)](#2-the-cpu-baseline-numpy)\n", + "3. [The GPU Port (CuPy)](#3-the-gpu-port-cupy)\n", + "4. [Optimizing Data Generation](#4-optimizing-data-generation)\n", + "5. [Verification and Benchmarking](#5-verification-and-benchmarking)\n", + "6. [Extra Credit](#extra-credit)\n", + "\n", + "---\n", + "\n", + "### 1. Introduction to Memory Spaces\n", + "\n", + "Before we implement algorithms on the GPU, we must understand the hardware architecture. A heterogeneous system (like the one you are using) consists of two distinct memory spaces:\n", + "\n", + "1. **Host Memory:** Accessible by the CPU.\n", + "2. **Device Memory:** Accessible by the GPU.\n", + "\n", + "To ensure data is accessible from a particular processor, we need to explicity transfer it:\n", + "\n", + "* **Host $\\to$ Device:** Move data to the GPU to compute.\n", + " * Syntax: `x_device = cp.asarray(x_host)`\n", + "* **Device $\\to$ Host:** Move results back to the CPU to save to disk, plot with Matplotlib, or print.\n", + " * Syntax: `y_host = cp.asnumpy(y_device)`\n", + "\n", + "#### Implicit Transfers and Synchronization\n", + "\n", + "It is crucial to understand when CuPy interacts with the CPU implicitly. These interactions can kill performance because they force the GPU to pause (synchronize) while data moves.\n", + "\n", + "CuPy silently transfers and synchronizes when you:\n", + "1. **Print** a GPU array (`print(gpu_array)`).\n", + "2. **Convert** to a Python scalar (`float(gpu_array)` or `.item()`).\n", + "3. **Evaluate** a GPU scalar in a boolean context (`if gpu_scalar > 0:`).\n", + "\n", + "#### The Task\n", + "To understand the implications of these concepts, let's experiment with estimating the dominant eigenvalue of a matrix using the **Power Iteration** algorithm.\n", + "\n", + "Before we dive into the code, let's understand the math behind the algorithm we are implementing.\n", + "\n", + "**Power Iteration** is a classic iterative method used to find the dominant eigenvalue (the eigenvalue with the largest absolute value) and its corresponding eigenvector of a square matrix $A$.\n", + "\n", + "##### How It Works\n", + "\n", + "The core idea is simple: if you repeatedly multiply a vector by a matrix $A$, the vector will eventually converge towards the dominant eigenvector of $A$, regardless of the initial vector you started with (provided the initial vector has some component in the direction of the dominant eigenvector).\n", + "\n", + "##### The Mathematical Steps\n", + "\n", + "Given a square matrix $A$ and a random initial vector $x_0$, the algorithm proceeds as follows for each step $k$:\n", + "\n", + "**1. Matrix-Vector Multiplication:**\n", + "\n", + "We calculate the next approximation of the vector:\n", + "\n", + "$$y = A x_k$$\n", + "\n", + "**2. Eigenvalue Estimation (Rayleigh Quotient):**\n", + "\n", + "We estimate the eigenvalue $\\lambda$ using the current vector. This is essentially projecting $y$ onto $x$:\n", + "\n", + "$$\\lambda_k = \\frac{x_k^T y}{x_k^T x_k} = \\frac{x_k^T A x_k}{x_k^T x_k}$$\n", + "\n", + "**3. Residual Calculation (Error Check):**\n", + "\n", + "We check how close we are to the true definition of an eigenvector ($Ax = \\lambda x$) by calculating the \"residual\" (error):\n", + "\n", + "$$r = ||y - \\lambda_k x_k||$$\n", + "\n", + "If $r$ is close to 0, we have converged.\n", + "\n", + "**4. Normalization:**\n", + "\n", + "To prevent the numbers from exploding (overflow) or vanishing (underflow), we normalize the vector for the next iteration:\n", + "\n", + "$$x_{k+1} = \\frac{y}{||y||}$$\n", + "\n", + "We will start with a standard CPU implementation, port it to the GPU using CuPy, and analyze the performance impact of memory transfers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0cc26840", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:47:53.898364Z", + "iopub.status.busy": "2026-03-09T19:47:53.898143Z", + "iopub.status.idle": "2026-03-09T19:47:54.953845Z", + "shell.execute_reply": "2026-03-09T19:47:54.952344Z", + "shell.execute_reply.started": "2026-03-09T19:47:53.898341Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import cupy as cp\n", + "import cupyx as cpx\n", + "import time\n", + "from dataclasses import dataclass\n", + "\n", + "# Configuration for the algorithm\n", + "@dataclass\n", + "class PowerIterationConfig:\n", + " dim: int = 10000 # Matrix size (dim x dim)\n", + " dominance: float = 0.05 # How much larger the top eigenvalue is (controls convergence, higher == faster)\n", + " max_steps: int = 1000 # Maximum iterations\n", + " check_frequency: int = 25 # Check for convergence every N steps\n", + " progress: bool = True # Print progress logs\n", + " residual_threshold: float = 1e-10 # Stop if error is below this" + ] + }, + { + "cell_type": "markdown", + "id": "4e86805e", + "metadata": {}, + "source": [ + "### 2. The CPU Baseline (NumPy)\n", + "\n", + "We generate a random dense matrix that is diagonalizable. This data is generated on the **Host (CPU)** and resides in **Host Memory**.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b2503345", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:47:54.955341Z", + "iopub.status.busy": "2026-03-09T19:47:54.954881Z", + "iopub.status.idle": "2026-03-09T19:48:00.598782Z", + "shell.execute_reply": "2026-03-09T19:48:00.597564Z", + "shell.execute_reply.started": "2026-03-09T19:47:54.955308Z" + } + }, + "outputs": [], + "source": [ + "def generate_host(cfg=PowerIterationConfig()):\n", + " \"\"\"Generates a random diagonalizable matrix on the CPU.\"\"\"\n", + " np.random.seed(42)\n", + "\n", + " # Create eigenvalues: One large one (1.0), the rest smaller\n", + " weak_lam = np.random.random(cfg.dim - 1) * (1.0 - cfg.dominance)\n", + " lam = np.random.permutation(np.concatenate(([1.0], weak_lam)))\n", + "\n", + " # Construct matrix A = P * D * P^-1\n", + " P = np.random.random((cfg.dim, cfg.dim)) # Random invertible matrix\n", + " D = np.diag(np.random.permutation(lam)) # Diagonal matrix of eigenvalues\n", + " A = ((P @ D) @ np.linalg.inv(P)) # The final matrix\n", + " return A\n", + "\n", + "# Generate the data on Host\n", + "print(\"Generating Host Data...\")\n", + "A_host = generate_host()\n", + "print(f\"Host Matrix Shape: {A_host.shape}\")\n", + "print(f\"Data Type: {A_host.dtype}\")" + ] + }, + { + "cell_type": "markdown", + "id": "da7cb3e6", + "metadata": {}, + "source": [ + "#### Implementing Power Iteration (CPU)\n", + "\n", + "As described above, the Power Iteration algorithm repeatedly multiplies a vector $x$ by matrix $A$ ($y = Ax$) and normalizes the result. We initialize this algorithm with a vector of 1s ($x_0$) as our initial guess." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3c8229aa", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:48:00.599800Z", + "iopub.status.busy": "2026-03-09T19:48:00.599526Z", + "iopub.status.idle": "2026-03-09T19:48:01.945015Z", + "shell.execute_reply": "2026-03-09T19:48:01.943567Z", + "shell.execute_reply.started": "2026-03-09T19:48:00.599776Z" + } + }, + "outputs": [], + "source": [ + "def estimate_host(A, cfg=PowerIterationConfig()) -> np.ndarray:\n", + " \"\"\"\n", + " Performs power iteration using purely NumPy (CPU).\n", + " \"\"\"\n", + " # Initialize solution vector.\n", + " x = np.ones(A.shape[0], dtype=np.float64)\n", + "\n", + " for i in range(0, cfg.max_steps, cfg.check_frequency):\n", + " # Matrix-Vector multiplication.\n", + " y = A @ x\n", + "\n", + " # Rayleigh quotient.\n", + " lam = (x @ y) / (x @ x)\n", + "\n", + " # Calculate residual (error).\n", + " res = np.linalg.norm(y - lam * x)\n", + "\n", + " # Normalize vector for next step.\n", + " x = y / np.linalg.norm(y)\n", + "\n", + " if cfg.progress:\n", + " print(f\"Step {i}: residual = {res:.3e}\")\n", + "\n", + " # Save a checkpoint.\n", + " np.savetxt(f\"/tmp/host_{i}.txt\", x)\n", + "\n", + " # Convergence check.\n", + " if res < cfg.residual_threshold:\n", + " break\n", + "\n", + " # Run intermediate steps without checking residual to save compute.\n", + " for _ in range(i + 1, min(i + cfg.check_frequency, cfg.max_steps)):\n", + " y = A @ x\n", + " x = y / np.linalg.norm(y)\n", + "\n", + " return (x.T @ (A @ x)) / (x.T @ x)\n", + "\n", + "lam_est_host = estimate_host(A_host)\n", + "\n", + "assert isinstance(lam_est_host, (np.ndarray, np.generic)), \"Must return a NumPy array or NumPy scalar\"\n", + "np.testing.assert_allclose(lam_est_host, 1, atol=1e-4)\n", + "\n", + "print()\n", + "print(\"Dominant Eigenvalue:\", lam_est_host)" + ] + }, + { + "cell_type": "markdown", + "id": "206ad4c8", + "metadata": {}, + "source": [ + "### 3. The GPU Port (CuPy)\n", + "\n", + "#### Exercise: Port the CPU Implementation to GPU\n", + "\n", + "Now it's your turn! Your task is to convert the `estimate_host` function to run on the GPU using CuPy.\n", + "\n", + "**Remember the rules of Memory Spaces:**\n", + "1. **Transfer:** Move `A_host` from CPU to GPU using `cp.asarray()`.\n", + "2. **Compute:** Perform math using `cp` functions on the GPU.\n", + "3. **Retrieve:** Move result back to CPU using `cp.asnumpy()`.\n", + "\n", + "**Hint:** CuPy tries to replicate the NumPy API. In many cases, you can simply change `np.` to `cp.`. However, CuPy operations *must* run on data present in Device Memory.\n", + "\n", + "**The code below starts as a copy of the CPU implementation. Modify it to run on the GPU:**\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f36586ee", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:48:01.946197Z", + "iopub.status.busy": "2026-03-09T19:48:01.945815Z", + "iopub.status.idle": "2026-03-09T19:48:04.244716Z", + "shell.execute_reply": "2026-03-09T19:48:04.243488Z", + "shell.execute_reply.started": "2026-03-09T19:48:01.946170Z" + } + }, + "outputs": [], + "source": [ + "def estimate_device(A, cfg=PowerIterationConfig()) -> np.ndarray:\n", + " \"\"\"\n", + " Port the power iteration algorithm to the GPU using CuPy.\n", + "\n", + " Steps to complete:\n", + " 1. Transfer the input matrix A to the GPU\n", + " 2. Initialize the vector x on the GPU\n", + " 3. Replace np operations with cp operations\n", + " 4. Copy x from device to host and save a checkpoint\n", + " 5. Return the result as a NumPy array\n", + " \"\"\"\n", + " # Transfer the input matrix from host to device.\n", + " # If A is on the host, cp.asarray copies it to the GPU.\n", + " # If A is already on the GPU, cp.asarray is a no-op.\n", + " A_gpu = cp.asarray(A)\n", + "\n", + " # Initialize vector of ones on the device.\n", + " x = cp.ones(A_gpu.shape[0], dtype=cp.float64)\n", + "\n", + " for i in range(0, cfg.max_steps, cfg.check_frequency):\n", + " # Matrix-Vector multiplication (same as NumPy).\n", + " y = A_gpu @ x\n", + "\n", + " # Rayleigh quotient (same as NumPy).\n", + " lam = (x @ y) / (x @ x)\n", + "\n", + " # Calculate residual using cp.linalg.norm.\n", + " res = cp.linalg.norm(y - lam * x)\n", + "\n", + " # Normalize x using cp.linalg.norm.\n", + " x = y / cp.linalg.norm(y)\n", + "\n", + " if cfg.progress:\n", + " print(f\"Step {i}: residual = {res:.3e}\")\n", + "\n", + " # Transfer x from device to host and save a checkpoint.\n", + " np.savetxt(f\"/tmp/device_{i}.txt\", cp.asnumpy(x))\n", + "\n", + " # Convergence check.\n", + " if res < cfg.residual_threshold:\n", + " break\n", + "\n", + " # Run intermediate steps without checking residual to save compute.\n", + " for _ in range(i + 1, min(i + cfg.check_frequency, cfg.max_steps)):\n", + " y = A_gpu @ x\n", + " x = y / cp.linalg.norm(y)\n", + "\n", + " # Compute result and transfer it from device to host.\n", + " return cp.asnumpy((x.T @ (A_gpu @ x)) / (x.T @ x))\n", + "\n", + "lam_est_device = estimate_device(A_host)\n", + "\n", + "assert isinstance(lam_est_device, (np.ndarray, np.generic)), \"Must return a NumPy array or NumPy scalar\"\n", + "np.testing.assert_allclose(lam_est_device, 1, atol=1e-4)\n", + "\n", + "print()\n", + "print(\"Dominant Eigenvalue:\", lam_est_device)" + ] + }, + { + "cell_type": "markdown", + "id": "f89daef5", + "metadata": {}, + "source": [ + "### 4. Optimizing Data Generation\n", + "\n", + "In the previous step, we generated data on the CPU and copied it to the GPU. For large datasets, the transfer time from host to device can be a bottleneck. \n", + "\n", + "It is almost always faster to **generate** the data directly on the GPU if possible.\n", + "\n", + "#### Exercise: Generate Data Directly on the GPU\n", + "\n", + "Your task is to convert the `generate_host` function to generate the matrix directly on the GPU using CuPy's random functions.\n", + "\n", + "**Hints:**\n", + "- Use `cp.random.seed()` instead of `np.random.seed()`\n", + "- Use `cp.random.random()` instead of `np.random.random()`\n", + "- Use `cp.random.permutation()` instead of `np.random.permutation()`\n", + "- Use `cp.concatenate()`, `cp.array()`, `cp.diag()`, and `cp.linalg.inv()`\n", + "\n", + "**The code below starts as a copy of the CPU implementation. Modify it to generate data directly on the GPU:**\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7a363755", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:48:04.246171Z", + "iopub.status.busy": "2026-03-09T19:48:04.245926Z", + "iopub.status.idle": "2026-03-09T19:48:06.775967Z", + "shell.execute_reply": "2026-03-09T19:48:06.774876Z", + "shell.execute_reply.started": "2026-03-09T19:48:04.246149Z" + } + }, + "outputs": [], + "source": [ + "def generate_device(cfg=PowerIterationConfig()):\n", + " \"\"\"\n", + " Generate a random diagonalizable matrix directly on the GPU.\n", + "\n", + " This should mirror the generate_host function but use CuPy instead of NumPy.\n", + " The key benefit: no Host->Device transfer needed!\n", + " \"\"\"\n", + " # ---------------------------------------------------------\n", + " # SOLUTION: Set the random seed on the GPU\n", + " # ---------------------------------------------------------\n", + " cp.random.seed(42)\n", + "\n", + " # ---------------------------------------------------------\n", + " # SOLUTION: Create eigenvalues on the GPU\n", + " # Generate (dim-1) random values, scale them, then combine with 1.0\n", + " # ---------------------------------------------------------\n", + " # SOLUTION: Generate weak eigenvalues using cp.random.random()\n", + " weak_lam = cp.random.random(cfg.dim - 1) * (1.0 - cfg.dominance)\n", + "\n", + " # SOLUTION: Concatenate [1.0] with weak_lam using cp.concatenate and cp.array\n", + " # Then permute them using cp.random.permutation\n", + " lam = cp.random.permutation(cp.concatenate((cp.array([1.0]), weak_lam)))\n", + "\n", + " # ---------------------------------------------------------\n", + " # SOLUTION: Construct the matrix A = P * D * P^-1 on the GPU\n", + " # ---------------------------------------------------------\n", + " # SOLUTION: Generate random matrix P using cp.random.random()\n", + " P = cp.random.random((cfg.dim, cfg.dim))\n", + "\n", + " # SOLUTION: Create diagonal matrix D using cp.diag()\n", + " D = cp.diag(cp.random.permutation(lam))\n", + "\n", + " # SOLUTION: Compute A = P @ D @ P^-1 using cp.linalg.inv()\n", + " A = ((P @ D) @ cp.linalg.inv(P))\n", + "\n", + " return A\n", + "\n", + "A_device = generate_device()\n", + "\n", + "lam_est_device_gen = estimate_device(A_device)\n", + "\n", + "np.testing.assert_allclose(lam_est_device_gen, 1, atol=1e-4)\n", + "\n", + "print()\n", + "print(\"Dominant Eigenvalue:\", lam_est_device_gen)" + ] + }, + { + "cell_type": "markdown", + "id": "a6f05ce3", + "metadata": {}, + "source": [ + "#### Think About It\n", + "\n", + "Both functions use `seed(42)`. Are `A_host` and `A_device` identical? Try comparing them:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "611859bb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:48:06.777042Z", + "iopub.status.busy": "2026-03-09T19:48:06.776701Z", + "iopub.status.idle": "2026-03-09T19:48:06.839719Z", + "shell.execute_reply": "2026-03-09T19:48:06.838593Z", + "shell.execute_reply.started": "2026-03-09T19:48:06.777021Z" + } + }, + "outputs": [], + "source": [ + "with np.printoptions(precision=4):\n", + " print(\"A_host:\")\n", + " print(A_host)\n", + " print()\n", + " print(\"A_device:\")\n", + " print(A_device)\n", + " print()" + ] + }, + { + "cell_type": "markdown", + "id": "3de33b00", + "metadata": {}, + "source": [ + "What does this reveal about `np.random` vs `cp.random`?\n", + "\n", + "**SOLUTION:**\n", + "\n", + "This reveals that `np.random` and `cp.random` use **different random number generator (RNG) implementations**, even with the same seed.\n", + "\n", + "- NumPy uses the Mersenne Twister algorithm (or PCG64 in newer versions) on the CPU.\n", + "- CuPy uses a GPU-optimized RNG (typically XORWOW from cuRAND) that runs efficiently in parallel on thousands of GPU threads.\n", + "\n", + "Even with the same seed value (`42`), these different algorithms produce completely different sequences of \"random\" numbers. This is why `A_host` and `A_device` contain different values.\n", + "\n", + "**Key Takeaway:** If you need *identical* data on both CPU and GPU for verification purposes, you should:\n", + "1. Generate the data on one device (e.g., CPU with NumPy)\n", + "2. Transfer it to the other device (e.g., `cp.asarray(A_host)`)\n", + "\n", + "This guarantees bit-for-bit identical data, which is essential for debugging and validation." + ] + }, + { + "cell_type": "markdown", + "id": "1bc46555", + "metadata": {}, + "source": [ + "### 5. Verification and Benchmarking\n", + "\n", + "Finally, let's verify our accuracy against a reference implementation (`numpy.linalg.eigvals()`) and benchmark the speedup.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5d4e603", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:48:06.840791Z", + "iopub.status.busy": "2026-03-09T19:48:06.840556Z", + "iopub.status.idle": "2026-03-09T19:48:40.245264Z", + "shell.execute_reply": "2026-03-09T19:48:40.244277Z", + "shell.execute_reply.started": "2026-03-09T19:48:06.840773Z" + } + }, + "outputs": [], + "source": [ + "start = time.perf_counter()\n", + "lam_ref = np.linalg.eigvals(A_host).real.max()\n", + "T_ref = time.perf_counter() - start" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "80b127ed", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:48:40.246588Z", + "iopub.status.busy": "2026-03-09T19:48:40.246128Z", + "iopub.status.idle": "2026-03-09T19:48:40.259350Z", + "shell.execute_reply": "2026-03-09T19:48:40.257131Z", + "shell.execute_reply.started": "2026-03-09T19:48:40.246561Z" + } + }, + "outputs": [], + "source": [ + "print(f\"Power Iteration (Host) = {lam_est_host}\")\n", + "print(f\"Power Iteration (Device) = {lam_est_device}\")\n", + "print(f\"`eigvals` Reference = {lam_ref}\")\n", + "\n", + "rel_err_host = abs(lam_est_host - lam_ref) / abs(lam_ref)\n", + "rel_err_device = abs(lam_est_device - lam_ref) / abs(lam_ref)\n", + "print()\n", + "print(f\"Relative Error (Host) = {rel_err_host:.3e}\")\n", + "print(f\"Relative Error (Device) = {rel_err_device:.3e}\")\n", + "\n", + "np.testing.assert_allclose(lam_est_host, lam_ref, rtol=1e-4)\n", + "np.testing.assert_allclose(lam_est_device, lam_ref, rtol=1e-4)" + ] + }, + { + "cell_type": "markdown", + "id": "ef0bda61", + "metadata": {}, + "source": [ + "#### Benchmarking with `cupyx.profiler.benchmark()`\n", + "\n", + "We use CuPy's built-in benchmarking utility for accurate GPU timing. This handles warmup and synchronization automatically.\n", + "\n", + "We intentionally use `A_host` for both benchmarks, not `A_device`, because they're not the same matrices due to differences in NumPy and CuPy's random facilities. Different matrices converge at different rates, so it's only valid to benchmark on the same inputs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39403462", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:48:40.260135Z", + "iopub.status.busy": "2026-03-09T19:48:40.259900Z", + "iopub.status.idle": "2026-03-09T19:48:58.805216Z", + "shell.execute_reply": "2026-03-09T19:48:58.803809Z", + "shell.execute_reply.started": "2026-03-09T19:48:40.260113Z" + } + }, + "outputs": [], + "source": [ + "cfg = PowerIterationConfig(progress=False)\n", + "\n", + "print(\"Timing Host...\")\n", + "T_host = cpx.profiler.benchmark(estimate_host, args=(A_host, cfg), n_repeat=10, n_warmup=1).cpu_times\n", + "\n", + "print(\"Timing Device...\")\n", + "T_device = cpx.profiler.benchmark(estimate_device, args=(A_host, cfg), n_repeat=10, n_warmup=1).cpu_times\n", + "\n", + "print()\n", + "print(f\"Power Iteration (Host) = {T_host.mean() * 1000:.6g} ms ± {(T_host.std() / T_host.mean()):.2%} (mean ± relative stdev of {T_host.size} runs)\")\n", + "print(f\"Power Iteration (Device) = {T_device.mean() * 1000:.6g} ms ± {(T_device.std() / T_device.mean()):.2%} (mean ± relative stdev of {T_device.size} runs)\")\n", + "print(f\"`eigvals` Reference = {T_ref * 1000:.6g} ms\")\n", + "\n", + "speedup = T_host.mean() / T_device.mean()\n", + "print()\n", + "print(f\"Speedup (Device over Host) = {speedup:.1f}x\")" + ] + }, + { + "cell_type": "markdown", + "id": "cdd3bff4", + "metadata": {}, + "source": [ + "---\n", + "\n", + "### Extra Credit\n", + "\n", + "**Explore the impact of changing the following parameters:**\n", + "\n", + "1. **Problem Size (`dim`):** How does the GPU speedup change as you decrease the matrix dimensions? Try values like 1024, 2048, 4096, 8192.\n", + "\n", + "2. **Compute Workload (`max_steps` and `dominance`):** The `dominance` parameter controls how quickly the algorithm converges. A smaller dominance means eigenvalues are closer together, requiring more iterations. How does this affect the CPU vs GPU comparison?\n", + "\n", + "3. **Check Frequency (`check_frequency`):** This controls how often we check for convergence (and trigger implicit CPU synchronization via the print statement). What happens to GPU performance when you check every step (`check_frequency=1`) vs. less frequently (`check_frequency=50`)?\n", + "\n", + "**Experiment below:**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2cdee8ff", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:48:58.806418Z", + "iopub.status.busy": "2026-03-09T19:48:58.806064Z", + "iopub.status.idle": "2026-03-09T19:48:58.810403Z", + "shell.execute_reply": "2026-03-09T19:48:58.809061Z", + "shell.execute_reply.started": "2026-03-09T19:48:58.806369Z" + } + }, + "outputs": [], + "source": [ + "# Try different configurations here!\n", + "# Example:\n", + "# cfg_smaller = PowerIterationConfig(dim=8192, progress=False)\n", + "# cfg_slow_converge = PowerIterationConfig(dominance=0.01, progress=False)\n", + "# cfg_frequent_check = PowerIterationConfig(check_frequency=1, progress=True)\n", + "\n", + "# Your experiments:" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/03__power_iteration__cupy__asynchrony__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/03__power_iteration__cupy__asynchrony__SOLUTION.ipynb new file mode 100644 index 00000000..5b818df9 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/03__power_iteration__cupy__asynchrony__SOLUTION.ipynb @@ -0,0 +1,524 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f699ecf8", + "metadata": {}, + "source": [ + "## Power Iteration - CuPy - Asynchrony - SOLUTION\n", + "\n", + "### Table of Contents\n", + "1. [Introduction and Setup](#1-Introduction-and-Setup)\n", + "2. [Theory: Streams and Synchronization](#2-Theory:-Streams-and-Synchronization)\n", + "3. [The Baseline Implementation](#3-The-Baseline-Implementation)\n", + "4. [Profiling the Baseline](#4-Profiling-the-Baseline)\n", + "5. [Better Visibility with NVTX](#5-Better-Visibility-with-NVTX)\n", + "6. [Implementing Asynchrony](#6-Implementing-Asynchrony)\n", + "7. [Performance Analysis](#7-Performance-Analysis)\n", + "8. [Balancing CPU I/O and GPU Compute](#8-Balancing-CPU-I/O-and-GPU-Compute)\n", + "\n", + "### 1. Introduction and Setup\n", + "\n", + "GPU programming is inherently asynchronous. In this exercise, we will explore the implications of this behavior when using CuPy and learn how to analyze the flow of execution using profiling tools.\n", + "\n", + "We will revisit the Power Iteration algorithm. Our goal is to take a standard implementation, profile it to identify bottlenecks caused by implicit synchronization, and then optimize it using CUDA streams and asynchronous memory transfers.\n", + "\n", + "First, we need to ensure NVIDIA's developer tools are installed and available and do all of our imports." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34d2b509", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# Install necessary packages if running in Google Colab.\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not os.path.exists(\"/accelerated-computing-hub-installed\"):\n", + " print(\"Downloading Nsight Systems package.\")\n", + " !curl -s -L -O https://developer.nvidia.com/downloads/assets/tools/secure/nsight-systems/2026_1/NsightSystems-linux-cli-public-2026.1.1.204-3717666.deb\n", + " print(\"Installing Nsight Systems package.\")\n", + " !dpkg -i NsightSystems-linux-cli-public-2026.1.1.204-3717666.deb > /dev/null\n", + " print(\"Installing PIP packages.\")\n", + " !pip install \"nvtx\" \"nsightful[notebook] @ git+https://github.com/brycelelbach/nsightful.git@fa9ee4d81441ac62379c5306f2f2d8b0894d06ec\" > /dev/null 2>&1\n", + " open(\"/accelerated-computing-hub-installed\", \"a\").close()\n", + " print(\"All packages installed.\")\n", + "\n", + "import numpy as np\n", + "import cupy as cp\n", + "import cupyx as cpx\n", + "import nvtx\n", + "from dataclasses import dataclass\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "markdown", + "id": "af3e8dc5", + "metadata": {}, + "source": [ + "### 2. Theory: Streams and Synchronization\n", + "\n", + "All GPU work is launched asynchronously on a stream. The work items in a stream are executed in order. If you launch `f` on a stream and later launch `g` on that same stream, then `f` will be executed before `g`. But if `f` and `g` are launched on different streams, then their execution might overlap.\n", + "\n", + "**How CuPy handles this:**\n", + "\n", + "- **Default Stream:** Unless specified, CuPy launches work on the default CUDA stream.\n", + "- **Sequential Device Execution:** By default, CuPy work executes sequentially on the GPU.\n", + "- **Asynchronous Host Execution:** From the Python (Host) perspective, the code often returns immediately after launching the GPU kernel, before the work is actually finished.\n", + "\n", + "**SOLUTION:** Certain operations force the CPU to wait for the GPU to finish (implicit synchronization):\n", + "- Accessing element values from device arrays (e.g., `x[0]`)\n", + "- Printing device array values\n", + "- Device-to-host memory transfers with `cp.asnumpy()` (by default)\n", + "- Explicit synchronization calls\n", + "\n", + "### 3. The Baseline Implementation\n", + "\n", + "We will start with a baseline implementation of the Power Iteration algorithm.\n", + "\n", + "The setup below mirrors the previous memory-spaces notebook: one configuration, one generated matrix, and one estimator function. The Nsight Systems kernel lets us profile these ordinary notebook cells directly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7dd38317", + "metadata": {}, + "outputs": [], + "source": [ + "@dataclass\n", + "class PowerIterationConfig:\n", + " dim: int = 19000\n", + " dominance: float = 0.05\n", + " max_steps: int = 400\n", + " check_frequency: int = 25\n", + " progress: bool = True\n", + " residual_threshold: float = 1e-10" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "81b3f4c6", + "metadata": {}, + "outputs": [], + "source": [ + "def generate_device(cfg=PowerIterationConfig()):\n", + " cp.random.seed(42)\n", + " weak_lam = cp.random.random(cfg.dim - 1) * (1.0 - cfg.dominance)\n", + " lam = cp.random.permutation(cp.concatenate((cp.asarray([1.0]), weak_lam)))\n", + " P = cp.random.random((cfg.dim, cfg.dim))\n", + " D = cp.diag(cp.random.permutation(lam))\n", + " return (P @ D) @ cp.linalg.inv(P)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f083598a", + "metadata": {}, + "outputs": [], + "source": [ + "A_device = generate_device()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d8cdd65d", + "metadata": {}, + "outputs": [], + "source": [ + "def estimate_device_baseline(A, cfg=PowerIterationConfig()) -> np.ndarray:\n", + " with nvtx.annotate(\"Setup\"):\n", + " A_gpu = cp.asarray(A)\n", + " x = cp.ones(A_gpu.shape[0], dtype=np.float64)\n", + "\n", + " with nvtx.annotate(\"Loop\"):\n", + " for i in range(0, cfg.max_steps, cfg.check_frequency):\n", + " with nvtx.annotate(f\"Step {i} to {i + cfg.check_frequency}\"):\n", + " with nvtx.annotate(f\"Compute & Residual {i}\"):\n", + " y = A_gpu @ x\n", + " lam = (x @ y) / (x @ x)\n", + " res = cp.linalg.norm(y - lam * x)\n", + " x = y / cp.linalg.norm(y)\n", + "\n", + " with nvtx.annotate(f\"Copy {i}\"):\n", + " x_host = cp.asnumpy(x)\n", + "\n", + " with nvtx.annotate(f\"I/O {i}\", payload=i):\n", + " if cfg.progress:\n", + " print(f\"step {i}: residual = {res:.3e}\")\n", + " np.savetxt(f\"/tmp/device_{i}.txt\", x_host)\n", + "\n", + " if res < cfg.residual_threshold:\n", + " break\n", + "\n", + " with nvtx.annotate(f\"Compute {i + 1} to {i + cfg.check_frequency}\"):\n", + " for j in range(i + 1, min(i + cfg.check_frequency, cfg.max_steps)):\n", + " with nvtx.annotate(f\"Compute Step {j}\"):\n", + " y = A_gpu @ x\n", + " x = y / cp.linalg.norm(y)\n", + "\n", + " return cp.asnumpy((x.T @ (A_gpu @ x)) / (x.T @ x))" + ] + }, + { + "cell_type": "markdown", + "id": "2ca78d38", + "metadata": {}, + "source": [ + "Now let's make sure it works:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fbda9e32", + "metadata": {}, + "outputs": [], + "source": [ + "lam_est_baseline = estimate_device_baseline(A_device)\n", + "\n", + "assert isinstance(lam_est_baseline, (np.ndarray, np.generic)), \"Must return a NumPy array or NumPy scalar\"\n", + "np.testing.assert_allclose(lam_est_baseline, 1, atol=1e-4)\n", + "\n", + "print()\n", + "print(\"Dominant Eigenvalue:\", lam_est_baseline)" + ] + }, + { + "cell_type": "markdown", + "id": "2f3e8e69", + "metadata": {}, + "source": [ + "### 4. Profiling the Baseline\n", + "\n", + "The `%%nsys` cell magic profiles the code in its cell with Nsight Systems and saves the native report to the path given by `-o`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4fbea5ca", + "metadata": {}, + "outputs": [], + "source": [ + "%%nsys -o power_iteration__baseline.nsys-rep\n", + "lam_est_baseline = estimate_device_baseline(A_device)" + ] + }, + { + "cell_type": "markdown", + "id": "2b09a36e", + "metadata": {}, + "source": [ + "Explore the profile in Perfetto by clicking the button below the profiled cell. For richer analysis, click the **+** in the JupyterLab tab bar and open Nsight Systems. You can also install the Nsight Systems GUI on your local system, download the `.nsys-rep` report, and open it there." + ] + }, + { + "cell_type": "markdown", + "id": "d14840e9", + "metadata": {}, + "source": [ + "### 5. Better Visibility with NVTX\n", + "\n", + "Nsight Systems shows us a lot of information—sometimes too much, and not all of it is relevant. We can annotate specific regions of our code so they stand out in the timeline. These regions can have categories, domains, and colors, and they can be nested. To add them, use the `nvtx.annotate()` context manager:\n", + "\n", + "```\n", + "with nvtx.annotate(\"Loop\"):\n", + " for i in range(20):\n", + " with nvtx.annotate(f\"Step {i}\"):\n", + " pass\n", + "```\n", + "\n", + "**SOLUTION:** The baseline code above includes `nvtx.annotate()` regions for the setup, loop, step, compute and residual, copy, I/O, and compute phases.\n", + "\n", + "From our profile trace, we can see that both our CPU and GPU are idly waiting for each other! Device code is idle during every I/O step when we print the residual and write the checkpoint, and host code spends a long time synchronizing on `cudaMemcpyAsync`.\n", + "\n", + "Here's what happens at the start of each I/O step:\n", + "\n", + "- We copy from device to host, which synchronizes with any outstanding work on the device. This blocks the host for awhile.\n", + "- After that synchronous transfer has completed, we begin the I/O (printing and writing the checkpoint). During this time, the device is idle.\n", + "- After the I/O has completed on the host, we start launching the next set of iterations.\n", + "\n", + "This is inefficient; we can do better by overlapping compute and I/O:\n", + "\n", + "- First, host code asynchronously initiates our device-to-host copies.\n", + "- Then, host code asynchronously launches the next set of compute steps on the device.\n", + "- Next, host code synchronizes with the asynchronous copies we started.\n", + "- Finally, the host performs the I/O while the device performs the next set of compute steps.\n", + "\n", + "Everything is still going to run on one stream, but we want to be able to synchronize with just the I/O, which is launched on the stream before the compute work. We'll use a CUDA event, which we will record on the stream right after the copy. Then, we can synchronize with the event later, waiting for the I/O but not the compute!" + ] + }, + { + "cell_type": "markdown", + "id": "899e01cf", + "metadata": {}, + "source": [ + "### 6. Implementing Asynchrony\n", + "\n", + "Remember what we've learned about streams and how to use them with CuPy:\n", + "\n", + "- By default, all CuPy operations within a single thread run on the same stream. You can access this stream with `cp.cuda.get_current_stream()`.\n", + "- You can create a new stream with `cp.cuda.Stream(non_blocking=True)`. Use `with` statements to use the stream for all CuPy operations within a block.\n", + "- You can record an event on a stream by calling `.record()` on it.\n", + "- You can synchronize on an event (or an entire stream) by calling `.synchronize()` on it.\n", + "- Memory transfers will block by default. You can launch them asynchronously with `cp.asarray(..., blocking=False)` (for host to device transfers) and `cp.asnumpy(..., blocking=False)` (for device to host transfers).\n", + "\n", + "**SOLUTION:** The implementation below uses asynchronous memory transfers and CUDA events to overlap compute and I/O operations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46b6abc9", + "metadata": {}, + "outputs": [], + "source": [ + "def estimate_device_async(A, cfg=PowerIterationConfig()) -> np.ndarray:\n", + " with nvtx.annotate(\"Setup\"):\n", + " A_gpu = cp.asarray(A) # If `A` is on the host, copy from host to device.\n", + " # Otherwise, does nothing.\n", + "\n", + " x = cp.ones(A_gpu.shape[0], dtype=np.float64)\n", + "\n", + " with nvtx.annotate(\"Loop\"):\n", + " for i in range(0, cfg.max_steps, cfg.check_frequency):\n", + " with nvtx.annotate(f\"Step {i} to {i + cfg.check_frequency}\"):\n", + " with nvtx.annotate(f\"Compute & Residual {i}\"):\n", + " y = A_gpu @ x\n", + " lam = (x @ y) / (x @ x) # Rayleigh quotient.\n", + " res = cp.linalg.norm(y - lam * x)\n", + " x = y / cp.linalg.norm(y) # Normalize for next step.\n", + "\n", + " with nvtx.annotate(f\"Copy {i}\"):\n", + " res_host = cp.asnumpy(res, blocking=False)\n", + " x_host = cp.asnumpy(x, blocking=False)\n", + " copy_event = cp.cuda.get_current_stream().record()\n", + "\n", + " with nvtx.annotate(f\"Compute {i + 1} to {i + cfg.check_frequency}\"):\n", + " for j in range(i + 1, min(i + cfg.check_frequency, cfg.max_steps)):\n", + " with nvtx.annotate(f\"Compute Step {j}\"):\n", + " y = A_gpu @ x # We have to use `A_gpu` here as well.\n", + " x = y / cp.linalg.norm(y) # Normalize for next step.\n", + "\n", + " with nvtx.annotate(f\"I/O {i}\", payload=i):\n", + " copy_event.synchronize() # Wait for the copies to complete.\n", + "\n", + " if cfg.progress:\n", + " print(f\"step {i}: residual = {res_host:.3e}\")\n", + "\n", + " # Save a checkpoint.\n", + " np.savetxt(f\"/tmp/device_{i}.txt\", x_host)\n", + "\n", + " if res_host < cfg.residual_threshold:\n", + " break\n", + "\n", + " return cp.asnumpy((x.T @ (A_gpu @ x)) / (x.T @ x)) # Copy from device to host." + ] + }, + { + "cell_type": "markdown", + "id": "63099fd1", + "metadata": {}, + "source": [ + "Now let's make sure it works:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35664377", + "metadata": {}, + "outputs": [], + "source": [ + "lam_est_async = estimate_device_async(A_device)\n", + "\n", + "assert isinstance(lam_est_async, (np.ndarray, np.generic)), \"Must return a NumPy array or NumPy scalar\"\n", + "np.testing.assert_allclose(lam_est_async, 1, atol=1e-4)\n", + "\n", + "print()\n", + "print(\"Dominant Eigenvalue:\", lam_est_baseline)" + ] + }, + { + "cell_type": "markdown", + "id": "3ba67a45", + "metadata": {}, + "source": [ + "### 7. Performance Analysis\n", + "\n", + "Before we profile the improved code, let's compare the execution times of both." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "109f2b26", + "metadata": {}, + "outputs": [], + "source": [ + "quiet_cfg = PowerIterationConfig(progress=False)\n", + "power_iteration_baseline_duration = cpx.profiler.benchmark(\n", + " estimate_device_baseline, (A_device, quiet_cfg), n_repeat=5, n_warmup=1\n", + ").cpu_times.mean() * 1000\n", + "power_iteration_async_duration = cpx.profiler.benchmark(\n", + " estimate_device_async, (A_device, quiet_cfg), n_repeat=5, n_warmup=1\n", + ").cpu_times.mean() * 1000\n", + "speedup = power_iteration_baseline_duration / power_iteration_async_duration\n", + "\n", + "print(f\"power_iteration_baseline: {power_iteration_baseline_duration:.3f} ms\")\n", + "print(f\"power_iteration_async: {power_iteration_async_duration:.3f} ms\")\n", + "print(f\"power_iteration_async speedup over power_iteration_baseline: {speedup:.2f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ae811953", + "metadata": {}, + "source": [ + "Next, let's capture a profile report of our improved code with the same Nsight Systems kernel." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a38d8b4", + "metadata": {}, + "outputs": [], + "source": [ + "%%nsys -o power_iteration__async.nsys-rep\n", + "lam_est_async = estimate_device_async(A_device)" + ] + }, + { + "cell_type": "markdown", + "id": "58041a45", + "metadata": {}, + "source": [ + "Finally, let's look at the profile in Perfetto and confirm we've gotten rid of the idling." + ] + }, + { + "cell_type": "markdown", + "id": "a47d9c2b", + "metadata": {}, + "source": [ + "### 8. Balancing CPU I/O and GPU Compute\n", + "\n", + "Our asynchronous implementation overlaps checkpoint I/O on the CPU with power-iteration steps on the GPU. `check_frequency` controls how many GPU steps run between residual checks and checkpoints. A smaller value produces output more frequently, but may not provide enough GPU work to hide the I/O. A larger value provides more GPU work to overlap with the I/O, but delays output and convergence checks.\n", + "\n", + "**SOLUTION:** Sweep check frequencies from 20 through 35 in increments of 1 and determine the output frequency with the lowest execution time. Reuse the same matrix and benchmark `estimate_device_async` with progress disabled, so matrix setup remains outside the measurement." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b52e8f31", + "metadata": {}, + "outputs": [], + "source": [ + "check_frequencies = list(range(20, 36))\n", + "async_durations = []\n", + "\n", + "print(\"Sweeping async check frequencies...\")\n", + "print(\"=\" * 45)\n", + "print(f\"{'Check Frequency':>18} | {'Time (ms)':>12}\")\n", + "print(\"-\" * 45)\n", + "\n", + "for check_frequency in check_frequencies:\n", + " cfg = PowerIterationConfig(check_frequency=check_frequency, progress=False)\n", + " timing = cpx.profiler.benchmark(\n", + " estimate_device_async, (A_device, cfg), n_repeat=5, n_warmup=1\n", + " )\n", + " duration_ms = timing.cpu_times.mean() * 1000\n", + " async_durations.append(duration_ms)\n", + " print(f\"{check_frequency:>18} | {duration_ms:>9.3f} ms\")\n", + "\n", + "print(\"=\" * 45)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c81a6d40", + "metadata": {}, + "outputs": [], + "source": [ + "optimal_index = async_durations.index(min(async_durations))\n", + "optimal_check_frequency = check_frequencies[optimal_index]\n", + "optimal_duration = async_durations[optimal_index]\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 5))\n", + "ax.plot(check_frequencies, async_durations, 'b-o', linewidth=2, markersize=8)\n", + "ax.scatter(optimal_check_frequency, optimal_duration, color='red', s=100, zorder=3,\n", + " label=f'Optimal: {optimal_check_frequency} steps')\n", + "for check_frequency, duration in zip(check_frequencies, async_durations):\n", + " ax.text(check_frequency, duration, f'{duration:.1f}', va='bottom', ha='center')\n", + "ax.set_xticks(check_frequencies)\n", + "ax.set_xlabel('Check Frequency (steps)', fontsize=12)\n", + "ax.set_ylabel('Execution Time (ms)', fontsize=12)\n", + "ax.set_title('Async Power Iteration: Check Frequency Sweep', fontsize=14)\n", + "ax.legend(fontsize=11)\n", + "ax.grid(True, alpha=0.3)\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "print(f'Optimal check frequency: {optimal_check_frequency} steps ({optimal_duration:.3f} ms)')" + ] + }, + { + "cell_type": "markdown", + "id": "d26f7b95", + "metadata": {}, + "source": [ + "Finally, profile the optimal check frequency and verify that the GPU compute between checks overlaps the CPU I/O." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e73b4a08", + "metadata": {}, + "outputs": [], + "source": [ + "%%nsys -o power_iteration__async__optimal.nsys-rep\n", + "optimal_cfg = PowerIterationConfig(check_frequency=optimal_check_frequency)\n", + "lam_est_optimal = estimate_device_async(A_device, cfg=optimal_cfg)\n", + "np.testing.assert_allclose(lam_est_optimal, 1, atol=1e-4)" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (Nsight Systems)", + "language": "python", + "name": "nsightful-nsys" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/04__copy__kernel_authoring__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/04__copy__kernel_authoring__SOLUTION.ipynb new file mode 100644 index 00000000..8fc5fb5e --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/04__copy__kernel_authoring__SOLUTION.ipynb @@ -0,0 +1,545 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e02d2ec1", + "metadata": { + "id": "-JpGaP7-D_5W" + }, + "source": [ + "## Copy - Kernel Authoring - SOLUTION\n", + "\n", + "### Table of Contents\n", + "1. [Environment Setup](#1-environment-setup)\n", + "2. [The Baseline Kernel: Blocked Copy](#2-the-baseline-kernel-blocked-copy)\n", + "3. [Profiling the Baseline](#3-profiling-the-baseline)\n", + "4. [Solution: Optimized Memory Access](#4-solution-optimized-memory-access)\n", + "5. [Verification & Benchmarking](#5-verification--benchmarking)\n", + "6. [Profiling the Optimized Kernel](#6-profiling-the-optimized-kernel)\n", + "\n", + "---\n", + "\n", + "### 1. Environment Setup\n", + "\n", + "In this exercise, we'll learn how to analyze and reason about the performance of CUDA kernels using the NVIDIA Nsight Compute profiler. We'll look at a few different ways of writing a simple kernel that copies items from one array to another.\n", + "\n", + "First, we need to ensure NVIDIA's developer tools are installed and available and do all of our imports." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "82b8f596", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:43:42.383973Z", + "iopub.status.busy": "2026-03-09T19:43:42.383690Z", + "iopub.status.idle": "2026-03-09T19:43:42.397535Z", + "shell.execute_reply": "2026-03-09T19:43:42.396362Z", + "shell.execute_reply.started": "2026-03-09T19:43:42.383942Z" + }, + "id": "AoHkvSPMC5Fs" + }, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# Install necessary packages if running in Google Colab.\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not os.path.exists(\"/accelerated-computing-hub-installed\"):\n", + " print(\"Downloading NCU package.\")\n", + " !curl -s -L -O https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/nsight-compute-2025.2.1_2025.2.1.3-1_amd64.deb\n", + " print(\"Installing NCU package.\")\n", + " !dpkg -i nsight-compute-2025.2.1_2025.2.1.3-1_amd64.deb > /dev/null\n", + " !update-alternatives --install /opt/bin/ncu ncu /opt/nvidia/nsight-compute/2025.2.1/ncu 20250201 > /dev/null\n", + " print(\"Uninstalling PIP packages.\")\n", + " !pip uninstall \"cuda-python\" --yes > /dev/null\n", + " print(\"Installing PIP packages.\")\n", + " !pip install \"numba-cuda\" \"cuda-cccl[test-cu12]\" \"nvtx\" \"nsightful[notebook] @ git+https://github.com/brycelelbach/nsightful.git@a41989403430168e02ac3cfdc4060bca4ebb8040\" > /dev/null 2>&1\n", + " open(\"/accelerated-computing-hub-installed\", \"a\").close()\n", + " print(\"All packages installed.\")\n", + "\n", + "from numba import cuda\n", + "import cupy as cp\n", + "import cupyx as cpx" + ] + }, + { + "cell_type": "markdown", + "id": "be878fa3", + "metadata": { + "id": "A1SfTQk0EwUl" + }, + "source": [ + "### 2. The Baseline Kernel: Blocked Copy\n", + "\n", + "Now, we'll write our first kernel. Each thread will copy `items_per_thread` items from the `src` array to the `dst` array. We'll set the number of threads per block to a constant, `threads_per_block`. We'll calculate how many blocks to launch based on `items_per_thread` and `threads_per_block`. We use `cuda.grid(1)` to get the unique global 1D index of each thread.\n", + "\n", + "Each thread will copy a contiguous set of items, e.g. the items with indices `[base, base + items_per_thread)`:\n", + "\n", + "![image.png](data:image/png;base64,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)\n", + "\n", + "**NOTE: The next cell won't actually run any code, it will just write its contents to a file. This is necessary because we have to run the code with the Nsight Compute profiler.**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f724215c", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:43:42.398487Z", + "iopub.status.busy": "2026-03-09T19:43:42.398179Z", + "iopub.status.idle": "2026-03-09T19:43:42.407555Z", + "shell.execute_reply": "2026-03-09T19:43:42.406444Z", + "shell.execute_reply.started": "2026-03-09T19:43:42.398458Z" + }, + "id": "I9Tz2hG-_tBj", + "outputId": "e52f41cb-f70b-4792-ea76-e78a554956f4" + }, + "outputs": [], + "source": [ + "threads_per_block = 256\n", + "items_per_thread = 64\n", + "total_items = 2**28\n", + "blocks = total_items // (threads_per_block * items_per_thread)\n", + "\n", + "src = cp.arange(total_items)\n", + "dst = cp.empty_like(src)\n", + "\n", + "@cuda.jit\n", + "def copy_blocked(src, dst, items_per_thread):\n", + " base = cuda.grid(1) * items_per_thread\n", + " for i in range(items_per_thread):\n", + " dst[base + i] = src[base + i]" + ] + }, + { + "cell_type": "markdown", + "id": "bdf017f2", + "metadata": { + "id": "TuR4yDV4H6IB" + }, + "source": [ + "Let's make sure it runs correctly:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "baacfd0d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:43:42.408684Z", + "iopub.status.busy": "2026-03-09T19:43:42.408440Z", + "iopub.status.idle": "2026-03-09T19:43:58.505975Z", + "shell.execute_reply": "2026-03-09T19:43:58.504698Z", + "shell.execute_reply.started": "2026-03-09T19:43:42.408663Z" + } + }, + "outputs": [], + "source": [ + "dst[:] = 0\n", + "copy_blocked[blocks, threads_per_block](src, dst, items_per_thread)\n", + "cp.testing.assert_array_equal(src, dst)\n", + "print(f\"Problem size: {total_items * src.dtype.itemsize / 2**30:.2f} GB, dtype: {src.dtype}\")" + ] + }, + { + "cell_type": "markdown", + "id": "32331179", + "metadata": {}, + "source": [ + "### 3. Profiling the Baseline\n", + "\n", + "Select the **Python 3 (Nsight Compute)** kernel. It was started under `ncu`, so the `%%ncu` cell magic profiles only the code in that cell while preserving the imports, arrays, and kernel definitions loaded above.\n", + "\n", + "There is an overhead to running code under the profiler. Your program may execute noticeably slower.\n", + "\n", + "Modify the kernel in the previous cell and rerun these cells to check your changes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "54aea2c6", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:43:58.507221Z", + "iopub.status.busy": "2026-03-09T19:43:58.506962Z", + "iopub.status.idle": "2026-03-09T19:44:09.831662Z", + "shell.execute_reply": "2026-03-09T19:44:09.830366Z", + "shell.execute_reply.started": "2026-03-09T19:43:58.507197Z" + }, + "id": "5pyHvJtxVnDB", + "outputId": "87ad5a72-9244-4b21-9776-ecd93262f274" + }, + "outputs": [], + "source": [ + "%%ncu -o copy_blocked.ncu-rep --kernel-name regex:copy_blocked\n", + "dst[:] = 0\n", + "copy_blocked[blocks, threads_per_block](src, dst, items_per_thread)" + ] + }, + { + "cell_type": "markdown", + "id": "95702768", + "metadata": { + "id": "ijEtNGHhpLPu" + }, + "source": [ + "Let's take a look at the profiling report on the kernel. When you run the next cell, a number of tabs will be displayed. The first tab will have a summary of all of the Nsight recommendations and advisories. Subsequent tabs will have more detailed information on a particular area.\n", + "\n", + "The Nsight Compute GUI provides richer information, charts, and diagrams. Click the **+** in the JupyterLab tab bar and open Nsight Compute, or install the GUI on your local system and download the `.ncu-rep` report." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4e99c6b9", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1176, + "referenced_widgets": [ + "b47fd7b6a0154f21bc182a368783f018", + "88670f4cd80d4823962f681c5db0b05c", + "efbee61b66c74e939e6ba9eb177b5104", + "da5ad97524f4499cb466a461c9e9a014", + "8bd862cae4a34d9eb8c6e0cbaba458fd", + "bd3d14f42d7c4e058cf86091a34e0495", + "fd81c004921e40acb4a5c89ce6b59345", + "5bc24b339fc8419da15158a611ccc7c0", + "e0520258747c44c3b3c03df575d08958", + "f02b7d8187324ecda1befc6000394fb8", + "a3c5590a108e4630ac47a029112fb8da", + "f8282e5d62ba4fdfbb055930aae07bb0", + "ddfcf54f17cb48b18c316d6fbc3f2df2", + "2566b43ad6544ff5b3535e1bcf848394", + "2be329446de8406aa008cba59ea2cfc0", + "8d0b84554fc14eb8aa1d73db53e7446c", + "7190fa5e9c1d4db5b53a19a00a43e29b", + "bca08631512947a9bb7678cae262caa1", + "520d86e9e9e142d2a9404675f4acab09", + "3b435d6a3d0148bc82ac4c72334d03ab", + "3d25f6fa3bd447d38ef0619d34129054", + "06d6f26f29494f45abcd43b287163314", + "5862752de4db4eafa9c1894aa8c85385", + "e2c606a3ec4b41bc85095363bed2a8bb", + "3fb5720992194190a644475c35bb3962", + "184b041944b040c1a715b4b892fe3405", + "81ee7055ea504002a61d86e34b9d2564", + "40e55caada4b4d4280e5d57bbf41daf7", + "8234d3926a0c413a996b1965cf862551", + "e2f9cbd9e16f49b7a84dc9bfe8808107", + "678ec7c648a94af9a6d16e4bc22d743c" + ] + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:44:09.833556Z", + "iopub.status.busy": "2026-03-09T19:44:09.833232Z", + "iopub.status.idle": "2026-03-09T19:44:09.969164Z", + "shell.execute_reply": "2026-03-09T19:44:09.967883Z", + "shell.execute_reply.started": "2026-03-09T19:44:09.833528Z" + }, + "id": "40w07iG5k6Vl", + "outputId": "f5d0becc-0285-4cb7-a78e-427cdc105454" + }, + "outputs": [], + "source": [ + "cp.testing.assert_array_equal(src, dst)" + ] + }, + { + "cell_type": "markdown", + "id": "bb30afde", + "metadata": { + "id": "mL_9xT44qbMA" + }, + "source": [ + "### 4. Solution: Optimized Memory Access\n", + "\n", + "In our kernel, each thread linearly accesses a chunk of contiguous memory, which is what you'd want on the CPU, but not on the GPU! Our access pattern looks like this:\n", + "\n", + "![image.png](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAf0AAAFTCAYAAAAz2tUWAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAAFxEAABcRAcom8z8AAP+lSURBVHhe7J0FnFTVF8cllJBSqb+KgS12YWIrqFhYCCKNSAkqISGCqEg3SEhJCSjd3d3d3V1KeP/ne2bu8nb27e7MsgOz7Hv7+X1m571773vvnHvO78a5d67wDu/wDu/wDu/wjmRy/Pfff+MEgwU9Bb09hAzk1l8wwRgzwP/dLZ2HSwv0MlAwXoC+PD1FJtDLIMEEQR9BL4FbOg+XFj0EfwnQ0x8CT0+RCexp9Llz59rJZxolfSEqs/LYfjNsywozcttqDyECuc3Ztw0xmnkHtpvhW1a6pvNwaYGeFh3apXqatXeLfPf0FIlAT8uO7FE9Tdq1wbOnCMWQTcvN2uMHVU9jt681I7auck3n4dJi6OblZrf5FzXNF9JPp6Qv/+z5bHg3c8X3Rc0VjUp5CBXfFzGP/97I7D1x1Dzbs7Enx0iF6Cl/v5Zmx9FD5sEu9T09RSpELx8M7mgOnDxucrb62lzxQzH3dB4uLWp/bMqP7q1+L92v5c0VDYu7p/NwaVG3sPll0QQjPD9cCZ8D0i86rKtcLCKKK+khVNT91DzaraFW/qd7/GyuqOfJMSIhenq9bwsl/fs7f+/pKVIhfqjQoA5K+tlbVpVGwGfu6TxcWnz3kfliVC/1e2l++UIaZ5+7p/NwaVH7E/PTgnExSb+Y9vTFuBqV9hAqpGfyhL+n/xw9/fqeHCMSoqcC/p7+Q11+8PQUqRA/9KG/p/8/7ekLmbil83BpUecT86W/p5/+1y+FYEq4p/NwaSGdnV8WjvdIP1HhkX7SgEf6SQMe6ScNeKSfNOCRfhjgkX7SgEf6SQMe6ScNeKSfNOCRfhjgkX7SgEf6SQMe6ScNeKSfNHDRSb+BVASMlgjcBsXNFT8SUeiSLikjIaSPHNyCLtzwo4A8/C/3Uj0hy8AykwrqS13gHfh0ux4bkNmF1J+EkH6C9CR1nvdDV0nZESIf3kOj512uhwtyz5BJP1Q9/VTGZ0NRepJzbuVGOnhvfQf0FGLjSOvrBdhTQkg/JD35n82pJ2tjSQ08d0L1hCzczgeLi0b6OHS5GZXhxtbfmpvb1jCZm1byORI3Z4uCUS640Je82JDKmPCePhU7LvjTiEHdIHIsMqSLKTa0q3mwc309F7O8CIbo+Mpfypm3BrQ2nw/rpgR85c/lzhu3G6yx1Cns+yTinv+pX3Hlc0NCSD8KVh+xQdJoHS5h7uxYW3VUVHSVu12tJKmnq0RPHwzuoO/xSp9mJiUkGZe8sVmx92jASbuljQ+i59B7+vbZrD5igz/ND8XN/b99r/UQm8rVprrqLnqZEQ6pV5nEpyIr9PRC7yY+PbilteAdsaHan/h8repK7ClUIgIX1NMP1Esg/OmkzMe7NYqyp2wtqiZJPWWV5/74r05a357s/lPcssLO8G/4OXRjbUtXGyXg3SVv+ElfnOm9HeuYFnPGmenb1pvNh/eb7eJo5+/abDovmmoe79rQdy/rROQTMri2eRVFusZSgeJyMJGGUElfFMp6/hHrl5rh65bECdJ0XDjFXPVzWZO/b0vdCIPj5xkjffdJSnISY6Xht+6gb+OV5ft2yPeKPufjlh5HJJX8dXnvH6cNM72XzTK/L5lhak0c5DOceiH2pEMlfUlfcECboPXUZv4E1W2FMX/o+3GUHtE98ezqYkH0kUX0dOL0P/oOM8SGtXEWm6zlfM5W1Uy18QNMrUmDTI0JA03tyX+ZT/76zVc/Q62joZL+D8XMp393DkpPI9cvM3Wn/G2u+PZd03DacH0/jg8GdfQ5VbfyIxVSf+/pVNecOnNa32HMxhVa/2KVt1zL3KyyKT+qt/qUgasWiD1NN9Un/KmdMs3rli82hEL6PJPYf9mRPUQHwemJsrkHz2qPfL3EvyY1PUl9fqbHL+bcf+f0HXosnaG+xVVPdHKkvj8s/qnBtKGmy+Jp5u+1i/SzzIgePjsMVU9hJ30po0DfFmbX8SP6gm7HfjHmj8Wo1Zh58bpFzOvijA+eOqHAYahDdys/EhEq6YsSaPEFexz555S5ukkF89IfzfxnjDisYb77hOpQLyXE6OmZLN+7Q99hwa4t8j0W0pdzWeVaV6ns5/77T9M7jxOn/zW1Jw02V5E22JGhUEm/9sfmu0l/+e8Y/7H7+FGt02VH9vSfMaa46Dkpkj6NswOnjus7TNq8Jm7SF1t9f2B7Tes8Zm7foDoPuY6GSvpCAnQwgj1mbF9vrqhW0Hw/ZYj/jDHvyfMnRdK/WzpXh/45qe8wbN1SHyG4yVtkeHeHOmbuzk2aNvDYcviAeZGRglDqaqikL40zCC/Yo++KOboXQOt57G7uO57t+UuSJP2nuv9sTpzRXfFMF+n4upK+ED7+rMbEgebov6c0beAxdO1ik1M6xoxURcsbF8JK+uIsrmtWyazav9P/iBj+evO9kHizWWPMrB0b/GchspM+g+blaxZS0rdHAwit1ge+ChxICP6WkD4reZlvdKtspCE/17TCkUfSax5/YyMwT0IhZYZE+vLsLwuBz96x0QeR0ZI926LIjYaP7/wG/ey/cr5J+0s582qf5nqdQ2VUzz+ESgPJvqvzPsiK89YRcN1+d6aDNGPIJxYi1bSS36blXd1IGwTKXYz16iYVzbK92/UdGPlxJ33JJ+mbzR6r6TggeWSxcPdW/xnfwbCfDn1Fyx8LpMyQSF+e96PBnc7rSXq8K/adr9v7Tx7Tc/Z6r2WzVTblRvbyp/CTfh15Pu6l8hLZBTZS+O6mJ2QXLZ2cj6GnWOqxLdOm5f/YhkVd9JRR9MT7cUzcvDoe0i+iPe11B/earUcOaB6OSVvW+O4Z2zPGBrGPkEhf3q209ILO62md2XBon/8pjNl57LBPT357ajJ7jPb06zlI/11IXxp5UX5FdRGopwC9UG+d3wPTqTylvDj15E+LDdv7xibnQD0J6d7Vobb6C46h0kPW/IH3krqQWj6niD7sseHQXjNuw3KzXj7twchbRkZZY7PnQITc0y9uqo7r59DTerPFUV+2HjmovtDqiREI7uEk/Wcg/e8celLZBuiJOmf14r+vfg98L5suaD1JmqD0JM+Dj9S0kqd2YR2ZPO4fOWO0W8sIvJeU/6D4JXv8c/aM8uf4TStVxvbosXSmSRHXswYirKQvL5JXXs4eC6UndzU7NeH0xDGkFGH8tmiKGbtxhflsSBeT+dcKJmeLr8wD0lqtOr6/P5fR3t39cg4i9c2J+pWqiixubmlbU533h4M6mPt++96k+qns+WcXQaT6uay5t1M9k/f3n0zW5l+psm+Tct4b2M68+2fb80NZgc43oZD3DnlOXyuGKA7ULWxua19Llczx95qFvvO2IlJZ5f83+7fW6xw1Jw4yV0oZz/T4WeeKeFd1zFS2Rsy/ltRe9WPdfjSPdm2oMmLXrIe7NlAgI6008qwp5HnuEnm/I7JBPne0/85XobUsxzPzXcq977d65gOR/UfimHnfa5pVVhlEq4S8n5R9Y+tvdIic9HdKuenEOSzYvUXfIVbSl/vwPIdP+Xow28QZaC+EZ5XnKjH896ghTRqY13L/wDLcIM8Y8pw+dcTqSXodeUXP9ui5bKbPAdnrqqei4gjPD+8jozRSzou9m+r/6ELlRHrKFzkx30f9eUT0wjUcKHrLI3KO0pOUm1r+Zzi30KD2KlPqdNS9nc8s75VSPh/s/IP5WBotpOe5qQ+qJ2dav55vEZvAPqgDt7apbtI2/sLs8Y/WxUv6/me+Ruz5KbF/qxvyXRTSB0491fpQG4P2+GXmKNWdz3/IdZ5JCP77qT7Sp6nNTo3pRb4F+rVSed2vMTNSpq1X8u48C85bd3OUcvAt2B11NSrmQRz9VWJnD0h+dhVk2+ebiBfQ+wbUUdHTlWKX6J5GE/J/rFtDH4G66ulzc4eQPLJ5q38bc73UG8pm5JQjVtKX71fK85Ub2dMskkbzmI3LTQ78ovjkHNJrxB9z/Cd/PIuW4cwfG0IhfQv/eyhqf2K+GttP783xrZ/ko/REWpFD2/kT9Tp6Ytibevam6InRpbs71vWl0y2AKb+kxmegF0ZBKAe98V53iuw0DfKRcjM0qSDnf1S7JG7l+lbf+O4dqCepj/hOhuiZskJP6Bd7jCEr8gruFTsl7Rv9W5nrxO6wRdt7j5X0xRdwn7fEz/+xfLbk7+QrX+rUw11/0IYaB6MyulNlA5GRM39sCDfpM4xhj2lb1/mUR09MKph+ikDUQPznvhn/p/n33Flz1j/fwUGPV8+dO6etPE0r5dwsymNO99i/vhYTx2lJN3bDCl9gG05cymdokmEsSLTRjBGmufQY//UTKgdGwn0T5JDcIO8dMukD7g0kPRXUkv6QtYt95+3zgQDSH7R6gTpVe/B+zGnmEpLViiLpGRmgTJ6LRtZoadnjkHdJz0eJWuR6n1RO5tCso+Y4Kf9T6W62RsCzilxvb1fT/LlqfrS06I0eAoaguv7R/16SvqKQH/eyBzplDnHtgd36PVbSl2cvKcRujx+mDvX1wtSxS1q5PnzdUv9Vaf2LMQYlc9FTyKQPrA5EXs/KvezRW2SkW1fb60DuYUkfJ0UD1jmkiuyQYVYIGHlJ+lIjuque6JF+OLiDzqHzfZE0jjT4Ve4LGUzYtCqqjnAw+vHbwqnquKOG++TzPmnwIh9nncdOGCV59Y9mKj/fe5U0qQTfTRqsBGsPeiTtFkwy+4Lt6QMaeTRepSFy0j+MeVFJn9Ehq4M6hU3JYefrT5PZo1WGWn9sGgfpIyfmTKnH9uAdOJepcXlf/RSCYtiVtIzKUY9W79+l3ydsWi2NJEkn/uwVkS/6Q972wNk3lWfIAjHaui7vhK+k942fswflEQelMSvW9kTuDPv+Omu0TvXZY6/o57eFU8y+Ez49xUr6gHPyfDT8GMXRBjz6lMZQ09ljND8H7xVVP+JDQkjfyl+fp6j5etwA/52lIzNpkNpDND3Jd0v6/549azounBxtFOe42ABTO+ngFO4vZTaT98FOiAugMbXp8D79ztC4phEZ0QlZIfp2Th0yskWdSO+s62JP+NFZ2zdES4sdj9qwTEdaojpHottMIgeG77FNezCa0Un0dNg/DRMr6QOVCz5FZMAnowUE9Mn/4/yNM0bTgrcLgdT98JG+CPNGaWUd9DuQM1KZh65dYsqO6KEBGPdLzyUdxkFEIs5A7kdQVlxH9QkDtXKlFUVM2brWf1Z6f+K41xzwBYRxEBymhFfvU3WU1oCt8HmWg//4hsHsQYVQobq9SygQBSaI9C2k0tAytA5dKyfnqfw2jcjKSfr2CHyn3svnmJTIVioJvQwOHAmE4jzocWcT4l/tJ2AOjMlpUJO2rPaN1IheCSTEmdmDobjNjqG549IQ8/WAxADkWd+WHqPzoMJD+s4jdtIvqo01exDIp1MZXPM7gtqTB/uvGlOYgLFg6q7kSxDpW4hM0a89aBjFqD9yDyfp2+OAfwjWHi3njjMpcNBSJvuXc6D/3Y5YmD1Sn4iiz922utl+1PdrZhzrD+4VR7bf/82YgdIAZPgWPWURh75KyMgeBNHucOh+1/HDQsw1NS3P6ow/4GCo+Fy0Jw+S9IG/8XppSN8BcXKlhnfXZ+CA1NSBOp/D2dN3On5/HIM9lIjQsZBj3Sm++A4aRYw+2WPlvl1a9qNdG0RrEK8R29rm0FsHIayUNM5ETje1/jYqZoIDu0Pf9lgr/kx74/6GIc/hPPY5Gmn2+eMkfQv0gR75FL/KCgbbgzwtpEoPVstwyxuIhJC+E6JnOl/2qCWNT7VJ5/PLu0f19OPQU+mRPTQtem47z5eeGBs79cExe/tGrRuvCIlbAj8rn9iLMwat/lR/vJQ8Xx7xy/hPe6BTO+3FQXxIBtswFF01n3N+SpKDhpk97PPHSfoW4vtztKymoxP3tq+lozTH/CMFjMykRNZx5XcirKQvD8FcA/PNbgfCY76GFjPDWgiJYe23ejc1Pzjy9Fsx17wh5woOaG1ugMjFQO08KYKj959FnCEvXmhgeyUcjobTh2uLPJM4vsXSGrfH5M1rzNNCyvdJL6SBpLHC51nS/cKvQ4VYWQMhCrzYpH9GCJQe2r3taph3/2wXRerIQpcfiRwYsnS2TpluqTauv/lcev0ppJL+NGOknqdBVGlMH5NGektpRIffjB8Qla8Yc9JiSJA+0ylTtq4xTaTHcZ3IOKO0rhk6tQc65L6pGpWJNgrRWNI8IJX3SXGKtI7tERfp04u2R4zgHfmfCGR7EDUe7XpskHIvJulzHDp1Uon1nrY1dGmYdRiQMUaNvAKJd+rWdearsX3Nh6JXyoQsOOjVsGzpStFRegGyt9plWJrnSy/1mVGSGdvWaf3ILA4pa+MvpPcxzZ/S6C+jXfHdh2on9Fo56JnWnTLE5BF7zCc9UEbp7HG5kz4HjSE6AXeJ7dAIs50F5pZ1NVHNQtEamhzjN68yFUTX74jsrxJ/NkxIlwMdvy+6Yyoqs/gpeuMcp8+d0elPem6Zfq1gKouOGQWqKLaXQdLlbF7ZDFg5T9NyIAOINXuLqlGNPkY5SX+X1KeX/2iqw/X2CIr0uSaySCeN+dIjukdrlHRfOsOkwuc4/U5cuMikz7FReu1MQd4t788qEaYkOCaKLvT+ovtWc8frOXv8tWaRkmaBP5pJ57Gs9to5kOkLvZqo/8kmHUUCBzkYTdEp4O+LaMeI58JXWdu7Ueok9+Pg/jr1KPXpVmlM23l7RpNJf4fUJ/ymM3YhXtLnvDQOf188Q/zHCXPSMWqw49ghX8MsFK4OK+kDMXLmiCuO+SPWSFEOghGuJD2V9Nt3zYtSge1Rh+j96u/pw+q8hQiIYVKOQ9KzxXHf1aaGuVeIJE+nemajv3fK8Db3J9Ldkj5Da490aeCb02MoVhRsHRqNkNzi5IJu2cYGeb6LTfr6rjhDRk3EiTB8xME7KUnK+zpJH5LRuUXkwDy0yGHuDp9+aOk/0e1Hc7c0iu5uW9O8JLqww73tF0zy3YdnkYpNPp43vZBJ7pZfK8HZRhRD9wRgsp8AZMehrWuenyEqIRrmqW0PJy7SZwjcHswTRyN1+d/p1GviLJzXY4OUe7FJXw0cmfN8Ir+/xQFxYMzM8yETJ+mzoiGzEILqSPKll/9X+oMHabQxp3+P1Ps7RZfM15/y15l6LEOjLuAwHHrKJLZwW6tvtAFgD0ZRKJvYDBp8HPQe1A54zlof6fCy7eEkB9L/cbrIpMb7kk58jsiBACoOGtM61yvXnKQ/buNK3zAwuq31odZ55lo5GLK/XfSTp2Nd1VNJx7MQcKh+DXsiLzKV97tGSOd2KYOluPbQUc6aH+jwsp0C6Cl+0xfIJvVOrlGf7REv6aMLudd70iBhZYXz6LN8jq/eBdpiXLgEpE9DSYO85d5MqdhRLUa/iKXABpykjx9JSXnIWmztLtHJUf8UyeA1CzV+JY/UWWykipRtD3ynPgvywJ54N+GirM2rmDtFT92WnG9EsxKL+kGdtUfjWaN8+eBUuRbDJ6C/2PTEedHTiLXnpzDtwZRbfvtsbnndEHbSB1RoEX5GcTiPdG2o871fSut58OqFUcTG8Qa9E15eHuqtAW38ZzFA6bFbJy7CxuGM2bBcrzEcQyv81JkzfpwfTluyd5tJLWlpmVvSZ9gsje3N+4XZzlGJGJIL2fkHQt7hYpM+vXS9D2nkGpWM47T02CBtKrmT9LtByOoQJL1U5Ouaf6WGwsGoAY4aAkGe/4hc7TF2k5ABTlfulUNIHkc0TXqRtFxPOGTPoetPpYIThEZcAAeNEd5PZS86YLh64S5f7yQu0v9NDMMeKlNbH/yR/fR27IHj0XrkLMMNkuZikz4jElFppFffe5lvBAPH82jXH5UwnKT/E4RMA4n0IndGuuxcICSMXKn3fDLHaY9Oi0TOOBnJw2ZYLEdjtQz7Y5x02ByHBrbJffM53oX5YpUhehJ9MO9re4HJgfR1TwHkjn3Ie47w99qR/c00lqUT4iR9RsyUSChL5IYfsT0y9MTyLOwZPWGT9qg/bahPzmKLBAA2njFKG3M0Lv5x6JOj5kQhQbEnpq/sodMNUo989/3M3NWhTlSMU5ykL34ihejiF/ETtnfMwYqL8iN7mdTkCVXOl4D0n+/1q+iSzmAJJX27IgF/lIU4JXkmJ+nrFK61JynrDfGhtgGFfqye8GX4QXtUGN3HZ7cik4el08h0HKNiTAOc9ue3RznhNuoHdcIevvrk15PUvWd7NI7iqqCG9+UaAdUEJL4t3NhY7NMGAmLTBN5eUT9IfYWd9DEaXogKgAL5H2B08tnSoRBddkarV0A0sj1+wDCskxflsnEPyxY4MCjWeS8VBSzds10xf+dmHeaiFUyUOr0jS/oQW5TD8lfs1nPPLwF5vFtD33MGvkcokPe62KSvwW3chzRi6DYYBwejw00BpP/jDGlIWYMSp56jVbWoeWGGs6jQKtO92/X/eTs3qQzbzZeevrwfa0Pn7Nyo6TkYwpwqBjdx0+oop8bw4BU1ColMz5N+Bx0psKRf0qQW/VA2R+yk/1m0nilR0FpPuEY58jw/zzzfIyrYv01wMpd8F5v0dUmhtStxPvSoOJA5DeJA0mfINSq9yI3RGTt9xfykT0/bddmj1RP/M2WGzm8VZ8C8oz2IwJ+0eZWZLLqydQHZBZK+NgT0vr76QWSzXX6XHEj/HaZSNA2kX1I3kOFA5jpCFkD6OvRufZTUgcd//zHKfvEDS8QfYUvoZrH8z6gn9qU9fakHjFBSD+2x89ghM37jCjPVEbdUY6IQvNhT4b/Pk76ec5A+8rZkECfpyzMSDW4PGij15f2zQ5S8R6iEDS4B6T/d42ff80q9Ij7MyouRTA16DSB9bSQwwktZkg9fYe2AhtZiaXA59YQ9EfCqpC32xC6HdtSTg1iOUVI34Bx7YL/Uj6oxSP98Y+PpHr9E+cSgevr4RGRBOt73u4+jOnYcBCLqqji3/IEIK+nLw6YRgobAuwsB00qJGuIA375jikjLyx7au/CTvpPQ1EhrFvK9NAYvFbn7Et+mDiiAis65VHaZDIYqn7qEQoSF8p3D+w/8Vl+eQ1rlCFDKs/PKRJ6zBEYNxe19goWUe7FJXxtM3CdI0ldHbw3K79gs+dJgomdHT8C39Ah5+mSqzl704yQmCFmjZaVSQ1z/+Cuzkn5NO7zvC6Ah+E+fn4ordeH2Dt9FRYXHRfosHbPHHyv85Irxyj2v+vmLKP2iQ5ZtBqVD0dPFJv1om/MERfoEJPnTi1wySl22OxhO37bOpBC5M1ypekL3oiem07TuS/kNpvtiY+i1MMqQFn0LSbCbY7S6IPWDqRbqCweRzipD3kdshdGa5DS8H7U5j9824if9Tr70lCU9rpuksWWD+waumq/vwLLJFCyfdeoJvYmc2/iDzZAxDtzqieVj9lCCF3t6rQ927Otdqo0xvE/dlbrj3LsjVtIXvRFDZQNxKYvg6iu+eUfloH6ROsdnKMR9CUg/Kr4nSNJXXxilp2I6H24b0W3p0Mg1YpDQjVNPqQT4QzvPTx72VlE9iU6Ie7KHkn7AiAy7PWrjjPeRhttnQ84vIY2T9PGFUt9Zxqt1kWfXMt6PFsxZbKj4FTuSEB/CR/ryAvJwZUacd2DMt7wjRHV3+1rmznY1TcF+raKcNcfnPLhWuKK66YIdXlkhPfl80jK6u/13JqcGO31sqvnX8cuzSq9kpAaRpRChsUSoxdxxSoha6URgTtLnwKEx/59LeqvM21hHt0BIJwOVNZB0QoU8f9IifYGU12qezzi4bxmpuGmkkqUWPRJs1EYMjXldlY1UGudGJjqHJeSQQRyJ0yB9Dukj7c1P8xsj92eHxZtafCV1oJYuNbRHrKQv34lCX7Lbp0PeiemMh6V3xBbOljg5hq9f4osNccoqNoieIp302WozKr28EwQ/aI1PZkxrsTb5SjnPO7P2Hwena5WJCpfy7ZalzNV/9Jf0RkV314hzhIjsoXWhbmHdatfGCxC5XXlMX3Oj9PzuE+Ie5x9Z4/BIPybps2mTpqcsSZ/2p3IaMMzBEjqWRqaUepxW/BTrrllWdlObGr53kfP9Vs7VtPg8yIRnztmyatSIJoeSvuj0f+IDbaAunRjs73rR3eNdG2gwsj1iJX25H+u67TQRw8yQ9ceDO2hwKSDoDGgAW7DkndRIX56PWCSW6nFsPnzA5+PEdq6W+s2o3K/Sm9YAW3mOVHLO6oORTfZ+oazcosc5/ngoDiX9Wh+Y24SvbG+ejg2jR9c3qaRcZu/JESvpyzsRrU/8Gg39OlP+Mg+KPd3Vtqb5YGC7qFUWdHQYOdAynPljQ/hIXyAVOrc8oHN4kYP5S9vzswdRsQRFqFMQZRDNCgk4D5YoEHUMkWQRcpjrGFJhaHPyltVmz/Hzy1w0+EKUHkj6HMx7OSNVObRXZSvEhUCEf6Gkz3Cfj579vS7OO4lMdOOcAtFgLO5DGjF0Gj72IKoXmTmDfH5lrbLToERXNJicO6ihE3qTtiWMc2BJDxWaCmwPIlQnbF6lw2LOo/fyWb6egxAhGwY5DyJlD0s9cB7o0JX0gVRURoVo5MV2YFh5pUcadOWXdBdK+vl6/eq/uzH9xXG7kX4lIU97EEkfZVfiwAes9JEv8/KP8uxC+s6VCEQZR7NDKS/v7z9GOWxIgh7bTIFtJDKfqcFmInsab/YgD+TNvC2HncuNqgvyPs5NsTgoy0Yg294lga/Bkv490gCxe27oPh2XiPRpPNkD21A7dz6HyMqOinDoFJKm8ZG+XRPNignfnD47+P2t5zh0+NbpO0RPrNG3745upm5Zq8Gydg6ZWJZrlZgKm28mnCc73pOYJUvs9mALaK3bgh8dS1g5IIAz/ntZGxkpZbiSvsjvjg7facMuvqPo313kfkH6xEQg/RoTzvde60z+O7qP0jRFdW2+PZ6ze+/7Sd9G4jM9YknfGbOlvjBAT9Qte6gv27RK98Swx3j5no6RM8nXyrEbID4M32w3Q7JHeeb05b4pRc4dF5z/nQDUYomaw9qfBjyj10A9ybu/5hi54aDTZG3fHv1WzvONtFJXnfljQ1hJHwiB3SbE31d6NHauyXlAJMy936aBCI77SWV9SpwbpOM8hqyRXq8YCdfvkpYUwYCBB8RP8N91zE9JmU7SJ6LWGRTGwXPVnzrUF9XpJNaEQhR4YaT/uW7yABmhYIK99Lzz2eT9GcqjF0Ejih351MD9pM9yOd4Lx8EGMjg1eoLIhvS0GjW9s6LJcz4jLV3n+nt7bDq0T0dX0uHsxcBYB86QpB05sEetiYPNiHVLtYVLi1wjyHkmAdtoOtcic/wh9YJ0NOgmCCER7HmFG+nznHKeJZl2r3574ESZo34S0uSdAvPGBtHTBZG+5KdnQKufbaRxRjFsRsokWpvdBA/Ku+seAvYZxfgIbKQByooTXXojjTN6GDSK6VWyiVKMd5Lvb0iDlsj+wAPZ0HhNgxMQeWX45QudWnMejBAwNEiEPg26qLog6RnGJHLdueEVB8/Jyg3Os+QpONIvpsFHNK6RD/kuCemLs6bBiDyxCV1KGuhkxUmzAxzvh03pSKGm8dVdlg3zDsQEMV3FEC1byJIe/bNMVtPb8ihbnvMjaTzY0RPnwagi78SSL9JlbVo52qgXB8/LM9F7xWY1Ul31VEL3KeEHneyUiz0I/MSm0DF7dGhwV6C85X7sYkrPFpnyvhAX//Np/wdE9gfNAxdK+mIrLHnkvan/7M6n7+t8fnkWlvxiT8idnSVV7shESH/Y2iWqY0ibJaj4HzpEvvTHfb4wmp5Ev1LvGYFzErI9aERga6mot8Jlt7aursuUnQcbjlUe00enRfHXGrdDw0LkzO57vZbNiurAccB52B9LOtEToz4q40A9+esGU5tzpAMWeFAOAdk5GfpHTs68cSHspA9UcSW11U8PEaEA1hKzAxxCd31ouT+Vh21iUS64XZxIFPlJJWEuk61MGeZkmAsB6Va9OHAqnZTtJH2GVZh6eEzy0PtkCCtqA4pQK2lskEp1QaQvyma5CXsWMDyqjs4lzdUiG+SBXPk1wqhKI59sCcp5hph0XbHIDFna9Nc50zuBzCU9ATLIBz0xHHkDz6AVE9kzLymykjKZhmF+CvmzcQXyZjiMeXUdFrOtT3QmcmDagghaekbIiKH/q3+toM/EfgI6N83UUOBzAZ5XZJu1xVc6x/yRPB/6ZvdF3QHNaczBQNJfEOnL89C7gNjQk+99A55dvlP/iMxmqE4dkU0jn+RBJtRZttxETuySFpVe/o9RJqhXVIfjqV/IkrrPu2RtJnpltEHz+Ig/xY++oX9rH7ezLFXOMxrAvaPVBb9zQx7YBsSErJnX5FlIzwoCnfOMD1ImqzNsPdY9NtzSxQepdxdE+lIHeXbkST3TrbgDZSpyv1Zkx/thIxlYruZIg6x4B/TEiiDq/zWSniWT6D+DU68WfBfnn13qK5uRFRnS2RQb1lVX02Smcav1VdJQ5+WdWNvPfH3xYb+rLgnCRE+svuC5dddMew85j65odH4mNkocgNZhOcdKHEYK/8doT+Az+Z+LRhvvQgwT7xsI3sn3XtHlECculPTlPkTcoyfqf7T3daTh53TRBc+J/dk0xLfQIENWRLPbeCT8BeUxuuH6i618F5u5vlU185LYCT6Pus8oHj42ml8R28godePN/q00HaPJNyJn0Qf35Ll0hMH6yR/E/iQfZaF7/CS/OEt6pqrRE/suxHgmC87L/bknurY+mREAdtrUehCqPVwU0gcIAaeKAClX4f8/thcGOCHS4ciACF2Faa9DJlqOvyw+na1DEYqT9Ncd2C3n5Zq8eFQ+niuuZwgVUuYFkT7geXgP8jI/65oGmfrTICfnNb5zXmXhr4BxpXeCa8jaKVO3iqWy57o/jdUlafmfCulMb99J0/qhjQf/swZbebV8RxkgrveJDZLvgkgfRJNpLHqy70c6ZOa8Rh57Teug6CkqvSAux0neoPXkSGPvRVp97oB7qJ78aS0ow9apYPUErM5DzeeEPPcFkT7g+XkGt/dVOOXuoicrKys7CCWu9E6QN1BPbnWF8pxpAvUUWBeoe5rWn55P0lM2/8fmN0CUXuJBXO8ViAslfRAlU0Fs+WPYjOOalZXyhFv6ePTklD9w05PK15GOfKonZCbnAp8bGWpaW658Wr3yv9s9AhF1T8d9bTlu6ePCRSP9SwURFqS/0v9LfwR40CIMqTKHClHIBZO+h/BD9HTBpO8h/BA/dMGk7yH8SAzS9xB+JAfSZ2iPeTHm5FiW5pG+B4VH+kkDHuknDXiknzRw2ZP+j6V0gx7mdJibZL7HNV1iIpD0G4qTalreXNG8grmiRUVzRUsPEYHGpcwzIzobJn5u6dfEXPGr6M4tnYdLi8alTf7xvQ1bR6XpUsdc0aScezoPlxY/fW6KzRpiWKt1Rbtq5opm4vPc0iVnwAHNpEH0q9RhOp9u/BFuXPakb8F8C+/DvI7b9cSEn/T3nDllHh3Q0lz1XWHzYqk3zAeFXzZl3n3WlHvHQ0TgrbymdemPzKGWLUzDzwrK9yfd03m4tBC9dPqiiDneprWp/vGrplzBp9zTebi0eONx06dySXO0dUtT6f3nTbm3n3ZPl4zx6UcvmDeLvW5yf/2xEP8X0oAVuHFIOBEb6RcZ2sXotn4M0XgIHgQJ1S1s7uvf3OzYt8v0+aSgWZQts/k3VUpp/l7hwYMHDx6SOXZkSm8G5rnFPFbhXV/P/ychY7jDjVMSG999bBotGBuD9PeWGtnDpG1YUpdCeQgBv35p0rWobArXLGnOPPO0q8I9ePDgwYOHA+nSmEpvPW0yNKtk0jat6M4piY0fPjfNlkyC9Ef4KV9J/19+fIWgN3bF8xA85h3YZRaPGmJOZMzgqmQPHjx48ODBibXlS5t5x/a7ckpiY+6OjWb/6ZOQPj9gk96Svm8/R+8I/fj3tDEvvuyqWA8ePHjw4CEG0qU3Zkb0nTPDfQjPzxVk8kj/Qo9ff3VX6j33GFOtmjFt2xrz228eIgWdBB07ul/zEDno1MnTU1KAp6e4Ub++MQULunPE448bczL6vvrhPAJJ/99/z5wxJ/45KTjlIRicOW1OHjxgzj30UAxlni5QwJzYusXwE0MeIgvsMM++2PwqhNt1D5EBfqcPPeES3a57iAx4egoCp/81p1q3Muaqq2Jwxb/Dh5kT5866c0yiwdewEJ6PNry/d+veXWb+2uVm0fqVHoLA/F1bzOohg8y5dOmiKfGfG643K2ZOMQv27TCLNq52zevh0oD6vX7nVnNaGmyrt24wC7z6HpFAL5t2bzdnzp41yzatle8rXNN5uLSYt2aZ2Sa8gZ4Wb1htFqzz9BQDG1aZhVvXm4V7t5t9RQtH4wqws0pFjQtzzZtImL92mf7ctfA8P9WYwpL+boxs5srFZu7qpR6CwMwdm8yq7l1iKHHfB++ZWds3mjliEG75PFw6UL/XbNukpL9cyGSWV98jEuhl3Y4tSiY4rVmrlrim83BpMWPFIrPZ3zijQT3H01MsWGJm7txs1rjwxa7in5kZe7e55Ek8zFy5yBw9fQrSH6aEzyFf9mzes8PMFqXRevMQP2ZLT39t5w4xlVi6hJklrTq3PB4uLajfa7dvVtJfsXmdOim3dB4uLdALIzK+HuQqM0ccl1s6D5cWNM62CG+gJ3r5EIxbOg/ie4T0V/fsFoMvdpf83MzaF16+mL1qsTnmI/3om/N4pB8aYiX9MiXDrkQPCYNH+kkDHuknDXikHzwgfbeevkf6SQge6Sc9eKSfNOCRftKAR/rBwyP9ywAXm/QxqiWb1kTDwvW+YJqEgnm4xC4zknExSN9NT5xzSxssXMu8jPV0MUifeh4o0/nh0NMFlhnJuBikH6inxQL8llvacIBAvOj3X52g+ydZ0kepizeuNuv2bosGzl3KVp7zuZZtWXdRniVcpL9m9xbB1mjnqGSzli8042dPM+PnTNfPsbOmmGlL5sVZATHE2K5zfvbKRWbc7KkCyp2mn/GVmZSRWKSPfNDRajHkwPMzli2IJk/0NGPpgnhlynW3NJybKbqnrPPlTpUy58dbZlJFYpA+PoD6v3bPNrNi+0Yzb/X5a8htusjP6shi1opFscqU85QXG4lz3af78/YEZsq5y1VPiUH65IHY8d3Lt62PVgZywx856z3+Ly49Aa4FI3PSxKZPiykLZ6sN22eYOG+G+M3FQZXvRJIl/aWb15oeg/qaAm+/JXjTFHz/XfP+xx+aPkMH6TW3PBcDtP76yjPwLC07t78oz5LYpG8rYMny5UzxsqW0YtsKiTF07NnV3H7nHeaW3LeaG2+6yfzvhuvN17VrmNW7ohMPoOGzds9Ws0TksFIc3mp5Vi3L4fhWbNtg/hgy0NyQ60YpL5e56ZabzW133G6qfVfdrNyxKVp5lwsSg/QXCglNWzzPfFaquKlQrYrPwazzOYBVYti/tGpmbrvzdnPzrbf49HT99ebHZo19xBNQlg++BuvyrevNsq3r1AHOdVxfuWOjadu1k+oIUG5u0VPDpj9LmRtCdj5JAYlB+iyXxVF/UqyoqdvoB+2xWVkh69oN66s9Ue+tbH/7o4fajrMc7IbGHfqjjKVyfdXOTfq/k6Cw0Z9bNoump9vvutM0a98mRpmXCxKD9BdtXGVGTZ9kChX+yPzUool+tzZFz7parepa39HTDblyqc56DuontrI+Rlnkw68tF8AJgdctKJfGIGXAFfhH7ut8/gXrfR2mjz/7VH1tLrFlfO/Djz1qBo0eFrJOkyzpI9BWXdqbbDmymxz/y2lSX5lanvsKcXTN1RDc8lwMYJCN27TQZylRvsxFeZbEJn2MBoefKXMWky59Ou2J4Fi4huOHTHi/Z55/znwpZFO8bGnToUdXdULOcqjIg8eOEPKuYd567x1TrHQJ06JjW71GxbbpMIoRU8abkl+UNV9UqWg++PRjLf+jop+qQdh0lxMSg/SRG72N1KlTm1w336x6w0Fwbd3e7eabOjVVjq/kf10bBZ+XKWV6DOwrDTB3JwTRoy8aekWlIdF32OBoDgunNGDkEFPqy3Kqp4KF3tPyK0rZq3Zu8Ug/FmAHf40bqbLK99LzZsmW80PtNISRJ9c+/PQTU65KBf2OHmiA2TL4nyH/lr+1M0VKfC46fc28/8lH5vtfGmmvHvJwpu01eIApJY328lUqmRdeeVnL/67B9+o3bbrLCYlB+tR/6jyyervQu/rdkj51/wPRD9c+LlbElK34pdrAsEljo+lJyV4awEvFprv07SUdp7KmfuNGes3q3IIGwegZk1Uv+Lx3Pyxk6kijkIY8nSSbTp9B8LM0RIqXK20qVK1i7pBGHM/S+68BWo6z3PiQZEkfITDUOGHudDXML6RyS3bTpF0rJSa3PBbWOVKG23UL0kF+IL60gPQrRaAtOrXTZylftVK8z5IYSEzS5z3pmVCR6R3SoMKpOEnfNmp+FvLfceKATgNgFIG9jbbdfjNZrsmiaW+65RZz9dVX6/+0pFUHfiMgHzKmlbvlyF4zRgwhlRBZ4c+LxpheuFxwoaSPniCTSfNnmeuyXmfuvf++qHrNdRpLNb6vrfLu1Pt3s/PkIdUTJO7mEJE/en/2xec1D2jU/FclJZuGfDTW0NPWo/vMn6OGarpK31TVkYVgbCSp4UJJH5nQERg6cYxJL/U/f8E3tNFl6z7yLVvpS5UjQ7dbjuzx9fYcvXd0wzDuOx+8r+my58hhbrvjDmmUZ9LvkDpDz9ZGycd8L+VsEz217tJR09X58QeP9GMBeSBr6nTKVCm1V710y1rVnyV9OiHp06dXm9t0cJfId7Pqxt7Ljggw0lug4Jsqc/DUs89qOaTVdPI/daB1104m5/XX+3SaM4foM7P+n/fpJ+UeM6M1JgCjQoya7jh+QDtQpO0zZGDyIX2AohDyxgO7zNe1fb2a2EgfxZB2jd+gyLtqxyYVZGA6lA3ZWCOihc0wc2BP1oIKgZOldYbza/t7Z32WpET6VEhIASeEY8c5QNQ5csZO+t//8qPrSAZyptJef+MNJkuWLKZjr24qZ4Y4n/OTSlPRE7IKzKujA2OGe6QfCxZuQE9bfb0JqY84+2zZs8VJ+vQO3aZeLOYLsItaP9TT9Llvv00/GdGJjSRwNIwakM4j/ZjAhnDQNH6p06OnTzJXZ8gQJ+n/NW6E9i4Dy6LRwLQMaT4t/pnKmXyThXxsQ+CHX3/SOhEjr+ipcWufvXqkHxM0jrAVbAldDZk4Ok7ST5cunRk2eVy0kRVAOsqqWqu6yjpNmjTmqeeeNSlSpNBGGdedpI+ev6r5jXnw0YfNb727aweWxsRb77+r+RmZc5vaJC+89UmxIpou2ZG+BS3ar2p+q0KIjfQxiLGzpmq6Vwq8LuTzgilTsbwZOHqoCg1hkg7F0Nqr9G01TffYk3nNM8/n06GcIRPGaEWwZZKHchmaZk6bIewXX33F5HvpBX0Whr4jnfRtY2jOqqXm55ZNTdGSxfX5GWrKnCWzufHmXCGTPjKp91NDTVPj+zpmo7SKMUQaZ2NmTtEez5PPPq2NKWsIFh7pu8PXGF2nQZQ/NP5JZPOZefaF580b77xlMgiZPPDwQwkmfWQ+fPJ4kyFjRvPSa69oLAX5GMnxSD900oc8CLqq26iB+bBIYfUfxB5dJUTw1ntvh0z6OPlPSxTTNH/8PdBs2L9Dz2NXbX//Tc+XrfylNg4C83qkHzvQEx2RWg3qmXfE3+V76UXzujTKUqZMqY2rUEkfO6pRv47J9/KLZuiksaor5P68fOe609dZWyFeysZo4OuoA+R5Qnr7dnrB5rH5PNIXxEf6KJe5yNvuvEPTMB9y7/159P+MmTKaVkKYGAOVBeUyt8Y1AmAefOThqJ4PwWVDJozWHj3lQm7te3TVuAKuE1xx6225hdTS6/cKX38V8aSPA5q0YJZ58TXfvB89ewJU7DvdenvukEifSol8qOhXCnGPmDpeZYqs6FEyX5kqVSolfuYtMRRnfo/03UGwzqjpE03ep59S2RPQg56uy5ZVvxPUkxDS1zovzo05YvQyatoEU7vh95rPI/3QSB+iof7SacjzwP0qGwLp8DvXXHutfqdnHirpk6bWD3U1zZvvFtRzED91Ar1xvl33zq7O3yN9dzDyyHz4rbf5fLsNSrVD7EWFAEMhfUBa6gu+ctOh3aZb/95alhvpA56TYFz7nUY9U5vkgXe4Z6BN8d0jfUFcpI+gmQ+jJ5Q2bTofwQsB0bpq36OLySi9m5zX/0+XPzDfiVBxZn2GDNI5FdJhKJWrV9PyGR1YvpWh1bVm9IxJJmu2bFpZ+o/4WysSJNWgyc+aNtKH93lXbeRIb4TnpcU7j0olRM7Srmw5cqhsQiF9KjJyu/Oeu7QBMXvFYrN+3w4l+Ecef1Tz5fxfTv38/c8+MeTjkX5MUIcXrV9lXn0jv8rtx+a/6jA/xD58ivTQpacPySD7UEmf8zYws3L1r8324wdMtVo+W/JIPzTSR0+kf+Txx8xVV11lWnRqqwS/du8202/YYO3ps9IoVNLH9gimfeHVlzTdo3kf13iLV98sYK688kpTTOyc+mHLdMIj/Zhg6mXqojnaQWM0s0ufntqAwtf8PuAPkyJlCiXVUEkfqA0K8Gtd+/VSucdG+tEgz7t69xbTvEMbzfNZ6RJShje8HyviIn2u2cC60hW+kBbyThU+iqG1TISr5mvbUg2CyoIQ18s1hM7/Gw7sNMNF0VSG194qoApH8FX992zVpYOWa+f+beBMpJM+lXrQmBEa/f3Cyy+pA+LdkA+VPBdz+nEE8rmRPnlZt50jZw5trdKQQrYYF3Oabbp2NJW/9TWgfA0wj/TjI30ckh0qZNqFuok+GFGZsnCOyZo9a4Lm9OlZTJg7Q2MCaBTjUCCoqh7pRyEU0kcG7aXHjUyI2mb4ncYZdZopwITO6dsRBOzVNvws3v7gfdURunQjOo/0YwKf1bDJLyoTVrhsFP9OHmRP5yQhc/qBCIX0fZyz3kxeOFsbIow+E/jpjOC38Ejfj7hJf7OpUuNrvcYQmNP5YXB2TozACSoDisEo+w3/y3xTt5Yq/6XXXzWP5X3CXJHiCvPGuwX1Omlfk5Y2gRojp01Qo6NMFNC8Y1stM9JJn8rTQgiBZ9V19lIZOI8BIIf4ovdjI30cZa6bb1IiYnkLaRl+Jn5i18nDpvSXX+g5jMIj/fhJH0fdsKnPSRGwRb3lPA0qSDu+6H030qeBSl2lEUGvdODoYRpzsengbvNtve80X1PpdWyUxiy6D3SiHulHB+++csdmXZ6KTNp07RRlT/gGpgXji96PjfStPX5V4xtz7XXX6qgOc87Zc2RX3bGMy6YJzOuRfkwQYFnEHyPBEL/tUUPyTANfTNLnOe06e7tyBr/q1ssHHun7ERvpIyAEUqJcGb2mJOMQEP93/7OPXmMUQJc0bVwtjYRvTJq0aXTon94qgW0PP/aIpnvzXV8gDsYKkTG8xlIb2yrjfvT8SRvppA+h2oC7+r80iiJYjIZKzdrvUEnf5n3YP5QPWFdMwAoyWy/P897HH+j5P0cNEXl5c/rxkT5yq/RtVZUZqx6skUP6kxfMTlD0PlNRrbv6RqRekd7jn9Ig6zGonzq9wkIonK/wdRVdfkSjNtBheaQfHTSiIIlPpM4ik859ekT5GkifkcKE9vSxOTbwIQ2rX9hZj30Y6A3ec58vNqneTw102jIwr0f60UEdpaOR/603VCb9pXNn7Qn9XcyevpPwCSAkLdzD89j6EQjK8UhfEFdPH9KoWb+OXvuldTOzTpwh5xE4LfFGLZroNYyKnk6vv/7U7/c+cJ8GTpEWY8LAqAwox/b0WYpBpCfLcagUpGV41C6viXTShwh+ad1cn5WpChw353kXnF32nDlDntOnUpI/v3+d6jd1aqlMaEz5eozLzF333qPrUtljAeJy5vdIPyZwUja4jh4/OqD+Lpd6yWqIqzNcbfI8GNqcPjbDSg2uM23FZ2yAlCjPmd8j/ejg3fET5SpXUJm0FntEJupnRNYE9zGnz2qLUEifdBAMcQK+NCO1PK5hV1YHTz77jNSLTTF04JF+TOBTbBxT78H+nr6kX7NnqwbfXcicvkV8pB9F+PL5yhuvazpiaiB0txEBC8pJvqQvwkIRCHfbsQOmej2fg2PDAzZ4wZhQPgr97Y/uei2ftJIRNMYI4ZOfYXvmtP/0D282+NUXhIcjY2OLtVJBmPtnWJXzBTBaUThkx7wd55p3bKPDouzZTOOAbRo5H+nR+xAs2xYzRcHuejgEhr74pHfOO7AiIRTSBzi/Jm1baZrP5Rk2imxWSD5k1LRdaz3P8KRvCCu6k/JIPybolXfp21PlxvbOzOkzKkU9ZLqJ84xEhUL6bMRDnAD1mh3D6v3cUMH2o0xbkY9dwmhksEVyoJPzSD8mVomMf2rZVGVS/quKZvPhvVp/cfDP+ZfxvvNhaNH7/JgRJHD/Qw9qGrZdxU8xesAGMQQIcv6FV15Se/JIPxjS36KdEWTyXYN6Ub6bqTJGzDifkOh97knnhvK3H99v7O5+L7/+qtYDAppt7AXpqFt0juAfOp3wGCOh2Kq178D3SLakz4uz1IFtDiEeAsVee9MX4ML+1o2l94qz4kcJEBzBNKy55/rbhd6T1twfun/8y/7lLqW/LKeCwyBs6+yOu+/SqE4aDKzhZDtazmv0rZTJsDQR+1dedaWSPCsBmrZvbf53ww263znLn2j1RzLpYxyQ+VPPPaPvhvPuNbi/VkSGItn564ZcN4RM+kTHstkEG0+QrpQ0jnr9NUA3f0krRkP0/t/jR6kBBOb1SD8mIOiZyxaau+69W0eWWAvcXQiXaSfW1jOnf0+ePCGRvnU81HkntgpR2e17qc84RNIFOh+P9GOCUauJc6frcsoMGTPovhddxUc98PCDugQ2U6ZMvkDgEIf3sbHK/ukdlhq37tJBCGWQxg6xcojzzURXNA4D86JTj/SjA/mPnDpB9XHNddea5h3aamzXLblz67LsK6+6Sgi+cMikz7nef/+py5LZ58VunJTr5ly6zwtTyG26dVIds3y5YCHfRjws66SXT1wZnPFF5YpSxhc66oDtOe0qWZM+5FDok49UCaytzJwli275SpR4RlFmlmuukZ7Mn1rJUcZEacXRS6JVJbdRkLaiOCwUAKktkHIXSoVhWR5z+jZdLlEKLXgCaN79qJAqgopFpajTqIESpE1LdC09I3ajqyKKdJtnS2wklPQBlRkCvjvPvfr8gCF9gh4//qyIufOeu3UOMRTSB5TLksbnpQdiywX0WOidxOZ8PNJ3ByNUNMjYJdHKkkhf5o7p5T2a94mQSD82QEI169fVxkTTDkIkATEXFh7puwN5tfu9sxK/1VOeB+4zPQf30yWrb3/wnvqjUEifnj7LjiETfBtpGZ3jk4DZ2j/W9/kkF/l7pO8O/EyTti3MtdJgRjbg8afyqg6wK7a5JU4rFNLHF37/c0NtmLMjH2nhJHTGd+5RrHRJs37vdo3Uv/+hBzRujD0c6AyRxoIy2CQLO/NI3w9enjl3AjGYLxskREEQBlHIfOeTpWO2AqBAiIvz7aRV10F6+igPI+O8rSQYGNspDpTyWFLGFoms4cdY+HEF5u+tEviktwppsiywW7/eeo4yeBbmrAnwcT53OHAhpA8gFLbN5Udz2C+fwESGCpkvHjJxjMrGvnOwpA+QDc6KYS56J/wiFTpx65FYeKQfO6ir42ZN1VEl9DR5wSyVJcvBCBRzpk0o6WMvE+fNNAPFhqYsmqMOzy2dR/qxg3o/ZuZkDZSkBzldGs2cY+rP6T9AMKSP/eGjaFDgg9jWmp59V/E3DEljg7H5GY/03YEO8Hv0+PHd+Hl8lXaCxo3S3VutnoIlfe7LRmd/jhyifGQ5CfD/gBFDtNyF63wrnCgHPsLf2fQWlIFuA/XKMyXfOX0B5IzTgyhiYp0qwZme76TH0ACG6OaoOGfTEURlg83o2QcGnmnlkXuRjl49hG/Pae84CCdxobhQ0ge8l5ULw1++c6tVRs50TtJn+JLNXCAU8rgZGvLA0ags5ZOh/8A05CMd5Ww+vMeMEcfokb47kLPVE70737m16pSc6Zyk30lIYseJgyJf3+qUuBxiFMFI/Ucnbtcg+C1H9qljonyP9GMCnVg92Trv5j+4bkl/7KzJUv93qyyZvgzUE/LFHrFB/A2fbuRDPvSMPW0VPbXq7P3gTmxAnj7fvVFlzjkaA9a2gJP0+cGdidJBYm0/jS1sxN6LT77Hxkmcd9ofZcaVVvnD/wyA8/jD7ceS8Q/uePAhMUg/WOBo+PliUZUGerER0U8tmmor1mkooQBjY1SEneFodX9b1xdkw7IZj/QTBkjfypGfymVTJIL0GJUKdCbBgnwjp03UmBn0VPHrr7R8Pj3STxggfeZ6kWP1ut+JXNuqfPnJ1cBGV7Ag39AJY0xjsdOWv7WPCsolWMwj/dBhSd+5cykjLcS8jJ89LcF6ChbWrroP6KMdLXxu3md823Ezhe2RfjLExSR9RjMYLmYuip2jmPtlPurLapWFoH1LiUIFreyeg/rr5iWUp+VmyKCxFXZ50uWGcJM+jSWi8ZGnyhQ9pU2rPwKjqybWuueLC/RsCB5DN4C5SgLWWBLrFjl+OSDcpA8Jszbb2hLyBO3FKdPbc8sTHyABfpSJWKMo/Uv5LCVOaJmRjotB+jTOkCmy5JMt2Am2C7dMaVRgWwXfe0d9rb0/cWNsIhfq/SH91T27xeALj/STEGaLM17ds2sMJe77sJCZrQ4+8eIKMCh+ApKgMiLymadngyO3DVyCBWVOXTzX9BjYT8ujbBoBo6RXGe5W9KVCuEkfubF6RfWk8vTpiTlF5O2WJz6wfIz4Fso6r6d+vjL9AYSXG8JN+uiCXr3akl+egHiNBOtJ8vHrcU49sfMco2kJLTPSEU7SB5AuP0aFjpAnG1khU+Jewi5TfwOdUTps2OqUgGh+kyHU+9NJdCX9EsU80k8qmC0t+xV/DzRn06ePpsR/ct1oFgtxomS3fAkFhEJvghamQv5nWVlCDW2ugIpLoJKzzPjmn5Mywk36yI1pkxh6knPI2y1PfNAyVffR9aRlrnbPk9QRbtIHzPlHk6mABpZb2mDh072jTPmfpctuaS8HhJv0Af4oyp5Ensyxh53wHSB+40LvP1feAT7YU6RwNK4AOyqWN7P2Rt+EK7HhkX4iYa4Y+AJp8Z249+4Yijz46itm0czJZqa04GjFoVQPlx7Td28xq04eNaeNMUuP7jcz5LtbOg+XFuhlzT/HzRnR0/yDu82MPVtd03m4tJi2a7PZdOaU6mnO/p1mpqenGJgpcpkjDYfN9euZ/1KlisEVa7p0MLPDvMTcI/1ExKydm832ryrGUCQ4dfNNZs+nHws+MXs//tBDBGDPxx+Yw0U/NefKlDEHRS98d0vn4dICvRz5rIg5V7as2V9YbMgljYdLj90ffWCOFSuqetr3yUeuaZI1RCa7S3xujjz3jCtHHL8/j5m/bIGZc4EjTPEhNtLfvWn3djNzxSIzZ/USD8FAGkizUdaSeebIIw+5KtWDBw8ePHgIxNnUqc2qbp3MzG0blEtcOSaRMHPFQkv6w/yUf8UV586dO3X8n1Nm/9FD5uDRwx5CwL7/zppjM6YZky2bq3I9ePDgwYMHJ858+405cPpfc+D4UVdeSUwcOHLInP3vHKQ/TZBOSV/+OXX23Dnz75nTHkLE6dOnzSljzL9Tphjz3HOuCvbgwYMHDx7+y5TJ/NOwoTl16pT59+xZ5Q83XklUyD04Akl/z9GTx83O/XvMrgN7PYSInYL95pw5d/y4OVX/e3P6wQdcFe7BgwcPHpIfzmXPZk6+957ZP3Gc2XX2X7Pr6CFXLgkHdgiv+0k/eiCfR/oJB3Lbf+SQ0L4x+/87a3ZtXKfK3T92tIcIwr4xo8zRiePNuVmzzOHx4/S7WzoPlxaqp0kTVE8Hx4/x9BSh2DdmpDk+aZLq6cA49zQefNi7cJ7ZeeyI2fnPCbPr4D7hjYvHtR7phwFRpH/unM6f7JBW3M5/T5qdp095iCDsEJ0cYERGDGDfuTP63S2dh0sL9HLQ/Kd62i29Ik9PkYnt/54wh0VH6GnXmX9c03jw4+Qxs+vQflf+CDc80g8DAknfk2NkAr0Q2IKe9h8+6OkpQoFeDkmv6Nx/58wecZSeniITO/bvNkeOH1N72q29V/d0Hi4tPNIPA5CbR/qRD/TikX7kA714pB/58Eg/acAj/TAAuXmkH/lALx7pRz7Qi0f6kQ+P9JMGPNIPA5BbuEkf53fw+BFz4Phhc+DYYf1/7+EDZtcF3AtDdS3TJe3lAPQSTtKnPOSX2DJF91peMtJTuEk/mp784F5uaYOFT0/OMo9ccJmRjItB+nuP+PUk9V5lK58Xs4Gx78jBS3r/xMAlI/3LuSWI3MJJ+shu6+4dZuW61YI1ZoV8Ll+zymzcviVOueJwYrvO+W17dprla1dpebbcTfGUmZSBXsJJ+shty85t0eS5bM1Ks1nOxSdTrrsRhJa5a7tZsXb1+XLl/2DKTKpAL+EkfeS2acfWKB1ZYGOxydTqJzYS5zo6cepp5fo1qrvLVU/hJn3KxMc56z0yxW/FdT+uBfM8cenTYt2Wjepr7TOs3rjObN+7KyzvGy5cVNKnPFrUJ8/+a/YfvXyHU3mvcJL+kVPHzcAhg809995jbr/jDnPLrbeaXLlymYY//WhOsOYzIP3hk8fMP/+d0RbqsX9PqvwDnefRf06Y8VMmmZtvvlnLy33bbeauu+82DaTM42f+iVbe5QLeP5ykf0Lk1vn3Lubue+4xt91+u8r1RtFT+04dVQ9uecB+6T2gY/QW2IM/fvqU6TdwgJYFKBc9te3YXnWYlJxPsEAv4SR9ZN20ZXOxp3u13lvZ/jV8qOrAmZb7nzj7j+qPHUsPnTyq9rGPFTqO56LM37p1MTffcouWdfsdt5t78txruvfuGaPMywXhJH3kTq/6hx8ban1HT8j2bvGBoyeMU3kH5uEZsAlA3sDrFvTY8Y+HpYxDJ476+Sm6PrFDyitVprTaMDq94847zJNPPWlmzJmVpHR6UUkfwa3fusk0+uVn061ndx0qcUuX1IHcwkn6x8TJ4FBEVeblV18xterUNpWqVDED/x4co/JhDDPnzzUNGv1oPv7kY/Nl5YqmZ5/eZrdco2LbdAw/Lly22HxVraqpXqumKV6yhJZfUir5P+ZstDIvF6CXcJL+vyK3H3/5SeVY8J13zHd164ieKptR48doAywwPfdHf7PmzdF05StVMJOmT4nmsMg3ZeZ0U/Wbr0VPtcwnRQpr+ZSNs/JIP3QcO3PSfPV1NZVj8VIlTfWaNVS+M0UP1ka4J/9z/179/jDlviwvOn3bFCv+uWnVrrX24FVP+31l8v/YieO13Brf1TIF3nxDy/+1eVMlIef9LxeEm/Qh5OKlfH6pVNky5psa1dVXzVuyMJovs2RP+qEjh6tPa9WuTVQ5Nh0g3dJVK0yT5s3U5xX5rKhp1qqF2bhti9qarWuUCTp17Wwqf1VFfO535l5pJPIs4yZNSFI6TVTSR6AQOwgULgI7IoKZNG2KCqpYieIqKJs2tkrCeVtmXGmc1+xzxJYe2HIDnzMxoA4igaTPc8UlR8CPJXTp3k3l2EnIn+PUudNayZ33OvLPcdNv0J/m2muv1bS33X6byZAhg/7/ucifsvfIPUhLPu538ty/Wt6y1StM6tSpTZkvyppT0gq2ZV5O4J0TSvqBenKra/Qefm7SWOX917AhKlf0BCG43YtGMM7r1dde1TygQ+dO0UYFyEcaq6dps2doujrf19WRhbjqfFIF75xQ0rd2Hpeejp0+KQTyrcpx+ZqVKlfkiz7svfh/+55d5tOiRTTd//73P3P3PXebLFmy6HdInaFnevykJx96tnrqM6CfpoNQPNKPifj0hN7xbyXKlDRXX321WbN5vflP5MrIplNPpKMDM3HaZPP+B4VU5uDFl1+Kuoe9H6Te98/+5sYbb/Tp9Pr/Rekz3/PPmzWb1kdrcAM6Udg1R4VKFTXthCmTkhfpkwahM+xoey8IFAU5lcH3f805M2PeLBXUlyKw0/IdYR0+dUyvByqa4RaG0lAUZdGT0Xs4nos89I5QDv9rmhO+wCacJZ+B78F5DJ0KQnk4S/53prkQcL9QSZ80vANyZHiXc3sO+ip64LM5Sb9l29b6/M7rgPei0t50803mGiH9QUP+0grLXNSrr7+ueX/v1cM1L/qYOW+2R/ou0DwiW9WTOPid0rNDP9TBwPrrJP3e/fpo3XRej4aDe5Ugfm3WRNPfeddd+tn5966xOhQa0YwakM4j/ZhAT0fF1q394Avc/IyT9GfNn+M6VIvPaP9bR01T9otyWgbn1gr52IZAmw7t5H4xdYX+rL16pB8TyBtZ4uP57qYndMi5kmVKmfTp05v5Sxf5uMB/HZAeH8oUALJOmzatefGll0yKFCm1UcZ1yrZpue/3DX8wT+TNq43yLbu3q8/8uPAnmv+7urVdpzbJe/zMKVO6bBlNl7xIX64jZAIZmrduqcJ64aUXtVWFIYyZMF6Jm0ALhmJKlCpp3n73HRXUvXnyqAI/L1ncfFb8c/NtzRoaTGOVQiVgeI3z+d8oYN4s+Jap90N9s2zNCu3Bkoa0tK5r1K5p2nXqoE7v9949zUeFP1Ylt2zbJkrRvAsVB+UMGz1CW2mvvPqq+aTIpzrVgAO3le5Cwb1CIX2uU6HXbd5g6jdsYN4t9L55/oUXzMuvvGxKlytjFixdHK3FGQzp854t2rTSNL8I8dAq5v3ZLWvp6pXa439edMU5K3MLj/TdwW8q4CgIIKolDuHt994xzz2fT6dYvqhQ3qzesDbaMGMopI/MF4gjy5Qpk9b1Bo18jotpnNgcikf6sYMG7qLlS0ylr6qofui5vSaNXYbbN20/72dAMKSPky9b/gtNM27yRHNGLIrzZ8We+g/+U8/j4/BbgXnRn0f67kBPU2fNMBWrVDZvvPWm6un1AvlN7Xp1NRDS6ilY0seOfmn6q3ktf34zb/FCM37KRJU7ZVoucKbnc9vuHVGNDmyW6VDyYNvUBe5t89h8yZb094qQUIwl8jvvutPkL1DAPPLYo+aGG24wnbp20eEX5rteEgIj6IyAM9JmzpJFg1sYcgYYJvP9OE0E2HfgAHPNNddo2oceeViDofif4AnmNFEGARhEb2bMlNE88WReU/Xbr03KlCn1PtmyZdP0VB7KQ3HkwehIQzAGFeEG/9AOBg2xBio4IUBuoZA+96UH/my+5/RZ7n/gfm203Ceft956q84ZYRw2fXykv/uQb+jq9TfymyuvvNIsFOeHwdALocJW/aaaSZUqlRL/TJyco2zgkb47kOHcRfPNAw8+oPJ7+NFHtEF6b557Te7cuc38JQs1jU0fLOnjiAgIK/j221JuarN45TLTtEUzzeeRfuikjw4mz5iqfiV16lQm75NPmgJvvKH+5n7R3eoN66I1zoLt6Tf2j8J8+MlHeg7iJ+3b77yt5wcMHqg6CcyL/jzSjwn8DHFIWbNmNenSpTPPPPuMefW111RPz+bLZ9ZKJ8jqKRjSB/i+7Xt3K4HTwRk+ZpTK3Y30AeU6O3uHRJ9MbZLn8See0HsGckKyJn0MoWvP3/XFIVyGJ3l5SASip+dulQ+xnfzvtJk+Z6amL1+xgpCJbx4a5dmeLJ+LVywz2bJnM7fceovOzTDET5oWrVtq3udffEGUJ5VAFLRKelfMrUHkOXPm1F47DQ16s8zRUKFWScPgn7NnzPipk5QE6eHzbgRaMUrxbqH3tNw+A/rqsK3zHRMCyg6W9JEPQ5D1GtTXZ6B3zjQIcjwosiGgJHDpUHykTyXFWPLcf5+5/vrr9R1xUBD8k08/pflolPE5YuwodXrO/B7pxwTyh7QZIUJuPfv8YU6LTGmM0fjcsHWz2RawdCdY0ue8Dcys+8P3apA//NhAv3ukHxrp8/7Y8Icff6RyGTlujAaioqf9xw5pxwJ7cOYJhvQhhk07t0YF5EFQHX7rZN55711z1VVXmYqVK2maQIIA6M8j/eiAfCHop595xqRJm0Yb0/gZuAPfxdK4Hft2R6VHrsGQvk1L+eh12KgRKvfYSN8J6tXJc6dNjz96aR6moL3h/QAcF6ESzShZTOGin5qdB0Ro4sBoDKAQpwEgKJwU0cikJ/oVQaEE0tlK8u9/ZzUSmTQ4PMiK6zQGKPf1/K+bFClSaEAgjQxIn5YhvVaWsREnQKUh7eclS5js2bOb2QvmqQMuUbqUlothc+AIOLjOeaI3ef74Kmx8QG6hkD7PirPnGerWrydOx7cMiPMEpQQ+T3ykbx0UhE9rFVKiMXTNtdeYDBkzmr4D+0fd74/+ffU+zvwe6ccEOqBulC5XVuXGEi/khuwZQaF+BuopGNLHka1av9bk/F9O0dXjWgb56nukHwX0Eirp29HHbr26n9eTyMvGyzgRDOlzT/wF+oLoSWvBVCZlk8/t2bjmkX50KOlLmrzSCUmTJo36bjpr+D1toDlGYgB6D5b0LUIhfavftZs3mjvuvNNkzpzZzF28QO8ZmFbrWHIlfciFljNzH5LN5L4tty5noHWNcAONgJ45PXfSMpzOdWfl4H8UVejDD0zqK1MrGaMIe+249Pgb+IM0CEKjQQDp35jrRh1ypWJEDQcd3q9zr5AXLXuu5X3qSe3pE4jDHBItucpVvzJFixXTMl957VV97tgqRrDgnUMZ3ifwkNGN+x94QJ+D4f269b/XIUrIBNJ2lhEf6fP8vDNTA0yNsLyFtE8+9ZSZNmemKrzaN1/rOeIbPNIPbnif+jp5xjQNjkR2jz72mC4/nTF3tjoHCMVZRnykT53EWRQp9pk6PkbBmCNmWLJR4581H+u6ORe4Dhx4pO8O9DRizChz3XXXqWyeeuZp06R5UzNn4Xz1J/gtZxnBkD73Jd/3DX7Q0UOmdvAjRPFfJbqrXLWKpnHzHR7puwNd9OjTWwPukA3+t2Wb1mbJyuXik06pLK2ekG24SJ97WJ2/+vprmr6V+FUaj27voVyUXEmfNakInrkXIh1vzZ1bhQAKvl3QzJOWklMx8ZE+ioW0ETzz9HYu2l5nuRMBg+Tv1KWzOkNL+g8+9KASHYZJWp4dwoTAKJNrd919l04DsLkDrTniA5j/v+uuu3RjjvIVv9R8F5v0uY4sFi5boiMgOXLmjJLjZ58X0zlI3sWmD2Z4n/RPCcnbcsp9+YUGVELwjIYUFaLh/NTZM2JUWI/0Ywd6guQJSs1yjW95j21IMp1l6x+Ij/RxeiwZ4jo902lzZpjRE8ZqzEqZ8uX0PHaFzSxasTRGvfRIP3agp4nTpphPPysStUyVeeOvq38jfmtP1FJVEAzpQwA2zoLVL1t2blc7Ylga38N5fBNTdYF5PdJ3B2nw70NHDTfvFXpfG77IKGu2bNqY5jqdN9KGi/St7+X/9wr5lvh936C+6oh7OtNaUE7yJX0BaSAYBMw8/pARw6LmyCFvp6Ah/QlTJ+m1Lyp8qU6P67Ys/mfI3hISzs7OsXMNp0aLmmt/DR+iUwHOnj6k5nS6Ftyf/CzNIDqabRR5bpw02Lxjm34ydx6YNyGg7FBIXyFpqNRUHkZP+g/607z08kv6rmXKldUKbithMIF8lGXXqf74808av4CedCRE7kWQIDEPxDsEGo9H+nED+TD3yPIeNjp6XOoVcoZQnKMm8ZE+db282AHXU0hjlM/YACn9Y6LrwSP9uIF8sAOCfX/r1lV9BLKCeIkTsuniI33uiY0wYkCa2Qvm6twv14gXGDV+rJ5n5ZJb79Aj/dhBOmwG+RLA2qxl86jOY48+vaKWQIaD9LVenTyq09J2OqjeD9/rFENg48AJykm2pM91DARng4EhCA6UwzaJ2bJn173crXIYpqb3zpx8/jfya88dhUP+CA1h4hhtlGzFKpWUrGg9E9y2dtMGjba/6eablRjJEwzpUy7PSU+ectmylIN78czMJeFQec5gHUpcoIxQSR858Cy8E/LkoCFCL+WBBx/UoBZWS2jaeEgfUA7z+KSpULmyYckeRsAnUyOcZ7SFewbm9UjfHdZB4dhVT1JvOFZIHUeez+XLF83px0f6BJaNnzzRtOnQ1rRu30Z02Ur12bHLb+bd930NZ+JMWI7K0iPnaA/wSN8dyAA9oCv0hNw5xk4ar7IqXORTrffnG9Fxkz4EsE/w2OOPaZoZc2fpKCPpsCdGFDhPkJ9H+qH19O2WxsgEPuCA7JEVuyJynnTBkj51AzuxZdnN4N56u6D4sdM6OqNE70+3Y+8u7Rzh6xjJ4SANdYYy3DhB65fUn2RJ+lR6ejoEHQ2VFhVGxT7IDElKMSpoepbWuGyP+/G8T+h11rWOmThOe+0EBPLjLgiZyE3W8ZOGdbVjpMyefXuL0T2u5zp26aTERyMC0s+RM4fuQx8b6QOede7i+dq7JdqWgDmGUnnmwcP+1qG52QvnRcUEXAiQWyiBfIDtOdkKcuS40fpM9B4YMuZ9iQymwp93UvGTPu/ByAWjGz45VtWlf2z+gtEQvT9nwTwtNzCvR/oxwWoRhnR/afKradmmVZSeho8ZGRUTwj4Sxxybs8RH+twPx4PDcILDbt/LvhMQS2C8APBIPybwMezd0ajxT7qZDvJBT3+PGGYKvFFAZcUKGafNxEf6gN5fHfEZpHno4Yd1hz2CkpmTZtqQ89179dQOTGBedOqRfnSgJzo1cEWX7l2VB9ATm4jZpcuskLGyCpb0OTdu8gRdllzn+3pRGyexVz5b9hLLxG9XHBF+4xk/+dS3jfUtt9yivfzv6tTWvWGq1/JtxTx89Ej1pc734f9kS/q0hGrUrqUv7kSq1KmU8BcsiakYDGrC1Mnm4UceiZYHkl8vZI+AaYnNmj83KqjCgrX19NIJfKMS4DD5lSOWpj333HNxkj5gSBYyfewJX+PBiazZs5kR4sjtxj8XAuQWLOnzHrwzc1qBz0SACz09GkHOxkgwpA8wkiWrlutacme5BKDRAo6tonqkHxP7jh7U+sU6YqcsAaMxFaRhtm3P+ZgSEB/pxwZ6PmwwwlRUDyF9NyIBHunHBHpi7xA3G7/2umtNTfFXpKMcmycY0oektov+6YRkzpJZ0zJiyeetuW8VMm+uNuos18Ij/ZjATmicMWqLXJygQ0IgK7K08uQzGNKHjPGJxG6lTZNW0l6t25ATje8LGEyhvz/CCDJ+FV+YSa5dd11WLZc0FilTptLfLHGbhk62pI8iCOKjZTVQWmit27XVHjkRzlZJgWXwHSGyrpkof2IAhowcpsFRXFPh7vcRNN9pIPQb2F9b6kwV4BBtRSAtw95zFy3QXeui8jvuFwjmiHAKkF4veVaio2nNcX+cenz5gwHPEcrwPvdkpQEjD8zltxI50hrlmWjYAGcZwZI+II6CIEaGIOmdcA9iGOJq3Hik7wbfiAw/zkG9RT8Mx//592Aze+F8bYgGRtgnlPSp3zRmp82eqdNYsdVJj/TdgQwWLFtsRowdrUtSicSmB4mPwPdAOM4ygiF90pMPu5kvnZlBQ//WBhlzxow2MhJg/VIgPNKPHfhuVlr83ruHad2hnY66Ll29Qqd0aWjZdJZP4iN90q3ZtMFMnTVdp2Hwoezsij/jO0GyxHRRNtyBLqfP8aXzpT8Pdgpkl81AvfJuyZb0rSGgIAwHQrXzM06FuYFWMWkhMODauj5yQMvSdAI3JaMAKgMb2QReiw3c21ku83DcP7EcJnIJdU4fYrfPY+WIg3JzJE7SZ1qEA0JBPjHuZXXkf18+naMGFuRDZ3aeeumq5R7pu0D1JHXdypL/qTtuenKS/mAhCQ70FNiICwTXlGBE/4F2xDX0x1woB86N8j3Sjw5sQX2LQ0/4CTf5OEmfbb45kG9g4wCQH33bOsAne2k40wDVk+jZ2pP3gzvuQCdR8Rf4PfmfBnRgOvTuI33fD+6s3rTesLSVOo+NWD3xaW3HF3AbHZzHfmx6yowrLWU5n4PzNl7gy0oVVKfJivQ9uEMNPkTSDwUYBj/EIqrS3y7oM6C/LmGkFQuhuOWJD1Rueiydu3U1vfr+oUtmKJ81/h7pJwyQvl1vzx7wff8cYDqKnogdYRjaLU98wGGxfI9GX6++fUzter6NrPj0SD9hgLirfevbt+In0RdxSsiXHmd8nZfYgD3Ri8VOGeVhKS7lN2nRzCP9BMCSPktlkeOvzZrqKC0xL2xhnlA9BQv7Powesb08jbjnX3hen2X85Ike6Sd3ILdwkj4jKzgS5qKYq+KTIa9adb/TVqhbnvhAS5sgTOanmUum3Izy+XX1b6OWJ11uQC/hJH10QdAf8rS6Yq1481YtXaO8gwHEDimhG6eeCDBMaJmRDvQSTtKnh8mmO+goUybgk+2AvwZKzy5hMT6QAL+6lzFjxmj6J7iQHqRbnqSOcJM+ozfVvv1GZar1Xj5z5MihwbThlimNCt6JH5XD16JP7n/TTTeZydOnJimdeqQfBiC3cJI+BrB203qNdh0z0bdignlm3cAlYDgqWFDmhm2bNdCR8iiXiFp2Cgx3K/pSAb2Ek/SRG70QnyytnkZHzSm65YkP5GO+n9gMqyc+iXe5nPUUTtKnTHr1YydN0DqPPAFLhLnmlic+kA/dj3bYE6tn2HI5oWVGOsJJ+oAyF4uPs/aEr0JnxL2EW6b2fdglFl9rdTpJCJ9VCElJpx7phwHILZykT3k4eIKKaGEqpGfhnKsKFeSj4kaVlwhlRjp4r3CSPuXp/KJDT/QAOZfQe5FPde/Q04WWGengvcJJ+pRHPXfKFFxII4oyVfcBZXKOOBu3PEkd4SZ9wN4W0fyeIBx1IjYw2oBftPdmjj8pET7wSD8MUCcSRtL3kDhAL+EkfQ+JA/QSTtL3kDi4GKTv4cLhkX4YgNw80o98oBeP9CMf6MUj/ciHR/pJA7GR/m52LGIdIwbmITQgt31CIpZMPDlGJtALjTL0tO+Qb92uWzoPlxbo5eAxaZwJ6UMmnp4iE9v37TKH/aQPubil8XDpsV3sx0/6w/yUf8UVorRTZ0Vx/54+7SGBOH2GH/41+ul23UNk4MxZ9PSfp6cIx5mzZ3FS5rTLNQ+RA/QkdOJ6zUPkwE/60wTplPTln1Mn/zklresjOqzmITQgN6ZHcFLHTp7w5BihQC/HTp2QHuR/qi9PT5EJ9HL81Em1J4aPPT1FJpgqgzfQ06HjRz09RSjQCx0d0VM00t+zec8OM3vVEjNvzTIPIQK5rdyyXnuRq7duNHM8OUYk0NPa7Zull3/arNi8ztNThAK9rN+5VXuRizesMnNWL3VN5+HSYtbKxWaL8AZ6WrBuhZnr6SkiMXvVYt3TQ3g+eiAfpI8SUZyH0IDcVvhJf5WQ/mxPjhEJ9GRJf7mQvqenyAR6saS/aMNKbay5pfNwaTFzxaIo0p+/brk21tzSebi0mLVyke7q6kr6Xk8/YfB6+kkDXk8/acDr6ScNeD39pAF6+h7pJzI80k8a8Eg/acAj/aQBj/STBjzSDwM80k8a8Eg/acAj/aQBj/STBjzSDwM80k8a8Eg/acAj/aQBj/STBjzSDwMuBuljVEs2rYmGhetXuqYNFvPXLk/0MiMZF4P03fTEObe0wcK1zMtYTxeD9KnngTKdHw49XWCZkYyLQfqBeloswG+5pQ0HFkn9i37/1Rf1/omBCyJ9lLp442qzbu+2aODcpWzlOZ9r2ZZ1F/1ZEpP01+zeItga7RyVbNbyhWb87Glm/Jzp+jl21hQzbcm8OCsg12K7zvnZKxeZcbOnCih3mn7GV2ZSRmKRPvJBR6t3bo5xfsayBdHkiZ5mLF0QQ6Z8jw2B6WaK7inrfLlTpcz5MdJeLkgM0scHQERr92wzK7ZvNPNWn7+G3KaL/KyOLGatWBSrTDlPeXFd9+n+vD2BmXIutjxJHYlB+uSB2PHdy7etj1YGcsMfOes9/i8uPQGuxSdznnfB+tj1aTFl4Wy1YfsME+fN0NUl8eWLJFwQ6S/dvNb0GNTXFHj7LcGbpuD775r3P/7Q9Bk6SK+55bkYoPXXV56BZ2nZuf1Ff5bEIH0qERWxZPlypnjZUlqx+c41jKFjz67m9jvvMLfkvtXceNNN5n83XG++rl3DrN4VnXgslkrjZ+WOTfrpVkFXbNtg/hgy0NyQ60YpL5e56ZabzW133G6qfVdd8wWmvxyQGKS/UEho2uJ55rNSxU2FalVUtixX4toqaQT80qqZue3O283Nt97i09P115sfmzX2EY+jHPSybCtY78C6GHV35Y6Npm3XTqojQLm5RU8Nm/4sZW5IUs4nWCQG6S+STgCO+pNiRU3dRj9oj83KarnIunbD+mpP1Hsr29/+6KGdBmc55KFxh6zp5a3etUV15UwDsNGfWzaLpqfb77rTNGvfJkaZlwsSg/QXbVxlRk2fZAoV/sj81KKJfrc2Rc+6Wq3qWt/R0w25cqnOeg7q56oD8uHXlgvghMDrPB/n6Vgt2ewbLUNv6JT64Xx+2yD4+LNP1dfmElvG9z782KNm0OhhSUqnF0T6CLRVl/YmW47sJsf/cprUV6Y2kl0cXXNxeJeOKHCojdu00GcpUb7MRX+WxCB9jIYWb6bMWUy69Om0J0JF5BqOHzLh/Z55/jnzpZBN8bKlTYceXZU8nOVAGpDPsEljzRdVKprSFb7Q1jHG5ExH5R8xZbwp+UVZTffBpx9r+R8V/VR7R860lwsSg/SRG/JMnTq1yXXzzao3HATX1u3dbr6pU1Pl+Er+17VR8HmZUqbHwL7qZEhDenp/lb+tZj4tUUzkXTgKHxT5xFSp/nVUOj7R54CRQ0ypL8upngoWek/Lryhlr9q5JYrILickBulDCn+NG6myyvfS82bJlvND7SvFXyBPrn346SemXJUK+n3w2BE6YmjLwP7QdxtpdH1a/DPz+ltvmPJfVVJ94Audsidfr8EDTClptJevUsm88MrLWv53Db7XtDbd5YTEIH0aun2HDVZZvV3oXf1uSZ+6/4Hoh2sfFytiylb8Um0A3+bUk5K9NMqWik136dtLOk5lTf3GjfSa1Tkgz3DxedjeW++9Y5576QXzyedFJe1P2pB3Nrj1GQQ/S0OkeLnSpkLVKuYOacTxLL3/GqANC5s20nFBpI8QGGqcMHe6GuYXUrklu2nSrpUSk1seC4QfzHAK6SA/EF9aQPqVQnItOrXTZylftVK8z5LYuFDS5z3pmVAp6R3SoGKo0En6tlHzs5D/jhMHtLVKJXUaGg5q7OypplzlCiZDxgya/sorrzSDxgw3y7ZFbxyQDxnTyt1yZK8ZM2OySSVEVliMIHB64XLBhZI+eoJMJs2fZa7Lep259/77ouo112ks1fi+tsq9U+/fzc6Th1RP6MXqCZ1OXjDbZM6SRdNlueYaP7LIucwm79NPRemG9PxPgw09bT26z/w5aqjmq/RNVW3cBWMjSQ0XSvrIhI7A0IljTPqrrzb5C77h69n5CQDSL1vpS5UjQ7dbjuyJ0dtjRGfO6iXmvY8/0HRZs2fTXmbKFCnFtjJKQ6BjNDInnx0J2CZ6at2lo+ar8+MPHunHAvJA1tTplKlSaq966Za1qj9L+nRC0qdPrza36eAuke9mtQ17LzsiwEhvgYJvqszBU88+q+VYOwLoBn1wnd47Iwjoku/Y3QRpzDsbE4BRobV7tpodxw+YYqVLaNo+QwYmH9IHKAohbzywy3xd29eriY30UQxp1/gNiryrdmxSQQamQ9mQjSU6gmrs8LQzrQUVAie7RD5xfm1/76zPkpRInwoJKeCEcOw4h5tuucXkyBk76X//y4+uIxkMN3Xp09Ncc921mu7xp5401994g8mQIYP2eGhBB+axgMgGS8PAI313LNyAnrb6ehMiZ+YZswkJxEX6LX9rpw4qsCx0OmXhHGk0ZJUe6AvizGZKI3qGzldCQHwPzGOBo2HUgPI90o8JbAgHzZAtdXr09Enmaqn/cZH+X+NGuNoGvqfadzU0TdFSxeWZlqofo5efI2cOtdEJ82aofwvMix03bu2zV4/0Y4LGEbaCLaGrIRNHx0n66dKlM8Mmj4sha9JRVtVa1VXWadKkMU8996xJkSKFjrQEkj62R0MQ0mZeHntijv7tD97X/F/V/EbrhvMegHLgrU+KFdF0yY70LWg1fVXzWxVCbKSPkxw7a6qme6XA6+a5F18wZSqWNwNHD1WhWYeFYmjtVfq2mqZ77Mm85pnn8+lQzpAJY7Qi2DLJQ7kMTTOnzTDNi6++og6UZ2HoO9JJ3zaGcCQ/t2xqipYsrs//7oeFtLd34825Qid9cXLd+vc2j4vsiGtgROaBhx40V151lUf6foRK+r7G6DoNovyh8U8im8/Msy88b9545y1tTD3w8EMJJv1rr7vOvPZGfnV6ODd6GDgwq3M3eKQfO5AjQVd1GzUwHxYprP6D2KOrhAjeeu/tkEifXj6kwHAujbOpi+ZoHmS9Yf/OqM5O3Z8a6IiCMy/wSD92oCdiLWo1qGfeEX+X76UXzevSKEuZMqVOoYRK+viuGvXrmHwvv2iGThpr/vh7oMr9efnOdSfp83zYGZ1OznN9ndjsH3//qXnelHriFs9EOo/0BfGRPsqlVXzbnXdoGgzo3vvz6P8ZM2U0rTp3UGOgsqBc5ta4RgDMg488bHLffpt+J7hsyITR2qOnXAi/fY+uGlfAdYIrbr0tt0l/dXr9XuHrryKe9HFAkxbMMi++5pv3o9fA0KF9p1tvzx0y6YO5q5doBV0jvR1I/5778qjT80jfh1BJn9GTUdMn6tAfsmdIED1dly2rfieoJ6Gkf8211+pw5Ib9O7R3ii3gkNBfYB4Lj/RjAkdO/aXTkOeB+1U2BNLhd5Ax39+RnlwopI8/olPBFMwjjz+m52gI8Mm9ev01IKpc7RkG6MAjfXdAtsyH33qbz7fboNRMmTPr96IlPw+J9AFpqS/Y1aZDu7XjQ1lupG/Bs2Lb6H3LoT2mUbPGmqdqrW9dG3GU45G+IC7SR9C0lOkJpU2bzkfwQtYItH2PLiZjxowm5/X/06EV5jsRKs6sz5BB6vhIh6FUrl5Ny2d0YPlWhlbXmtEzJpms2bJpZek/4m+tSJBUgyY/a9pIH97nXbWRI70RnpcWL04DImdpV7YcOVQ2CSF9W8mJD2AI2iP96AiF9FWO61eZV6U3jtx/bP6rDvND7AQD0dOHZBJK+hDK/0TPkE+lb6tqTApzitRn9BiYD3ikHxPoifSQ81VXXSVybKsEv3bvNtNv2GCt/6w0CoX00RH2Q4cCX8OUCz18dLNZSAICIu9Lr7+qthmoA4/0Y4KpF0ZM6KAxmslUJMSLr/l9wB8mRcoUSqqhkj5QGxSgi679eqncYyN90k1dNNf0HNzf9P57gGkkds0zMXo3fcn54GknKMcjfUFcpM81G1hH9DgGg/AROD0bIlw1X9uWahBUFoS4Xq4xvML/Gw7sNMNF0VSG194qoApH8FX992zVpYOWa+f+beBMpJM+lXrQmBEa/f3Cyy+pA+LdkA+VPBdz+nEE8sVF+hYe6bsjFNLHIdmhQqZdqJvogxEnSDtr9qwJmtNHz0QKP/fi8+bW22/TYX7kTh7mitEz9w7MBzzSjwlk0L67L56HqO2NB3dp44w6TW89oXP6dDwIiCXNC6+8pCQFSbCsjKFkzr/2Zn61TY/04yd9fFbDJr+oTFjhslH8O3mQPasmEjKnH4hgSB/ddx/QRwOcSQfSpk1rWtMxlfy2jjjhkb4fcZP+ZlOlxtd6rZ0YpNP5IfS2v/+m11jSRGVAMRhlv+F/mW/q1lLl04p+LO8T5ooUV5g33i2o10n72psFNFBj5LQJUkF8xooCmndsq2VGOulTeVoIIfCsus5enBbnqWzIIb7ofY/0E45QSB9H3bCpz0n98OtPWm85z8gUgXfxRe/HRvoA50g0Mkv/ICb0g16JUk4rDo5IZDei8Eg/Onj3lTs2RwXcsbTO2hO+gWnB+KL3YyN9bA8bLPTJh2pDLE9muoBG2v0PPah5GUHwSD840mcKq0iJYioThvjt3DkkzzTwxSJ99Dpu1lS1aWyOGDC7FK/i11/JPWPu+Md3j/QFsZE+AkIgJcqV0WsowVnp+b/7n330GqMAuqRJSKpKjW9MmrRpdOifOX0C2x5+7BFN9+a7vkAcjJV5VFppRDrbeX7uR8+ftJFO+hBqvZ8a6rPW/6VRFMFiNFRq1n57pB8ehEL61DWG3ZF5U6nf1sghfZbcJTR634I6j75xbNjE5kO7o6KQv5WG7yqxr0Dn45F+dDDKB0mw1hqZdO7TI8rXQPqMFCa0pz9P7ulbRrtKOyNN27fWjgVLWzv/0V3zlq1cwXUOmGfwSP88qKPIKf9bb6hM+os8rT2hv4vZ0+cZeVZ0wpQzvm7ywtnmznvuMldedaXGhiwPqA+U45G+IK6ePoKsWb+OXvuldTONkOQ8Aqcl3qhFE73Grlgs/ev1ly968t4H7tPAKdKiEJZXUBmI7rQ9fZZiEOnJchwqBWmZv2PXM8qIdNKHCH5p3VyflakKHDfneRfyZc+ZM8Fz+hYe6bsjFNLHSdVu+L3KnB4/OqD+Lpd6OWbmFCGTq02eB0Of03cD5TLc+Wublpq3xBdltP4HErpH+tHBuyMnOwzPEC0yUT8j/gkHTv1nvjZk0veDhgX2gc2t37fdbDm813xY1BeP05l56W0xd4bzSD8m8Ck2jqn3YH9PX9ITdEzw3YXM6VsEQ/qBIA3r/+0a/M5/9PA9W0Ca5Ev6oiQUgXC3HTtgqtfzObjWXTvpBi8YE8pHaL/5W8P5Xnxe5ygxRgif/AzbM6f95+hhSvoNfvUF4eHI2NhirVQQ5v4ZguF8AYxWFI7hMW/HueYd24iyduuezTQO2KaR85EevY8DYdtipijYXQ+HYKO3i5T4XN+BAKJQSR+DQ/bImXgIln/d9+AD6vRYNol+cJCB+YBH+jFB0FaXvj1V5mzvzJy+bt8p9ZDpJs4zEhUq6aMnHBFOwxdB7Nv8g1Grl/O/pnmJT8GWAvN6pB8Tq0TGP7VsqjIp/1VFs1lImfqLnNlxjfPvfBha9D6weiItemS3Reyx9o/1Nd+Lr76s391++Mgj/ZjAdr6pU0tl8l2DelG+m6kyRsw4n5Dofe7JqBnlbz++39jd/V5+/VWtB+v37dBRH/ts/G9tDxvHrlmSe+fdd+kGWYzk2BFki2RL+rw4y1bY5hDiIQiPQBbJLsIoKpW8ufaI+FEClEMwDWvuuf52ofekNfeH7h9vHVvpL8up4DAI2zq7QwRPwAwNBtZwsh0t5zX6VspkGSAR+wzDQPKsBGDY7X833KD7nadKlUpb/ZFM+hgHzuKp557Rd8N59xrc3+Qv+KYORWbPkcPckOuGkEmftKx/rVz9a93fgG1F7ZIliPwLcYhsTcmqCuv8LDzSjwlGS2YuW2juuvduHVliLXB3IVymndjFizn9e/LkUVmGGsg3duYUud5e9xD/XeziR7GbJ556UvMxVI0TC9QR8Eg/JphumTh3ui6nZBdK9r3oKj7qgYcf1CWwmTJl8gUCh0j6+C9WCrEqiE1bPpOeoCWnp6WxzvQiBBWYD3ikHxPIf+TUCaoPNhBr3qGtxnbdkju3LstmPxG2og6V9DnX++8/1d+xzwvLKJF7rptzqR9kCrlNt05aFunpybeT+7LsEp75/ucfzf1SV8iDjdMQCLxHsiZ9yKHQJx+pElhbybIju31oRlEm24my0QGVHGVMlFYcvSR69XIbBWkrisOaL2VqS5nGhFQYluUxp2/T5bopl7bgWRb17keFohwhlaJOowZKkDYty6r48Rh2oGPv8th6tOFCKKQPqIB/jx9l7s5zb9Q7MKRP0OPHnxUxd95zt+7PHgrpU1kHjh6mS4yIeUiTNq3qKIvoiOAwiCvvM09Ha0xYeKTvDnoDNMjYJdHqieU9zB0T0f1o3ie0ToZC+tgGw8I0UG2Z4Pobb9R1wjwTdT0wH/BI3x10Btr93lmJ38ozzwP3mZ6D+5lHHn/UvP3Be+qPQiF97KlTr9/Vr1199dW6soJYIxoBNpYgNpLzSN8d+JkmbVuYa6XBbPX0+FN5VQfYFUPs9LJDIX184fc/N1T/xo58pIWT0BvfuUex0iV1NG3+muXm6XzPRt0bYId0NlmrT/yGrSNOJFvSB7w8c+4EYjBfxp7uBGFANnznk5/8tBUABUIwnKd11UF6+igPI+O8rST0fhiOHijlsab/t97ddQ0/xsKPKzB/b50bnwzRQJosC+zWr7eeowyehd8FwCidzx1uhEr6AEJh/S8/mtO222/ac2BahPniIRPHqGzsOwdD+qRlQx7m75G31Q3g/z9HDjXDp4yLVq6FR/qxg7pKtC+jSuhp8oJZSghE3RMo5kwbDOljG6xX5hfd0CUjVb3/+lPrLXqlHgfmsfBIP3bgE8bMnGxad+2oPcjp0rjlHFN/Tv8BgiF90k9dPFftBvvBPm3euHQEPNJ3BzLF79Hjx3fj5xl51E7QuFE6DWn1FCzpc182Ovtz5BDVU6DfGzBiiJZrG+b4xl/btlTb69izm2E6gPX52JIb4QOeKVkH8kHOOD2IIibWxRAc30mPsQAM0WmAFpyz6QiiYtiO87So7f/OtNyLdPTqMUJ7TnuxQTiJxERCSB/wXlYuDH/5zq1WGTnTOUmf4cvtxw8ooZDHaWgqA8nrphtka+8ByIfcKGfz4T1mjDhGj/TdgdysnmwvnAatHTK0cJJ+p17dzI4TB0W+vtUpVk9W7rbu+j59ezU4y7IgPXUap7TlyD51bpTvkX5MoBOrJ/urkm7+g+uW9MfOmiz1f7fKkulLpz3hu3z2s071ExfZkw89Y09bRU+tOns/uBMb8G+23kftdCjnnCNcTtJnKetE6SAR7GobxvZefPI9Nk7ivNP+uI+1Pfwq5G3rihsoA3+4/Vgy/sEdDzGRUNIPFlROfr5YVGX4CVwCvX5q0VRbsU5DCQUYG71LfrKXVjfLxCifZTMe6ScMkL6VY/GypfSX2NjMhVGpwCmVYEG+kdMmaswMemItMeXz6ZF+wgDpM9eLHKvX/U7k2lblO3rG5DiJPS6Qb+iEMaax2CnxGjYolxVKHumHDkv6zp1Lm7VvrSNj42dPS7CegoW1KzbyoaOFz837jG87bqawPdJP5gg36dMqZbiYPQz43QICyZin/7JaZSHoLa554gOt3Z6D+uvmJZSn5WbIoLEV9E7d8iR1hJv0aSzV+7mhylNlip7SptUfgdFlQGvd88UFejasD0c3gLlKAtZYEkuZHumHDkiYfUGsLSFP0L57F+0ZuuWJD5AAP8pErFGU/qV8lhIntMxIx8UgfRpnyBRZ8knMEkv8wi1TGhXYVsH33lFfa+9P3Bj7NiQlnXqkHwaEm/QxKHZwI6iMqFOivtngiF0JE9ripUzmLHsM7KflUTaNgFHSqwx3K/pSIdykj9xYvaJ6Unn69KRziiJvtzzxgSVhxLdQ1nk99Ys2T3m5Idykjy7o1ast+eUJiNdIsJ4kH6tnnHpi5zlG0xJaZqQjnKQPIN1R4uPQEfLsIXJFplMWzQm/TP0NdEbpsGGrU37PYbo/bi1GngiFR/phQLhJnxgFCIUoZVqYCulZsKwsoYZGPipuYJnO+a/LDeEmfeTGcDxyjKYnOXchelLd2/ISocxIR7hJ37fbHnpyyFRwQY2o1UyZBerJ9xOurukvA4Sb9AH+yGlPzLFfTMIlliPq/qJPt7i1SIdH+mFA2EnfQ6Ig3KTvIXEQdtL3kCi4GKTv4cLhkX4Y4JF+0oBH+kkDHuknDXiknzQQG+nv3rR7u5m5YpEY2BIPIQK5LRcSgfRXbdlgZnlyjEigpzXbNinpL9u01tNThAK9rNuxRclk4foVZpY4Lbd0Hi4tZixfaDYLb6An5sAhF7d0Hi4tZq5YaEl/mJ/yr7ji3Llzp47/c8rsP3rIHDx62EOIQG5HThxDqOboiePmgEsaD5ce6OnoyRPm3H/nVF+eniITB0RPx06dUHs6dOyop6cIxf4jh8yJU0omoqcjnp4iFAdET2fF54mepgnSKenLP6fOnjtn/pUekIeEgV4+lZ9Pt+seIgP0Ssx/xpz29BTRQE/Y0+kzZ3Rkxi2Nh0uM06fN2XNWT6c9PUUqRE8coqdopL/n6MnjZuf+PWbXgb0eQgRyo9V7ThpOtKo8OUYm0AstX/S0//BBT08RCvRCz5ERmT2H9nt6ilDs2L/bHDl+TO1p98F9rmk8XHrsEPvxk370QD6P9BMO5OaRfuQDvXikH/lALx7pRz480k8a8Eg/DEBuHulHPtCLR/qRD/TikX7kwyP9pAGP9MMA5OaRfuQDvXikH/lALx7pRz480k8a8Eg/DEBuHulHPtCLR/qRD/TikX7kwyP9pAGP9MMA5OaRfuQDvXikH/lALx7pRz480k8a8Eg/DEBu4SZ9nN/B40fMgeOHzYFjh/X/vYcPmF0XcC8M1bVMl7SXA9BLOEmf8pBfYssU3Wt5yUhP4Sb9aHryg3u5pQ0WPj05yzxywWVGMi4G6e894teT1HuVrXxezAbGviMHL+n9EwOXjPQv55Ygcgsn6SO7rbt3mJXrVgvWmBXyuXzNKrNx+5Y45cq12JwO17bt2WmWr12l5dlyN8VTZlIGegkn6SO3LTu3RZPnsjUrzWY5FyhTq5tAuKXbsmu7WbF29fly5X+3Mi8XoJdwkj5y27Rja5SOLLCx2GTKeTf9OK+jE6eeVq5fo7qLLU9SR7hJnzLxcc56j0zxW3Hdj2vxPQ+6pOEXX7p1Wzaqr7XPsHrjOrN9765480USLirpUx6CPXn2X7P/6OU7nMp7hZP0j5w6bgYOGWzuufcec/sdd5hbbr3V5MqVyzT86UdzQmTrlufQyWPm+OlT8nnUtYIe/eeEGT9lkrn55pu1vNy33Wbuuvtu00DKPH7mnxjpLwegl3CS/gmRW+ffu5i777nH3Hb77SrXG0VP7Tt1NMf+PRkt7aETR83hU8dUtxaHRWecd6ZDh/0GDtCyAOWip7Yd26sOk5LzCRboJZykj6ybtmwu9nSv1nsr27+GD1UdONNy/xNn/zFH/z2hvTx8GfmdaQDnfuvWxdx8yy1a1u133G7uyXOv6d67Z4wyLxeEk/SRO/L+4ceGWt/RE7K9W3zg6AnjXHXAM2ATgLyB16lHnD917nTUaNmRf46bk+fgp+h+2zYISpUprTaMTu+48w7z5FNPmhlzZiUpnV5U0kdw67duMo1++dl069ldh0rc0iV1ILdwkv4xIRMciqjKvPzqK6ZWndqmUpUqZuDfg2NUPkgD8pm3ZJGpXquGqfrN19o6plI70zH8uHDZYvNVtaqSrqYpXrKEll9SKvk/5my0tJcL0Es4Sf9fkduPv/ykciz4zjvmu7p1RE+VzajxY9TJkAaboPdXt349U+7LL0yJ0qVMST+Klyph6v7wvdm13+f0SE++KTOnqx6r16plPilSWMunbAjII/3QcezMSfPV19VUjsVLlTTVa/rsZOa8OVF2wj3xV5BE34H9Tdny5cx7hd43Nb6rZabOmh6jwUW6sRPHa7mkKfDmG1r+r82balqb7nJCuEkfX4ZNIMdSZcuYb2pUV181b8nCaP7Mkj3ph44crj6tVbs2UeXYdORZuGyJ2t5Hn3xsXsv/uildrqxp3b6t2bhts+a3dY0yQaeunU3lr6qIz/3O3CuNRJ5l3KQJSUqniUr6CBQnBpzCBQjsiAhm0rQpKqhiJYqroGza2CoJ522ZcaVxXrPPEVt6YMsNfM7EAHJLKOnzXHHJEfBjCV26d1M5dhLy56C16qykAMfDcP23NaubjJkyaforr7zSzJg3O0bLmHzcj1Yux7LVK0zq1KlNmS/KmlP/nYmW9nIB75xQ0g/Uk1td+0fk9nOTxir3v4YNUbmiJ/Ri74XjWbd5o7nm2ms13bXXXeuDfL/m2mtMvuefNzv3+XRDevKRx+pp2uwZmq/O93W1cRdXnU+q4J0TSvrWzuPS07HTJ4VAvlU5Ll+zUuWKfCF5ey/9f99uU/TzYpouR84c5p577jEpUqY0mTJnMn3/7B/N8ZMPPVs99RnQT/M1a9XCI30XxKcn9I5/K1GmpLn66qvNms3r2T1bRzadeiIdHZiJ0yab9z8opDIHL778UtQ9bJnoBn1wnd47IwiZ/H4y3wvPa+cIHdr0AL+JXXNUqFRR006YMil5kT5pEDrDjrb3gnBRkFMZfP/XnBPCmaWC+lIEdlq+IyyGNbkeqOjDImCG0lAUZdGT0Xs4nos89G5RDv9rmhO+oRqGUPkMfA/OY+hUEMrDWfK/M82FgPuFSvqk4R2Q435/Rdtz0FfRA5/NSfot27bW53deBwzjDxk5zGTNmlXTPZsvn7np5ptMxowZzawFc+McjkIfM6Vh4JF+TGgeqTOqJyHfnf5eOPIMrL9O0u/dr4/WTed1oKS/ZaPJlj2b9jTWbFxnVm1Yq3OGEBDfA/NY0Ihm1IDyPdKPCfR0VGzd2g++wM3POEl/1vw5rraBvhs0aqhpylf80uyQBgANOEZdrr/+enP9Ddeb1RvWxSAJgI+z9uqRfkwgb3wyPp7vbnpCh5wrWaaUSZ8+vZm/dFEU31iQHvkzBYCs06ZNa1586SWTIkVKHWnhOmXb9Nxv3uIFOq1JXABD++iwcNEimv/7hj/EmIYDlHP8zClTumwZTZe8SF+uI2QCGZq3bmk+LvyJeeGlF7VV9akIbsyE8UrcCJShmBKlSpq3331HBXVvnjyqwM9LFjefFf9ceqM1NJjGKgVhM7zG+fxvFDBvFnzL1Puhvlm2ZoUqhzSkJbCjRu2apl2nDur0fu/d03xU+GNVcsu2baIUzbtQcVDOsNEjtJX2yquvmk+KfKpTDThfW+kuFNwrFNLnOhV63eYNpn7DBubdQu+b5194wbz8ysumdLkyZsHSxdGcSTCkT4t0+JiR5tnnnlXCYQj5sccfM1dddZVH+n4g91BIf6cAuRFAVKtubfP2e++Y557Pp1MsX1QoLw5jrdYjmz4U0s+aLat55713VW/UBfQNnOUFwiP92IEcFy1fYip9VUX1w4jJa6+/rsPtm7af9zMgPtLHL+DjGM7Nnj27TlHSmEDWZ8V5EktDXnygG0l4pB870NPUWTNMxSqVzRtvval6er1AflO7Xl0NhLR6Cpb08V2/NP1VGtD5hdAXCqFPVLlTpuUCm556hI3xDJznOlNy4yf78jDkT2PPeQ9AumRL+nvFGFCMJfI777rT5C9QwDzy2KPmhhtuMJ26dtHhFwjnJSEwgs4IOCNt5ixZNLjltttvU2CYGBNODgH2HTjAXHPNNZr2oUce1mAo/id4gtY1hsmSCYZgMmbKaJ54Mq+p+u3XJmXKlHqfbNmyaXoqD+VRaciD0ZGG4Rwqwg033qjpypb/QisA6dzeNRQgt1BIn/vSs3s233P6LPc/cL82Wu6Tz1tvvVXnjKiYNn0wpL/7wD7tjVBBIW108OBDD5o0adJ4pO8HegmF9HE6cxfNNw88+IBJlSqVefjRR7RBem+ee03u3LnN/CULNY1NHwrpX5f1OvP+h4XMafOf5qPOUi/QX2AeC4/03YEOJs+Yqn4ldepUJu+TT5oCb7yh/uZ+0R29OWdjKj7SpzziXZhyeerpp/Wc7SBgl2Mmjde8n35WRMsK1AG69Eg/JvAzxCExGpkuXTrzzLPPmFdfe031xMjkWukEWT0FQ/pg96F90kDbrfo5J8Q2fMwolbsb6QPqkuUGwNGhcyfN88OPDbyefiAQSNeev+uLQ7jMkfDy9O4hGXruVvk4sJP/nTbT58zU9OUrVhAy8c1Dozyu23SLVyzT4c5bbr1F52YY4idNC2lJk/f5F18Q5UklECUxFHr3PXcrkefMmVN77TQ0lq5eaf53/f+0Qq2ShsE/Z8+Y8VMn6Zw2PXzejVYdLfh3C72n5fYZ0Ne1ZRcqKDtY0kc+9BrqNaivz9CiTSudBkGOB0U2G7dtibF0KBjSB7aSYziMiHikHx3oJVjSR5aQNiNEyL1nnz+UoHH6ND43bN1stgUs3Qma9P1z+jfkulFHxOrUr2d69f1D6u1aLd9ZphMe6ccE748Nf/jxRyqXkePGaCAqctx/7JB2LLB5Z574SN/aD2SUI0d2s2bTenPGr3vmlSEg8r71dkG1zUAdeKQfE9qzFoJ++plnTJq0abQxjZ+BO9QmpCFMp8WmD5b0bVrKR6/DRo1QucdG+qTdIHVizMRxZtzkCUr4dF4/+OhD5a994r+d6YHWseRK+sdFqEQzShZTuOinZueBPerYaAygEARq0yIoDeSb7gvkK/dleRUUSiCdrST//ndWI5FJQ4Q6xsV1GgOU+3r+102KFCk0IJBGBqSPMWbIkEGXsREnQKUh7eclS+hw3OwF89QBExlNuRg2B0bLwXXOE7HO88dXYeMDcguF9HlWorR5BiJJD588qsvkOE9QSuDzBEv6Fh7puwO9hEr6RPcid5Z4oR9kb5dvBeopGNKnR0LD7tXXXxdnc5eOUCF38jBP3KVHt1h15ZF+TPD+kL4dfezWq/t5PYm8bLyME/GRPmVSBlONpGEUbsjI4ToC17HLb0oonGd6xiP9EEhf0uR9+in1SfhuOmv4PW2gic9ypkfvwZK+RTCkj15Hjh2lnUHSAUYdCLzk2h7pXDrTA8pJtqSPw6LlzLymZDO5b8utyxloXSNcjMdZBj1zeu6kZTid687Kwf8oqtCHH5jUV6ZWMqYC2GvHpcffwB+k8XuvHtoggPRvlB4SQ65UDFtZUBZzr5AXLXuu5X3qSVVu2S/K6RwSwYSVq35lihbzReS+8tqr+tyBFSNU8M6hDO8TeMjoxv0PPKDPwfB+3frf6xAlZEJP0lmGR/qJA2QayvA+Mps8Y5oGRCL7Rx97TJefzpg7Wx0ShOIsIxjSB+Sh98hUFUuIZs2fq3pNf/XV6oAmTJ3s6lQ80ncHehoxZpS57rrrVDZPPfO0adK8qZmzcL76E/yWs4z4SB+Qh9HLz4oXExtKq37kuqxZTVZppFEPyIvf8kg/+OF9dNGjT28NuEM2+N+WbVqbJSuXC+GeUj9s9RQu0kevK9auMm06tNNlfcTqEG9Gntr16qhvDnwXvifjQD5fhCxzL9+JsG7NnVuFAAq+XVAjI52KiY/0LWm/+vprOk+/cPkSVbS9TrQswTLk79SlswbRWNKH0CB3lEhanh3ChMAok2t33X2XTgOwNOOOO+/U+ADm/++SHhYbcxCVS76LTfpcRxY4fEZAcuTMGSXHzz4vFiMq2CP9xAFyD4X0AXKD5AlKzXJNFtWBbUj6hgPPB4MGS/qAoWdGdajvOBCO+v4GLmv93UagPNKPHehp4rQpOs/OKCAyogH1dfVvxG/tkU7BeRsPhvS5J3YEJk2fqpvs9BTCWrpqhfl7+FDN+03N6to7DMzrkb47SEN9HzpquO55gG9CRjSkaExz3fa0w0X66JWy0QkjdnAMAdV57rtPg56nzZ4Zoz5QTvIlfQFpdOhdBExLeMiIYVFz5JC3U9CQ/oSpk/TaFxW+1JYe121Z/M+QfdFin2kaGgh2jp1rOLXKVavotb+GD9GpAGdPn1UCTqdrwf3J/0TevLoOk20UeW6cNNi8Y5t+MncemDchUAcRAukrJI11+Iye9B/0p3np5Zf0XcuUK6sVnMpJWo/0EwfoJVTSB8iHuUd65zj+x6VeoQsIxen0QyF9J3gOgpC69vDFy1Sp9lVUtLgznUf6cQP5YFOMoPzWrav6CGQF8RInZNMFQ/oW3B/9I2umEjlKlC6peVkia0cmnfBIP3aQDpvBvy1eucw0a9k8qvPYo08vJWLShYv0A0EatFqhsi92Bz4LjPMiTbIlfa5jIBgABoYgOFAO2yRmy55dN4exymGYmt47c/L538ivrSoUjqEgNISJY2zcrIkKs2KVShpsh8MjuG3tpg0abX/TzTf7ls1InmBIn3J5TnrylMuWpRzci2dmLukfc0afM1iHEhcoI1TSRw48C++EPDloiNBLeeDBBzWohdUSmjYI0ueeGArXGBFh6PmRRx9R0kcnHMg1MB/wSN8d1CPVk8hf9ST1hoPhQXTxXL58eo10pA+G9LknjojyqKPInnzUxYLvvK15dX5RHFhgXo/03YEM0AO6Qq7InWOsP8q+cJFP1dYoi/TBkL7VE2Wid/wRjemmLZtpPpab7Tt60JVUeAaP9GOCNDS+rJ7gAw7IHlmxKyLnSRcs6aMnOqG2LLsZHEGWBI7TUGMPE1uH+J97oHN4iCnjrbu3m/ukp3/tddfpSE7gfbR+JVfSR1D0dOr/2MAMlRYVRsU+yAz1SzEqaAzDGpftcT+e9wm9TqQyUZP02gkI5MddEDCRm3ZehXW1Y6TMnn17m8cef1zPdezSSYmPRgSkr7tj3XtPrKQPeNa5i+drRD/DNgTMjZ4wVp958LC/ddpg9sJ5+rxu+UMBcgslkA+wPWeT5s3MyHGj9ZlGjR+rQ8a8b8XKlbTCn3dS8ZM+78EyQGIDatSqqQbEsjDyQORsX9m6XVtfvEOAo/JIPyZYLbJl53bzS5NfTcs2raL0xF4INiaEfSSO+XsmIBjSpyHHj/D0kuvUR8pjzwkbJ1NG6oDThpzwSD8m8DGMajVq/JNp/1tHlQ96+lt6bAXeKKCyYoWM02aCIX38ypJVy7XDUL/BDxoP9NDDD2keliOzkRI2GpgPQAge6UcHeqJTA1d06d5VeQA9DRryV9TSZVbIWFmh92BIn3NE4Vf9pprYRD3dL4ay2Csfn1dN/CC/XcHKKNLTk2dUVevI8KHqT9nPhDys93cbueHdki3p05qqUbuWvrgTqVKnUsJfsCSmYjAoApMefuSRaHkg+fVC9jg4Wl8EMzE94EzD2nqMjuAKKgEtOn7lKM/995nnnnsuTtIHDMlCpo894Ws8OJE1ezYzQhy53fjnQoDcgiV93oN3Zk4r8JkIcGFFAY0gZ2MkGNKnsrI8MnuOnObKK6+SstKZLFmy6FrjdOnSm5QpU5nnX3he91nY5ygbeKQfE/TiqF+sIw7UE6MxFaRhtm3P+ZgSEAzp4ywgJNb9O8tkNIudxdiCl3rOypjAvB7pxwR6ok672TjbG9cUf0U6yrF5giF9bGLw0L9N5syZVd/swkfnBX9kCSm25/JIPyawExpn1HOnjgB7vDRq/LPK1erJyjg+0oeM8YnEbqVNk1bSXq0+D735AgZTmC8rV9ReP89Ig815b+wwz315dOkePtdt5CZZkz6KIIiPltVAaaHRc6RHToRzbIbAdwiJdc1E+dPSYi6M4CiuaWXZ7yNovtNA6DewvzpGhqUZCrIVgbQMe89dtEB3rYvK77hfIJgjwikw7NNLnpWAnOGjR+r9cerx5Q8GPEcow/vck5UG9PRodbYSOdIa5Zlw+Or0HWUEQ/rIaOuu7dp4gvxnzJ0lRD5HQbnsghWbzDzSd4NvRIbhPuot+mnZtpX58+/BZvbC+doQpfHkLCMY0kdPTFXRyyB6mPo4dtIEXaOPXnGOsT2XR/ruQAYLli02I8aONn/072taiY3Qg6S+43sCZRoM6XNffoQFu8F+6NlrXvFHcXU0gEf6sQPfzUqL33v3MK07tNNR16WrV+jUo5NwkX8wpE+6NZs26I8g4fPQlc/vzdbvbOxGTJctG99I7Ay2Rx0h+JMRCKYdKCuwfMC7JVvS5zoVHgVhOBCqnZ9xayE5QSuKtBAYcDO0vUd8c52aTuCmZBRAZbDDNcGAezvLZf6P+yeWw0Quoc7pQ+z2eawccVBuFc9J+kyLcEAoyMd5L96H96IciNwJzjnlST50Zuepl65a7pG+C1RPUtdVT9Qh+R8Zu+nJSfr0EjnQk7MRZ+Xu1DsgTWB5Nj31F6fEgXOjfI/0o4O6rb7FoSf8hJt8nKTPNt8cyDewccA0mNqS6Jvy4/JxqifRobUn7wd33IFOouIvqP/yPw3owHTo3Uf6vh/cWb1pvQbcUefRg9UTn+jNzedZv4f92PTo8vy9fXWF6857O0EZNl7gy0oVVKfJivQ9uEMNPkTSDwUYRuffu2qF47cL+gzor0sYacXGRhbxAUMhPqJzt666GxxLZiifn7D0SD9hgPQZpkSO7AHf988BpqPoidgRhqHd8sQHHNKiFUu10derbx9dS0z5fHqknzDg7Kt9+7XK8SfRF3FKyJceZ3ydl9iAPdGLxU4Z5WEpLuU3adHMI/0EwJI+S2WR46/NmuqoGL+1QuxSQvUULOz7MHrE9vI04pge5VnGT57okX5yB3ILJ+kzsoIjyZQ5s85V8cmQV62632kr1C1PfKC1SxAm85Usa6Rcfo736+rfmpMJLDPSgV7CSfrogqA/5Gl1xVrx5q1aRovyDwUQO6SEbpx6IsAwoWVGOtBLOEmfXt73DX5QHWXKBHyyHfDXQOnZJSzGBxJgwxd+1dKpf4IL6W265UnqCDfpM7pS7dtvVKZa7+UzR44cGvwabpnSqOCd+FE5fC365P433XSTmTx9apLSqUf6YQByCyfpYwBrN63XiNMxE30rJphnpgdol/WFCsrcsG2zBjpSHuUSUctOgeFuRV8qoJdwkj5yoxfik6XV0+hoc4qhgnwErxL/YfXEJ/Eul7Oewkn6lEmvnlgK6jzyBCwR5ppbnvhAPnQ/2mFPbNtLrEZCy4x0hJP0AWUuFh9n7Qlfhc6IiQm3TO37sEssvtbqlI2aiAFISjr1SD8MQG7hJH3Kw8Gz0oAWpsI/F5XQe5GPihtVXiKUGengvcJJ+pSn84sOPdEDDJwrDgXkU9079HShZUY6eK9wkj7lUc+dMgUX0oiiTNV9QJmcYyMutzxJHeEmfcDOldH8niAcdSI2MNqAX7T3Zo4/KRE+8Eg/DFAnEkbS95A4QC/hJH0PiQP0Ek7S95A4uBik7+HC4ZF+GIDcPNKPfKAXj/QjH+jFI/3Ih0f6SQOxkv6xUyfMbkmA8jyECJHbAT+ZQCqeHCMUohclE3/jzNNThEL0cvj4USV9htw9PUUmdh3YY46eOK72RONsj0saD5ceEL8r6R8UZ7h513azZc8ODyECue0S4doWryfHyAR62XvogDl77qwagqenyAR62Xf4oJL+9n27zObdnp4iEZt2bYsaOdu6d6fZsts9nYdLi01iT2fFlmKQ/qY9282slYvN3NVLPYQI5LZi8zpz5uwZs2rLBjPbk2NEAj2t2b7JnD5z2izftNbTU4QCvazbuUXs6axZtH6lmb1qiWs6D5cWM1csMpuFVNDT/LXLzRxPTxGJWSsX6V4vMUgf5WFc89Ys8xAikNvKLeuV9Fdv3aiV3y2dh0sL9LR2+2YlfRppnp4iE+hl/c6tSiaLN6wyc8RxuaXzcGlBI5qeJHpasG6FEoxbOg+XFrNXLfZIP7HhkX7SgEf6SQMe6ScNeKSfNOCRfhjgkX7SgEf6SQMe6ScNeKSfNOCRfhjgkX7SgEf6SQMe6ScNeKSfNOCRfhjgkX7SgEf6SQMe6ScNeKSfNOCRfhjgkX7SgEf6SQMe6ScNeKSfNOCRfhhwMUgfo1qyaU00LFy/0jVtsGCZTWKXGcm4GKTvpifOuaUNFq5lXsZ6uhikTz0PlOn8C9RTKAi8/2LuL/boljZScTFI/1LLaZHUv+j3X53k9HRBpI9SF29cbdbt3RYNnLuUrTzncy3bsu6iP0tikv6a3VsEW6Odo5LNWr7QjJ89zYyfM10/x86aYqYtmRdrBeQ8hhjX9dkrF5lxs6cKKHeafsZVZlJHYpE+8kFHq3dujnF+xrIF0eSJnmYsXRC0TAPT8X2m6J6yzpc7VcqcH3SZSQ2JQfr4AOr/2j3bzIrtG8281eevIbfpIj+rI4tZKxbFKVOuBSNz0mhDL5a0XJ+2eK7Ujal+m55mJsydYWavWBxrnkhEYpA+eSB2fPfybeujlaFyEn/krPf4v8TSE9C0cfjJKQtnqw3bZ5g4T/Qk7x1s+ZGACyL9pZvXmh6D+poCb78leNMUfP9d8/7HH5o+QwfpNbc8FwO0/vrKM/AsLTu3v+jPkhikTyXCcEqWL2eKly2lFVsdh1zDGDr27Gpuv/MOc0vuW82NN91k/nfD9ebr2jXM6l3RiYc8q3Zu0vM0hFbu2ChOb0O0NGDFtg3mjyEDzQ25bpTycpmbbrnZ3HbH7abad9Ulz6YY6S8HJAbpLxQSmrZ4nvmsVHFToVoVv9PwOYBV0gj4pVUzc9udt5ubb73Fp6frrzc/NmvsI56AsgAOD/1ATjT4lkqj1elQ0F/brp1UR4Byc4ueGjb9WfMlJecTLBKD9BdJ3cdRf1KsqKnb6AftsVlZLd+63tRuWF/tiXpvZfvbHz200xBYFvnIgw6XbF4T47qFz/Y2q/3RK1y9a4varjMNZeGfKn7zlbn+xhtMrptvUpvOc/99pvdfA1T/zvSRjMQg/UUbV5lR0yeZQoU/Mj+1aKLfkRE2hQyr1aqu9R093ZArl+qs56B+ZpnoI7As8uHXlgvghMDrbkDfINDuFqz3NQQ+/uxT9bW5xJbR08OPPWoGjR7mWk8iFRdE+gi0VZf2JluO7CbH/3Ka1FemNpJdHF1zrehueS4GMMbGbVros5QoX+aiP0tikD5GAwFkypzFpEufTnsiOCqu4fghE97vmeefM18K2RQvW9p06NE1yklgcKSnjHa/dzZlKpU3BQq+aUp9Wc70+XugVlJnpcYoRkwZb0p+UdZ8UaWi+eDTj7X8j4p+qgRk011OSAzSR270NlKnTi0O+2bVGw6Ca+v2bjff1Kmpcnwl/+vaKPi8TCnTY2BfV7LAOaG3rn17me9/+dGUr1rZlKlYXnoTM6N0D0EMGDlE9YieChZ6T8uvKGWv2rklmk4vFyQG6UMKf40bqbLK99LzZsmW89MsK8VfIE+uffjpJ6ZclQr6ffDYEdpQtmUgW8gePfUSQi5d4QtT4/vaet429IDanuTD9tp26yQN97KmwDtvqf77Dhusz2L1xCe6bSMNuRLlypjyX1UyDzz8oD5Lp16/x2gkRDISg/SXbV2nMuL93y70rn638qXufyD64drHxYqYshW/VBsYNmlsDD3RAF4qNt1FbAn512/cSK9ZnbsB39mkXStTtOTn0giraqYumiONet+0mT6D4GdpiBQvV9pUqFrF3HHXnfosNM6oE4HlRSouiPQRAkONE+ZOV8P8okolFQKCg5jc8lhY50gZbtctSIfxgPjSAtKvlNZ1i07t9FnKV60U77MkNi6U9HlPnAYVmd4hDSqGiZ2kbxs1Pwv57zhxwNcrFKOwhkYLefKCWeaNdwpquvTp02sLlf9pRDRq/mu0iko+ZExvZMuRvWbMjMkmlRBZ4c+LxpheuFxwoaSPnnDgk+bPMtdlvc7cK70zW6+5TmMJUkDmnXr/bnaePKR6oqHgdIjzBTRUcfIPPPyQpgdp06Y19+S5V3s+9HJI6yOUVaqnrUf3mT9HDdW0lcRJ0asMxkaSGi6U9JEJ8h06cYxJf/XVJn/BN7TRZQkA0i9b6UuVI0O3W47sUflib049oYNBY4ZrLzRVqlSa/q577ommc4AdEWNR+PPPNE06sb2bbrlF/+f+zTu2jWF7S7eslfqy1WwV26tS/WtN2/mPHsmK9MkDWVOnU6ZKqb1q5IL+LOnTCcGXYXObDu7SEUzkbe9lRwQY6aWTgxzBU88+q+WQNvC+5KUOoFvKJn2WLNeYMTMnx2ic0+hDTzuOHzDFSpfQtH2GDEw+pA9QFELeeGCX+bq2r1cTG+kjXNKu8RsUeVft2KSCDEyHsiEbS3TMszDMHNtwFxUCJ7tEPnF+baV3y7MkJdKnQkIKVEAcOyMpOIscOWMnfXqEbiMZyHnk1Anm/oce0N7imJlTVK7tu3cxV4vjuebaa3VOzNlCtoDIBosBeKTvDlr/yER7E1IfmWfMlj1bnKTf8rd26qACywIQUpe+PdXhZM+Zw3xdp6bp/mcfM3TSGHFuM/W53MgcR8OoAeV7pB8T2BAOGuKkTo+WxtPVGTLESfp/jRuhvcvAspB1nR9/MKmvvNKkSJHC5HvpBU3/4CMPR9M5wKf92ralXn85/+s6T8/IWsee3UzGTJlMTml804u09myB7vCFX0jvlbzJhfQJhsNWsCXed8jE0XGSfrp06cywyePUxznLIR1lVa1VXeWXJk0a89Rzz6q+XnjlZb3uRvrYM7p4Ot+z5trrrtXOUbbs2bUB6DYiRzno+JNiRfQ+yY70LWgZf1XzWxVCbKSPkyRYhXSvFHjdPPfiC0pIA0cPVaEhTNKhGFp7lb6tpukeezKveeb5fGoMQyaM0YpgyyQP5TI0zZz2W++9Y1589ZUoo2ToO9JJ3zaG5qxaan5u2dQULVlcn//dDwuZzFkymxtvzhUy6QN6G4zEUDYyg+Bxgjg98rbt9ptrZfVI3x2+xug6DaL8ofFP2pN79oXnzRvvvGUyCJnQSw+V9PkBGXTL3OS1112nRL9+3w4lIpyc6txvF4HwSD92QB4EXdVt1MB8WKSw+g9ij64SInjrvbdDJn1solGLJuZpIZE//h6ohEB6huKdOudzgejgkSceM+kzXK2ET4AnPm37sQNRhITd0sB33gPdJTfS5/2ItajVoJ55R/xdvpdeNK+Lf0qZMqX5tPhnIZM+eqpRv47J9/KLYktjVVfI8nn5znU30kcPtX6oq+l++PUnJf9MmTN7pB8f4iN9lMtc5G3i3EjDfMi99+fR/zNmymhade6gPVsqC8plbo1rBCrRms59+236neCyIRNGa4+eciH89j26alwB1wmuuPW23Cb91b5hmgpffxXxpE/FmrRglnnxtZf1menZQwL2nW69PXeCSB8QaIbB2O84uI+kBU3elr+1V5k70wOP9N1Bb23U9Ikm79NPqfzoEaCn67Jl1e8E9YRK+hB16y4dNQ3BZHv+OaqxAEQvkz7QsTnhkX5MQDTUXzoNeR64X2VDUB5+h9Etvr/zwfshk74FhL5h/w4lHdIHkj6+i9gBYjxeev1V9VPYLf6R4ePHnnxC8zHtxn3sMwB0l5xIn3dlPvzW23y+3QalQrh8Z249FNIHpKW+IPNNh3abbv17a1mxkT7PQEzA1dJAe+7F57XuPPL4Yx7pB4O4SB9Bs6yBnlDatOl8BC9kzbBm+x5dTMaMGU3O6/+nyx+Y70SoOLM+QwZp75R0kFPl6tW0fEYHlm9laHWtGT1jksmaLZtWlv4j/lYlQlINmvysaSN9eJ931UaO9EZ4Xlq89OwgcpZ2ZcuRQ2WTUNJ3guFOdIEzTJM2rY6OuBmOR/oxgdwWrV9lXn0jv8r9x+a/6rAgxD5c5EhPH7mGQvrofrXIlvqcMlUq07h1c9U/hJC/4Fvmqxrf6BRNbJHBHunHBHoiPY77qquuMi06tVXHvVYaUf2GDdaePiuNEkL6yJby0QfxAaQPJH381G9/dNdrBPpRB5iWbNi0sckgfg5yYV7//oceNNOXzI8RgJZcSB9fxBQHHTRGM7v06alyxdf8PuAPkyJlCiXVUEkfqD4E+Mmu/XqpLN1In5FQRu5efO0V1Qk6xUYfeOQhj/SDQVykzzUbWIchbNi/U4WPYmg1lyrvi5xt0ralGg2VBSGul2sYDP9vOLDTDBdFUxlee6uAKhzBV/Xfs1WXDlqunfu3vadIJ30q9aAxI7Rn8MLLL6kD4t2QD5U8F3P6cQTyBUv6yHqtGJSNuyhWpoQ6GCpwYFqP9GMCh2SHCpl2oW6iD3pyUxbOMVmzZw15Tp+06JqeJ/OOadKm0SWTDz76sI5Yke8WcYr0ENGJMy/wSD8mkEH77r54HqK2Nx7cpY0z5EcjN6Fz+k7ERfqrRMcE13KNoWICct//5EP9/rj08olMp55AdtSb5Er6+KyGTX7R92SFy0bx7+RB9qyaSMicfiDiI314qWFT3zN8W/c7tVds2iN9l0xuiJv0N5sqNXwRqe3EIJ3OD4Nr+/tveo0lLVQGFIOw+w3/y3xTt5Yqn6Gyx/I+Ya5IcYV5492Cep20r71ZQB3myGkTtNVGmSiACFnKjHTSp/K0EELgWXWdvTgtzmMAyCG+6P1gSJ+G0OrdW1Qv5Hn4sUd0CSBBL27pPdKPCRqj1kHgzKm3nGdkio1U4ovedyN92/B96bVXNQ17IkycPzPKMdWs75tnZHQB3dtyLTzSjw7efeWOzSLHGioTlsFZe8I3MC0YX/T+BZO+2HOzDq3VJ73/8Ufm4ccf1XTFSpfUxsf8tct0GJupTadNA54/uZA+sUVFShTT92SI3+4FAskzDRxu0qcsovOzZsuqy57xofi5lfL5kPjHzFmymCmL5si5LVH1xIJyPNIXxEb6WpFFIKxB5RpKcM4j8z+RylxjFECXNEnrt0qNb7Tnw9A/c/oEtkFWpHvzXV8gDsbKPOqVV17pb5X55vm5Hz1/0kY66VPR6v3UUJ+1/i+NogiWikalZu33hZA+5UA2TUUnjCbc9+D9GrUPscdmlB7pxwR1rdK3VVXmyNIaOaQ/ecHsBEXvM7yIE3rptVe0Mcs89Lp9O/QcxIId0NPPlCWz7tjmJAjgkX500LiFJD6ROotMOvfpEeVrIH1GCsPd04e0ew7ur40LrlMvmndoo1OUjAKMmDpBI/iffSGfjgLYZwDqK5MB6fOeyCP/W76A4v7SubP2hP7C3dPnE/0zYse17xs30oYGm/yw/wLxZtQTGo39hg9W2wvUk0f6grh6+pBGzfp19NovrZuZdeIMOU+loCVOVCzXCGRi6V+vv/7U7/c+cJ8GTpGWGAAMjcpAdKft6bMUg0hPluNQKUjL/B27nlFGpJM+RPBL6+b6rExV4Lg5z7uQL3vOnAme09cePoTfnp5HSnPPfXl060hk6ZbewiP9mMBJ1W74vcqcHj86oP4uF1myHJK52jzSoHISQHykb52fjefo0KOL2pG9RtnECaQVJ8fSPedQMPBIPzp88txgylWuoDJp3bmDykT9jMiVRhVz+qy2CBfpQ0T4LIg9yzVZdHRhjfRqlShE/5aE6OVSH5z6Up0nA9IH+BRb73sP9vf0JT2yIvjuQub0LWIjffwoI5033+qbQrN7Lrghbbq0MXb8U10mW9IXJaEIhLvt2AFTvZ7PwbWWFhIbvGBMKB+F2uCWfERIitFgjBA++Rm2pxf65+hhSvoNfvUF4eHIth3dp3PRzP0zrMp5drdC4ZAd83aca96xjdl0cLdGPWOQbNPI+UiP3qcysW0xw4EMM9EzYeiLzyIlPtd3YH43ZNLHgYjDoFfKJi9PP/+sGiEbuuAAAXpxOh0Lj/RjAmfMWnpkzvbOzOkzKkU9ZLqJ84xEhUL6ADuwU1GfChGs37tddb9+33YN4qMx8ajYB+XaoUkLj/RjAmL9qWVTlUn5ryqazYf3av2l7j/nX8b7zoehR++Tn545drPl8B4zce4MTY/O0fO6fdu0jiD/JZKO6chUqVNp8CBxBdgaDTy7WVYPIRLSO+9B3uRD+lvMN3Vq6Xt+16BelO9mqowRM84nJHqfe9I4pvztx/cbu7vfy6IP6gHLYZEx25oTUF5fOAUfWu/nhrprH2ArZDYwI/7pp5ZNdNml0/Z4nmRJ+rw4y8HY5hChEYT32pu+yGb2tyYSmR4RPUuUw3wWa+65/nah96Q194fuH/9y/tf0XOkvy6ngcHi2dXbH3XdpVCcNhnc+LKSK4LxG30qZGAQR+1dedaWSPCsB6NX+74YbdL9zWnC0+iOZ9DEOyPyp557Rd8N59xrc3+Qv+KYOMWXPkcPckOuGkEkf4kZ29GxI90Hhj3WNMPIoV7miRox/W7eWBhMFkolH+jGBw5+5bKG56967dWSJtcDdhXCZdiIqmzn9e/LkUX2GQvpW9nZ5GaNlEAXLUO++9x6N6mc/BTtM7YRH+jHBdMvEudN1OWWGjBl03wu2NaZHzhLYTNID10DgEEkfIhkgvqacpCP2iDXkpGdOGJLGnvCB3B9y/116q6weYJTuV7FVSN4G9L39wXuazt7fIjmRPvKnUYs+rrnuWtO8Q1uN7bold25dln2lyO6jooVDJn3O9f77T91GmX1eCJJFlrluzqVyZQqZYXvS4eewKwv8KKtp7nvoAZ2eYVifhoibnpIt6SO0Qp98pEog2pHgB4a0WILhG966xvwhCkCgCJnWMb0kevVyGwVp2eeYrUghNdbBLhQhY0TM6dt0uW7KpS14nOO7HxVSI0QZVIo6jRooQdq0BD7x4zH8gAVbWsY3nJ3YCIX0AZX57/GjzN157o16B5wFQY8ff1bE3HnP3UI4oZE+Q8PECKCba669RomJHaos0AF7H7DFK/d35vVI3x2MUNEgs1uqAqKwmTt+4ZWXonrkoZA+IBaFtd15n/Gt/weM/DDCQ+OZ+Wg3MvdI3x0QJb83YbedBnkeuM/0HNzPPPL4o0q6+CPrzIMhfWTduHULJXLA6Bm+LlPmTGpPNAQLvv+OEj46QGctOrbVQFz7DIzaME9NLzNwqgaQL7mQPsDPNGnbwlwrDWYro8efyqs6wK7Y5hbbQC7Bkj6+8HvptaMP9EJa9AQf8Z17EFQJaQfaCs9MR5YRV3r79PBpnDjTAPIl2+F9Xp75KwIxmC9jaRFBGANHD9PvfPKTn7YC2I0qON9OWnUdpKeP8jAyzttKQu+HyPKBUh5DML/17q5r+Gk8sJEC8/dWYXxiYJAmywK79eut5yiDZ+F3AZjbdj53uBEq6QMIhXlbfjSHnh2BiQwJMl88ZOIYlY1952BIf/7aFbr3PrJGDujGiYGiH+Yb3X4W0iP92EFdHTdrqo4qoSdkjKNmORiBYs60wZI+utUhx+ULNZCopdRj1ipTNroO1I+FR/qxA59AdHbrrh21BzldGs2cY+rP6T9AMKSPD2FUDHsC1tdZ2/pz5BAt1zYkKB/9MFyN/2L7678njPL5Ngjf5R00TzIifd4Xv0ePH9+NnPBH2gkaN0p84NQoPQVL+tyXjc7Qh/V1Tj0NGDFEy7V6ioG1y9SW4ZPZK923v+Zc8p3TF0DOVFSIIiai7zgF+E56DA3E1ovhnE1H65nhMM7Ts7f/O9NyL9LRq8ew7DntHQfhJBITCSF9wHtZudgWJvJFRs50TtJn+HL78QNKKORxGhpyiF0361WWVvbkIz3lbD68x4wRB+aRvjuQs9WT7bHRoA0cMXGSfqde3cyOEwdFvr7VKYEOke/UVRq2UXXepTdo00HwW47sU+dG+R7pxwQ6sXpiMxjOufkPrlvSHztrstT/3SpLen2B9uRmRwA703IDng1iQpfcA4JzIxvuQd1hHpp4geT2gzv4N5/v3qgy5xyyctZ/J+nzGxUsbWVtPx0e9GLvxWdcfk/15GJ/TlBvuL+bLVEG/pAtlZPtD+54iImEkn6wgPT5+WJRlf4ELhsR/dSiqbZi3YgiGGBsjIrwk720upnvp3yGIz3STxggfSvH4mVLmTbS6+Q3wulFaGPUJU98IN/IaRN12B89Vfz6Ky2fT4/0EwYImble5Fi97nci17Yq39EzJiuBuOVJTHAPAs5+adlMR3kIOuNZ6PkmB9IPBpb0nTuXNmvfWmO4AgPtwgFrV90H9NGOFj7XTscxhe2RfjJHuEmf0QyGi9nDgN8tYL6eZV1fVqusvQW3PPGBVnbPQb71xZSn5WbIoLEV9E7d8iR1hJv0aSwRFYw8VaboKW1a/REYXaK01j1fXKBnQ7Q/ugHMVRKwxpLY2FZjJHWEm/QZXWFfEGtLyBMwJE/P0C1PYgF9QWYEnvETvNQR7n1d1qy6dI2epVu+SMTFIH0aZ8RvoSs+2YIdOYVbTzQq0FXB995RX2vvT9wYm8iF+/6JCY/0w4Bwkz4Gxe9JE1TGHDDrSNngiF0JE9ripcypi+eaHgP7+TapkLJpBIySXuXF6O1cCoSb9JEbq1dUTypPn57inFOMB2zoQ3wLZZ3XUz9fmf4AwssN4SZ9dEGvXm3JL09ATEVC9RQKuAfzyKwGwebQKct4py6aq7EEbnkiEeEkfQDpjhIfh46QESsi2M2PnfPCrid/A51ROmzY2h4rbVjzfzHqSWLBI/0wINykz7whhMLQHy1MhfRWWFaWUEMjHxU3sMz45r+SMsJN+siN4XjkGE1Pcu5C9KS6t+UlQpmRjnCTPvbEnH+0ui+4mI0o7Mx5f3r4SYlIQLhJH/jkdN6eLraciNGIur/oyy1uLdLhkX4YEHbS95AoCDfpe0gchJ30PSQKLgbpe7hweKQfBniknzTgkX7SgEf6SQMe6ScNxEr6m3ZvNzNFiRiYh9CA3CARSH/Vlg1qDG7pPFxaoKc12zYp6S/ftNbTU4QCvazbsUXJZNH6lWaWNALc0nm4tJixYpHZLLyBntgGnEa1WzoPlxYzVy4yR91If/v+3WbhupVmyYbVHkIEclsnPUhIf/2OrWaRJ8eIBHrauGubOXPmjFkr5O/pKTKBXhh5hExWbFknxL/KNZ2HSwt699v37VY9LZNG9GJPTxEJdrw9eebfmKR/7ORxs+vAXrP74D4PIQK5HThy2Jw7d84cPHLIk2OEAr0cOurT04HDnp4iFejl8LEj5tx/58zewwc8PUUodh7YY44cP6b2tOfQftc0Hi49dor9cMQg/aNC+jv371ED8xAakNt+IXslE/n05BiZQC8H/aS///BBT08RCvRyyE/6kImnp8jEjv27o0gfcnFL4+HSY4fYj0f6iQzk5pF+5AO9eKQf+UAvHulHPjzSTxrwSD8MQG4e6Uc+0ItH+pEP9OKRfuTDI/2kAY/0wwDk5pF+5AO9eKQf+UAvHulHPjzSTxrwSD8MQG4e6Uc+0ItH+pEP9OKRfuTDI/2kAY/0wwDk5pF+5AO9eKQf+UAvHulHPjzSTxrwSD8MQG7hJn2c38HjR8yB44fNgWOH9X9dznQB98JQXct0SXs5AL2Ek/QpD/kltkzRvZaXjPQUbtKPpic/uJdb2nAg6v6iT6vXpEacF4P09x5xysn3eTHltO/IwUt6/8TAJSP9pCaoUIDcwkn6yG7r7h1m5brVgjVmhXwuX7PKbNy+JVa5ch4nFtf1bXt2muVrV2l5ttxNcZSZ1IFewkn6yG3Lzm3R5LlszUqzWc4FK9PAdFrmru1mxdrV58uV/0MpM6kBvYST9JHbph1bo3RkgY3FJVOuBSNz0sRpe4IN2zarDVudrtqwVu0xmPIjBeEmfcrExznr/cr1a+KVE9eCfR7SxaWrdVs2RtPT6o3rzPa9u4IuPxJwUUmf8mjRnjz7r9l/9PIdTuW9wkn6R04dNwOHDDb33HuPuf2OO8wtt95qcuXKZRr+9KM5IbJ1pqUCHz/zj5z/R1ulx0+fMkf/OREtDeDc+CmTzM0336zl5b7tNnPX3XebBlIm+QPTXw5AL+Ek/RMit86/dzF333OPue3221WuN4qe2nfqaI79e9I1D/Zx9N8T5h9zxpw6d9ocOnksmkNBf/0GDtCyAOWip7Yd26sOk5LzCRboJZykjz01bdlc7OlerfdWtn8NH2oOi/wD0yNj8hwVHdLrC7xuwbNSB7AfeoX4vSMBtkdZlFH7+7om1003mVvlvtj0Qw8/bMZOmmAOnTgaLX0kI5ykjyzxXz/82FDrO3q6+ZZbzN3iA0dPGKf6CMzDM2ATgLyB192AvsGhk0ejvQN2yfdSZUqrDVM/7rjzDvPkU0+aGXNmudaTSMVFJX0Et37rJtPol59Nt57ddajELV1SB3ILJ+kfEyfyW7cuRlRlXn71FVOrTm1TqUoVM/DvwVGVT5/h6CGVef/BA83X1b81739QyFT9ppqQ+0RN56zUDCkuXLbYfFWtqqleq6YpXrKEll9SKvk/5mxUussJyCicpP+vyO3HX35SORZ85x3zXd06oqfKZtT4Ma5kASHwDENHjTAt27Y2Net8p3qjN2FthXxTZk4XPX4teqplPilSWMunbEglsZ1tJACZhJP0j505ab76uprKsXipkqZ6zRoq35nz5qgN2XTIVsle9DR24nhN8/Ovv+h5nsum4/ms7dFA++rrqqbQRx+KjmqbidOmmMOnztsen/skbd8/B5gqVb8yNb6rZR57/HF9lsFD/3Yls0hFuEmfBlDxUj6/VKpsGfNNjerqq+YtWRhDT+iI9ENHDlef1qpdm6hybLpAQPTw0hcVvjS169U1G4SrrN1RJujUtbOp/FUV8bnfmXulkcizjJPGGfcLLC9Skaikj0Cp6CBQuAgMpzZJKr3cwhQrUVwFZdPGVkk4b8uMK43zmn2O2NIDW25clSChUKNPIOnzXHHJEfALSV26d1M5dhLy59BeoVRyey+MYO3mDeaDjz7SdFdffbW2UPk//dXpTcfOnVT+Vkbk434nz/2r5S1bvcKkTp3alPmirDn135lo979cwDsnlPQD9eRW1/4Ruf3cpLHK/K9hQ1Su6IleR+C96Pnj5B97wufwQbp06cwDDz5oFq9cFtVTIR+6tXqaNnuGpq0jPUV6lW7PkdTBOyeU9JGH1VFsejp2+qQQyLcqx+VrVqpckS8O33kvGlzT584yxYp/blKlSqXp77v//qh6YNPx/55DB0zZL8ppGmzvtttv8/2fIYPp2ad3tB6/vp/YLvWFo94P9TXt38OHJhvSj09PyBgZlShTUuW5ZvN685/IipFNp55IRwdm4rTJ2slBjuDFl1+KuoezXEBe7G/G3NlaNumvufZa9YGBjXP0YfVUoVJFTTthyqTkRfqkQegMO1oBIVwU5FQG3/8158yMebNUUF+KwE7Ld4RFy5frgYo+LAJmWBpFURY9Gb2H47nIQ68Vp8j/muaEL7AJRfIZ+B6cx9CpIJSHs+R/Z5oLAfcLlfRJwzsgx/1+B7/noK+iBz6bk/TpEfL8zuuA4cRFK5aaRx97VHqL32gF5h4D/hpoMmTMaLJmvU7nxNyGvdDHzHmzPdJ3geYR2aqehHx37vc5msCRE+Ak/d79+mjddF63oD4OGTFcHc7/rv+fafhzIzNy3Bgzb/FCs2bTerNj3+4YZQOIg1EDyvdIPybQE0Pw1n7wBW5+xkn6s+bPcR2qxU81a9ncXHnllSZFihTm9QKva/on8j6h5TvJhLrRtUd3vf7WO28bYm8oc+CQv0zmLJm18c2Ip7N3CnguSIXeK3mTC+kjG2wAH893Nz0hY86VLFPKpE+f3sxfuiiKbyxIjz9jCgD5pU2b1rz40kuir5SmwJtv6HWnniy4L3hJGgZZs2U1uW7KZXLmzKkNwMB7AMo5fuaUKV22jN4neZG+XEfIBDI0b93SfFz4E/PCSy9qq+rTokXMmAnjlbgJtGAopkSpkubtd99RQd2bJ48q8POSxc1n0nL+tmYNDaaxSqESMLzG+fxvFDBvFnxLW8DL1qwQZ+czBNIS2FGjdk3TrlMHdXq/9+5pPir8sSq5Zds2UYrmXag4KGfY6BHaSnvl1VfNJ0U+1SEdDNBWugsF9wqF9LlOhV4nPfP6DRuYdwu9b55/4QXz8isvm9LlypgFSxdHI+dgSB/w3gR9UXEpn3eEiN7/4H3N23/Qn66V1SN9d/BDFTgoGku16tY2b7/3jnnu+Xw6xfJFhfJm9Ya10Rx5MKRPnUNHxGdky55NhyppDFP/D/p15nR+TnikHzsgy0XLl5hKX1VR/eR7/nnz2uuv6zD+pu3n/QwIhvTxYx27/GZeeuUlnR4jIJP0jz3+WDTS53O3fH/q6aelcZ1BA77QO+c5LCG1Ertl1Md5D3SX3Eif95s6a4apWKWyeeOtN1VPrxfIr8PrBKdauQZL+viuX5r+al7Ln18bzegKWVIm1516t2BUp3GzJpqubYd2ouOXTZZrrvFI3w17xWGhGEvkd951p8lfoIB5RHqXN9xwg+nUtYsOv+DUECQBKgSckTZzlizy/XYd9gIYpm39IsC+AweYa0TwpH3okYc1GIr/CZ5gThPDpDdL9GbGTBnNE0/mNVW//dqkTJlS75MtWzZNT+WhPCoNeZq1aqFpaG1TEW648UZNV7b8F0qspHN711CA3EIhfe6Lc3g233P6LPc/cL82Wu6TTwJ7mDNyGn+wpA8gFec7QSbM05MXIvJIP3jSx+nMXTTfPPDgAzq8+/Cjj2iD9N4895rcuXOb+ULYpLHpgyF9dNfnz/6ahmAyDkbEiAfAdpyNvUB4pO8OdDB5xlT1K6lTpzJ5n3zSFHjjDfU394vuVm9YF61xFgzpW3D/M+Y/aYgv0vSBpM+9Z82fa1Jfmdq89XZBJQ3uBbHMmDvLPPOcz8Y/+OhDvY/TNtFdciJ9/AxxSFmzZtWprGeefca8+tprqqdn8+XT6UmrJ+QUH+mD3Yf2SSd0t/q9c2JLw8eMUlnGRvrIF7vNmDGjefX11/T7U08/5ZF+bIBAuvb8XV8cwqVi8/K0iiF6eu5W+Tivk/+dNtPnzNT05StWEDLxzUMjWOvc+Fy8Ypn2em659Radm2GInzQtWrfUvM+/+IIoTyqBGA1LW+6+524lcoZk6LXjLJeuXqlDpVSoVdIw+OfsGTN+6iQdnqOHz7vhWBmleLfQe1punwF9dWjO+Y4JAWUHS/rIhyHIeg1883gt2rRSp48c6elt3LYlxtKhUEjfCQyISv/wI4/o0BeBe26V2iP9mED+kDYjRMi9Z58/pEf+nzoJGp8btm422wKW7sRH+lqm2Ey16t9oI6Jz967mV+lxfPDxR6bQBx+Y7xv8IL3VpbGSkEf6McH7Y8MfigyRC9MkBKKip/3HDmnHApt35gmW9C25c33e4gWaPpD0seW/hg/Ra9XEJ/4rdYDnYSQyU6ZMJmOGDCaD4NHHH9cRB2fjg2fnOZMD6SMvCPrpZ54xadKm0cY0fgbuQCYsjWNay6YPlvRtWspHr8NGjVBZupE+/8MhjDCgk7mi0xNn/jWPP/GER/qx4bgIlWhGyWIKF/1Uf08Zx6ZDkyIshG/TaoUWwUya7gvkK/dleRUUgiedrST//ndWI5FJQ4Q6rWqu0xig3Nfzv65zagQE4jAhfVqGKI1lbAyNUmlI+3nJEiZ79uxm9oJ56oBLlC6l5WLYHBgUB9c5T8Q6zx9fhY0PyC0U0udZ6/7wvT5D3fr1xKkc1WU+nCcoJfB5EkL6yBCjavhTI81XoXIlfX+GIgPTeqQfE+iAulG6XFmVH71y9IPsWWJH/QzUU3ykj06wE2yHOk1D7Kabb9Z5Yuo0+RjZoofoRkQe6ccE7w/J2tHHbr26n9eTyMvGyzgRSk8fxEX6dDjsypo27duZ/QcPaOAf3xnJw/899PBDotc7zfot0ef1efZkRfqSJq/0qtOkSaO+G9nh93hnp1wAMg6W9C3iI33skcYY1xs1/lntlRECj/RdMlkgIFrOzGtKNpP7tty6nIHWNcLFOJxl0Kqi505ahtO57qwc/I+iCn34gQ6PQca20qugpcffwD8n9nuvHtoggPRvzHWjDrlSMWxl2XN4v869Ql607LmW96kntadPVC1zSAQTVq76lSlarJiW+cprr+pzOytGQsA7hzK8T+Ahoxv3P/CAPgfD+3Xrf69DlJAJPUlnGaGSPu/O/GG3nj00D3LYvGNbrEbjkb47qK+TZ0wTYr5J5fjoY4/p8lOifnFIEIqzjPhIn3qGbohXIc0PjRpq4J6tf42b+uYZ33733RjTNMAjfXegpxFjRpnrrrtOZfPUM0+bJs2bmjkL56s/QZbOMhKT9GlwdO/dUxtxxCo9KaRGugqVK+p90Q97K0D8TI0mV9IHvFuPPr21scv74n9btmltlqxcLg21UypTqydknJikT96lq1eYHDly6NQyJI6PRH9MBxG9D7dxLtDuKCfZkv6u/T7hMffCGtRbc+dWIYCCbxdUw3AqJj7SR7gYAXMrzNMvXL5EFW2vowACBsnfqUtnc1Ye3JL+gw89qOSOYZGWZ4cwITDK5Npdd9+l0wBs7kBLm14U8/933XWXbsxRvuKXms9WjISCMkIN5EMWC5ct0RGQHDlzRsnxs8+L6Rwk72LTh0L6yJQWNI0kGlKPPPqoNoYwuNieyyP92IGeIHmCUrNck0V1YBuSTGfZ+geCIX3A8OIVKa7Q5XeMVHGO+xyQZ6Oech92bAvs/XikHzuQH2viP/2siI4CIiPmjVnJQgDyHoeNJybpY1djJo6LumeOnDlMjz966bA/05TYeObMmZXg0KeTUNBdciJ90uDfh44abt4r9L72+HnvrNmyaWOa63TeSIucEov0+STvp58V1Wus4Z86a7rqbeyk8RpkDv/0HdhfR2awvUA9JV/SF5BGh95FwMzjDxkxLGqOHPK2giYtpD9h6iS99kWFL7VCc92Wxf8M2Rct9pmmoYFAy8tew6lVrlpFrzFvxlSAs6fPKgGn07Wwin4ib16dV2MbRZ4bJw3o9fLJ3Hlg3oSAskMhfYWkoVJTeWhhElnPEhLetUy5slpJbcULlvRJD9Foz0MaOw8+9JAGPjLc6ZbewiP9uIF8mHukV86a68elXqELCMUp2/hInzoJGZQoXVLT/Pn34Kg0ek3qwsOPPKz7KqyVe3mkHzzpA+SDTVHnf+vWVX0EsiKYFwK26RKT9PGFS1YuU2K/VnqLjC5gP+q/RLeWhMp9+YXq2qkv/k9OpA9Ih83g39iPgmWRtvPYow+NJR+ZIuPEIn02Q8LfM+LCNbvnghvSpU8XY8c/ykm2pM91DABng4EhCA6UwzaJ2bJn173crXIYpqb3ztBX/jfya88dhSNQhIYwMQS7fKJilUoabIdjJLht7aYNGm3PvCfESJ5gSJ9yeU568pTLlqUc3ItnxhjZ9pTnDNahxAXKCJX0kQPPwjshTw4qJj0GNmghqIXVEpo2CNLffcAXQ/F77x7aw2H1BM/BwXuf9A9luTlRj/TdYR0UclM9iRw5VkgdRxfP5cun10hH+vhIH6A7Gg6kgQjs3hV8EsSH/p959tloxGLhkb47kAF6QFfIErlz0ItDVoWLfKq2RlmkD4b0uSeNLjolHKs3rtX0eZ/Mq74DfdlODNM8TNlgP8QeEUVun4eoffK5bR1LXs4lp54+jS+rJ/iAA7Ln/dnxkPOkC5b00RMNL1uW3QyOlRQEjls9wRV/9O9rWndoqz60ZdtW2uNv3b6trpiioc1+GR27/qYrq5y2x/MkW9LHOHBY9X9soFuHYlRUZob6pRgVtHMIy/a4H8/7hF5n7T5DKvTaCQjkx11QJJGbDLGQhnW1Y6TMnn17S6vat1tZxy6dlPhoRED6DKGxzjk20gc869zF8zWi/6qrrtKAudETxuozDx72t04bzF44L0ZvKiFQBxEk6SMP8GvzpqZJ82Zm5LjR+kyjxo+N2tGrYuVKWuHPO6n4Sf+IEPeQkcOihszYD4E1wux7UL1WDd3elSE0ZB0oM4/0Y4LVIlt2bje/NPnVtGzTKkpPw8eMjIoJYR+JY/6eCQiG9K0zYfkf6eo3/EGHFP/8e5B54AHf0sB+g/5Ugg/M65F+TCBP9u5o1Pgn0/63jiof9PT3iGGmwBsFVFaskHHaTDCkT+T/lJnT1Gd9V6e2KVf+C01PoHANIemvv/1WVw4ReAuJEVNwVZqrtJPStcfvas+ffe4L6KPRge+y9myB7pIL6aMnOjVwRZfuXaOG1gcN+Stq6TIrZCyZIqtgSJ9z4yZPkAZDNbGJerpfDGWxVz5yrSYNCZaDk47ROsq3gMjpDBGrw86JG7dt1k5nYJ3j3ZIt6dOaqlG7lr64E6lSp1LCX7AkpmIwqAlTJ+uyMWceSH69EBCkyx7IrHVlesCZhrX19NIJfEMRtOjYlzzP/feZ5557Lk7SBygZ43NudWqRNXs2M0Icud3450KA3IIlfd6Dd2ZOK/CZCHBhRQHE7GyMBEP6DIu1lpZr+vRXa0AT0xqUZ8E89J133aVDahiTM69H+jGx7+hBrV+sIw7UE71xVkNs23M+pgQEQ/oA+c9aMFeXotoyGQ0j3gQ9c93NiXqkHxPoiQA5Nxu/9rprTU3xV6SjHJsnGNLHL6CLq65Ko0iXLr0O32fJkkXtiVghNic7KmWhA3wYHSLIxt6fteClypY2W6UeuXUuyJdcSB87oXHGqK2VjwV7vBBNj46snvgMhvQhY3wi+kibJq36P/TEdAt6uuKKFBrA7RyRs+AePNfLr7yiUwz08N3uQb5kS/oIiSA+WlZsMdm6XVvtkRPhbJUUWAbfqcisaybKnxgAeqQER3FNFbHfR9B8p4HQb2B/bakzVUAr2lYE0jLsPXfRAt21Liq/436BgAxxCgz79JJnZb57+OiRen+cenz5gwHPEcrwPvckuI6RB+byW4kc+aEOnomGDXCWEdTwvl830+fMUgKnLJZ+WUybPdPMWTTf9Z090neDb0Rm6aoVWm/RD0OCzMPPXjhfG6LMFTrLCJb0yYNNEFPCL6v1krQjxo5S/aHr2OqkR/ruQAYLli0WGY7WIVx2v6MHiY9Azjh2ZxnBkD73pVOCPQHsiR1DAfbErnJEnTt9E0TASCS/u/DnX4PMnIXztIcbuMrDgjzJhfQt8N2MijAN2bpDOx11JaqeKV07CgYsn8RH+qRbs2mDBub5fJ3Vk8//sbEbMV1WT4Hgudm/BF0R/O32HpxLtqTPdQwIBWE4EKqdn3EqzA20dEmLUwNuhrb3yAEtS9MJYmt1URnYyCbwWmzg3s5yafVx/2AranxALqHO6UPs9nmsHDF4t8rpJH2mRTggFOQTdS/5JAaAMmhA+YLPzoPzzh4k+dCZnadeumq5R/ouUD1JXVc9UYfkf+qOm56cpM+P6XCgp8BGHOC72pKjXpLOmcamo/4yF8qBc6N8j/SjA1tQ3+LQk7O+O+Ekfbb55kC+0RoH0hHBPuKyJze96nOgzzjqib6fPJudh66bjH5wh/eOir/A78n/NKAD0yE30pb0/+DO6k3rNVaCOo9erNz5RG8+PUXXkdUT9hOoJyfQWWx8QBlWT19WqqB6Slak78EdyC1U0g8FGEbn37tqhWOuvs+A/rqEkVasG1EEAwyFXknnbl1Nr75/6Hw/5fMTlh7pJwyQPsOUyJE94Pn51I6iJ2JHGIZ2yxMfcFj8kBKNvl59+5ja9XwbWfHpkX7CACGzex5y/En0xbA88qXHGV/nJTHAPRh5ZFOf3qJTpkZ5lsHDhlz2pB8sLOmzVBbZ/NqsqY7S8lsrgYF24YB9H0aP2F6+z4B+5vkXntdnGT95okf6yR3ILZykz8gKw8WZMmfWuSo+GfKqVfc7bYW65YkPtLQJwmR+mvl/ys0onwT8EdziliepA72Ek/TRBUF/yNPqipUUzVu1dJ1TDAYQO6SEbpx6IsAwoWVGOtBLOEmfHiZbHqOjTJmAT7b8IiW9erc8iQX0BZkRqZ5eerA8A/okQJB9491GQCMV4SZ9euDVvv1GYyO03ssnm+sQTBvuxhGNCt6JuA18repJ7n/TTTeZydOnJrHGmUf6iQ7kFk7SxwBYt02065iJvhUTzDPTA7TL+kIFZbIJBYGOlEe5RNSyU+DF6O1cCqCXcJI+cqMX4pOl1dNonVNMqEzJR/Aq8R9WT3wS73I56ymcpE+Z9OqJp6DOI0/AEmGuueVJTHAP5pGxYVYqjRWdEssUuClMpCOcpA8oc7H4OGtP+Cp0xvLtcMvJvg+7xKIna3uThPBZhZC09OSRfqIDuYWT9CkPB09EMS1MxT8n4p2rigvko+JGlZcIZUY6eK9wkj7l6fyiQ08MA0abKw4R5FPdO/R0oWVGOnivcJI+5VHPnTIFF7MRpfd32rMgKREJCDfpA5ZOusnpYtV9Rhvwi/bezPEnPT15pJ/oUCcSRtL3kDhAL+EkfQ+JA/QSTtL3kDi4GKTv4cLhkX4YgNw80o98oBeP9CMf6MUj/ciHR/pJA7GS/rFTJ8xuSYDyPIQIkRs/lkLlh1Q8OUYoRC9KJv7GmaenCIXo5fDxo0r6DLl7eopM7Dqwxxw9cVzticbZHpc0Hi49IH5X0j8oznDzru1my54dHkIEctslwrUtXk+OkQn0svfQAXP23Fk1BE9PkQn0su/wQSX97ft2mc27PT1FIjbt2hY1crZ1706zZbd7Og+XFpvEns6KLcUg/U17tptZKxebuauXeggRyG3F5nXmzNkzZtWWDWa2J8eIBHpas32TOX3mtFm+aa2npwgFelm3c4vY01mzaP1KM3vVEtd0Hi4tZq5YZDYLqaCn+WuXmzmeniISs1Yu0r1eYpA+ysO45q1Z5iFEILeVW9Yr6a/eulErv1s6D5cW6Gnt9s1K+jTSPD1FJtDL+p1blUwWb1hl5ojjckvn4dKCRjQ9SfS0YN0KJRi3dB4uLWavWuyRfmLDI/2kAY/0kwY80k8a8Eg/acAj/TDAI/2kAY/0kwY80k8a8Eg/acAj/TDAI/2kAY/0kwY80k8a8Eg/acAj/TDAI/2kAY/0kwY80k8a8Eg/acAj/TDAI/2kAY/0kwY80k8a8Eg/acAj/TDgYpA+RrVk05poWLh+pWvaYMEym8QuM5JxMUjfTU+cc0sbLFzLvIz1dDFIn3oeKNP5F6inUBB4/8XcX+zRLW2k4mKQ/qWW0yKpf9HvvzrJ6emCSB+lLt64+v/tnQm8TVX7xzNFZSpTb4WSaB40qP/7SpHQpNKk0RQVUhRJmudUkooipUIyJAmZ53me5ylCZtJArf/zfc5Z177nnnvdc93D4T7P5/P7nLP3XmvtvZ9nree35u2WblqbDJw7nLW84HPNXb30kD9LZpL+4g2rBWuSnSOTTZw3ww2bNNYNmzxOf3+eONqNnT01agbkHIUQpJZBOT9pwUw3dNIYAemO1d/U0jwakFmkj36w0aL1q1KcHz93ejJ9Yqfxc6Yn0yn/D4Rg2Alie9Lan+4YSXNasnBHEzKD9PEB5P8lG9e6+b+scFMX7b+G3saJ/ryNPCbOn5mmTrmWXp2nFZbzY2dNkbwxJlymx7rhU8a7SfNnuanpTD8RkBmkTxyIHd89b+2yZGmonsQfBfM9/u+g7BS+lhoiw4+eMUnLsH+GEVPFTvLe0cImKg6K9OesWuK+7NPDVb/lJsGN7ubbb3W3332n6/5DH70WLc6hALW/HvIMPEu7zz4+5M+SGaRPJqLg1H20oavdoJ5mbI65RmHo2K2LK13mLHd6qTPcaSVKuP+ceopr3rqlW/TrfuIhjYXrVroF4uQoSFSEIKb5a5encCac+6Z/b3dq8dMkveKuxOkl3ZlnlXbNnm3hFkgawbBHCzKD9GcICY2dNdU9UK+2a9SsachZLA3pdqHo+s0P3nVnlintSp5xeshOp5ziXn33rRDxhNOgxTB3zVLBsihY6mYH8u+CdStchy6d1EaAdEuJnV5p+4akufyIcj7pRWaQ/kzJ+zjqex6837V57SVtsXldzRM9t37lRS1P5Huv20+/+VIbDZFpEY842HD2qsUprgdBmSUcZZbyF3mdtPBPjZ96wp1y2qmueMkSWqbPu+B893W/Xm5OlPsnKjKD9GeuWOgGjRvpata6y73+/jt67MsU5aRZqxaa37HTqcWLq8269empZSUyLeLh1+YJ4ITI676CEb3sLdU84sNOXxZqMN39wL3qa4tLWcZOl1x2qeszeEDUfJKoOCjSR6EfdP7YFSlW1BX7z8kuZ66cTqKLo3tPHN7hIwoK2Vsfvq/PUufRhw/5s2QG6VNoyJD5CxR0xx1/nLZEfCbE8UMmvN9/K1ZwjwnZ1G5Q333yZZckJ0F8aqBtP27vHqxf11WsXMlVu/lG9+gTTVx3IXeIhEzs70ehGDh6mKv7SAP3SNPG7o5779b077r/Xm0d+XBHEzKD9NEbrY2cOXOKwy6pesdBcG3ppl/cU889o3q8rlpVrRQ89HA992XvHkoW3jE++8oL7u4H73O1Hro/CffwW/sB90D9Oq7lC6312QgPQfT6qb+r91hDtdPNNW/T9BtL2gvXr05m06MFmUH6OPJ+Q39SXV1dqaKbvXr/MAuVYvTJtTvvvcc1bNpIj/v+PDAZUaNbyB4S+UoIuX6jR9Q2nPcVPQ/S1kqB5I+Pun7majesrz6J42A44lKuP5SKXJ2GD2v5vPCSi/RZOn3VVSsLwfCJjMwgfci2x4C++v631LxVj71+yft3iH24Rnlp0PgxLQMDRv6cwk5UgOdIme7c4ytpODVwL771ml7zNgf4ym69e4p/rCOVwfvcnffVEn8Xwp333SPc9kmSvfQZBG9IRQRbNnqyqTurbBl9Fipn5AmfbqLjoEgfJdDVOHzKOC2YjzRtokp456MPlJiixfFA+b72FO26B+EgP3CgsIDwC6SF9X6nj/RZHn2yyQGfJbNxsKTPe9IyISPTOqRCRTdxkPR9peYNIf91v2/RYQAKhS9oZNafJ4x2pUqXdnnz5Qv1CEjNmDjHn3C8tAzfUkfo7+lrvYt+Xe1W79jkhowf5XIIkUFAkcMLRwsOlvSxEzocOW2iK1S4kDtXWmc+X3OdyhKkgM47fd3Vrd+zTe1ERUH1vXyB3pOKG2GyZcuWDDly5NDzZ5Q+Uytw2Id4tH6w05qdv7nvBv2gYZo89aT2LKSnjBxpOFjSRycQ8A8jhkjeP0EqvzckVbq4Duk3aPKY6pGu29U7Nqp+KW9B4qJM9Rnyo7ZCvW3KnnNOMpvr/eSYsvtFr+5a2SYcuPHWW7TnLdJG3GPO6iWSX9a4NVL2mrZoruE/++bLLEX6xIGsydPZc2TXVjV6QV+e9GmEHH/88VrmVm79VXs2fbkgDd8jQE9vdWnkeN1f9b//aTqE9ffDr7V6sY1ez5Mnjzux0Emu4IkFFQUKFnRNWzYP9YqGwwMqfdhp3e4tWlkgLo2oLEP6AEOh5BVbfnXNW4daNamRPoYh7OJwgSIuhQBFRobD2BjFEx0FiW7m1Lq7yBA4WVqwOL8OUrvmWY4k0idDQgo4IRw7Ga7E6ae7YienTvovvPlq1J4MdDtu9jT3Vd9e2l2G3kmfVsdxUmioSNAt7dMMAiLrK87NSD86IGt0oq0JyY+MMxYpWiRN0m/36UfJhl6Ad/7fDxskdvrWffP9d0noN3Sge+61lzVuzVp3a/4m7WB8HA29BoQx0k8JyhAOGuIkTw+WcnBC3rxpkj56p3UZmRa6fu7Vl1zOXLm0QnZ1pWs0/EXlLklmc/RPhf2hBvX0OuThK3W333On3is1G3EeX/iItF4Jn1VIn8lwlBXKEu/bf8TgNEn/uOOOcwNGDVWfFkyHcKT1ZKsWqr/cuXO7qyr8T+11zXWV9XqQ9KnYYVPC0jsdmivDnKYxOr8irTlS8Ba9A8TNcqTvgQKfeOZpVUJqpI+TRJmEu656VVfh2mvcw40fdb0H/6BK8wrGMNT2mjzdTMNddmV5KThXa2HoP3yIZgSfJnFIl65pxrRvuq2Gu7bKdUmFkq7vRCd9XxmavHCOe6NdW3d/3dr6/LfeWVOcRgF3WsniMZM+QDc4O9Km0kT8Zb+tc+X/e5XGRWeRBQcY6UdHqDK6VCdRvvTW66KbB9z/rqnobqhxk8srZHLhJRcnI4ADkb4HBISz065jAfZlaIA5MgyZdZMKAWQRGc9IP3WgTyZdtZGKE922+A/mHh0rRHDTbbfETPqUidfef8f9n5DIN9/31h4BwtMVH0n6lKlGzZu6KuK7IJAuPb/SsMwxMtJPDt6PuRatXn7e1RB/d3Wla11VqZRlz57d3Vv7gZhJHzu1fPE5d3Xla90PI39WW6HLinLM9dRIv2O3z92yzeu1h0YhlYfUVsSQjpG+4ECkj3EZizyzzFkahvGQcy84T//ny5/PffDZJ9qyJbNgXMbWuMZEJWrTpUqfqcdMLus/fLC26EkXwv/4yy46r4DrdGOfcWYp7cLmuFHzJxKe9HFAI6dPdNdeX1mfmZY9E1T8O51RulSGSN+DTIwjg3Ro/aBTWiBjZk7Ra5HhjfSjg8k6g8aNcOX/L1RpYkIPdipUpLAeM6knI6QfCbqiv+zdU+NVuaGa2gNHExnOSD8lIBr0RaPhvAsvUN0wKQ+/c+JJJ+lxjTtuj5n0PaaLfpdvXqekQ/hI0gfYgOegvK7avlF71whrpJ8cvCvj4WecGfLtflJq/gIF9Pj+ug/FRPqAsOQXdL9y2wb3+bdfa1oHIv3Pe37t1v2+TcsQ9g+GiwTpGOkL0iJ9FMiYJC2hPHmOCxG8kDXO7eMvO7t8+fK5k0/5jy5/YLwTpeLMuvfvo6REOCoEj7dopunTOzBvDV2rS9zg8SNd4SJFNLN8O/B7zUiQ1MvvvKFhE717n3fVSo60RnhearzMrIfIWdpVpFgx1U1GW/qAShL67Ppdd3ejtHL+c+qp7r2OHUI9AHI9Mp6RfkqQh2cuW6gkjN5ffe9t7eaH2H8cPUxb+pDMwZI+8alcVK52vcaD/Ckr0cIa6acEdiJ8ucsvc8cee6x7v5PkcyH4JZvWup4D+mpLn5VGGSF9dEv62If5AYSPRvpAzwmweYfPP9WwRvr7QeNjzMzJ2kCjN7Nz926qV3xN117fuGzZsympxkr6wOseP+l7WaKSvtwLf8v162+s7ho3f1In3n7xXQ959tCQcTBdD9Ix0hekRfpc8xPrmPG6fPN6VT6GodZc79HQzNl3OrRTciezoMRlco1xfP4v37Le/SiGJjNcf1N1NTiKfzJ8T2Zakq4f+2/fuaOeT3TSJ1P3GTJQZ39fU7mSOgXeDf2Q6Yozpp/GRL60SB/9EoduYsJ6VK9xk377GnKPVjCN9FMCh+S7Chl2IW+iW3qcRs+Y7AoXLRzzmH40YH8l8mzHuArXVgyRUwSheBjppwQ6+PiLUMuaWdsrtv6qlTPyNMNZGR3TDyI9pO9hpB89LD7rlXfe1PeEaFeIfycOumfVREbG9CNxINLHFm0/au/OKltWl0oyyZOwgN68ASOHRCV+0jHSF6RN+qt0JiTXPpICGXR+KL5D11ChYEkTmQHDUCh7/tjPPdWmlRq/UtUq7rLyV6gzvOHWm/U6YamhMVHjp7HDJYOECisGoCVLmolO+mSe94UQeFZdZy9Oi/MUAPRwoNn76Wnpd+7RzT3/xivu2Zdf0PFMKhi0Sn8cPVR7ViLjGemnBJXRV9qGnNRLb7+u+Zbz6I+NVA40ez89pI9jg1CYx0IcesHIH9HCAiP95ODdF6xb5Zo921J1wjI4X57wDfR4HWj2vpF+xhEL6TPB8r46D+p70sXv9wKB5BkGPhSkz/GEuTPc8MnjdO4F+aPHgD66OoM4l191pZskjaNgHB/PSF+QGulrRhaFsAaVaxgBB+rj8f+L77rrNXoBdEnTikVSSXjK5c6TW7v+GdNnYtsll5XTcCx9odBSgBhHzZUrl06s8eP83I+WP2ETnfQh1Odff0Wf9cU3X0siWAoNmZq13xklfQ/SQc8Mk7DTVZvXQ7PC76vzkBJFZHgj/ZQgrzV5+knVW1vJ376QQ/qjpk/K0Oz9SHAPygKTmK6q8F8pBwtTJRNgpJ8c9PJBEuxxgE4+6/5lkq+B9OkptJZ+/JBe0uc98UXVbrpB3/Nbadz58oT9DlVLH3g/C7Ar/MN57Eo8eveCfAVIx0hfkFZLH9J45sXn9Nqb7d91S8UZcp5MQU2cWbFcY1cslv591e87PT73wvN14hRhGdekoJEZmN3pW/osxcBJshzHd8UwfseuZ6SR6KSPU3iz/Xv6rAxVeBLmXYhX9OSTMzymHw1kVsagiUtBIEOTiYNhjPRTAifV+pUXVG+0+LEB+Xee5MshE0YLmZzgzrso42P6xMPBMeOb8BBFtApZEEb6ycG74ycaPt5IddL+s09UJ+pnxD8xuY8xfVZbGOlnPmJp6eNT/Dymr/uGW/oSfvHGNTr57mDG9D3SQ/qR4Dr+79oqIV6htznSBqSTdUlfjIQhUO7aXVtci+dDDq59l066wQsZHONj0E+/+UKvXX1tRS00FEYIn/h029Pl/N3gAUr6L78dmoSHI1u78ze3RDIIY/90q3KeMWkMDtkxbse59zp+6FZu3aAtWQok2zRyPtFn75PB2LaYIQrW80LCdH3xS0ucd2BFQqykT4EjA0NWODF0jj2Y0Urlirg4l2iEbqSfEjhjhknQG86bMX1aBeRDhps4T09UkABiIX3IiglE5IMLLr5Izx3IQRnpp8RC0fHr7dqqTh59orFbtX2T5l/KQ4XwMt4ad8Y+e5/4rL+n8rB6+0Y3Ysp4DY/NsfPS39ZqHiEcoNeR/MGGTMwMJ+zdD9znfhE/SfnmOuGC98hapL/aPfVcK33PZ19+Psl3M1RGjxnnMzJ7n3vSU0z6v+ze7PzufpWrVtF8wJJlen2S7CRpUI7wj/wyt6DHgH4uj9zj7HPP0e3PZywL+V2PLEv6vDj7jrPNIcTDJLzrbwzNbGZ/67ek9UqLiI8SoFgm0/ixyltq3ia1uW90/3g/S7n+Yw1VcZCdr52ddXZZndVJhaHGnTV1O1rO6+xbNdYynbGf69hcSvKMgbLtLLPT2e+cXbOo9Scy6VM4IHO6c3k3nDcbtbBlLl2RRYsVc6cWPzVm0icsO/IR7gshBvanbtfpI/fgw3VVL+ecf55etyV76SN9HD7jf2XPPVtbAKwFRq8MO7HjIWP655x3XoZInzjY4drrQru30VOGU4kWNggj/ZRguGXElHG6nDJvvry670UX8VG0yFkCmz9//tBE4BhJH/v0El/TUMIx94g15IQvXKSwkjQrivCB+CXK3mfisxiufLxFM3dDjVClsOw5Z6udOM/Qg5+D5JGVSB/9/zRmuNqDnfDe+6SDzu06vVQpXZad69hjheBrxUz6nPv6++90G2X2eWF5JrosXrK46hXdf/h5pyQ7sXlZ+86faJyOX32uc89OKlRI537AT5Fd+yBLkz7kUPOeu9QIrK1k7XdoC8MCLp8Ys+CJJ+ruYigOJVM7ppVEq15uoyBsYykI0yRNjMA62BmSYShEjOn7cMVLFNcaPBPQbr2rphZCMhaZgp3LIEgflmVVfDyGD1iwpWVqS57ihVhIH5CZ2ZXt7PPOTXoHuvSZ9EjroIw4iwkxkj7n2PrVp+eBXSgIA6XApeZQjPSjg9YAFTJ2SfT6ZMkRDvwaIexLy1+heTJW0sfRs5c7joZNZNikyds6LRjpRwf5mrXxEL+303kXnu+69e3pyl1+qbvljtvUH8VC+uj6rfbv6zJAwLat+Lr8BfI7dn6jInjz7TW03GFPvoWQXSrXXGMHTMJSOSQuYZkfElmushLpA/zMOx3edydJhdnb6fKryqsNKFdsc0uPSCykj/5feOMV1bHqXsKie/wex9yDb5HQy0x5uaNW6BsjHnDTFVddqb162DxaeeJclu3e5+UZc2ciBuNl7EvNJIzegwfoMb988tNnAAyIM+P8R1Kr+0RqUhiPQsZ5n0no1mRHpN6SHmv6P/36C13DT+WBjyswfu+NwS81ZkiTZYF0pXGONHgWvgvABJ/gc8cbsZI+gFBGTpugH81hDJCJiQyLMF7cf8QQ1Y1/5/SQPu8/euZk10lqr8zcf1taIUwSY+iDe1FZYngmMh4w0k8d5NWhE8dorxJ2GjV9ojpqloMxUSwYNpaWPvm7xw999Tc9hA+M9FMHPmHIhFGufZeO2oIcJ5VmzpH/g/4DpIf08SEszcR3Ae/rAP+/+6m/postSZuPMHmfmCwsvlHC4pd8pSPpHhIvK5E+74svosWP78bPs5+LNoKGDtLdW72d0kv63JeNztBxCt3L/14D+2u6hMNHwicffPqx+siPv+gsNuuv9yJPRKbtwTNl3TF9AeRMRoUoUmJpiozNMeFRKqAgesMG4QsAYRiX9kvLIKvIZWaaeeRehKNVjzH9OXWg6XASmYmMkD7gvbxe6P4KnVukOgqGC5I+3Ze/7N6ihEIcX9D4RQ+EpbKEbtLStw9POuwiNkQcmJF+dKBnbyc/PEKFFqcUDBckfSpf637fKvoNrU5J5hDDusdx+PRSA/HI0xD86h2/qXMjfSP9lMAm3k5sBsO5aP6D6570f544SvL/BtUlw5dBO2Gj6H4utH0y6frw2Cg1v8j5YCOHX/IO49DMF8hqH9zBJ4V89wrVOeeoDATLQpD0+eDOCGkgMf5Ogwe7BHWpZSlC50Hd+/IHfFnGR/LLdeL7+0aCNPCHzM3Ish/cMaRERkk/vYDI+UCEmEo/gctGRK+/31ZrsQcijdRAYaP1wSd7qXU/3SY0yYZlM0b6GQOk7/VYu0E996G0OvlGOL1S6W3NR4J4P40doXNmsFPj5k9o+vwa6WcMOHvGetFjizbPil47qH4Hjx+VJgFkFrgHE87ebPeuzrth0hnPQss3K5B+euBJP7hz6bsft9c5XMMmjY27nXy54suJNLTwuf4bJgxhG+lnccSb9OnNoLuYPQz4bgFjhcw2fazZ49paiBbnQKCW3a3PtzquTHqabt68OreC1mm0OEc64k36VJboNkSfqlPslCePfgRGlygtiR4vLdCyYeMpbAMYq2TCGktiSdNIP3bQymNfEF+W0Cegu5eWX7Q4mQXsBZkx8Yyxf/II9y5UuLAuXaNlGS1eIuJQkD6VM+ZvYSt+2YIdPcXbTlQqsNXNt9VQX+vvz7wxXdZ3BNnJSD8OiDfpU6D4njSTypgAxsx8xuvZlTCjNV7SHDNrimOvd9IjbSoBg6RVeShaO4cD8SZ99MbqFbWT6jNkJz+mGC3OgcCXvxj3J639duoZSjOVteJHOuJN+tiCVr2WpbA+AfM1MmqnWMA9mBMSWmUTyiss4+WDWId6PtLBIJ6kDyDdQeLjsBE6+lJsxG5+zF2Ku53CFXR66SjDvuzxPYdx4XlrKeIkKIz044B4k/7+MeD9n2Ole4llZRktaMQj40ammWL8+ShCvEkfvenYrugxmZ3k3MHYSW3v08uENBMd8SZ9yhNj/snyvuBQVqIoZ8H708I/kogExJv0QUhP+8vTodYTkweT7i/2ijZvLdFhpB8HxJ30DZmCeJO+IXMQd9I3ZAoOBekbDh5G+nGAkf6RASP9IwNG+kcGjPSPDKRK+is3/OImiBEpYIbYgN4gEUh/4erlWhiihTMcXmCnxWtXKunPW7nE7JSgwC5L161WMpm5bIGbKJWAaOEMhxfj5890q4Q3sNPUJfO0Uh0tnOHwYsKCmW5nNNL/ZfMGN2PpAjd7+SJDjEBvS6UFCekvW7fGzTQ9JiSw04pf17q9e/e6JUL+ZqfEBHah5xEymb96qRD/wqjhDIcXtO5/+W2D2mmuVKJnmZ0SEux4u2fvXylJf9ee3e7XLZvchq2/GWIEetuyY7v7559/3NYd20yPCQrssm1nyE5btpudEhXYZfuuHe6ff/9xm7ZvMTslKNZv2eh27N6l5Wnjts1RwxgOP9ZL+UFSkP5OIf31mzdqATPEBvS2WcheyUR+TY+JCeyyNUz6m7dvNTslKLDLtjDpQyZmp8TEus0bkkgfcokWxnD4sU7Kj5F+JgO9GeknPrCLkX7iA7sY6Sc+jPSPDBjpxwHozUg/8YFdjPQTH9jFSD/xYaR/ZMBIPw5Ab0b6iQ/sYqSf+MAuRvqJDyP9IwNG+nEAejPST3xgFyP9xAd2MdJPfBjpHxkw0o8D0JuRfuIDuxjpJz6wi5F+4sNI/8iAkX4cgN7iTfo4v627d7gtu7e7Lbu2639dznQQ96KgRk0zStijAdglnqRPeugvs3WK7TW9LGSneJN+MjuFwb2ihY0Hku4v9vR2PdKI81CQ/qYdQT2Ffg+lnn7bsfWw3j8zcNhI/0hTVCxAb/EkfXS3ZsM6t2DpIsFiN19+5y1e6Fb8sjqqXjmHU8GJpaZ3zq/duN7NW7JQ0/PprkwlzaMB2CWepI/eVq9fm0yfcxcvcKvkXFCn/Mc2qSEy7Opff3Hzlyzan678j0zzaAJ2iSfpo7eV69Yk2ciDMpaWTrmWXp2nFXaDYPnaVVqGvU0XLl+i5TG96ScCMoP0sS2+6i+3z+34MzkHkSY+LpjvFyxbfEA9cS2165yPLG8e0eIsXb0imZ0WrVjqftn0a5r3TzQcUtL3Bt2z7y+3eefR253Ke8WT9Hf8sdv17t/XnXPuOa70WWe50884wxUvXty98vqr7nfRrQ9HRty99w+36689qndqpb/v+9Pt/PP3FJmUc8NGj3QlS5bU9EqdeaYre/bZ7mVJc/feP5OFPVqAXeJJ+r+L3j7r2tmdfc457szSpVWvp4mdPu7UUW3iw9Fy2L5nl9o1Etv/2KXXfdjdf//hevbupWkB0sVOHTp+HNWuRwOwSzxJHz23bfeelKdzNd973fb78Qe1S2R4dEycnWLDoG2igefF1hDY5l0pfQFpkUbrF9q44iVKuDPkvpTpiy+5xP08crjb9vvOZOETGZlB+pt3bnOzFsx1D9Wp7Tp2/lSPSQs94r9eevUVze/YqeTpp7uzxQcOHj5U7RGZFvEoE4C4kdexBS13ytj2cHnz4Bz39vbCf5JevYfraxkmf5xV5ix35VVXuvGTJ0bNJ4mKQ0r6KG7ZmpXutTffcJ93+0IVHi3ckQ70Fk/S3yVk8unnnZ2YylWucp1r9Vxr16RpU9f7+75JmY9CQg2469dfukaPN3bVqld3t99R07V89hkh9xHqaIIFky7FGXNnuSeaPelatHrG1a5bR9OvK5n8T6l1+3BHE7BLPEmf1sqrb76uery5Rg33bJvnxE6Pu0HDhqj+PYG9815bV7/Bw+7hRxrsR8MGrsGjDd1jTRq5N995y637bYOGJ97oCePck081Fzu1cvfcV0vTJ20q0xl1tokMdBRP0t+1d497onkz1WPtenVdi2daqn4nTJ2sjt+HQ7dK9kIiP48YpmHeePtNPc9zBdPkGLLfIs/9bd/erskTj7vOX3yegnyI+5vco8d3vVzTJ5+Q8tnKXXb55fosfX/4PiqZJSoyg/TxXyPGjdb3r3VvLT32+qUCVLteyC/Vk/LyVMsW6qumzp6Rwk7YiPA//PSj+rQPPvpQrwXttG3PTq0wUMbqN3zY1alf19WtX09RR/JB9149kyp1pAk6dfnMPf5EU/G5z7pzpZLIswyVyhn38+kmOjKV9FEoxA4iCwEK2yGKGTk2ZNAHpSaHonxYrgfDB+P5NNMKE7zmnyO18MCnG/mcmQH0llHS57nS0iPgC0k4EPTYScgf+eOfvzWT+3uRWelKplacP39+rZVSMybOCXlPkNbmJ0kFivDE4357/vlL05u7aL7LmTOnEtAf/+5Ndv+jBbxzRkk/0k7R8tqforc3hLDReb8B/VWv2AnHz72o9ELm111fRcNky5YtGXLkyKHny5YtqxU4whMPB+ftNHbSeA3znLQU6VmI9hxHOnjnjJI++vA2Ss1Ou/7eIwTytOpxnpQZBP16fftwlKlxUya6B2s/lGSb8y+4ICkf+HAcY6Ofhg521W64QcOBO+++S3tqIp9B30/KLvkFef6lFzX89z/+kKVIH73BCWMkT6NfWtXohbS4xv86D9d1J5xwglu8apn7V3RFz2bQToSjATNi7Cht5HjdX1u5UlJe8Pf749+/3VvvvqPXjzvuOFe4cGF30kknKU486UT3wssvpiBz7OHt1KhJY407fPTIrEX6hEHpZOZgrQgDBY3B8V/uHzd+6kRV1GOisL/lGGXRleKNG0ybLhe6ozEUadGS0XsEnos4kBeOlP8a5vfQxCbfrR35HpynoJNBSA9nyf9gmIMB94uV9AnDO6DHzeHWwMatoYwe+WxB0m/Xob0+f/A6IA7jlLRIZi+Yq++5SXRIq4NCc8qpp+j4WLCG7IE9JkydZKQfBRpHdKl2Et2t3xzSdbAC5REk/a97dte8Gbzuw0+aMdUNETvRAwOGjhrhJk6b4t77oJ3Gpatza5R8QCWaXgPCGOmnBHaiC97rDV8Qzc8ESX/itMlqy+B1gJ96t917LleuXFohq1q9qoa/ovwVmr4nE9LeIvmicdPH9TrkQW8c/x946EH1PanZiPOQCq1XwmcV0sfv0StGy5v3nTxzWqqkX/fheu7444930+bMDHFBIB3VvaTFEAD6y5Mnj7u2UiWxV3ZX/cYb9Lq3E6Bi92679zVs565ddA6Hn8/B3Ka05kgxbErvHHGzFunLdZTMRIb32rdzd9e6x11T6VqtVd17/31uyPBhSty0UuiKocvklltrqKLOPe88NeBDdWu7B6Tm/PQzLZWkvFEoHHSvcb7aDdXdjTffpDXguYvn6/gYYQiLYVq2fsZ9JC1XnF7Xr7u5u2rdrUZu1+HDJEPzLmQcjDNg8ECtpV1XpYq75757dagBB07FItn7ZRDcKxbS5zoZeumq5e7FV152t9a83VW85hpX+brK2u00fc4s1bMPnx7SB7w7hYhZprw777dX6scVr71G406fOytFwQFG+tHBhyogBCYQtWrT2t1yWw1XoeLV6tQfafSoW7R8SbJK1IFI3wMbkKexFSDvUyG+u1YtJZkhI4bquch4RvqpAz3OnDfbNXmiqdrn6ooV3fVVq2o3/spf9vsZkB7Sx48xxlzpukpaMaMXjfCXXX5ZCtLHnq3D+WP+4oXuh0EDNayRfkqQhylPb7/X1t37wH2uarVq7vaaNV327Nldg0caxkz6+K43277trpd0ps6aobZCl1WrV9PrQbsHSb9P/37qG/GzHsGwQZBOliV9Wo7MGvZEXqZsGR07LnfZpe7UU091nbp01u4XZhtXEgJjggoTzghboGBBOS7tzix9poKCyXg/ThMF9ujdy5144oka9uJyl+hkKP7TTc2YprbuxejM3syXP5+74sry7smnm2tm4T5FihTR8K2fb6PpkWmI8+4H72sYJmOQEU497TQN1+DRR9TQhIv2rrEAvcVC+tyX2uX/rq6gz3LBhRdopeV8+WViD2NGwcKfXtL3IH3eHVugX/ROF9byNauSkZSHkX504HSmSCvkwosu1JbIJZeW0wrpueed60qVKuWmzZ6hYXz49JJ+JMivg4b9rPEgDggnmhM10o8ObDBq/Bj1Kzlz5nDlr7zSVb/hBs33F4jtFi1fmizfp4f0Pbg/5DBdSIfwkaQPsMF6IUDugfTq21vDGuknB+/288hhwhtl9X2xF8ORBcN+/9FGj8VE+mDDtt+kEbpBGzj/iO5/HDJI0zoQ6f84+Ce1FWUI+6fVACSdLEv6ZOAu3brqi0O4KJGXx0lB9LTcvfEhnj3//u3GTZ4QMmjjRjqmgiExHtd9uFnz57oiRYu40884Xcdm6OInzPvtQ92dtFQ3bZdMIMZhacvZ55ytRH7yySdrqx1ym7NogfvPKf/RcZqFUjH4c99eN2zMSG050cLn3ehSopfi1pq3abrde/XQbtvgO2YEpJ1e0kc/dEE+/3JoHO/9Dz/QYRD0SJfuirWrUywdiqWlD+guGzR8iI4x3nXP3Vrx6tb9a9VpMF0PI/2UQE+QNj1E6L1b92+kNf6vOi4qn1Sg1kYs3ckI6ePccDo317hF4w0e/nOqDsVIPyV4f8ow4+fo5aehQ3QiKnZi9jwNC8p8ME56Sd+TO9enzpqu4aORvg8LsPm3fb7TsEb6+/Hbzq1qizJlymjjrv9PP6pemfPy48+D1J9DqrGSPvD2wK4Dwr0s0Ugf/nn73bZ6/dbbb9MG4qtvvK55Rn2D3C+YrgfpZFnS3y1KZTajRHG17r9Xv6dMJidjYxCU78NqhhbFjAzPzGz42KOqKIxAOJ9J/vp3n85EJgwz1KlVc53KAOlWrVZVx9SYEEglA9KnBp83b15dxka3KDVswj5Ut44rWrSomzR9qjrgOvXraboUbIQChXCd88xY5/n9s2QU6C0W0udZ27z0gj5Dmxefl8y/U5fJcZ5JKZHPk17S3yi6RRf33Bua4e1R88473FqpSPD+0Z7NSD8lsAF5o37DBqpDlnhhH3S/86/QkqBIO2WE9ElzkDidbBKnStWq6ngiCcXDSD8leH9I3/c+fv7VF/vtJPry82WCiKWlD9JD+h5G+tGBPT7q+LG+J0S7T/wwOuR9x4sNMjKmH4kDkT7XWd3EUDM9qnAIYcHV11TU4QHuGUwTkE6WJX26QKitMa4p0VypM0vpcgZqSiiXwhFMg5Y5LXfC0p3O9WDm4D+GgJRy5sqpZOwzvSpaWvwvhydpdP3qS60QQPqnFT9Nu1zJGL7bbuP2zTpWBHlRs+da+auu1JY+Y0VMtGEy4eNPPuHuf/BBTZNZ1Dx3agU4veCdY+neZ+IhvRsXXHihPgfd+21efEG7KCETWpLBNGJp6fPe1KIJx7jZndLSh8zLlSunXZTRCo6RfnSQX0eNH+tKlCyhur/0sst0+en4KZPUOUAowTRiJX11bFLhuyXcyv+uX580e56M9KMDOw0cMsgVKlRIdXPVf/9Pl0VOnjFN/Ql+K5iGkX7mIBbSp2w0FA7gPYeOGp6UzylHoyeOOySkzzE90gwRM3GPHlH46aE6D2mcCldfrfPRGMYOpku8LDyRLzQJacmq5dI6b+3OKFVKlQBuvuVmLRhBwxyI9DEspF2l6vU6Tj9j3uxkNS26fpgwSPxOnT/T2qEn/YsuvkjJ3Y/F8OwQJgRGmlwre3ZZ7TZi3OisMmV0fgDj/yyJYmOORxs/pvEONelzHV3MmDtbe0CKnXxykh5xFIxB8i4+fKxj+tiATInDYUjDD5M8IveKFtdIP3VgJ0ieSakFTyyoevQVSYazgmOBsZI+dqXCTB5l1jH5Nq28aKSfOrDTiLGjdXKYb8GxLKt5i6fEb23UXjAf1kg/c5Be0ucaerit5u36njRuPGni78dPm3RISB+QLr2ppIVd4Rh2SPR7JQwbNSIFoZNO1iV9AWG0610UTK2p/8ABSWPkkHdQ0ZD+8DEj9dojjR7TDM11nxb/6bK//8EHNAwVBF8D5BpO7fEnm+q1fj/216GAYEvfr2X26Xlwf+JfUb68rllnG0WeGycNVq1bq7+MnUfGzQhIOxbSV0gYMjWZh94THEWlypX0XdmohUxJBiVsrKQfBPpkEx7iMumSrumgDYCRftpAP8xbWbxymc6NuFzyFfqEUHBmPlwspO+dmu+W7in2P5BdjfTTBvpBp7TkPv28i/oIdMVkXuYJ+XBG+pmDWFv6friVZcX4JeyqY/pDfgqN6TfM2Ji+R3pIPxJcR+dMpM4hzzBq3JgUNiCdLEv6XKcA4GwoYCgCwThsk1ikaFHtNvHGoZua1jtj8tVuqKYGphCgVJSGMikkfsOExk2baMuUiW5MbluycrnOti9RsqQSI3HSQ/qky3PSkiddtixFuBfPzMS/P91efc70OpS0QBqxkj564Fl4J/SJUBGhlXLhRRfpJi6+myk9pM89ycCky7sDCgEbWrQNz1ht0aql2iAyrpF+dJCP1E6if7WT5BtkvuRx9El3INcIR/hYSJ/KFxMtKRuXCpFwLlpeDsJIPzrQAXbAVtgJvSPMFEdXte67V8saaRE+PaTPPel5oVGCLFqxRMOXv7K8+g7mEuHHCAcYsqNsIcwMJywtV4R8Ec3X8NykkRVIH9343SoZdsQv4esXLluiWxBzPiOz99EpjVCve78Z3E233Cx+7G+1E0NoSXaSNMgj2Jxfeo+Zd8Y9GG6lIWjd+wGgKFo6L776sq5FpVCxrSFd/ZKMKpqC4guXb3FfXv4Kvc7afdYg02pnQiAfd8EIfNSAyRWEYV3tEEmzW4+vk7pcOnbupMRHJQLSL3ZyMd2HPjXSBzzrlFnTdEb/scceqxPmmBnNM/cd8L0OG7BJCs8bLX4sQG+xTOQDZPx33ntXHT/PxJItuox538aPN9EMv99JHZj0eQ/WEnf+oqt2GfOuXwnxkFaOnDncRRdfrLuPRZvYZKSfEqwWWb3+F/fmO2+7dh9+kGQnWiV+Tgj7SOwS8vZx0kv62BVHxbIywjJfJZpNI2GknxL4GPbueO2t193Hn3ZU/WCn7wcOEP1WV12xQiao3/SQPjP/R08Yqz7r2edaJ41HM1G4pZB086ef1pVD+C/KHvv2N3u6uc7NueOu0EqC8y84X+3U7Knm2iMK+QTvge2yCumT32kAsnSbFVbwCD0ipcucpcOv+Oi69WPv3ucccwSefKqZ6Pp53S8GXbJXPnpF93y7wtuJXoZvevXUOH1+6KdlmJVjefOFJoZHI3OeJ8uSPrWplq1b6YsHAalA+NNnpzQMBWr4mFHuknLlksWB5JcJ2WMICgM7kjE8EAzD2npa6dSivaPkK0fnSWGqUKFCmqQP6JKFTC+7IlR5CKKwGHqgOHK/8c/BAL2ll/R5D97Zj28FwY5SrCigEkQYHyc9pM85tn6NTLNAgQJaEChw+q5Rns1IPyVYYkT+qnJ98jwJ6I1pJJWptRv3zykB6SV9HDs78pFO5SpVtFfnQK18YKSfEtiJvUOilfGTCp3knhF/RTjS8XHSQ/qUFcrcscfmVhx33PG610VBIS3KKd3RbE5GDwP2pOHDuHTu3Hl0B0zCMrSYO3duDfucNDrIH8F7ZCXSB7wbS779niqgwtUV3MSpk3UpH98MgT9iIX3IGJ+IjvOI7o8/PqR7/B52OuaYbO4xSZeyAljh5e8N8Hk8ww+DflQij/YenMuypI8hmMRHLal3/36u/UcdtEXODGdvpMg0OMbYrGumBUqNt/9PA3RyFNdUyZtDBM0xFYSevb/VmjpDBXTZ+QJLWBzklJnTdde6pPiB+0WCblScAt0+X8mzfvF1N+1+4/449QPFTw94jli697knKw1ojVPb/UD0SG2UZ6JiA4JppIf0afEsW71Sa6+EoRVC65RlKDi1aLbxMNKPhlCPzJyF8zXfYp92HT5w333f102aMU0ronw4JZhGLC19Kq9MPGPSJulECxcJI/3oQAfsNjnw58Hum297uA8k/7PjGj4C30OFKphGekif+9IoGTd5ooKyyY6hYPyUiW7MxPFu9oJ5Gg4wj4BzXNsfdpLG5Tx7h1BGg/fgubMS6RMGXTOB+ase32gj5ZdN63WZ6qTpU3TulU8HnaaH9Am3eOVy0fG4FLrnmI3dSJdw6B9/SB6hLPfq10dtw/bn8Exk2h48U5Ylfa5TgBhzp+BAqH4cLTJDR4KWK2EhMBCtoG3asUXT0nCCaEbGAGSG1DZSiAbuHUyX2jn3T09GTQ/QS6xj+hC7fx6vRwo8mTMybJD0GRZBIBT04+/FLzZQuwTelV4Udq2KTNOH9+PUcxbOM9KPArWT6FTthF7lP3knmp2CpM8X0xDsFFmJ87qHxLkWrffFg7DkXyaiITg30jfSTw7KgvqWgJ3wE9H0wzVP+mzzjaDfZJUDaYiojaRM0lL4LzsAAAdiSURBVCAJTebcD84H7YqNQmGThyMu54MVRH0/eTY/Dt0mi31wB5/k/ZPv4aJMBYcesXuI9EMf3Fm0cpljxz3yPHYJ6pI0ous+ZCdsQzig+SSQR7hOev6+kSANbye+zoedshTpG6IDvcVK+rEAZ/ZZ1y6a4fh2Qfde3+oSRmqxShpR4hwIFBTmR3z2eRetdbP+nPT5hKWRfsYA6b/2VkiP7AHP51M7ip2YO0I3dLQ4BwIOa+b8OVrp+6pHd9f6+dBGVvwa6WcMOHvG39Hj62IvxpfR75xF89MkgMwC96Dnkc3IvhabMjTKs/SVlm9WIP30wJM+S2XRzdvvttVeWr61whbm8baTfx96j9hens/uVrymoj7LsCjL+hIZRvpxAHqLJ+nTs0J3cf4CBXSsil+6vFq1eVZrodHiHAjUdpmEybgyY4+km09+m7d42u3JYJqJDuwST9LHFkz6Q5/eVqwV5+t5wVn+sQBih5SwTdBOTDDMaJqJDuwST9KnZ+2Fl19SG+XPD0K67dWvt7Ts4ku62Asy49v8x0sLlmfAnkwQZN/4aD2giYp4kz6t8mZPP+Xy5csXyvfyW6xYMZ1MG+/KEZUK3ol5G/hatZPcv0SJElGX9SUyjPTjAPQWT9KnACxZuUxnJTMBDLJmnJkWYOTykvSCNJevXaUTHUmPdFlZwU6Bh6K1cziAXeJJ+uiNVkhIl95Og3VMMaM6JR7j/8z/8Hbil/kuR7Od4kn6pEmr/ueRwzXPo0/AEmGuRYuTmeAe7J1BGWalEjPKmctEeTwU988sxJP0AWnOEh/nyxO+CpuxfDveevLvwy6xodVQobI3UgifpdVHlp2M9DMd6C2epE96OPjg51gZD/ZjVdHiHAjEI+MmpZcJaSY6eK94kj7p6fhiwE50AyYbK44RxFPbB+x0sGkmOniveJI+6fkx+CAOZSVK7x8sz4IjiUhAvEkfsHQymp4OVd6ntwG/6O/NGP+RZycj/UyHOpE4kr4hc4Bd4kn6hswBdokn6RsyB4eC9A0HDyP9OAC9GeknPrCLkX7iA7sY6Sc+jPSPDKRG+jvZFjG0FC+0pM6QfqC3P//Zq4r9S35Nj4kJ7PL3v2y86dyf+/42OyUosMteXaAVWkpndkpM7Pjr97CVnNNPg0cJYzj82CHlBxGeHxqmfCX9yTv37Jq57reNk9Zv2WSIEeht847t06TGu3iL/JoeExPYZevOHdP37du3ePP2LWanBAV22b5rxwxp6S/euHXzFLNTYmLdbxsm7di1axZ+b8OWTZOjhTEcflB+hOPnCD4LU76SfiGpCGQPH5pkUESPJ4X/miSwSF4/MfzXJEFFbJTNylPii9gpp9kp8UVslFtsVSB8aGJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiEpM457IJTg4fHvPvv/8eJygUPuS4kOCE8KFJKiI6zCV6Kho+PGbbtm0F5VyB8OExu3btKibHx4YPTQ6TiI1yix0Khw+x24nB/C3/i/Xq1StH+NDkMInYId/27dtPCh9ip8Jr1qw5Lnx4zO7du0+Wc9nDhyaHScQGBbBV+BC7FZFzufiPfX7//fdT9IJJ4ogYqaVg6b59++4PH3cTzBODlZLfUwTzBQMFRvypiOgqm+jnHcEiwVXo6p9//hkrmEqhEFwq/xfL+Y7y34j/MInoP7fgS7HFAvk9S2xRWH7nCn4KX6siWCzX3whHMTkMInY5XuwwVOwwS37xQWUE8+W4p1yjrN0r/5fKb7NwFJPDINKQKSo2mCgYK3Yp+Pfff18h/xcKPuW6/DYVLBc8oBFMEkPEIF3FYE5+W8tPdvmdEz4u9+eff5bhvxSwDXKcVOs2SS6iopyioyHoSuRWAbXfPRzIbzFB9fD/ifKTJxzN5BCL6P8EweywLa6Sn9PD/zfLT175fYhjseWAcBSTwyBih/xig61h25QWXBH+v0R+csjvs+HjTuEoJodBRP8lw3bYJyiyd+/em8LHk8LXPwwfv6ARTBJDxCBFxS7XCQqGj6lVV5RjujipVV8t/y/mmknqIjoqIbqipZibY/ml1ntV+Bpd/9fLbymOTQ6fiB3I35XD+ZvjCoIL+S/naGFiw1M5Njl8IjYoJ/gv/7GV4Bo5Pid8Lb8AOyUNp5kcehGbwA+Xy++l4WP8XCXBGRxjH0E1OZ80zGliYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJyJMkxx/w/+CcXRIfio/kAAAAASUVORK5CYII=)\n", + "\n", + "Threads are executed together in small groups called blocks. For load and store operations, if each thread in the block is operating on an array index that is nearby the array indices of the other threads, then the GPU can combine those contiguous memory operation into more efficient bulk operations. This is called memory coalescing, and it's essential for GPU performance.\n", + "\n", + "This means we want each thread to access a strided chunk of memory instead, like so:\n", + "\n", + "![image.png](data:image/png;base64,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)\n", + "\n", + "We call these access patterns blocked and striped respectively.\n", + "\n", + "![image.png](data:image/png;base64,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)\n", + "\n", + "We can no longer use the `cuda.grid()` utility when implementing the striped arrangement. We need to access the hierarchical coordinates of our thread to compute the right step size:\n", + "\n", + "- `cuda.blockDim.x`: The number of threads per block.\n", + "- `cuda.blockIdx.x`: The global index of the current thread block.\n", + "- `cuda.threadIdx.x`: The local index of the current thread within this block." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7b514e9", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:44:09.970420Z", + "iopub.status.busy": "2026-03-09T19:44:09.970171Z", + "iopub.status.idle": "2026-03-09T19:44:09.976769Z", + "shell.execute_reply": "2026-03-09T19:44:09.975528Z", + "shell.execute_reply.started": "2026-03-09T19:44:09.970400Z" + }, + "id": "B5PBpaY2HnE0", + "outputId": "9bf8af36-f011-4eaa-ed28-c39abde2c315" + }, + "outputs": [], + "source": [ + "@cuda.jit\n", + "def copy_optimized(src, dst, items_per_thread):\n", + " bd = cuda.blockDim.x\n", + " bx = cuda.blockIdx.x\n", + " tx = cuda.threadIdx.x\n", + " items_per_block = bd * items_per_thread\n", + "\n", + " base = tx + bx * items_per_block\n", + " for i in range(0, items_per_block, bd):\n", + " dst[base + i] = src[base + i]" + ] + }, + { + "cell_type": "markdown", + "id": "885bfdfd", + "metadata": { + "id": "kC3Moh2m02-q" + }, + "source": [ + "### 5. Verification & Benchmarking\n", + "\n", + "Let's make sure it runs correctly:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20fca1a6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:44:09.978020Z", + "iopub.status.busy": "2026-03-09T19:44:09.977720Z", + "iopub.status.idle": "2026-03-09T19:44:18.612723Z", + "shell.execute_reply": "2026-03-09T19:44:18.611327Z", + "shell.execute_reply.started": "2026-03-09T19:44:09.977991Z" + } + }, + "outputs": [], + "source": [ + "dst[:] = 0\n", + "copy_optimized[blocks, threads_per_block](src, dst, items_per_thread)\n", + "cp.testing.assert_array_equal(src, dst)" + ] + }, + { + "cell_type": "markdown", + "id": "96464763", + "metadata": {}, + "source": [ + "Before we profile the optimized kernel, let's compare the runtimes of both versions:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0921fb67", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:44:18.613944Z", + "iopub.status.busy": "2026-03-09T19:44:18.613674Z", + "iopub.status.idle": "2026-03-09T19:44:35.459607Z", + "shell.execute_reply": "2026-03-09T19:44:35.458456Z", + "shell.execute_reply.started": "2026-03-09T19:44:18.613917Z" + }, + "id": "kJ7viF-i06qd", + "outputId": "e2d84395-6324-4e55-c144-bc34399cc515" + }, + "outputs": [], + "source": [ + "blocked_times = cpx.profiler.benchmark(lambda: copy_blocked[blocks, threads_per_block](src, dst, items_per_thread), n_repeat=15, n_warmup=1).gpu_times[0]\n", + "optimized_times = cpx.profiler.benchmark(lambda: copy_optimized[blocks, threads_per_block](src, dst, items_per_thread), n_repeat=15, n_warmup=1).gpu_times[0]\n", + "copy_blocked_duration = blocked_times.mean() * 1000\n", + "copy_optimized_duration = optimized_times.mean() * 1000\n", + "speedup = copy_blocked_duration / copy_optimized_duration\n", + "\n", + "print(f\"copy_blocked: {copy_blocked_duration:.3g} ms\")\n", + "print(f\"copy_optimized: {copy_optimized_duration:.3g} ms\")\n", + "print(f\"copy_optimized speedup over copy_blocked: {speedup:.2f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "46c3eb79", + "metadata": { + "id": "mfrqUdzozGeU" + }, + "source": [ + "### 6. Profiling the Optimized Kernel\n", + "\n", + "That's quite a difference! Now let's profile the optimized variant:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79bc5620", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:44:35.460832Z", + "iopub.status.busy": "2026-03-09T19:44:35.460599Z", + "iopub.status.idle": "2026-03-09T19:44:46.322224Z", + "shell.execute_reply": "2026-03-09T19:44:46.320473Z", + "shell.execute_reply.started": "2026-03-09T19:44:35.460814Z" + }, + "id": "zO_y6ObXV_wX", + "outputId": "8e643e49-6fbf-4b1e-ff58-d391700451ab" + }, + "outputs": [], + "source": [ + "%%ncu -o copy_optimized.ncu-rep --kernel-name regex:copy_optimized\n", + "dst[:] = 0\n", + "copy_optimized[blocks, threads_per_block](src, dst, items_per_thread)" + ] + }, + { + "cell_type": "markdown", + "id": "4fd9f2c8", + "metadata": { + "id": "DjPJRzXTD6uF" + }, + "source": [ + "Now let's dive into the profile report:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f83e8fe", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 821, + "referenced_widgets": [ + "6be7ca5f9141465cb4b5126001b6c298", + "731f455485da4a31a8455bc7e3cdf1a8", + "adfba531343149f7a215ec7ec30bf8e1", + "7bd502fafaad4a1d9d5c7db9c1d54b9e", + "16b4cfe3f86041eea5ee6471175ddce6", + "d7dd0302a2cb475388f8fb2c5cf92f5d", + "8713dc92860b464aa1ce100891e33ed7", + "e3027ae6d73b4bca9df34d8bd61155c3", + "c0ac7614bdb74083a784cad1babfd7f4", + "ad7d9090fefd443fb56b97437c207ee8", + "f3b8695ddc6a445ebc82dde76f8d1f79", + "40c9bccee18f489b8a60f0bcc66b380e", + "143463ba065a4b96a6adbe97880b6330", + "fbcda75ba8764222b2acf028e8478b78", + "84fd4b1277464e378c55ceb8b0628045", + "a7948ce618014b68a77c29a97b5f8089", + "cfffbaee02274c83ac30daeac3c63f6e", + "b298377cb334442fb3be8dd55cfe8989", + "a9bbc802cafc48aca6c853e0ebd6806e", + "d74423ab0b3d416187826deaba0a57ba", + "dbe2bf8c7add48d8bf6e1f13b603fd38", + "0495f43209454c7dade96e09491b0a59", + "3bdb7a5b68b0484ebfa4584fe4440cef", + "c8a882d190254c939506955708d0a0aa", + "4812fe63767e41cc9cf82c9de944b383", + "17149f4044374e30b0926ee8683a08b6", + "144d071405684ec3bb0a8259ae566518", + "8fa15d63997f485795c9541d5151a876", + "918774a98d094a6b851bbc1df702ca4d", + "b457bbb2890e4f6ea0d007d3d05d555b", + "7ed48d6abaab4e95a347fa5c7b76bfac" + ] + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:44:46.323839Z", + "iopub.status.busy": "2026-03-09T19:44:46.323450Z", + "iopub.status.idle": "2026-03-09T19:44:46.401570Z", + "shell.execute_reply": "2026-03-09T19:44:46.399984Z", + "shell.execute_reply.started": "2026-03-09T19:44:46.323802Z" + }, + "id": "KjE0Vgu_zgs3", + "outputId": "632a4728-519d-48fa-938e-7a9777d52c89" + }, + "outputs": [], + "source": [ + "cp.testing.assert_array_equal(src, dst)" + ] + }, + { + "cell_type": "markdown", + "id": "a5f46116", + "metadata": {}, + "source": [ + "### 7. Further Exploration\n", + "\n", + "**EXTRA CREDIT:** Experiment with different problem sizes, threads per block, and items per thread by changing the configuration variables above. If you're feeling really ambitious, do a parameter sweep to study the impact these knobs have on performance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "90d2bf86", + "metadata": {}, + "outputs": [], + "source": [ + "# Try changing total_items, threads_per_block, and items_per_thread above.\n", + "# Rerun the kernel definitions, correctness checks, and benchmarks after each change.\n", + "# For a deeper study, sweep several values and plot runtime or memory throughput." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (Nsight Compute)", + "language": "python", + "name": "nsightful-ncu" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/05__book_histogram__kernel_authoring__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/05__book_histogram__kernel_authoring__SOLUTION.ipynb new file mode 100644 index 00000000..b2e2da48 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/05__book_histogram__kernel_authoring__SOLUTION.ipynb @@ -0,0 +1,600 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f1a8560a-c91b-48db-af1c-18fcd4892448", + "metadata": { + "id": "f1a8560a-c91b-48db-af1c-18fcd4892448" + }, + "source": [ + "## Book Histogram - Kernel Authoring - SOLUTION\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Environment Setup & Data Download](#1.-Environment-Setup-&-Data-Download)\n", + "2. [First Attempt: Global Memory Histogram](#2.-First-Attempt:-Global-Memory-Histogram)\n", + "3. [Fixing Data Races with Atomics](#3.-Fixing-Data-Races-with-Atomics)\n", + "4. [Profiling the Naive Solution](#4.-Profiling-the-Naive-Solution)\n", + "5. [Solution: Shared Memory](#5.-Solution:-Shared-Memory)\n", + "6. [Performance Comparison](#6.-Performance-Comparison)\n", + "\n", + "### 1. Environment Setup & Data Download\n", + "\n", + "Let's learn to use some advanced CUDA features like shared memory, atomics, and [cuda.cooperative](https://nvidia.github.io/cccl/unstable/python/coop.html) to write an efficient histogram kernel to determine the most frequent characters in a collection of books.\n", + "\n", + "First, let's download our dataset and install the necessary tools." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ce42d5e5-db1e-46da-a64a-831d0f3d59ff", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-09T19:42:36.221548Z", + "iopub.status.busy": "2026-03-09T19:42:36.221262Z", + "iopub.status.idle": "2026-03-09T19:42:37.304766Z", + "shell.execute_reply": "2026-03-09T19:42:37.303366Z", + "shell.execute_reply.started": "2026-03-09T19:42:36.221514Z" + }, + "id": "ce42d5e5-db1e-46da-a64a-831d0f3d59ff" + }, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# Install necessary packages if running in Google Colab.\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not os.path.exists(\"/accelerated-computing-hub-installed\"):\n", + " print(\"Downloading NCU package.\")\n", + " !curl -s -L -O https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/nsight-compute-2025.2.1_2025.2.1.3-1_amd64.deb\n", + " print(\"Installing NCU package.\")\n", + " !dpkg -i nsight-compute-2025.2.1_2025.2.1.3-1_amd64.deb > /dev/null\n", + " !update-alternatives --install /opt/bin/ncu ncu /opt/nvidia/nsight-compute/2025.2.1/ncu 20250201 > /dev/null\n", + " print(\"Uninstalling PIP packages.\")\n", + " !pip uninstall \"cuda-python\" --yes > /dev/null\n", + " print(\"Installing PIP packages.\")\n", + " !pip install \"numba-cuda\" \"cuda-cccl[test-cu12]\" \"nvtx\" \"nsightful[notebook] @ git+https://github.com/brycelelbach/nsightful.git@a41989403430168e02ac3cfdc4060bca4ebb8040\" > /dev/null 2>&1\n", + " open(\"/accelerated-computing-hub-installed\", \"a\").close()\n", + " print(\"All packages installed.\")\n", + "\n", + "import numpy as np\n", + "import urllib.request\n", + "import matplotlib.pyplot as plt\n", + "from numba import cuda\n", + "import cupy as cp\n", + "import cupyx as cpx" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e1ae9dc-39f1-4c93-b923-85a96b45a057", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:42:37.306151Z", + "iopub.status.busy": "2026-03-09T19:42:37.305690Z", + "iopub.status.idle": "2026-03-09T19:42:39.419885Z", + "shell.execute_reply": "2026-03-09T19:42:39.418825Z", + "shell.execute_reply.started": "2026-03-09T19:42:37.306115Z" + }, + "id": "6e1ae9dc-39f1-4c93-b923-85a96b45a057", + "outputId": "ee334037-7f39-4a91-ad60-f0408a56b4de" + }, + "outputs": [], + "source": [ + "urllib.request.urlretrieve(\n", + " \"https://drive.usercontent.google.com/download?id=1MW1lPgkTq3YG9ikuq6u3d9sfpt-wKQZ0&export=download\",\n", + " \"books__15m.txt\")" + ] + }, + { + "cell_type": "markdown", + "id": "9109d3c0-e276-44cc-9f36-f8c79eb48b31", + "metadata": { + "id": "9109d3c0-e276-44cc-9f36-f8c79eb48b31" + }, + "source": [ + "### 2. First Attempt: Global Memory Histogram\n", + "\n", + "A histogram kernel counts the number of times a value occurs in a dataset. To implement this, we create an array that is large enough to store all possible values (in the case of counting 1-byte ASCII characters, 256 elements). Then for the value of each element in the dataset, we increment its location in the array.\n", + "\n", + "Let's try a simple way to implement this:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61c12795-b14a-4447-9dcf-9748616cc453", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:42:39.421022Z", + "iopub.status.busy": "2026-03-09T19:42:39.420694Z", + "iopub.status.idle": "2026-03-09T19:42:39.428753Z", + "shell.execute_reply": "2026-03-09T19:42:39.427169Z", + "shell.execute_reply.started": "2026-03-09T19:42:39.420984Z" + }, + "id": "61c12795-b14a-4447-9dcf-9748616cc453", + "outputId": "141b29b8-bbf7-4224-c85a-23e49ee7abaf" + }, + "outputs": [], + "source": [ + "bins = 256\n", + "\n", + "values = cp.fromfile(\"books__15m.txt\", dtype=cp.uint8)\n", + "histogram = cp.zeros(bins, dtype=cp.int32)\n", + "\n", + "threads_per_block = 512\n", + "items_per_thread = 8\n", + "items_per_block = threads_per_block * items_per_thread\n", + "blocks = len(values) // items_per_block\n", + "assert values.size % items_per_block == 0\n", + "\n", + "@cuda.jit\n", + "def histogram_global(values, histogram):\n", + " for i in range(items_per_thread):\n", + " value = values[cuda.grid(1) * items_per_thread + i]\n", + " cuda.atomic.add(histogram, value, 1)" + ] + }, + { + "cell_type": "markdown", + "id": "27e30efa-3a37-402f-9414-e444214d8ce6", + "metadata": { + "id": "27e30efa-3a37-402f-9414-e444214d8ce6" + }, + "source": [ + "Now let's make sure it runs and check the output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3f32240-ad7b-4717-98b3-82b0298a099a", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:42:39.429706Z", + "iopub.status.busy": "2026-03-09T19:42:39.429468Z", + "iopub.status.idle": "2026-03-09T19:42:47.995492Z", + "shell.execute_reply": "2026-03-09T19:42:47.994353Z", + "shell.execute_reply.started": "2026-03-09T19:42:39.429687Z" + }, + "id": "b3f32240-ad7b-4717-98b3-82b0298a099a", + "outputId": "cdf42faf-b6e6-45ad-85e2-d3b0d4c87ad5" + }, + "outputs": [], + "source": [ + "histogram[:] = 0\n", + "histogram_global[blocks, threads_per_block](values, histogram)\n", + "assert cp.sum(histogram) == len(values)\n", + "\n", + "histogram_host = cp.asnumpy(histogram)\n", + "\n", + "# Print most frequently occurring characters.\n", + "pairs = sorted(((i, c) for i, c in enumerate(histogram_host) if c), key=lambda x: x[1], reverse=True)[:20]\n", + "labels = [('SPACE' if i == 32 else chr(i)) if 32 <= i <= 126 else f'0x{i:02X}' for i, _ in pairs]\n", + "plt.barh(labels[::-1], [c for _, c in pairs][::-1])\n", + "plt.xlabel('count')\n", + "plt.tight_layout()\n", + "plt.title(\"Top 20 Bins\")\n", + "plt.show()\n", + "\n", + "print(f\"Characters in dataset: {values.size / 1e6:.1f} MB\")" + ] + }, + { + "cell_type": "markdown", + "id": "b14fa522-b41b-4538-8c34-ecc355e55116", + "metadata": { + "id": "b14fa522-b41b-4538-8c34-ecc355e55116" + }, + "source": [ + "### 3. Fixing Data Races with Atomics\n", + "\n", + "It looks like something is wrong - our counts are very low, and the most common characters don't make a lot of sense. Many of our increments seem to get lost!\n", + "\n", + "What's happening here is called a data race. Many different threads are trying to access the bins of the histogram at the same time.\n", + "\n", + "Imagine that two threads are trying to update the same bin:\n", + "\n", + "- Thread 0 reads the count of the bin, which is 0, and stores it in its local variable `old_count`.\n", + "- Thread 0 adds 1 to its `old_count`, producing a `new_count` of 1.\n", + "- Thread 1 reads the count of the bin, which is still 0, and stores it in its local variable `old_count`.\n", + "- Thread 1 adds 1 to its `old_count`, producing a `new_count` of 1.\n", + "- Thread 0 stores `new_count` to the bin, setting it to 1.\n", + "- Thread 1 stores `new_count` to the bin, setting it to 1, and losing the increment from thread 0!\n", + "\n", + "To fix this, we need to use atomic operations. `cuda.atomic.add(array, index, value)` will perform `array[index] += value` as a single indivisible operation. This will ensure that no increments get lost." + ] + }, + { + "cell_type": "markdown", + "id": "08f4dded-26a7-4ef8-b981-e00c569ca4d0", + "metadata": { + "id": "08f4dded-26a7-4ef8-b981-e00c569ca4d0" + }, + "source": [ + "### 4. Profiling the Naive Solution\n", + "\n", + "Select the **Python 3 (Nsight Compute)** kernel, then profile the naive kernel in place. The `%%ncu` magic preserves the arrays and compiled kernel from the earlier cells and displays the report below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8dbd226c-66f2-43df-868a-6b024b1de24c", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:42:50.355341Z", + "iopub.status.busy": "2026-03-09T19:42:50.355104Z", + "iopub.status.idle": "2026-03-09T19:42:59.350039Z", + "shell.execute_reply": "2026-03-09T19:42:59.347797Z", + "shell.execute_reply.started": "2026-03-09T19:42:50.355322Z" + }, + "id": "8dbd226c-66f2-43df-868a-6b024b1de24c", + "outputId": "a082d13a-8e86-436d-c3de-8351f337d824" + }, + "outputs": [], + "source": [ + "%%ncu -o histogram_global.ncu-rep --kernel-name regex:histogram_global\n", + "histogram[:] = 0\n", + "histogram_global[blocks, threads_per_block](values, histogram)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad12380e-253b-4410-ab34-9479411fdf81", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1105, + "referenced_widgets": [ + "a21053863aa949e484f30f47bcf5c045", + "061f8098ad9a430aad63a106b4629fb5", + "5e77669944a346e6b628004967e7ba75", + "804c25036085410fbbcba82f19667d1d", + "face58a5cc384e4cb2b75921d967b2c5", + "899b07bbb6304e2a9143dece489f7775", + "b881cf3221d54ac4a68c38792d53bdf5", + "0d706bed98bf41eeb56205638c01f1ec", + "2a50582b630a4e63a41f10adb53303e2", + "dbf4eef8f47e4789a62977505c049825", + "179ce686ac1f4e60b5d34951446ad599", + "4d9753b4b11f425d9476d4f69932988c", + "15e004fe6f924bf5887331f0ad9da19f", + "f02f5a0cfd354371b95987c862f10637", + "50cc8f97b44549faa0f202361f5f7e6c", + "95e56a0ec4484f06aea59796d88e0464", + "39e58d26a4a84b99ad768f93a7a3a8a5", + "1edda20eae6441a99d31a1105f30b8c6", + "05bad554df84498daf462d395e980f52", + "af61114771e442e2bb8bc033e882dbe2", + "ea81f1518f0d47b995f50df7e74f9329", + "e9a4e840c12041809843a134cc29d791", + "b6bfff789d354bdd8c630826eaae63b6", + "0952fc28a1244c46befc2bed2cd7cb4c", + "83f0975a18e0401d859f001422aebc74", + "24c64a3e43f44f1bb65cee915d1aa9ce", + "63f8786f07e74f4caef3ebf115110daa", + "1006aafeb85f4ae9b1cabe28f33184f4", + "0f94f91896304b3c861750946febeccc", + "f47dbfc47f8647a38c370a2b7be404b2", + "f440961da49a4e6c9185ae5f55373df3", + "607214ca07174871b459e301eb7ad699", + "f1930ef855f844ee947a1c9b07ed95a4" + ] + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:42:59.351619Z", + "iopub.status.busy": "2026-03-09T19:42:59.351019Z", + "iopub.status.idle": "2026-03-09T19:42:59.519590Z", + "shell.execute_reply": "2026-03-09T19:42:59.518487Z", + "shell.execute_reply.started": "2026-03-09T19:42:59.351347Z" + }, + "id": "ad12380e-253b-4410-ab34-9479411fdf81", + "outputId": "f2326771-7c13-46fa-a489-bf494d76cc1e" + }, + "outputs": [], + "source": [ + "assert cp.sum(histogram) == len(values)" + ] + }, + { + "cell_type": "markdown", + "id": "e1f72831-780f-4cf5-8ff1-2092ecb193d9", + "metadata": { + "id": "e1f72831-780f-4cf5-8ff1-2092ecb193d9" + }, + "source": [ + "### 5. Solution: Shared Memory\n", + "\n", + "We improved the code by separating loading values from the histogram update and to perform striped loads (also known as coalesced access) using [cuda.cooperative](https://nvidia.github.io/cccl/unstable/python/coop.html)'s block load instead of doing the I/O by hand.\n", + "\n", + "While that helps a bit, our code still has major issues. It's taking thousand of cycles to issue a single operation!\n", + "\n", + "This is happening due to contention - we have hundreds of thousands of threads performing atomic updates to just 256 bins of a global histogram. All of those atomic operations have to happen in order, so they are serialized by the memory subsystem, destroying our parallelism.\n", + "\n", + "Instead, we can construct a local histogram for each block, which we will update atomically within the block. Then, we synchronize all of the threads within the block, and we perform atomic updates of the global histogram with the aggregate counts t" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cf7c9865-646a-4bbd-9b41-61cadfc5484c", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:42:59.521142Z", + "iopub.status.busy": "2026-03-09T19:42:59.520709Z", + "iopub.status.idle": "2026-03-09T19:42:59.528970Z", + "shell.execute_reply": "2026-03-09T19:42:59.527704Z", + "shell.execute_reply.started": "2026-03-09T19:42:59.521108Z" + }, + "id": "cf7c9865-646a-4bbd-9b41-61cadfc5484c", + "outputId": "851b231f-4431-4cf3-b857-c5a966a7162f" + }, + "outputs": [], + "source": [ + "import cuda.coop as coop\n", + "\n", + "items_per_thread = 8\n", + "items_per_block = threads_per_block * items_per_thread\n", + "blocks = len(values) // items_per_block\n", + "assert values.size % items_per_block == 0\n", + "block_load = coop.block.load(cp.uint8, threads_per_block, items_per_thread, 'striped')\n", + "\n", + "@cuda.jit(link=block_load.files)\n", + "def histogram_localized(values, histogram):\n", + " items = cuda.local.array(items_per_thread, dtype=values.dtype)\n", + "\n", + " base = cuda.blockIdx.x * items_per_block\n", + "\n", + " block_load(values[base : base + items_per_block], items)\n", + "\n", + " local_histogram = cuda.shared.array(bins, dtype=histogram.dtype)\n", + "\n", + " for i in range(0, bins, threads_per_block):\n", + " bin = i + cuda.threadIdx.x\n", + " if bin < local_histogram.size:\n", + " local_histogram[bin] = 0\n", + "\n", + " cuda.syncthreads()\n", + "\n", + " for i in range(items_per_thread):\n", + " cuda.atomic.add(local_histogram, items[i], 1)\n", + "\n", + " cuda.syncthreads()\n", + "\n", + " for i in range(0, bins, threads_per_block):\n", + " bin = i + cuda.threadIdx.x\n", + " if bin < histogram.size:\n", + " cuda.atomic.add(histogram, bin, local_histogram[bin])\n", + "\n", + "def launch_localized():\n", + " histogram_localized[blocks, threads_per_block](values, histogram)" + ] + }, + { + "cell_type": "markdown", + "id": "d2de69a4-644d-481d-b2c7-a8674616e9e2", + "metadata": { + "id": "d2de69a4-644d-481d-b2c7-a8674616e9e2" + }, + "source": [ + "Let's make sure it runs correctly:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b30e9b3-5a4c-4181-b642-b7def5e9f258", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:42:59.530815Z", + "iopub.status.busy": "2026-03-09T19:42:59.530600Z", + "iopub.status.idle": "2026-03-09T19:43:04.084544Z", + "shell.execute_reply": "2026-03-09T19:43:04.083429Z", + "shell.execute_reply.started": "2026-03-09T19:42:59.530797Z" + }, + "id": "1b30e9b3-5a4c-4181-b642-b7def5e9f258", + "outputId": "3a04d1d0-3409-441c-af2a-3138e9dec324" + }, + "outputs": [], + "source": [ + "histogram[:] = 0\n", + "launch_localized()\n", + "assert cp.sum(histogram) == len(values)\n", + "\n", + "histogram_host = cp.asnumpy(histogram)\n", + "pairs = sorted(((i, c) for i, c in enumerate(histogram_host) if c), key=lambda x: x[1], reverse=True)[:20]\n", + "labels = [('SPACE' if i == 32 else chr(i)) if 32 <= i <= 126 else f'0x{i:02X}' for i, _ in pairs]\n", + "plt.barh(labels[::-1], [c for _, c in pairs][::-1])\n", + "plt.xlabel('count')\n", + "plt.tight_layout()\n", + "plt.title(\"Top 20 Bins\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "06857a59-20f3-4e39-aece-18cfbb514170", + "metadata": { + "id": "06857a59-20f3-4e39-aece-18cfbb514170" + }, + "source": [ + "Now let's profile the optimized kernel with the same Nsight Compute kernel:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d637b6b1-fb0b-4807-b70b-c80227c0fd6f", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:43:08.197078Z", + "iopub.status.busy": "2026-03-09T19:43:08.196782Z", + "iopub.status.idle": "2026-03-09T19:43:17.304512Z", + "shell.execute_reply": "2026-03-09T19:43:17.303068Z", + "shell.execute_reply.started": "2026-03-09T19:43:08.197047Z" + }, + "id": "d637b6b1-fb0b-4807-b70b-c80227c0fd6f", + "outputId": "e3790713-5a2e-4b51-dd2c-182b8bc27a5b" + }, + "outputs": [], + "source": [ + "%%ncu -o histogram_localized.ncu-rep --kernel-name regex:histogram_localized\n", + "histogram[:] = 0\n", + "histogram_localized[blocks, threads_per_block](values, histogram)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "114e8ff7-b6fb-42ad-abda-f6d53479c052", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1111, + "referenced_widgets": [ + "72eba4c5f11b49fb89df581268cc1ddc", + "277a7612626b44a0b009d28fe486ab3d", + "6f476f6ca14a45699799d56dedbfc4a9", + "29f54431514146cea6e08d352058ccfd", + "a48f630bb3584ce294f2e79d60c4b40e", + "a122e99121234ce396fa20aa6e462574", + "ea2f4b91760f475ebc10da01166b63b8", + "8d3c55d6d03b4d79aba3d5e7b1a73f2e", + "2a64bde12026429db15794fad73e7759", + "75a37b0df227406d87849b8ccc0e8cc8", + "0dcbea49322640a28c4f3459ea50c204", + "2bf258c6b7734068a20ed1fe5bcacf61", + "a130d437cec947d3ad49ab36fcfe1ef6", + "77b1acef7c294c85a13b408d4763b865", + "b8afefd142c641dd8733d7695d9cf14a", + "6439cea566ff46e08f08022e1c0d40d6", + "c799b237ce024a29b2e23307a7ff9e67", + "c92de1f9425a4f2f85bb07555c3d51a1", + "16d240c1461a444ea9cdbbd4a856e8f3", + "c8a33b735706409e93422519ca88fd38", + "650c0471ffea4b4bad75444a904978b1", + "da059452a8744262a3ebfec6543107bf", + "bb74cb5d3eb042e0b148a8554236a950", + "9f7ceb76de8e4d9c8a6d03403fc87038", + "cb604331c58f43bda3a0135cc761ef22", + "f2ef2b3576a54144a9faadd4ea42f0f4", + "9aee114e6f204e139cad4e9797cd9cc5", + "9c1ff914f65345ddba6465fc350ffa77", + "2ebcfbe930ca4061a86c8f1ea87b8eb0", + "64037d07a15d4e238471eeccce3d0ead", + "f261d85898c54dc9a72eaf5bda2703ca", + "d7db1b0b42b948ae8c3b97b059f68cdb", + "fd8251af0c4743e29b89a76a73a27af4" + ] + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:43:17.306084Z", + "iopub.status.busy": "2026-03-09T19:43:17.305764Z", + "iopub.status.idle": "2026-03-09T19:43:17.384239Z", + "shell.execute_reply": "2026-03-09T19:43:17.382943Z", + "shell.execute_reply.started": "2026-03-09T19:43:17.306044Z" + }, + "id": "114e8ff7-b6fb-42ad-abda-f6d53479c052", + "outputId": "1936cb5f-6708-45bc-ee39-e01ddc857b89" + }, + "outputs": [], + "source": [ + "assert cp.sum(histogram) == len(values)" + ] + }, + { + "cell_type": "markdown", + "id": "9aa1da9a-097a-4f7f-9e96-8891f5d7a2a2", + "metadata": { + "id": "9aa1da9a-097a-4f7f-9e96-8891f5d7a2a2" + }, + "source": [ + "### 6. Performance Comparison\n", + "\n", + "Finally, let's benchmark our two approaches." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2a3f9ca4-b61b-4536-9896-7a41498cc986", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "execution": { + "iopub.execute_input": "2026-03-09T19:43:17.385448Z", + "iopub.status.busy": "2026-03-09T19:43:17.385083Z", + "iopub.status.idle": "2026-03-09T19:43:23.549772Z", + "shell.execute_reply": "2026-03-09T19:43:23.548634Z", + "shell.execute_reply.started": "2026-03-09T19:43:17.385418Z" + }, + "id": "2a3f9ca4-b61b-4536-9896-7a41498cc986", + "outputId": "08bb2e07-1dda-4cf1-c2d3-7125d575cd8f" + }, + "outputs": [], + "source": [ + "global_times = cpx.profiler.benchmark(lambda: histogram_global[blocks, threads_per_block](values, histogram), n_repeat=15, n_warmup=4).gpu_times[0]\n", + "localized_times = cpx.profiler.benchmark(launch_localized, n_repeat=15, n_warmup=4).gpu_times[0]\n", + "histogram_global_duration = global_times.mean() * 1000\n", + "histogram_localized_duration = localized_times.mean() * 1000\n", + "speedup = histogram_global_duration / histogram_localized_duration\n", + "\n", + "print(f\"histogram_global: {histogram_global_duration:.3g} ms\")\n", + "print(f\"histogram_localized: {histogram_localized_duration:.3g} ms\")\n", + "print(f\"histogram_localized speedup over histogram_global: {speedup:.2f}\")" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (Nsight Compute)", + "language": "python", + "name": "nsightful-ncu" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/06__mpi4py__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/06__mpi4py__SOLUTION.ipynb new file mode 100644 index 00000000..adc25c01 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/06__mpi4py__SOLUTION.ipynb @@ -0,0 +1,782 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "mpi-title", + "metadata": {}, + "source": [ + "## mpi4py - SOLUTION\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Environment Setup](#1-environment-setup)\n", + "2. [One Program, Many Processes](#2-one-program-many-processes)\n", + "3. [Solution 1: Distributed Sum of Squares](#3-solution-1-distributed-sum-of-squares)\n", + "4. [Domain Decomposition and Halo Exchange](#4-domain-decomposition-and-halo-exchange)\n", + "5. [The Serial Heat Equation Baseline](#5-the-serial-heat-equation-baseline)\n", + "6. [Solution 2: Distributed Heat Equation](#6-solution-2-distributed-heat-equation)\n", + "7. [Verification and Visualization](#7-verification-and-visualization)\n", + "8. [Key Takeaways](#8-key-takeaways)\n", + "\n", + "---\n", + "\n", + "### 1. Environment Setup\n", + "\n", + "[MPI](https://www.mpi-forum.org/) (Message Passing Interface) lets independent processes cooperate by sending data to one another. [mpi4py](https://mpi4py.readthedocs.io/en/stable/) exposes MPI in Python while supporting efficient communication directly from NumPy arrays.\n", + "\n", + "In this notebook, we'll learn to:\n", + "\n", + "1. Launch the same Python program as several MPI processes.\n", + "2. Combine distributed results with a collective operation.\n", + "3. Split a two-dimensional grid into row-wise subdomains.\n", + "4. Exchange halo rows between neighboring processes.\n", + "5. Verify a distributed heat-equation stencil against a serial reference.\n", + "\n", + "First, let's make sure the MPI launcher and Python environment are ready. The setup selects MPICH's local `fork` launcher in the CSCS environment and Open MPI's oversubscription mode in Colab or a local tutorial environment. MPI does not use the GPU in this lesson; the standard GPU notebook environment is retained so this notebook composes with the rest of the tutorial." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "mpi-setup", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:58.479544Z", + "iopub.status.busy": "2026-07-21T13:23:58.479350Z", + "iopub.status.idle": "2026-07-21T13:23:58.747402Z", + "shell.execute_reply": "2026-07-21T13:23:58.746728Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "import shutil\n", + "import subprocess\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "# Install Open MPI and mpi4py if running in Google Colab.\n", + "colab_marker = Path(\"/tmp/accelerated-computing-hub-mpi4py-installed\")\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not colab_marker.exists():\n", + " print(\"Installing Open MPI and mpi4py.\")\n", + " subprocess.run([\"apt-get\", \"-qq\", \"update\"], check=True)\n", + " subprocess.run(\n", + " [\"apt-get\", \"-qq\", \"install\", \"-y\", \"openmpi-bin\", \"libopenmpi-dev\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " subprocess.run(\n", + " [sys.executable, \"-m\", \"pip\", \"install\", \"mpi4py\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " colab_marker.touch()\n", + " print(\"Open MPI and mpi4py installed.\")\n", + "\n", + "# Open MPI protects against accidental root launches. Colab and the tutorial\n", + "# container are isolated environments where explicitly allowing this is safe.\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT\", \"1\")\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT_CONFIRM\", \"1\")\n", + "\n", + "# Match the launcher to the MPI library against which mpi4py was built.\n", + "vendor_result = subprocess.run(\n", + " [\n", + " sys.executable,\n", + " \"-c\",\n", + " \"from mpi4py import MPI; print(MPI.get_vendor()[0])\",\n", + " ],\n", + " check=True,\n", + " capture_output=True,\n", + " text=True,\n", + ")\n", + "MPI_VENDOR = vendor_result.stdout.strip()\n", + "\n", + "if MPI_VENDOR == \"MPICH\":\n", + " mpi_launcher = (\n", + " shutil.which(\"mpirun.mpich\")\n", + " or shutil.which(\"mpiexec.mpich\")\n", + " or shutil.which(\"mpiexec\")\n", + " )\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No MPICH launcher was found\")\n", + " # Do not delegate this nested launch back to Slurm on CSCS.\n", + " MPI_LAUNCHER = [mpi_launcher, \"-launcher\", \"fork\"]\n", + "elif MPI_VENDOR == \"Open MPI\":\n", + " mpi_launcher = shutil.which(\"mpirun.openmpi\") or shutil.which(\"mpirun\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No Open MPI launcher was found\")\n", + " MPI_LAUNCHER = [mpi_launcher, \"--oversubscribe\"]\n", + "else:\n", + " mpi_launcher = shutil.which(\"mpiexec\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(f\"No launcher was found for {MPI_VENDOR}\")\n", + " MPI_LAUNCHER = [mpi_launcher]\n", + "\n", + "def run_program(command):\n", + " '''Run a child program, display its output, and fail on errors.'''\n", + " result = subprocess.run(\n", + " command,\n", + " capture_output=True,\n", + " text=True,\n", + " timeout=180,\n", + " )\n", + " print(result.stdout, end=\"\")\n", + " if result.stderr:\n", + " print(result.stderr, end=\"\", file=sys.stderr)\n", + " result.check_returncode()\n", + "\n", + "def run_mpi(rank_count, script):\n", + " '''Run a Python script under the selected MPI implementation.'''\n", + " command = [\n", + " *MPI_LAUNCHER,\n", + " \"-n\",\n", + " str(rank_count),\n", + " sys.executable,\n", + " \"-u\",\n", + " script,\n", + " ]\n", + " run_program(command)\n", + "\n", + "def run_python(script):\n", + " '''Run a serial Python reference with the notebook's interpreter.'''\n", + " run_program([sys.executable, \"-u\", script])" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-model", + "metadata": {}, + "source": [ + "### 2. One Program, Many Processes\n", + "\n", + "MPI follows the **single program, multiple data** model: every process runs the same script, but each process has a different integer **rank**. The processes belong to a **communicator**; `MPI.COMM_WORLD` contains every process launched for the program.\n", + "\n", + "The notebook kernel itself is a single process, so MPI examples need to be written to Python files and launched with `mpirun`. Passing `4` to the `run_mpi` helper below adds `-n 4` to the launcher and creates four independent Python processes. Each process discovers:\n", + "\n", + "- its own rank with `comm.Get_rank()`;\n", + "- the communicator size with `comm.Get_size()`; and\n", + "- its host name with `MPI.Get_processor_name()`.\n", + "\n", + "Calling `gather` is a **collective operation**: every rank participates, and rank 0 receives one value from each rank. Gathering the values before printing makes the rank order deterministic, even though host names depend on the system running the notebook." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "mpi-hello-write", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:58.749846Z", + "iopub.status.busy": "2026-07-21T13:23:58.749655Z", + "iopub.status.idle": "2026-07-21T13:23:58.753243Z", + "shell.execute_reply": "2026-07-21T13:23:58.752548Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing hello_mpi.py\n" + ] + } + ], + "source": [ + "%%writefile hello_mpi.py\n", + "\n", + "from mpi4py import MPI\n", + "\n", + "comm = MPI.COMM_WORLD\n", + "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "\n", + "message = f\"rank {rank} of {size} on {MPI.Get_processor_name()}\"\n", + "messages = comm.gather(message, root=0)\n", + "\n", + "if rank == 0:\n", + " print(f\"Launched {size} MPI processes:\")\n", + " for message in messages:\n", + " print(f\" {message}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "mpi-hello-run", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:58.754770Z", + "iopub.status.busy": "2026-07-21T13:23:58.754603Z", + "iopub.status.idle": "2026-07-21T13:23:59.047302Z", + "shell.execute_reply": "2026-07-21T13:23:59.046593Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Launched 4 MPI processes:\n", + " rank 0 of 4 on brev-9ox5z6vdv\n", + " rank 1 of 4 on brev-9ox5z6vdv\n", + " rank 2 of 4 on brev-9ox5z6vdv\n", + " rank 3 of 4 on brev-9ox5z6vdv\n" + ] + } + ], + "source": [ + "run_mpi(4, \"hello_mpi.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-simple-intro", + "metadata": {}, + "source": [ + "### 3. Solution 1: Distributed Sum of Squares\n", + "\n", + "Our first collective gathered Python strings. Now let's distribute numerical work. Rank 0 creates the integers 1 through 23 and `scatter` sends one NumPy chunk to each rank. The chunks may have different lengths, which the lowercase object-based `scatter` method handles naturally.\n", + "\n", + "Each rank computes a local sum of squares. A single `reduce` with `op=MPI.SUM` then combines those partial results on rank 0. This is the only communication needed for the calculation." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "mpi-simple-write", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:59.049090Z", + "iopub.status.busy": "2026-07-21T13:23:59.048943Z", + "iopub.status.idle": "2026-07-21T13:23:59.052153Z", + "shell.execute_reply": "2026-07-21T13:23:59.051552Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing distributed_sum_squares.py\n" + ] + } + ], + "source": [ + "%%writefile distributed_sum_squares.py\n", + "\n", + "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", + "comm = MPI.COMM_WORLD\n", + "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "\n", + "values = np.arange(1, 24, dtype=np.int64) if rank == 0 else None\n", + "chunks = np.array_split(values, size) if rank == 0 else None\n", + "local_values = comm.scatter(chunks, root=0)\n", + "\n", + "local_sum_squares = np.dot(local_values, local_values)\n", + "\n", + "# Add every rank's partial result and return the total to rank 0.\n", + "global_sum_squares = comm.reduce(local_sum_squares, op=MPI.SUM, root=0)\n", + "\n", + "if rank == 0:\n", + " expected = np.dot(values, values)\n", + " assert global_sum_squares == expected\n", + " print(f\"sum(i**2 for i in 1..23) = {global_sum_squares}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "mpi-simple-run", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:59.053841Z", + "iopub.status.busy": "2026-07-21T13:23:59.053704Z", + "iopub.status.idle": "2026-07-21T13:23:59.959851Z", + "shell.execute_reply": "2026-07-21T13:23:59.959204Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sum(i**2 for i in 1..23) = 4324\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sum(i**2 for i in 1..23) = 4324\n" + ] + } + ], + "source": [ + "run_mpi(1, \"distributed_sum_squares.py\")\n", + "run_mpi(4, \"distributed_sum_squares.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-think", + "metadata": {}, + "source": [ + "#### Think About It\n", + "\n", + "What does `global_sum_squares` contain on ranks other than rank 0? When would `allreduce` be more appropriate than `reduce`?\n", + "\n", + "\n", + "**SOLUTION:** On every non-root rank, `global_sum_squares` is `None`. Only rank 0 needs the total for validation, so `reduce` avoids sending the same answer back to every rank. `allreduce` would be appropriate if subsequent work on every rank required the global total." + ] + }, + { + "cell_type": "markdown", + "id": "mpi-decomposition", + "metadata": {}, + "source": [ + "### 4. Domain Decomposition and Halo Exchange\n", + "\n", + "Collectives move data across an entire communicator. Stencil computations have a more local communication pattern: each grid point depends only on nearby points. We can split a $66 \\times 66$ grid by rows and give each of four ranks 16 of the 64 interior rows:\n", + "\n", + "```text\n", + "global row: 0 | 1 ... 16 | 17 ... 32 | 33 ... 48 | 49 ... 64 | 65\n", + "owner: boundary | rank 0 | rank 1 | rank 2 | rank 3 | boundary\n", + "```\n", + "\n", + "Every rank stores two extra **halo rows** around its owned rows:\n", + "\n", + "```text\n", + " top halo\n", + " +----------------+\n", + " | owned rows |\n", + " +----------------+\n", + " bottom halo\n", + "```\n", + "\n", + "Before each stencil update, adjacent ranks exchange their edge rows. `MPI.PROC_NULL` represents a missing neighbor at a physical boundary: communication with it completes immediately and leaves the receive buffer unchanged. Using `Sendrecv` pairs a send and receive in one operation, avoiding the deadlock risks of separate blocking sends.\n", + "\n", + "For NumPy arrays, mpi4py's uppercase methods such as `Sendrecv` and `Gather` communicate typed buffers directly. The lowercase methods used above can serialize general Python objects; uppercase methods are the natural choice for fixed-shape numerical arrays." + ] + }, + { + "cell_type": "markdown", + "id": "mpi-serial-intro", + "metadata": {}, + "source": [ + "### 5. The Serial Heat Equation Baseline\n", + "\n", + "The two-dimensional heat equation is\n", + "\n", + "$$\n", + "\\frac{\\partial u}{\\partial t}\n", + "= \\kappa \\left(\\frac{\\partial^2 u}{\\partial x^2}\n", + "+ \\frac{\\partial^2 u}{\\partial y^2}\\right).\n", + "$$\n", + "\n", + "For equal grid spacing $h = \\Delta x = \\Delta y$, define the dimensionless diffusion number\n", + "\n", + "$$r = \\frac{\\kappa\\,\\Delta t}{h^2}.$$\n", + "\n", + "A five-point finite-difference stencil then advances one time step:\n", + "\n", + "$$\n", + "u^{n+1}_{i,j} = u^n_{i,j} + r\\left(\n", + "u^n_{i-1,j} + u^n_{i+1,j} + u^n_{i,j-1} + u^n_{i,j+1}\n", + "- 4u^n_{i,j}\\right).\n", + "$$\n", + "\n", + "We use $r=0.2$, below the two-dimensional explicit stability limit $r \\leq 1/4$, and hold the outer boundary at zero. The complete serial implementation below gives us a trusted reference before communication is introduced." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "mpi-serial-write", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:59.961945Z", + "iopub.status.busy": "2026-07-21T13:23:59.961794Z", + "iopub.status.idle": "2026-07-21T13:23:59.965867Z", + "shell.execute_reply": "2026-07-21T13:23:59.965259Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing heat_reference.py\n" + ] + } + ], + "source": [ + "%%writefile heat_reference.py\n", + "\n", + "import numpy as np\n", + "\n", + "NY = 66\n", + "NX = 66\n", + "STEPS = 120\n", + "DIFFUSION_NUMBER = 0.2\n", + "\n", + "def initial_temperature_rows(global_rows, ny=NY, nx=NX):\n", + " \"\"\"Create the initial hot disk for selected global rows.\"\"\"\n", + " y = np.asarray(global_rows)[:, np.newaxis]\n", + " x = np.arange(nx)[np.newaxis, :]\n", + " center_y = (ny - 1) / 2\n", + " center_x = (nx - 1) / 2\n", + " hot = (y - center_y) ** 2 + (x - center_x) ** 2 <= 6**2\n", + " temperature = hot.astype(np.float64)\n", + " temperature[:, 0] = 0.0\n", + " temperature[:, -1] = 0.0\n", + " return temperature\n", + "\n", + "def initial_temperature(ny=NY, nx=NX):\n", + " \"\"\"Create the full initial field with zero-valued boundaries.\"\"\"\n", + " temperature = np.zeros((ny, nx), dtype=np.float64)\n", + " temperature[1:-1] = initial_temperature_rows(range(1, ny - 1), ny, nx)\n", + " return temperature\n", + "\n", + "def advance(temperature, diffusion_number=DIFFUSION_NUMBER):\n", + " \"\"\"Apply one serial five-point stencil update.\"\"\"\n", + " next_temperature = np.zeros_like(temperature)\n", + " center = temperature[1:-1, 1:-1]\n", + " next_temperature[1:-1, 1:-1] = center + diffusion_number * (\n", + " temperature[:-2, 1:-1]\n", + " + temperature[2:, 1:-1]\n", + " + temperature[1:-1, :-2]\n", + " + temperature[1:-1, 2:]\n", + " - 4.0 * center\n", + " )\n", + " return next_temperature\n", + "\n", + "def solve_serial(steps=STEPS):\n", + " \"\"\"Evolve the complete grid on one process.\"\"\"\n", + " temperature = initial_temperature()\n", + " for _ in range(steps):\n", + " temperature = advance(temperature)\n", + " return temperature\n", + "\n", + "if __name__ == \"__main__\":\n", + " temperature = solve_serial()\n", + " assert np.isfinite(temperature).all()\n", + " assert np.all(temperature >= 0.0)\n", + " assert np.all(temperature <= 1.0)\n", + " assert np.all(temperature[[0, -1], :] == 0.0)\n", + " assert np.all(temperature[:, [0, -1]] == 0.0)\n", + " print(\n", + " f\"Serial reference passed: shape={temperature.shape}, \"\n", + " f\"maximum={temperature.max():.6f}\"\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "mpi-serial-run", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:59.967536Z", + "iopub.status.busy": "2026-07-21T13:23:59.967386Z", + "iopub.status.idle": "2026-07-21T13:24:00.087733Z", + "shell.execute_reply": "2026-07-21T13:24:00.087058Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Serial reference passed: shape=(66, 66), maximum=0.308333\n" + ] + } + ], + "source": [ + "run_python(\"heat_reference.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-advanced-intro", + "metadata": {}, + "source": [ + "### 6. Solution 2: Distributed Heat Equation\n", + "\n", + "The distributed solver retains the serial stencil but stores only one row slab per rank. Two `Sendrecv` calls fill the top and bottom halos before every update. Because all four ranks own the same number of rows, one buffer-based `Gather` reconstructs the field on rank 0.\n", + "\n", + "The communication is deliberately isolated in `exchange_halos`, while `advance_local` contains only the numerical stencil. Keeping those responsibilities separate makes both pieces easier to reason about and verify." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "mpi-heat-write", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:24:00.089877Z", + "iopub.status.busy": "2026-07-21T13:24:00.089711Z", + "iopub.status.idle": "2026-07-21T13:24:00.094249Z", + "shell.execute_reply": "2026-07-21T13:24:00.093546Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing heat_mpi.py\n" + ] + } + ], + "source": [ + "%%writefile heat_mpi.py\n", + "\n", + "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", + "from heat_reference import (\n", + " DIFFUSION_NUMBER,\n", + " NX,\n", + " NY,\n", + " STEPS,\n", + " initial_temperature,\n", + " initial_temperature_rows,\n", + " solve_serial,\n", + ")\n", + "\n", + "def decompose_rows(comm):\n", + " \"\"\"Return the local row count and first owned global row.\"\"\"\n", + " interior_rows = NY - 2\n", + " if interior_rows % comm.Get_size() != 0:\n", + " raise ValueError(\"The interior row count must be divisible by the rank count\")\n", + " local_rows = interior_rows // comm.Get_size()\n", + " first_global_row = 1 + comm.Get_rank() * local_rows\n", + " return local_rows, first_global_row\n", + "\n", + "def exchange_halos(temperature, comm):\n", + " \"\"\"Exchange edge rows with the ranks above and below.\"\"\"\n", + " rank = comm.Get_rank()\n", + " above = rank - 1 if rank > 0 else MPI.PROC_NULL\n", + " below = rank + 1 if rank + 1 < comm.Get_size() else MPI.PROC_NULL\n", + "\n", + " # Send the first owned row up and receive the bottom halo from below.\n", + " comm.Sendrecv(\n", + " sendbuf=temperature[1],\n", + " dest=above,\n", + " sendtag=0,\n", + " recvbuf=temperature[-1],\n", + " source=below,\n", + " recvtag=0,\n", + " )\n", + "\n", + " # Send the last owned row down and receive the top halo from above.\n", + " comm.Sendrecv(\n", + " sendbuf=temperature[-2],\n", + " dest=below,\n", + " sendtag=1,\n", + " recvbuf=temperature[0],\n", + " source=above,\n", + " recvtag=1,\n", + " )\n", + "\n", + "def advance_local(temperature):\n", + " \"\"\"Apply one five-point update to the locally owned rows.\"\"\"\n", + " next_temperature = np.zeros_like(temperature)\n", + " center = temperature[1:-1, 1:-1]\n", + " next_temperature[1:-1, 1:-1] = center + DIFFUSION_NUMBER * (\n", + " temperature[:-2, 1:-1]\n", + " + temperature[2:, 1:-1]\n", + " + temperature[1:-1, :-2]\n", + " + temperature[1:-1, 2:]\n", + " - 4.0 * center\n", + " )\n", + " return next_temperature\n", + "\n", + "def gather_field(local_temperature, local_rows, comm):\n", + " \"\"\"Gather equal-sized row slabs and restore the physical boundaries.\"\"\"\n", + " owned_rows = np.ascontiguousarray(local_temperature[1:-1])\n", + " gathered = None\n", + " if comm.Get_rank() == 0:\n", + " gathered = np.empty((comm.Get_size(), local_rows, NX), dtype=np.float64)\n", + "\n", + " comm.Gather(owned_rows, gathered, root=0)\n", + "\n", + " if comm.Get_rank() != 0:\n", + " return None\n", + "\n", + " temperature = np.zeros((NY, NX), dtype=np.float64)\n", + " temperature[1:-1] = gathered.reshape(NY - 2, NX)\n", + " return temperature\n", + "\n", + "def main():\n", + " comm = MPI.COMM_WORLD\n", + " local_rows, first_global_row = decompose_rows(comm)\n", + " global_rows = np.arange(first_global_row, first_global_row + local_rows)\n", + "\n", + " temperature = np.zeros((local_rows + 2, NX), dtype=np.float64)\n", + " temperature[1:-1] = initial_temperature_rows(global_rows)\n", + "\n", + " for _ in range(STEPS):\n", + " exchange_halos(temperature, comm)\n", + " temperature = advance_local(temperature)\n", + "\n", + " distributed = gather_field(temperature, local_rows, comm)\n", + "\n", + " if comm.Get_rank() == 0:\n", + " reference = solve_serial()\n", + " np.testing.assert_allclose(distributed, reference, rtol=1e-13, atol=1e-13)\n", + " assert np.isfinite(distributed).all()\n", + " assert np.all(distributed[[0, -1], :] == 0.0)\n", + " assert np.all(distributed[:, [0, -1]] == 0.0)\n", + " np.savez(\n", + " \"heat_equation_result.npz\",\n", + " initial=initial_temperature(),\n", + " final=distributed,\n", + " steps=STEPS,\n", + " )\n", + " print(\n", + " \"Distributed heat equation matches the serial reference: \"\n", + " f\"shape={distributed.shape}, maximum={distributed.max():.6f}\"\n", + " )\n", + "\n", + "if __name__ == \"__main__\":\n", + " main()" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-verify-intro", + "metadata": {}, + "source": [ + "### 7. Verification and Visualization\n", + "\n", + "Correctness comes before interpretation. The MPI program gathers the distributed row slabs on rank 0, compares every value with `solve_serial()` using `numpy.testing.assert_allclose`, checks the fixed boundaries, and only then saves the initial and final fields for visualization.\n", + "\n", + "Let's run the distributed solution with the four ranks used by our decomposition." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "mpi-heat-run", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:24:00.095879Z", + "iopub.status.busy": "2026-07-21T13:24:00.095737Z", + "iopub.status.idle": "2026-07-21T13:24:00.619325Z", + "shell.execute_reply": "2026-07-21T13:24:00.618472Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Distributed heat equation matches the serial reference: shape=(66, 66), maximum=0.308333\n" + ] + } + ], + "source": [ + "result_path = Path(\"heat_equation_result.npz\")\n", + "result_path.unlink(missing_ok=True)\n", + "run_mpi(4, \"heat_mpi.py\")\n", + "assert result_path.exists()" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "mpi-plot", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:24:00.621052Z", + "iopub.status.busy": "2026-07-21T13:24:00.620859Z", + "iopub.status.idle": "2026-07-21T13:24:01.841750Z", + "shell.execute_reply": "2026-07-21T13:24:01.840936Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA7QAAAGbCAYAAAD0otxkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAAYbxJREFUeJzt3Xl8VNX5x/HvTcgChIRFSNgERBQVEA0aIygukdSFSlFBSwVxt4BKfrRCWxY3UFsRWxCUVrEWi6LFWhcUI6AVBEGoWBRRUVBJAJUEgmSZOb8/aEaGe4BZQuZO8nn7ui/JmbucMzOZM0/OPc9xjDFGAAAAAADEmYRYVwAAAAAAgEgQ0AIAAAAA4hIBLQAAAAAgLhHQAgAAAADiEgEtAAAAACAuEdACAAAAAOISAS0AAAAAIC4R0AIAAAAA4hIBLQAAAAAgLhHQImYcx9GkSZNC2rdjx4665pprwr7GF198IcdxNGfOnLCPBQCgtj311FPq2rWrkpKS1LRp01hXBwA8j4AWEZszZ44cx9GqVatq5HzLli3TpEmTtHPnzho5XzjWr1+vSZMm6Ysvvqj1a3vdK6+8EvIfHgAAB/fII4/IcRzl5ORYH//44491zTXXqHPnzpo9e7Yee+wx7dmzR5MmTdKSJUtqta4zZ87UFVdcoaOPPlqO4xz0j8qFhYW69tprddxxx6lRo0Y65phjdP3112vr1q3W/ZctW6Y+ffqoUaNGysrK0q233qrdu3fXSJ1j+T0CQOw0iHUFUH/98MMPatDgx7fgsmXLdOedd+qaa65x/VV6w4YNSkg4cn9/Wb9+ve68806dc8456tix4xG7Tjx65ZVXNGPGDIJaAIjS3Llz1bFjR61cuVKffvqpjj322KDHlyxZIr/fr4cffjjw2I4dO3TnnXdKks4555xaq+v999+vXbt26fTTTz9ocCpJd9xxh7777jtdccUV6tKliz7//HNNnz5dL730ktauXausrKzAvmvXrtX555+vE044QVOnTtVXX32lP/zhD9q4caNeffXVqOt8qO8RAOouAlrETGpqasj7pqSkHMGa1C9lZWVq3LhxrKvhmXoAQG3YtGmTli1bpn/84x+66aabNHfuXE2cODFon23btklSrQRjh/sMXrp0aWB0Ni0t7aD7TZ06VX369An6o/NPfvIT9e3bV9OnT9c999wTKP/Nb36jZs2aacmSJUpPT5e0b0rRDTfcoNdff139+vWrgZYBqG+45Rg16pprrlFaWpq+/vprDRgwQGlpaWrZsqXGjBkjn88XtO/+c2gnTZqkX/3qV5KkTp06yXEcOY4TuAX4wDm03333ncaMGaPu3bsrLS1N6enpuvDCC/Wf//wn7DrPmTNHV1xxhSTp3HPPDVx7/9u7Xn31VZ111llq3LixmjRpoosvvlj//e9/rW3fvHmzLrnkEqWlpalt27aaMWOGJGndunU677zz1LhxY3Xo0EFPP/20qx6O4+itt97STTfdpBYtWig9PV1Dhw7V999/76p3OHX67LPPdNFFF6lJkyYaMmSIJOntt98O3E6WkpKi9u3ba/To0frhhx+Cjq+uf/Xz4jiOpH0jCQc+T5J93vKh6uH3+zVt2jSddNJJSk1NVWZmpm666SZrmwEgXs2dO1fNmjXTxRdfrMsvv1xz584Nerxjx46BALdly5aB23xbtmwpSbrzzjsDn8H73zHz8ccf6/LLL1fz5s2VmpqqXr166cUXXww6d3X/snTpUv3yl79Uq1at1K5du0PWt0OHDoHP+0M5++yzXXdQnX322WrevLk++uijQFlpaakWLVqkX/ziF4FgVpKGDh2qtLQ0Pfvss4e91p/+9CeddNJJatSokZo1a6ZevXoF+tLDfY+QpL/97W/Kzs5Ww4YN1bx5c1155ZXasmVL0DXOOeccdevWTatXr9aZZ56phg0bqlOnTpo1a1ZY9QFQexihRY3z+XzKz89XTk6O/vCHP+iNN97Qgw8+qM6dO+uWW26xHjNw4EB98skn+vvf/66HHnpIRx11lCQFOvIDff7553rhhRd0xRVXqFOnTiouLtajjz6qvn37av369WrTpk3I9T377LN166236o9//KN+85vf6IQTTpCkwP+feuopDRs2TPn5+br//vu1Z88ezZw5U3369NGaNWuCblH2+Xy68MILdfbZZ+uBBx7Q3LlzNXLkSDVu3Fi//e1vNWTIEA0cOFCzZs3S0KFDlZubq06dOgXVZ+TIkWratKkmTZqkDRs2aObMmfryyy8DAWS4daqqqlJ+fr769OmjP/zhD2rUqJEkaf78+dqzZ49uueUWtWjRQitXrtSf/vQnffXVV5o/f74k6aabbtI333yjRYsW6amnngr5ObU5WD1uuukmzZkzR8OHD9ett96qTZs2afr06VqzZo3eeecdJSUlRXVdAPCCuXPnauDAgUpOTtZVV12lmTNn6r333tNpp50mSZo2bZr++te/asGCBZo5c6bS0tLUvXt3nXHGGbrlllv0s5/9TAMHDpQk9ejRQ5L03//+V71791bbtm01duxYNW7cWM8++6wGDBig559/Xj/72c+C6vDLX/5SLVu21IQJE1RWVnbE2rp7927t3r070JdL+/6oW1VVpV69egXtm5ycrJ49e2rNmjWHPOfs2bN166236vLLL9dtt92mvXv36oMPPtCKFSv085///LDfI+69916NHz9egwYN0vXXX6/t27frT3/6k84++2ytWbMmaFT8+++/10UXXaRBgwbpqquu0rPPPqtbbrlFycnJuvbaa0OqD4BaZIAIPfHEE0aSee+99wJlw4YNM5LMXXfdFbTvKaecYrKzs4PKJJmJEycGfv79739vJJlNmza5rtWhQwczbNiwwM979+41Pp8vaJ9NmzaZlJSUoGtv2rTJSDJPPPHEIdsyf/58I8ksXrw4qHzXrl2madOm5oYbbggqLyoqMhkZGUHl1W2fPHlyoOz77783DRs2NI7jmHnz5gXKP/74Y1f7q5/P7OxsU1FRESh/4IEHjCTzz3/+M+I6jR071tXmPXv2uMqmTJliHMcxX375ZaBsxIgRxvZRsXjxYutzZnvOD1aPt99+20gyc+fODSpfuHChtRwA4tGqVauMJLNo0SJjjDF+v9+0a9fO3HbbbUH7TZw40Ugy27dvD5Rt377d1V9UO//880337t3N3r17A2V+v9+ceeaZpkuXLoGy6v6lT58+pqqqKuz6N27cOKgPPpy7777bSDKFhYWBsup+9q233nLtf8UVV5isrKxDnvPSSy81J5100iH3Odj3iC+++MIkJiaae++9N6h83bp1pkGDBkHlffv2NZLMgw8+GCgrLy83PXv2NK1atQr0z6HUB0Dt4JZjHBE333xz0M9nnXWWPv/88xo7f0pKSuAWJ5/Pp2+//VZpaWk6/vjj9f7779fYdRYtWqSdO3fqqquu0o4dOwJbYmKicnJytHjxYtcx119/feDfTZs21fHHH6/GjRtr0KBBgfLjjz9eTZs2tT4nN954Y9Co5C233KIGDRrolVdeibhOtpHxhg0bBv5dVlamHTt26Mwzz5Qx5rB/KY/UgfWYP3++MjIy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fLTBZv+sj8bERzv+7//+zyxZssRs2rTJvPPOOyYvL88cddRRZtu2bcaY+GjDypUrTYMGDcy9995rNm7caObOnWsaNWpk/va3vwX28frvNryN/i426kKfR3/nHfR33vndRnyoFwGtMcb86U9/MkcffbRJTk42p59+unn33XdjXaVDWrx4sZHk2oYNG2aM2ZfufPz48SYzM9OkpKSY888/32zYsCG2ld6Pre6SzBNPPBHY54cffjC//OUvTbNmzUyjRo3Mz372M7N169bYVdri2muvNR06dDDJycmmZcuW5vzzzw907sbERxtsDuzg46EdgwcPNq1btzbJycmmbdu2ZvDgwebTTz8NPB4PbTDGmH/961+mW7duJiUlxXTt2tU89thjQY97/Xcb3kd/V/vqQp9Hf+cd9HdAeBxjjKm98WAAAAAAAGpGnZ9DCwAAAAComwhoAQAAAABxiYAWAAAAABCXCGgBAAAAAHGJgBYAAAAAEJcIaAEAAAAAcYmAFgAAAAAQlwhoAQAAAABxiYAWAAAAABCXCGgBAAAAAHGJgBYAAAAAEJcIaIEjaPv27crKytLkyZMDZcuWLVNycrIKCwtjWDMAAGoWfR6AWHCMMSbWlQDqsldeeUUDBgzQsmXLdPzxx6tnz5669NJLNXXq1FhXDQCAGkWfB6C2EdACtWDEiBF644031KtXL61bt07vvfeeUlJSYl0tAABqHH0egNpEQAvUgh9++EHdunXTli1btHr1anXv3j3WVQIA4IigzwNQm5hDC9SCzz77TN988438fr+++OKLWFcHAIAjhj4PQG1ihBY4wioqKnT66aerZ8+eOv744zVt2jStW7dOrVq1inXVAACoUfR5AGobAS1whP3qV7/Sc889p//85z9KS0tT3759lZGRoZdeeinWVQMAoEbR5wGobdxyDBxBS5Ys0bRp0/TUU08pPT1dCQkJeuqpp/T2229r5syZsa4eAAA1hj4PQCwwQgsAAAAAiEuM0AIAAAAA4hIBLQAAAAAgLhHQAgAAAADiEgEtAAAAACAuEdACAAAAAOISAS0AAAAAIC4R0AIAAAAA4hIBLQAAAAAgLhHQAgAAAADiEgEtAAAAACAuEdACAAAAAOLS/wPycjEq9aFUpgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "with np.load(\"heat_equation_result.npz\") as result:\n", + " initial = result[\"initial\"]\n", + " final = result[\"final\"]\n", + " steps = int(result[\"steps\"])\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(10, 4), constrained_layout=True)\n", + "for axis, field, title in zip(\n", + " axes,\n", + " (initial, final),\n", + " (\"Initial temperature\", f\"After {steps} steps\"),\n", + "):\n", + " image = axis.imshow(field, origin=\"lower\", cmap=\"inferno\", vmin=0.0, vmax=1.0)\n", + " axis.set_title(title)\n", + " axis.set_xlabel(\"x\")\n", + " axis.set_ylabel(\"y\")\n", + "\n", + "fig.colorbar(image, ax=axes, label=\"temperature\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-takeaways", + "metadata": {}, + "source": [ + "### 8. Key Takeaways\n", + "\n", + "- Every MPI rank runs the same program with private memory and a distinct rank.\n", + "- Collectives such as `scatter`, `reduce`, and `Gather` coordinate all ranks in a communicator.\n", + "- A row-wise decomposition turns a global stencil into local NumPy work plus two neighbor exchanges.\n", + "- Halo rows make the local stencil expression match the serial expression.\n", + "- `Sendrecv` and `MPI.PROC_NULL` express boundary-safe neighbor communication without extra synchronization.\n", + "- Comparing against a small trusted serial implementation catches communication and indexing mistakes.\n", + "\n", + "The same decomposition pattern extends to larger grids, uneven partitions with `Gatherv`, and higher-dimensional process topologies." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/07__cpp_interop__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/07__cpp_interop__SOLUTION.ipynb new file mode 100644 index 00000000..f4f266c5 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/07__cpp_interop__SOLUTION.ipynb @@ -0,0 +1,3235 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1622633e-eba8-4fd3-9d24-3487c3fe2188", + "metadata": {}, + "source": [ + "## C++ Interop - SOLUTION\n", + "\n", + "**SOLUTION**" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c4cf0d1f-383f-4abe-a9d7-e230af150445", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:36.026479Z", + "iopub.status.busy": "2026-07-27T10:44:36.026390Z", + "iopub.status.idle": "2026-07-27T10:44:36.061337Z", + "shell.execute_reply": "2026-07-27T10:44:36.060965Z" + } + }, + "outputs": [], + "source": [ + "%load_ext memory_profiler" + ] + }, + { + "cell_type": "markdown", + "id": "a74c5756-669b-4375-a405-dfe4bd901d1b", + "metadata": {}, + "source": [ + "Calling into C or C++ functions from Python is fundamental for binding to existing libraries, and for running your most expensive computations without any Python overhead.\n", + "\n", + "In this tutorial, we'll show a few ways to do Python/C++ interoperability in your HPC project:\n", + "\n", + " * Basic C library calls with **ctypes** and **cffi**\n", + " * Generate bindings to C++ code with **nanobind**\n", + " * Use C++ dynamically from Python with automatic bindings provided by **cppjit**" + ] + }, + { + "cell_type": "markdown", + "id": "4e2316c8-bda0-4846-9af7-30e5e7bdfdc1", + "metadata": {}, + "source": [ + "## Calling basic functions with **ctypes**\n", + "\n", + "[ctypes](https://docs.python.org/3.14/library/ctypes.html) is a builtin Python module for foreign function calling:\n", + "* Zero dependencies\n", + "* Shared libraries with C interfaces only\n", + "* No help with creating/managing the SO's." + ] + }, + { + "cell_type": "markdown", + "id": "a77d3d4e-7bfb-4396-a9b9-b462a7cd199b", + "metadata": {}, + "source": [ + "#### Example 1:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "af91f560-dacd-418a-b21e-22bdaf31c936", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:36.062295Z", + "iopub.status.busy": "2026-07-27T10:44:36.062180Z", + "iopub.status.idle": "2026-07-27T10:44:36.066002Z", + "shell.execute_reply": "2026-07-27T10:44:36.065613Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing simple.c\n" + ] + } + ], + "source": [ + "%%writefile simple.c\n", + "\n", + "float square(float x) {\n", + " return x*x;\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "79e3f009-4263-4789-8581-0bf1ba6e43ab", + "metadata": {}, + "source": [ + "Compile this to a shared library that Python knows how to use with `ctypes`:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a4835d33-0280-4c2e-a600-af01075e0e8f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:36.066846Z", + "iopub.status.busy": "2026-07-27T10:44:36.066742Z", + "iopub.status.idle": "2026-07-27T10:44:36.256170Z", + "shell.execute_reply": "2026-07-27T10:44:36.255721Z" + } + }, + "outputs": [], + "source": [ + "!gcc -shared -fPIC -o simple.so simple.c" + ] + }, + { + "cell_type": "markdown", + "id": "f2e4ac3b-e2c8-45cd-a492-fee5d2d7bf45", + "metadata": {}, + "source": [ + "#### Desired usage in Python:\n", + "\n", + "```python\n", + "y = square(x)\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "ef48e939-88de-4e1e-b330-fd97b42d95b6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:36.257470Z", + "iopub.status.busy": "2026-07-27T10:44:36.257352Z", + "iopub.status.idle": "2026-07-27T10:44:36.259632Z", + "shell.execute_reply": "2026-07-27T10:44:36.259282Z" + } + }, + "outputs": [], + "source": [ + "import ctypes\n", + "\n", + "lib = ctypes.cdll.LoadLibrary(\"./simple.so\")" + ] + }, + { + "cell_type": "markdown", + "id": "05ee90ed-8b5f-45a9-a07d-c1e7cbae861a", + "metadata": {}, + "source": [ + "> Warning: You can only load a file once; future loads just reuse the existing library (much like Python imports). So if you change the file, you need to restart the kernel." + ] + }, + { + "cell_type": "markdown", + "id": "326c0f62-aa7c-444c-b104-4fe9f1aa3f81", + "metadata": {}, + "source": [ + "Now, we need to set the argument types - they can't be inferred from an SO." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "154f9a54-0d42-4b3e-bc5b-257df6c3cb91", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:36.260495Z", + "iopub.status.busy": "2026-07-27T10:44:36.260392Z", + "iopub.status.idle": "2026-07-27T10:44:36.262064Z", + "shell.execute_reply": "2026-07-27T10:44:36.261753Z" + } + }, + "outputs": [], + "source": [ + "lib.square.argtypes = (ctypes.c_float,)\n", + "lib.square.restype = ctypes.c_float" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "7f7e099c-748b-49df-98fa-8a4c55378398", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:36.262870Z", + "iopub.status.busy": "2026-07-27T10:44:36.262775Z", + "iopub.status.idle": "2026-07-27T10:44:51.803025Z", + "shell.execute_reply": "2026-07-27T10:44:51.802517Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "191 ns ± 1.14 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)\n" + ] + } + ], + "source": [ + "%%timeit\n", + "lib.square(2.5)" + ] + }, + { + "cell_type": "markdown", + "id": "c283eb97-c0a9-428e-bd4d-1dfd8ffadfa9", + "metadata": {}, + "source": [ + "Doing the same with Python directly:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "983a6c4e-95bd-4042-8f01-1aa9edef294f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:51.804129Z", + "iopub.status.busy": "2026-07-27T10:44:51.803971Z", + "iopub.status.idle": "2026-07-27T10:44:51.805854Z", + "shell.execute_reply": "2026-07-27T10:44:51.805420Z" + } + }, + "outputs": [], + "source": [ + "def mul(a):\n", + " return a*a" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "d5dd9f35-c86d-4fec-9cf4-3b1bdf5f4f86", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:51.806667Z", + "iopub.status.busy": "2026-07-27T10:44:51.806570Z", + "iopub.status.idle": "2026-07-27T10:44:54.249415Z", + "shell.execute_reply": "2026-07-27T10:44:54.248916Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "30 ns ± 0.0331 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)\n" + ] + } + ], + "source": [ + "%%timeit\n", + "mul(2.5)" + ] + }, + { + "cell_type": "markdown", + "id": "9a3d759d-8570-4b94-9e1b-9991463462b6", + "metadata": {}, + "source": [ + "*Question: why is using just Python faster in this case?*" + ] + }, + { + "cell_type": "markdown", + "id": "7629f938-e227-4795-897d-20e8e2a7cd71", + "metadata": {}, + "source": [ + "What were the steps taken?\n", + "\n", + "1. Load the library, by passing the `.so` file to the ctypes library loader.\n", + "1. Set the expected arg types and return type for the function call. We had to use the types from ctypes since compiled types are not the same as Python types. SO's do not store signatures!" + ] + }, + { + "cell_type": "markdown", + "id": "14e3a4c6-3e3f-488e-be94-bce5309255ae", + "metadata": {}, + "source": [ + "There is an alternate way to do this:\n", + "\n", + "1. Create a new C Function type, listing the return type, then the argument type(s) if there are any. This is a different order and structure from type hinting in modern Python." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "cd436422-0a44-44cd-b3d9-a7d7994fa768", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:54.250406Z", + "iopub.status.busy": "2026-07-27T10:44:54.250311Z", + "iopub.status.idle": "2026-07-27T10:44:54.252107Z", + "shell.execute_reply": "2026-07-27T10:44:54.251691Z" + } + }, + "outputs": [], + "source": [ + "float_float_t = ctypes.CFUNCTYPE(ctypes.c_float, ctypes.c_float)" + ] + }, + { + "cell_type": "markdown", + "id": "756afa59-cfa0-44a2-88c0-2bae1187b638", + "metadata": {}, + "source": [ + "Now \"cast\" the function with your function type." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "08e3725d-f35d-4654-b960-d7bb3a48c6bb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:54.252897Z", + "iopub.status.busy": "2026-07-27T10:44:54.252804Z", + "iopub.status.idle": "2026-07-27T10:44:54.254717Z", + "shell.execute_reply": "2026-07-27T10:44:54.254372Z" + } + }, + "outputs": [], + "source": [ + "squarefunc = float_float_t(lib.square)" + ] + }, + { + "cell_type": "markdown", + "id": "62952a49-6c76-44ba-87f2-d051a28aefa6", + "metadata": {}, + "source": [ + "And this also works:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "613b8324-930e-426f-b15c-1dd6a97e44bd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:54.255555Z", + "iopub.status.busy": "2026-07-27T10:44:54.255463Z", + "iopub.status.idle": "2026-07-27T10:44:57.877056Z", + "shell.execute_reply": "2026-07-27T10:44:57.876565Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "447 ns ± 3.73 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)\n" + ] + } + ], + "source": [ + "%%timeit\n", + "squarefunc(2.0)" + ] + }, + { + "cell_type": "markdown", + "id": "9edfade8-923f-4752-81f7-cef1fcecb459", + "metadata": {}, + "source": [ + "However, it sets up a little extra machinery, so it is slightly slower. In exchange, it is itself a valid c_type and can be passed to C code. You can even wrap Python functions this way." + ] + }, + { + "cell_type": "markdown", + "id": "75eb3a9e-8a81-4ba7-8fed-cc6c8275b90b", + "metadata": {}, + "source": [ + "Suggestion: wrap your library in a class!" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "2b643f59-0c19-4247-add1-ece61195cbab", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:57.878030Z", + "iopub.status.busy": "2026-07-27T10:44:57.877932Z", + "iopub.status.idle": "2026-07-27T10:44:57.880114Z", + "shell.execute_reply": "2026-07-27T10:44:57.879709Z" + } + }, + "outputs": [], + "source": [ + "class SimpleLib:\n", + " def __init__(self, file_to_load: str):\n", + " self._lib = ctypes.cdll.LoadLibrary(file_to_load)\n", + " self._lib.square.argtypes = (ctypes.c_float,)\n", + " self._lib.square.restype = ctypes.c_float\n", + "\n", + " def square(self, x: float) -> float:\n", + " return self._lib.square(x)\n", + " \n", + " #... N" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "d77a3cad-5ad1-4814-8837-59f38e8dee2d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:57.880899Z", + "iopub.status.busy": "2026-07-27T10:44:57.880806Z", + "iopub.status.idle": "2026-07-27T10:44:57.884062Z", + "shell.execute_reply": "2026-07-27T10:44:57.883732Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "4.0" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "simple = SimpleLib(\"./simple.so\")\n", + "simple.square(2)" + ] + }, + { + "cell_type": "markdown", + "id": "f7faf3dd-09c8-440d-9c99-57f3278487d2", + "metadata": {}, + "source": [ + "In practice, you will usually have a Python wrapper module (not just a class) to \"pythonize\" the interface to the shared library." + ] + }, + { + "cell_type": "markdown", + "id": "fa30e335-8e33-4db1-b411-c6c259869ddc", + "metadata": {}, + "source": [ + "### But what about C++ (and all other languages)?\n", + "\n", + "This wasn't language specific; it is a property of compiled libraries. C++ (and other languages) do not export a clean interface by default. In C++, because of features like overloading, the names are \"mangled\" in a compiler specific way. But you can manually export a nice interface:" + ] + }, + { + "cell_type": "markdown", + "id": "a7334a47-93fd-41f6-8ce3-87166504661d", + "metadata": {}, + "source": [ + " Let's try a slightly more advanced example, this time in C++:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "6dc4a59e-f02f-46ae-aba6-1151dbbcaaa1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:57.884986Z", + "iopub.status.busy": "2026-07-27T10:44:57.884887Z", + "iopub.status.idle": "2026-07-27T10:44:57.888615Z", + "shell.execute_reply": "2026-07-27T10:44:57.888217Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing templates.cpp\n" + ] + } + ], + "source": [ + "%%writefile templates.cpp\n", + "\n", + "\n", + "template\n", + "T square(T x) {\n", + " return x*x;\n", + "}\n", + "\n", + "\n", + "extern \"C\" {\n", + " int square_int(int x) {\n", + " return square(x);\n", + " }\n", + " \n", + " double square_double(double x) {\n", + " return square(x);\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "4765b3ca-4ce2-4f03-8100-1f356b8c42b8", + "metadata": {}, + "source": [ + "This is not Python specific! If you want to load a shared object (DLL on Windows) from any language, you need to do this:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "24abf578-b279-4ec1-a4fb-5572ea018314", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:57.889456Z", + "iopub.status.busy": "2026-07-27T10:44:57.889361Z", + "iopub.status.idle": "2026-07-27T10:44:58.042628Z", + "shell.execute_reply": "2026-07-27T10:44:58.042198Z" + } + }, + "outputs": [], + "source": [ + "!g++ templates.cpp -shared -fPIC -o templates.so" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "54143525-1ad1-4ea5-9049-9b031ced0704", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:58.043620Z", + "iopub.status.busy": "2026-07-27T10:44:58.043508Z", + "iopub.status.idle": "2026-07-27T10:44:58.046247Z", + "shell.execute_reply": "2026-07-27T10:44:58.045926Z" + } + }, + "outputs": [], + "source": [ + "from functools import singledispatchmethod\n", + "\n", + "\n", + "class TemplateLib(object):\n", + " def __init__(self, file_to_load: str):\n", + " self._lib = ctypes.cdll.LoadLibrary(file_to_load)\n", + "\n", + " self._lib.square_double.argtypes = (ctypes.c_double,)\n", + " self._lib.square_double.restype = ctypes.c_double\n", + "\n", + " self._lib.square_int.argtypes = (ctypes.c_int,)\n", + " self._lib.square_int.restype = ctypes.c_int\n", + "\n", + " @singledispatchmethod\n", + " def square(self, arg):\n", + " raise NotImplementedError(\"Not a registered type\")\n", + "\n", + " @square.register\n", + " def _(self, x: int):\n", + " return self._lib.square_int(x)\n", + "\n", + " @square.register\n", + " def _(self, x: float):\n", + " return self._lib.square_double(x)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "0f08d785-64fb-4546-b603-1749d84324cd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:58.047052Z", + "iopub.status.busy": "2026-07-27T10:44:58.046954Z", + "iopub.status.idle": "2026-07-27T10:44:58.048987Z", + "shell.execute_reply": "2026-07-27T10:44:58.048656Z" + } + }, + "outputs": [], + "source": [ + "templates = TemplateLib(\"./templates.so\")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "00fa9406-2809-4f7c-9544-d229a48cafa2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:58.049737Z", + "iopub.status.busy": "2026-07-27T10:44:58.049646Z", + "iopub.status.idle": "2026-07-27T10:44:58.051744Z", + "shell.execute_reply": "2026-07-27T10:44:58.051409Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "4" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "templates.square(2)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "f3d91601-1b5b-4022-902d-b92b32c25928", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:58.052490Z", + "iopub.status.busy": "2026-07-27T10:44:58.052399Z", + "iopub.status.idle": "2026-07-27T10:44:58.054361Z", + "shell.execute_reply": "2026-07-27T10:44:58.054058Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "4.0" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "templates.square(2.0)" + ] + }, + { + "cell_type": "markdown", + "id": "249d1c41-62e8-4d20-8883-194d5d28324d", + "metadata": {}, + "source": [ + "## NumPy tools\n", + "\n", + "NumPy has adapters to help you with ctypes, called [numpy.ctypeslib](https://numpy.org/doc/stable/reference/routines.ctypeslib.html?highlight=ctypeslib#module-numpy.ctypeslib). You can convert to/from *any* object providing an `__array_interface__`, like NumPy arrays. It has a loader with more consistent defaults across operating systems. You also have a special pointer that can do bounds checking and such for array arguments." + ] + }, + { + "cell_type": "markdown", + "id": "2e66f698-46a9-446d-9511-3f227868d004", + "metadata": {}, + "source": [ + "## Alternative for foreign function calling - **cffi**\n", + "\n", + "The [CFFI](http://cffi.readthedocs.io/en/latest/overview.html) module is an alternative to the builtin **ctypes** that requires less boilerplate code.\n", + "\n", + "* The *C Foreign Function Interface* for Python\n", + "* C only\n", + "* Developed for PyPy, but available in CPython too\n", + "\n", + "The same example as before:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "0dc9197e-e331-41c2-a4a2-dac9bd0b1bd3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:58.055207Z", + "iopub.status.busy": "2026-07-27T10:44:58.055115Z", + "iopub.status.idle": "2026-07-27T10:44:58.070286Z", + "shell.execute_reply": "2026-07-27T10:44:58.069957Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "4.0" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from cffi import FFI\n", + "\n", + "ffi = FFI()\n", + "\n", + "ffi.cdef(\"float square(float);\")\n", + "\n", + "C = ffi.dlopen(\"./simple.so\")\n", + "\n", + "C.square(2.0)" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "55149395-bd94-441e-9e1b-cec39ec3e248", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:58.071091Z", + "iopub.status.busy": "2026-07-27T10:44:58.070984Z", + "iopub.status.idle": "2026-07-27T10:44:58.072996Z", + "shell.execute_reply": "2026-07-27T10:44:58.072690Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "C.square" + ] + }, + { + "cell_type": "markdown", + "id": "5addb3b1-18f6-44cc-ab51-57f2b338a075", + "metadata": {}, + "source": [ + "Notice we were able to give a C header this time and it's not necessary to bookkeep the C function types by hand." + ] + }, + { + "cell_type": "markdown", + "id": "78356b53-e92d-4694-9d09-08b9f89fc12f", + "metadata": {}, + "source": [ + "### Exercise 1- Try it yourself\n", + "\n", + "Square the elements in this array (for simplicity, do it in-place):" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "78b5bcac-7a7e-4259-89ba-3def528a1a1b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:58.073765Z", + "iopub.status.busy": "2026-07-27T10:44:58.073674Z", + "iopub.status.idle": "2026-07-27T10:44:58.077098Z", + "shell.execute_reply": "2026-07-27T10:44:58.076747Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing array_square.c\n" + ] + } + ], + "source": [ + "%%writefile array_square.c\n", + "\n", + "void squares(float* arr, int size) {\n", + " for(int i=0; i\n", + "\n", + "namespace nb = nanobind;\n", + "\n", + "float square(float x) {\n", + " return x*x;\n", + "}\n", + "\n", + "NB_MODULE(pysimple, m) {\n", + " m.def(\"square\", &square);\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "e2f4ad3c-59dd-4adf-97bc-939f7ba6e466", + "metadata": {}, + "source": [ + "Compile, import and run. Note that **nanobind requires C++17** (pybind11 only needed C++11), and we compile `{nbsrc}` into the module:" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "46b5d530-32c2-4667-92b4-50f59cba4151", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:44:58.325662Z", + "iopub.status.busy": "2026-07-27T10:44:58.325574Z", + "iopub.status.idle": "2026-07-27T10:45:02.332783Z", + "shell.execute_reply": "2026-07-27T10:45:02.332071Z" + } + }, + "outputs": [], + "source": [ + "!c++ -std=c++17 pysimple.cpp {nbsrc} -shared {nbinc} {robin} {pyinc.s} -o pysimple.so {plat}" + ] + }, + { + "cell_type": "markdown", + "id": "1a720a7f-9377-49ce-b01f-8b81cfd0cb86", + "metadata": {}, + "source": [ + "Now import it and run it:" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "022a33cd-5870-4353-a21d-1dfd327db762", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:02.334578Z", + "iopub.status.busy": "2026-07-27T10:45:02.334462Z", + "iopub.status.idle": "2026-07-27T10:45:02.338189Z", + "shell.execute_reply": "2026-07-27T10:45:02.337839Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "9.0" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pysimple\n", + "\n", + "pysimple.square(3)" + ] + }, + { + "cell_type": "markdown", + "id": "1c80b378-671c-48fb-bb25-5dc1e7c0767d", + "metadata": {}, + "source": [ + "nanobind also allows using templated functions:" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "410ece9f-b061-4550-a7f1-bb52d2d72ead", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:02.339134Z", + "iopub.status.busy": "2026-07-27T10:45:02.339042Z", + "iopub.status.idle": "2026-07-27T10:45:02.342414Z", + "shell.execute_reply": "2026-07-27T10:45:02.342089Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing pytempl.cpp\n" + ] + } + ], + "source": [ + "%%writefile pytempl.cpp\n", + "\n", + "#include \n", + "\n", + "namespace nb = nanobind;\n", + "\n", + "template\n", + "T add(T x) {\n", + " return x+x;\n", + "}\n", + "\n", + "NB_MODULE(pytempl, m) {\n", + " m.def(\"add\", [](int value) { return add(value); });\n", + " m.def(\"add\", [](double value) { return add(value); });\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "49f9180e-66d4-4753-99e9-e2e6a34e1a72", + "metadata": {}, + "source": [ + "Note: Requires registering one binding per Python entry type; the rest is handled by C++." + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "id": "4e16d27b-c4e2-4022-a690-a088c7547552", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:02.343269Z", + "iopub.status.busy": "2026-07-27T10:45:02.343180Z", + "iopub.status.idle": "2026-07-27T10:45:06.370836Z", + "shell.execute_reply": "2026-07-27T10:45:06.370176Z" + } + }, + "outputs": [], + "source": [ + "!c++ pytempl.cpp -std=c++17 {nbsrc} -shared {nbinc} {robin} {pyinc.s} -o pytempl.so {plat}" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "id": "65a2bd22-613c-4ec3-bf91-6b765a216b6e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:06.372518Z", + "iopub.status.busy": "2026-07-27T10:45:06.372401Z", + "iopub.status.idle": "2026-07-27T10:45:06.376025Z", + "shell.execute_reply": "2026-07-27T10:45:06.375735Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "6" + ] + }, + "execution_count": 36, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pytempl\n", + "\n", + "pytempl.add(3)" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "id": "6a339240-ad2c-4b5e-a135-d2970579f8dc", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:06.376919Z", + "iopub.status.busy": "2026-07-27T10:45:06.376826Z", + "iopub.status.idle": "2026-07-27T10:45:06.378837Z", + "shell.execute_reply": "2026-07-27T10:45:06.378501Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "6.0" + ] + }, + "execution_count": 37, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pytempl.add(3.0)" + ] + }, + { + "cell_type": "markdown", + "id": "6da2137c-6cd9-4ee1-9ae8-3232130c71bb", + "metadata": {}, + "source": [ + "For production builds you would normally let CMake (via nanobind's `nanobind_add_module`) handle this, and add size/robustness flags such as `-Os -fvisibility=hidden -flto` plus stripping. The manual command above is kept minimal for this tutorial." + ] + }, + { + "cell_type": "markdown", + "id": "59c4f74e-0778-4778-b017-b7d70e91d4cb", + "metadata": {}, + "source": [ + "### nanobind example with classes\n", + "\n", + "What about classes?" + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "id": "fad28162-a6ad-4828-bbea-8eb4ea81e51d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:06.379666Z", + "iopub.status.busy": "2026-07-27T10:45:06.379581Z", + "iopub.status.idle": "2026-07-27T10:45:06.382984Z", + "shell.execute_reply": "2026-07-27T10:45:06.382666Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing VectorClass.hpp\n" + ] + } + ], + "source": [ + "%%writefile VectorClass.hpp\n", + "#pragma once\n", + "\n", + "class Vector2D {\n", + " double x;\n", + " double y;\n", + "\n", + "public:\n", + "\n", + " Vector2D(double x, double y): x(x), y(y) {}\n", + "\n", + " double get_x() const {\n", + " return x;\n", + " }\n", + "\n", + " double get_y() const {\n", + " return y;\n", + " }\n", + "\n", + " void set_x(double val) {\n", + " x = val;\n", + " }\n", + "\n", + " void set_y(double val) {\n", + " y = val;\n", + " }\n", + "\n", + "\n", + " Vector2D& operator+= (const Vector2D& other) {\n", + " x += other.x;\n", + " y += other.y;\n", + " return *this;\n", + " }\n", + "\n", + " Vector2D operator+ (const Vector2D& other) const {\n", + " return Vector2D(x + other.x, y + other.y);\n", + " }\n", + "};" + ] + }, + { + "cell_type": "markdown", + "id": "58c63d76-f203-4415-8c61-be3bd6f91e0f", + "metadata": {}, + "source": [ + "Our binding code shows a couple more features:" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "1274ac58-b8e7-4e3a-869e-b46898df6d52", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:06.383844Z", + "iopub.status.busy": "2026-07-27T10:45:06.383758Z", + "iopub.status.idle": "2026-07-27T10:45:06.387179Z", + "shell.execute_reply": "2026-07-27T10:45:06.386710Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing vectorclass.cpp\n" + ] + } + ], + "source": [ + "%%writefile vectorclass.cpp\n", + "\n", + "#include \n", + "#include \n", + "#include \"VectorClass.hpp\"\n", + "\n", + "namespace nb = nanobind;\n", + "using namespace nanobind::literals;\n", + "\n", + "NB_MODULE(vectorclass, m) {\n", + " nb::class_(m, \"Vector2D\")\n", + " .def(nb::init(), \"x\"_a, \"y\"_a)\n", + " .def_prop_rw(\"x\", &Vector2D::get_x, &Vector2D::set_x)\n", + " .def_prop_rw(\"y\", &Vector2D::get_y, &Vector2D::set_y)\n", + " .def(nb::self += nb::self)\n", + " .def(nb::self + nb::self)\n", + " .def(\"__repr__\", [](nb::object self){\n", + " return nb::str(\"{0.__class__.__name__}({0.x}, {0.y})\").format(self);\n", + " })\n", + " ;\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "e7692e55-c68c-4c93-a17d-b49fbd131835", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:06.387974Z", + "iopub.status.busy": "2026-07-27T10:45:06.387876Z", + "iopub.status.idle": "2026-07-27T10:45:10.526132Z", + "shell.execute_reply": "2026-07-27T10:45:10.525565Z" + } + }, + "outputs": [], + "source": [ + "!c++ -std=c++17 vectorclass.cpp {nbsrc} -shared {nbinc} {robin} {pyinc.s} -o vectorclass.so {plat}" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "cb8dffd5-ee55-4473-8c06-128382cd0fb5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:10.527964Z", + "iopub.status.busy": "2026-07-27T10:45:10.527848Z", + "iopub.status.idle": "2026-07-27T10:45:10.531554Z", + "shell.execute_reply": "2026-07-27T10:45:10.531194Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "v.x = 1.0, v.y = 2.0\n" + ] + } + ], + "source": [ + "import vectorclass\n", + "\n", + "v = vectorclass.Vector2D(1, 2)\n", + "print(f\"{v.x = }, {v.y = }\")" + ] + }, + { + "cell_type": "markdown", + "id": "982ef30d-25d3-4afb-9a40-931a09cd4ee8", + "metadata": {}, + "source": [ + "### Exercise 2 - NumPy arrays and nanobind\n", + "\n", + "Write `scale(arr, factor)` that multiplies a 1-D float64 NumPy array in place, using a plain C++ core function `void scale(double*, size_t, double)`. Bind it with `nb::ndarray` so the NumPy array is modified without copying." + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "01bd7ec6-f5f9-444a-8ce3-4d2de58eb3b4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:10.532409Z", + "iopub.status.busy": "2026-07-27T10:45:10.532314Z", + "iopub.status.idle": "2026-07-27T10:45:10.535693Z", + "shell.execute_reply": "2026-07-27T10:45:10.535395Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing nanobind_numpy.cpp\n" + ] + } + ], + "source": [ + "%%writefile nanobind_numpy.cpp\n", + "\n", + "#include \n", + "#include \n", + "namespace nb = nanobind;\n", + "using namespace nanobind::literals;\n", + "\n", + "void scale(double* data, size_t n, double factor) {\n", + " for (size_t i = 0; i < n; ++i) data[i] *= factor; // the \"C-style\" core\n", + "}\n", + "\n", + "NB_MODULE(nanobind_numpy, m) {\n", + " m.def(\"scale\",\n", + " [](nb::ndarray, nb::c_contig, nb::device::cpu> arr, double factor) {\n", + " scale(arr.data(), arr.shape(0), factor);\n", + " }, \"arr\"_a, \"factor\"_a);\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "id": "ab85cc5a-d4e5-4e1c-964c-491f6fe84bc9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:10.536549Z", + "iopub.status.busy": "2026-07-27T10:45:10.536460Z", + "iopub.status.idle": "2026-07-27T10:45:14.605928Z", + "shell.execute_reply": "2026-07-27T10:45:14.605343Z" + } + }, + "outputs": [], + "source": [ + "!c++ -std=c++17 nanobind_numpy.cpp {nbsrc} -shared {nbinc} {robin} {pyinc.s} -o nanobind_numpy.so {plat}" + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "id": "4bf03a02-de91-483f-8aee-e10661223324", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:14.607739Z", + "iopub.status.busy": "2026-07-27T10:45:14.607622Z", + "iopub.status.idle": "2026-07-27T10:45:14.611706Z", + "shell.execute_reply": "2026-07-27T10:45:14.611323Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[10. 20. 30. 40.]\n" + ] + } + ], + "source": [ + "import nanobind_numpy\n", + "import numpy as np\n", + "\n", + "# What happens if this would be dtype=np.int32 ?\n", + "# Can you ensure that the behavior is still sensible in this case?\n", + "a = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)\n", + "\n", + "nanobind_numpy.scale(a, 10.0)\n", + "print(a) # [10. 20. 30. 40.] -- original mutated, no copy" + ] + }, + { + "cell_type": "markdown", + "id": "6927f65e-d027-48aa-8c21-e00ab6100c90", + "metadata": {}, + "source": [ + "## Automatic bindings with **cppjit**\n", + "\n", + "cppjit combines the convenience of the Python language with the efficiency of C++ implementations. The dynamic C++ bindings are powered by the [clang-repl](https://clang.llvm.org/docs/ClangRepl.html) C++ interpreter and the\n", + "[CppInterOp](https://github.com/compiler-research/CppInterOp) interoperability library, allowing you to use efficient C++ implementations from Python." + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "id": "2a537eb2-cb49-4248-a84b-12231ba8f1ab", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:14.612592Z", + "iopub.status.busy": "2026-07-27T10:45:14.612496Z", + "iopub.status.idle": "2026-07-27T10:45:17.021515Z", + "shell.execute_reply": "2026-07-27T10:45:17.020970Z" + } + }, + "outputs": [], + "source": [ + "import swe_core\n", + "import cppjit\n", + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "id": "c7f00b6d-2715-4525-ab35-81f86005265f", + "metadata": {}, + "source": [ + "Just-in-time compilation of C++ functions" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "id": "76047b24-607b-41b3-8258-789c11f9ea4f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:17.023109Z", + "iopub.status.busy": "2026-07-27T10:45:17.022985Z", + "iopub.status.idle": "2026-07-27T10:45:17.047299Z", + "shell.execute_reply": "2026-07-27T10:45:17.046913Z" + } + }, + "outputs": [], + "source": [ + "cppjit.cppdef('''\n", + "\n", + "float smallest_diff(float* v1, float* v2, std::size_t size) {\n", + " float min_diff = std::numeric_limits::max();\n", + " for (std::size_t i1 = 0; i1 < size; i1++) {\n", + " for (std::size_t i2 = 0; i2 < size; i2++) {\n", + " float diff = std::abs(v1[i1] - v2[i2]);\n", + " if (diff < min_diff) {\n", + " min_diff = diff;\n", + " }\n", + " }\n", + " }\n", + " return min_diff;\n", + "}\n", + "''');" + ] + }, + { + "cell_type": "markdown", + "id": "6ef93f6a-4e6f-4545-9966-e70c045b4889", + "metadata": {}, + "source": [ + "As example inputs, we generate two numpy arrays with random numbers." + ] + }, + { + "cell_type": "code", + "execution_count": 47, + "id": "5c741469-53b2-44e5-a3d3-34b5f9bc2cc0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:17.048272Z", + "iopub.status.busy": "2026-07-27T10:45:17.048163Z", + "iopub.status.idle": "2026-07-27T10:45:17.055522Z", + "shell.execute_reply": "2026-07-27T10:45:17.055103Z" + } + }, + "outputs": [], + "source": [ + "size = 100\n", + "v1 = np.random.randn(size).astype(np.float32)\n", + "v2 = np.random.randn(size).astype(np.float32)" + ] + }, + { + "cell_type": "markdown", + "id": "a8de0868-50b4-4e96-a3c2-0d05e38fd022", + "metadata": {}, + "source": [ + "Use the function directly: cppjit accepts NumPy arrays with no wrapper code." + ] + }, + { + "cell_type": "code", + "execution_count": 48, + "id": "a0d2ed36-ef52-4f3d-8be5-73255d33c7d0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:17.056446Z", + "iopub.status.busy": "2026-07-27T10:45:17.056269Z", + "iopub.status.idle": "2026-07-27T10:45:28.689179Z", + "shell.execute_reply": "2026-07-27T10:45:28.688721Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "14.3 μs ± 5.41 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)\n" + ] + } + ], + "source": [ + "%%timeit\n", + "cppjit.gbl.smallest_diff(v1, v2, size)" + ] + }, + { + "cell_type": "markdown", + "id": "0f62a95d", + "metadata": {}, + "source": [ + "How does the C++ kernel compare to a pure Python implementation?" + ] + }, + { + "cell_type": "code", + "execution_count": 49, + "id": "5cb753ad", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:28.690335Z", + "iopub.status.busy": "2026-07-27T10:45:28.690222Z", + "iopub.status.idle": "2026-07-27T10:45:28.692357Z", + "shell.execute_reply": "2026-07-27T10:45:28.691987Z" + } + }, + "outputs": [], + "source": [ + "def smallest_diff(x1, x2):\n", + " min_diff = float('inf')\n", + " for e1 in x1:\n", + " for e2 in x2:\n", + " diff = abs(e1 - e2)\n", + " if diff < min_diff:\n", + " min_diff = diff\n", + " return min_diff" + ] + }, + { + "cell_type": "markdown", + "id": "2a618b20", + "metadata": {}, + "source": [ + "**The pure-Python implementation is far slower.**" + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "id": "955a2534", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:28.693286Z", + "iopub.status.busy": "2026-07-27T10:45:28.693187Z", + "iopub.status.idle": "2026-07-27T10:45:36.719915Z", + "shell.execute_reply": "2026-07-27T10:45:36.719513Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.01 ms ± 214 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)\n" + ] + } + ], + "source": [ + "%%timeit\n", + "smallest_diff(v1, v2)" + ] + }, + { + "cell_type": "markdown", + "id": "e6075329", + "metadata": {}, + "source": [ + "### Loading of precompiled functions\n", + "\n", + "Precompiling the functionality and loading the library into cppjit improves performance further. " + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "id": "5041095e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:36.720897Z", + "iopub.status.busy": "2026-07-27T10:45:36.720793Z", + "iopub.status.idle": "2026-07-27T10:45:36.724534Z", + "shell.execute_reply": "2026-07-27T10:45:36.724178Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing diff_small.hxx\n" + ] + } + ], + "source": [ + "%%writefile diff_small.hxx\n", + "\n", + "#include \n", + "#include \n", + "#include \n", + "float optimized_smallest_diff(float* v1, float* v2, std::size_t size);" + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "id": "085838e2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:36.725473Z", + "iopub.status.busy": "2026-07-27T10:45:36.725371Z", + "iopub.status.idle": "2026-07-27T10:45:36.728864Z", + "shell.execute_reply": "2026-07-27T10:45:36.728489Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing diff_small.cxx\n" + ] + } + ], + "source": [ + "%%writefile diff_small.cxx\n", + "\n", + "# include \"diff_small.hxx\"\n", + "\n", + "float optimized_smallest_diff(float* v1, float* v2, std::size_t size) {\n", + " float min_diff = std::numeric_limits::max();\n", + " for (std::size_t i1 = 0; i1 < size; i1++) {\n", + " for (std::size_t i2 = 0; i2 < size; i2++) {\n", + " float diff = std::abs(v1[i1] - v2[i2]);\n", + " if (diff < min_diff) {\n", + " min_diff = diff;\n", + " }\n", + " }\n", + " }\n", + " return min_diff;\n", + "}\n" + ] + }, + { + "cell_type": "code", + "execution_count": 53, + "id": "c8feb94b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:36.729724Z", + "iopub.status.busy": "2026-07-27T10:45:36.729628Z", + "iopub.status.idle": "2026-07-27T10:45:36.972346Z", + "shell.execute_reply": "2026-07-27T10:45:36.971857Z" + } + }, + "outputs": [], + "source": [ + "!g++ -Ofast -shared -fPIC -o libanalysis.so diff_small.cxx" + ] + }, + { + "cell_type": "markdown", + "id": "b1a58c13", + "metadata": {}, + "source": [ + "You can interactively include the header and functionality from the shared library." + ] + }, + { + "cell_type": "code", + "execution_count": 54, + "id": "5fe81028", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:36.973634Z", + "iopub.status.busy": "2026-07-27T10:45:36.973515Z", + "iopub.status.idle": "2026-07-27T10:45:37.042165Z", + "shell.execute_reply": "2026-07-27T10:45:37.041800Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 54, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit.cppdef('#include \"diff_small.hxx\"')\n", + "cppjit.load_library('libanalysis.so')" + ] + }, + { + "cell_type": "code", + "execution_count": 55, + "id": "c28a558a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:37.043097Z", + "iopub.status.busy": "2026-07-27T10:45:37.042989Z", + "iopub.status.idle": "2026-07-27T10:45:37.045312Z", + "shell.execute_reply": "2026-07-27T10:45:37.044962Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "mappingproxy({'__module__': 'cppjit.cppyy._cpython_cppyy',\n", + " '__dict__': ,\n", + " '__weakref__': ,\n", + " '__doc__': None,\n", + " '__init__': ,\n", + " 'std': ,\n", + " 'Dispatch': ,\n", + " 'cling': ,\n", + " 'int8_t': cppyy.gbl.int8_t,\n", + " 'uint8_t': cppyy.gbl.uint8_t,\n", + " 'smallest_diff': })" + ] + }, + "execution_count": 55, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit.gbl.__dict__" + ] + }, + { + "cell_type": "markdown", + "id": "6445dc5f", + "metadata": {}, + "source": [ + "**The loaded library improves the runtime further.**\n", + "\n", + "While this may not make a huge difference for small files like this example, large files benefit since your code is recompiled every time you `cppdef`." + ] + }, + { + "cell_type": "code", + "execution_count": 56, + "id": "9e7ec5a8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:37.046154Z", + "iopub.status.busy": "2026-07-27T10:45:37.046052Z", + "iopub.status.idle": "2026-07-27T10:45:51.507147Z", + "shell.execute_reply": "2026-07-27T10:45:51.506631Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.78 μs ± 1.3 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)\n" + ] + } + ], + "source": [ + "%%timeit\n", + "\n", + "cppjit.gbl.optimized_smallest_diff(v1, v2, size)" + ] + }, + { + "cell_type": "code", + "execution_count": 57, + "id": "b1c20b0e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:51.508248Z", + "iopub.status.busy": "2026-07-27T10:45:51.508136Z", + "iopub.status.idle": "2026-07-27T10:45:51.510655Z", + "shell.execute_reply": "2026-07-27T10:45:51.510243Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "mappingproxy({'__module__': 'cppjit.cppyy._cpython_cppyy',\n", + " '__dict__': ,\n", + " '__weakref__': ,\n", + " '__doc__': None,\n", + " '__init__': ,\n", + " 'std': ,\n", + " 'Dispatch': ,\n", + " 'cling': ,\n", + " 'int8_t': cppyy.gbl.int8_t,\n", + " 'uint8_t': cppyy.gbl.uint8_t,\n", + " 'smallest_diff': ,\n", + " 'optimized_smallest_diff': })" + ] + }, + "execution_count": 57, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit.gbl.__dict__" + ] + }, + { + "cell_type": "markdown", + "id": "033d45ed", + "metadata": {}, + "source": [ + "Finally, we can show that all implementations come to the same result:" + ] + }, + { + "cell_type": "code", + "execution_count": 58, + "id": "7552e0d5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:51.511516Z", + "iopub.status.busy": "2026-07-27T10:45:51.511413Z", + "iopub.status.idle": "2026-07-27T10:45:51.514414Z", + "shell.execute_reply": "2026-07-27T10:45:51.514070Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cppjit: 0.00012062489986419678\n", + "Native Python: 0.0001206249\n", + "cppjit (loaded): 0.00012062489986419678\n" + ] + } + ], + "source": [ + "print('cppjit:', cppjit.gbl.smallest_diff(v1, v2, size))\n", + "print('Native Python:', smallest_diff(v1, v2))\n", + "print('cppjit (loaded):', cppjit.gbl.optimized_smallest_diff(v1, v2, size))" + ] + }, + { + "cell_type": "markdown", + "id": "189c189b-7828-4fe9-ba05-466b6a9cc80a", + "metadata": {}, + "source": [ + "### Let's look at examples of runtime features in cppjit\n", + "\n", + "We can easily do template instantiations *at runtime* with cppjit." + ] + }, + { + "cell_type": "code", + "execution_count": 59, + "id": "d89d302d-9944-4966-8b21-0fda755babc2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:51.515314Z", + "iopub.status.busy": "2026-07-27T10:45:51.515216Z", + "iopub.status.idle": "2026-07-27T10:45:51.536590Z", + "shell.execute_reply": "2026-07-27T10:45:51.536168Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 59, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit.cppdef('''template\n", + "T add(T x) {\n", + " return x+x;\n", + "}''')" + ] + }, + { + "cell_type": "markdown", + "id": "1d5fbf19-3ebc-405c-bc0f-56e7b6a9a48f", + "metadata": {}, + "source": [ + "We can automatically call `cppjit.gbl.add` now!" + ] + }, + { + "cell_type": "code", + "execution_count": 60, + "id": "a9dc55be-1cb9-4939-bec8-69f9b672bcb1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:51.537484Z", + "iopub.status.busy": "2026-07-27T10:45:51.537373Z", + "iopub.status.idle": "2026-07-27T10:45:51.561356Z", + "shell.execute_reply": "2026-07-27T10:45:51.560961Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "6" + ] + }, + "execution_count": 60, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit.gbl.add(3)" + ] + }, + { + "cell_type": "code", + "execution_count": 61, + "id": "f87f25ac-fed1-4cc2-9ac0-242d09256b6a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:51.562223Z", + "iopub.status.busy": "2026-07-27T10:45:51.562119Z", + "iopub.status.idle": "2026-07-27T10:45:51.585066Z", + "shell.execute_reply": "2026-07-27T10:45:51.584710Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "6.0" + ] + }, + "execution_count": 61, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit.gbl.add(3.0)" + ] + }, + { + "cell_type": "code", + "execution_count": 62, + "id": "d11ac293-501d-43e4-8ff6-3901c011e4ea", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:51.585953Z", + "iopub.status.busy": "2026-07-27T10:45:51.585845Z", + "iopub.status.idle": "2026-07-27T10:45:51.697679Z", + "shell.execute_reply": "2026-07-27T10:45:51.697302Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "b'aa'" + ] + }, + "execution_count": 62, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit.gbl.add('a')" + ] + }, + { + "cell_type": "markdown", + "id": "8add00ae", + "metadata": {}, + "source": [ + "More Runtime Template Instantiations:" + ] + }, + { + "cell_type": "code", + "execution_count": 63, + "id": "adac071d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:51.698638Z", + "iopub.status.busy": "2026-07-27T10:45:51.698532Z", + "iopub.status.idle": "2026-07-27T10:45:51.718625Z", + "shell.execute_reply": "2026-07-27T10:45:51.718222Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 63, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit.cppdef('''\n", + "struct MyClass {\n", + " MyClass(int i) : fData(i) {}\n", + " virtual ~MyClass() {}\n", + " virtual int add(int i) {\n", + " return fData + i;\n", + " }\n", + " int fData;\n", + "};''')" + ] + }, + { + "cell_type": "markdown", + "id": "35dfffc1", + "metadata": {}, + "source": [ + "Creating a std::vector of the C++ class" + ] + }, + { + "cell_type": "code", + "execution_count": 64, + "id": "5aea9086", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:51.719469Z", + "iopub.status.busy": "2026-07-27T10:45:51.719365Z", + "iopub.status.idle": "2026-07-27T10:45:51.981152Z", + "shell.execute_reply": "2026-07-27T10:45:51.980736Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "> object at 0x236b0ae0>" + ] + }, + "execution_count": 64, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "v = cppjit.gbl.std.vector[cppjit.gbl.MyClass]()\n", + "v" + ] + }, + { + "cell_type": "code", + "execution_count": 65, + "id": "97302198", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:51.982061Z", + "iopub.status.busy": "2026-07-27T10:45:51.981955Z", + "iopub.status.idle": "2026-07-27T10:45:52.037260Z", + "shell.execute_reply": "2026-07-27T10:45:52.036901Z" + } + }, + "outputs": [], + "source": [ + "for i in range(10):\n", + " v.emplace_back(i)" + ] + }, + { + "cell_type": "code", + "execution_count": 66, + "id": "8d89d251", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.038273Z", + "iopub.status.busy": "2026-07-27T10:45:52.038163Z", + "iopub.status.idle": "2026-07-27T10:45:52.061275Z", + "shell.execute_reply": "2026-07-27T10:45:52.060865Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "10" + ] + }, + "execution_count": 66, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "len(v)" + ] + }, + { + "cell_type": "code", + "execution_count": 67, + "id": "e478f8eb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.062121Z", + "iopub.status.busy": "2026-07-27T10:45:52.062013Z", + "iopub.status.idle": "2026-07-27T10:45:52.085218Z", + "shell.execute_reply": "2026-07-27T10:45:52.084794Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0 1 2 3 4 5 6 7 8 9 " + ] + } + ], + "source": [ + "for m in v:\n", + " print(m.fData, end = ' ')" + ] + }, + { + "cell_type": "markdown", + "id": "716b10f7", + "metadata": {}, + "source": [ + "Runtime Callbacks" + ] + }, + { + "cell_type": "code", + "execution_count": 68, + "id": "2dbe3382", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.086088Z", + "iopub.status.busy": "2026-07-27T10:45:52.085980Z", + "iopub.status.idle": "2026-07-27T10:45:52.108583Z", + "shell.execute_reply": "2026-07-27T10:45:52.108164Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 68, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit.cppdef('''\n", + "typedef std::function F;\n", + "int callFun(const F& f, int i) {\n", + " return f(i);\n", + "}\n", + "''')" + ] + }, + { + "cell_type": "markdown", + "id": "15530eb4-0c55-4cee-b8bf-291075e1c448", + "metadata": {}, + "source": [ + "### Exercise 3 - Using callbacks to Python\n", + "\n", + "The runtime callback mechanism is one of the most powerful features of cppjit. It can be used, for example, to call back into Python ML inference from C++.\n", + "\n", + "Can you write a little example of how `callFun` can be called with a Python callable as the first argument?" + ] + }, + { + "cell_type": "code", + "execution_count": 69, + "id": "a9a0c21c-1622-4de5-bc44-f4374b477172", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.109420Z", + "iopub.status.busy": "2026-07-27T10:45:52.109318Z", + "iopub.status.idle": "2026-07-27T10:45:52.111405Z", + "shell.execute_reply": "2026-07-27T10:45:52.111008Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "Ellipsis" + ] + }, + "execution_count": 69, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# TODO: write your code here.\n", + "..." + ] + }, + { + "cell_type": "code", + "execution_count": 70, + "id": "9237d2af-c680-4c50-8e5f-0dfac78113be", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.112221Z", + "iopub.status.busy": "2026-07-27T10:45:52.112119Z", + "iopub.status.idle": "2026-07-27T10:45:52.643082Z", + "shell.execute_reply": "2026-07-27T10:45:52.642665Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "4" + ] + }, + "execution_count": 70, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit.gbl.callFun(lambda x: x + x, 2)" + ] + }, + { + "cell_type": "markdown", + "id": "e2ee77b6-9685-474f-a4d5-c621c817da18", + "metadata": {}, + "source": [ + "## Putting it all together: A final overview\n", + "\n", + "Finally, we compare the C/C++ interop approaches presented here on a single compute-heavy example, along with NumPy." + ] + }, + { + "cell_type": "markdown", + "id": "5901d264-a1c0-49f0-ac1e-43a12a805c70", + "metadata": {}, + "source": [ + "Array-oriented programming (e.g. NumPy) connects Python with compiled code, but it's not the only way: the bindings above do the same." + ] + }, + { + "cell_type": "markdown", + "id": "5a51ef39-b1ea-4b98-a478-930b3a13ff1b", + "metadata": {}, + "source": [ + "Although much faster than pure Python, array-oriented techniques are not as fast as imperative, compiled code." + ] + }, + { + "cell_type": "code", + "execution_count": 71, + "id": "91e6a4f4-059c-436d-9ec4-b8b7478929f1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.644068Z", + "iopub.status.busy": "2026-07-27T10:45:52.643959Z", + "iopub.status.idle": "2026-07-27T10:45:52.648089Z", + "shell.execute_reply": "2026-07-27T10:45:52.647698Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing quadratic_formula_c.c\n" + ] + } + ], + "source": [ + "%%writefile quadratic_formula_c.c\n", + "\n", + "#include \n", + "\n", + "void run(double* a, double* b, double* c, double* output, int n_elems) {\n", + " for (int i = 0; i < n_elems; i++) {\n", + " output[i] = (-b[i] + sqrt(b[i]*b[i] - 4*a[i]*c[i])) / (2*a[i]);\n", + " }\n", + "}\n" + ] + }, + { + "cell_type": "code", + "execution_count": 72, + "id": "79f49e52-794f-45ef-b46b-4c689d2f3bf4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.648907Z", + "iopub.status.busy": "2026-07-27T10:45:52.648805Z", + "iopub.status.idle": "2026-07-27T10:45:52.807966Z", + "shell.execute_reply": "2026-07-27T10:45:52.807526Z" + } + }, + "outputs": [], + "source": [ + "!cc quadratic_formula_c.c -shared -fPIC -lm -o quadratic_formula_c.so" + ] + }, + { + "cell_type": "code", + "execution_count": 73, + "id": "a0d965c0-cb0d-4005-ba41-2f96813a8b71", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.809249Z", + "iopub.status.busy": "2026-07-27T10:45:52.809138Z", + "iopub.status.idle": "2026-07-27T10:45:52.811708Z", + "shell.execute_reply": "2026-07-27T10:45:52.811362Z" + } + }, + "outputs": [], + "source": [ + "import ctypes\n", + "import numpy as np\n", + "\n", + "quadratic_formula_c = ctypes.CDLL(\"./quadratic_formula_c.so\")\n", + "quadratic_formula_c.run.argtypes = (ctypes.POINTER(ctypes.c_double),) * 4 + (ctypes.c_int, )\n", + "quadratic_formula_c.run.restype = None" + ] + }, + { + "cell_type": "code", + "execution_count": 74, + "id": "cd66ffd4-05f0-4247-bb3b-df5dbf1827b3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.812552Z", + "iopub.status.busy": "2026-07-27T10:45:52.812452Z", + "iopub.status.idle": "2026-07-27T10:45:52.814082Z", + "shell.execute_reply": "2026-07-27T10:45:52.813714Z" + } + }, + "outputs": [], + "source": [ + "n_elems = 1000000" + ] + }, + { + "cell_type": "code", + "execution_count": 75, + "id": "5fb26358-17b6-4e2f-84ec-e29880e66274", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.814888Z", + "iopub.status.busy": "2026-07-27T10:45:52.814753Z", + "iopub.status.idle": "2026-07-27T10:45:52.828862Z", + "shell.execute_reply": "2026-07-27T10:45:52.828536Z" + } + }, + "outputs": [], + "source": [ + "a = np.random.uniform(5, 10, n_elems)\n", + "b = np.random.uniform(10, 20, n_elems)\n", + "c = np.random.uniform(-0.1, 0.1, n_elems)" + ] + }, + { + "cell_type": "code", + "execution_count": 76, + "id": "4c86c2e0-bd45-4470-b695-22bd04ffaf49", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.829708Z", + "iopub.status.busy": "2026-07-27T10:45:52.829611Z", + "iopub.status.idle": "2026-07-27T10:45:52.836851Z", + "shell.execute_reply": "2026-07-27T10:45:52.836489Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([ 0.0040901 , 0.0052492 , -0.00473426, ..., 0.00062202,\n", + " -0.00271347, 0.00045253], shape=(1000000,))" + ] + }, + "execution_count": 76, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "output = np.zeros(n_elems, dtype=np.float64)\n", + "quadratic_formula_c.run(*[arg.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) for arg in (a, b, c, output)], n_elems)\n", + "output" + ] + }, + { + "cell_type": "code", + "execution_count": 77, + "id": "cedbf713-0719-4bbe-984d-35a1f5cae1ae", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:52.837618Z", + "iopub.status.busy": "2026-07-27T10:45:52.837523Z", + "iopub.status.idle": "2026-07-27T10:45:56.232760Z", + "shell.execute_reply": "2026-07-27T10:45:56.232345Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "4.18 ms ± 47.4 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n" + ] + } + ], + "source": [ + "ctypes_time = %timeit -o quadratic_formula_c.run(*[arg.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) for arg in (a, b, c, output)], n_elems)" + ] + }, + { + "cell_type": "markdown", + "id": "58b921ba-f5f2-4415-879e-f4fe68fbb4cb", + "metadata": {}, + "source": [ + "In Pure Python it would look like:" + ] + }, + { + "cell_type": "code", + "execution_count": 78, + "id": "d38854c3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:56.233730Z", + "iopub.status.busy": "2026-07-27T10:45:56.233636Z", + "iopub.status.idle": "2026-07-27T10:45:56.235624Z", + "shell.execute_reply": "2026-07-27T10:45:56.235309Z" + } + }, + "outputs": [], + "source": [ + "import math\n", + "\n", + "def run(a, b, c):\n", + " output = []\n", + " for i in range(len(a)):\n", + " output.append((-b[i] + math.sqrt(b[i]*b[i] - 4*a[i]*c[i])) / (2*a[i]))\n", + " return output" + ] + }, + { + "cell_type": "code", + "execution_count": 79, + "id": "2a3cbbbf", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:45:56.236443Z", + "iopub.status.busy": "2026-07-27T10:45:56.236355Z", + "iopub.status.idle": "2026-07-27T10:46:00.000007Z", + "shell.execute_reply": "2026-07-27T10:45:59.999572Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "470 ms ± 716 μs per loop (mean ± std. dev. of 7 runs, 1 loop each)\n" + ] + } + ], + "source": [ + "%timeit run(a, b, c)" + ] + }, + { + "cell_type": "markdown", + "id": "7a69d373", + "metadata": {}, + "source": [ + "Let's look at memory:" + ] + }, + { + "cell_type": "code", + "execution_count": 80, + "id": "7fefbfe9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:00.001153Z", + "iopub.status.busy": "2026-07-27T10:46:00.001049Z", + "iopub.status.idle": "2026-07-27T10:46:00.611228Z", + "shell.execute_reply": "2026-07-27T10:46:00.610731Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "peak memory: 324.00 MiB, increment: 0.00 MiB\n" + ] + } + ], + "source": [ + "%%memit\n", + "\n", + "run(a, b, c)" + ] + }, + { + "cell_type": "markdown", + "id": "7b796ddb", + "metadata": {}, + "source": [ + "NumPy is fast but not much better with memory. Measure memory usage first, so that we see the memory increment from NumPy's initial allocation of working memory." + ] + }, + { + "cell_type": "code", + "execution_count": 81, + "id": "87ae2613", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:00.612527Z", + "iopub.status.busy": "2026-07-27T10:46:00.612350Z", + "iopub.status.idle": "2026-07-27T10:46:00.743791Z", + "shell.execute_reply": "2026-07-27T10:46:00.743290Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "peak memory: 324.00 MiB, increment: 0.00 MiB\n" + ] + } + ], + "source": [ + "%%memit\n", + "\n", + "output[:] = (-b + np.sqrt(b**2 - 4*a*c)) / (2*a)" + ] + }, + { + "cell_type": "markdown", + "id": "5f8b21a3-8553-4f36-a9e7-ee912ef5eece", + "metadata": {}, + "source": [ + "Why? NumPy allocates working memory for the intermediate steps.\n", + "\n", + "* Memory allocation is expensive (`malloc` has to search for unused memory).\n", + "* Accessing different memory is expensive (the CPU can't re-use its cache, and accessing RAM is much slower than most mathematical operations).\n", + "\n", + "*Note: the second time you run this cell you might not see memory increments, because NumPy re-uses pre-allocated working buffers.*" + ] + }, + { + "cell_type": "markdown", + "id": "0637b5f4-54e5-455e-94ed-fad7bd951306", + "metadata": {}, + "source": [ + "Now measure the time:" + ] + }, + { + "cell_type": "code", + "execution_count": 82, + "id": "3f3a7315", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:00.744904Z", + "iopub.status.busy": "2026-07-27T10:46:00.744776Z", + "iopub.status.idle": "2026-07-27T10:46:04.148919Z", + "shell.execute_reply": "2026-07-27T10:46:04.148493Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "4.19 ms ± 17.6 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n" + ] + } + ], + "source": [ + "np_time = %timeit -o (-b + np.sqrt(b**2 - 4*a*c)) / (2*a)" + ] + }, + { + "cell_type": "markdown", + "id": "1abd3e68", + "metadata": {}, + "source": [ + "### Let's try nanobind now\n", + "\n", + "nanobind builds the same kernel into a compiled extension module." + ] + }, + { + "cell_type": "code", + "execution_count": 83, + "id": "4085fa67-f986-4056-894a-fa08b37779c6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:04.150016Z", + "iopub.status.busy": "2026-07-27T10:46:04.149908Z", + "iopub.status.idle": "2026-07-27T10:46:04.154180Z", + "shell.execute_reply": "2026-07-27T10:46:04.153779Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing quadratic_formula_nanobind.cpp\n" + ] + } + ], + "source": [ + "%%writefile quadratic_formula_nanobind.cpp\n", + "\n", + "#include \n", + "#include \n", + "#include \n", + "namespace nb = nanobind;\n", + "\n", + "void run(nb::ndarray, nb::c_contig> a_numpy,\n", + " nb::ndarray, nb::c_contig> b_numpy,\n", + " nb::ndarray, nb::c_contig> c_numpy,\n", + " nb::ndarray, nb::c_contig> output_numpy) {\n", + " const double* a = a_numpy.data();\n", + " const double* b = b_numpy.data();\n", + " const double* c = c_numpy.data();\n", + " double* output = output_numpy.data();\n", + " for (size_t i = 0; i < output_numpy.size(); i++) {\n", + " output[i] = (-b[i] + std::sqrt(b[i]*b[i] - 4*a[i]*c[i])) / (2*a[i]);\n", + " }\n", + "}\n", + "\n", + "NB_MODULE(quadratic_formula_nanobind, m) {\n", + " m.def(\"run\", &run);\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 84, + "id": "264cd1d3-cc54-4413-8cb9-7f69c38d1b58", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:04.155029Z", + "iopub.status.busy": "2026-07-27T10:46:04.154932Z", + "iopub.status.idle": "2026-07-27T10:46:04.197220Z", + "shell.execute_reply": "2026-07-27T10:46:04.196782Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "import nanobind\n", + "from nanobind import include_dir, source_dir\n", + "\n", + "nbinc = \"-I \" + include_dir()\n", + "robin = \"-I \" + os.path.join(os.path.dirname(nanobind.__file__), \"ext\", \"robin_map\", \"include\")\n", + "nbsrc = source_dir() + \"/nb_combined.cpp\"\n", + "plat = \"-undefined dynamic_lookup\" if \"darwin\" in sys.platform else \"-fPIC\"\n", + "pyinc = !python3-config --cflags" + ] + }, + { + "cell_type": "code", + "execution_count": 85, + "id": "37c14e3a-ece8-4984-8b46-a5c8d22c1646", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:04.198207Z", + "iopub.status.busy": "2026-07-27T10:46:04.198098Z", + "iopub.status.idle": "2026-07-27T10:46:08.291028Z", + "shell.execute_reply": "2026-07-27T10:46:08.290416Z" + } + }, + "outputs": [], + "source": [ + "!c++ -std=c++17 quadratic_formula_nanobind.cpp {nbsrc} -shared {nbinc} {robin} {pyinc.s} -o quadratic_formula_nanobind.so {plat}" + ] + }, + { + "cell_type": "code", + "execution_count": 86, + "id": "04918033-e6a0-4384-acbb-ebbb2e86a581", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:08.292607Z", + "iopub.status.busy": "2026-07-27T10:46:08.292482Z", + "iopub.status.idle": "2026-07-27T10:46:08.295734Z", + "shell.execute_reply": "2026-07-27T10:46:08.295374Z" + } + }, + "outputs": [], + "source": [ + "import quadratic_formula_nanobind" + ] + }, + { + "cell_type": "code", + "execution_count": 87, + "id": "914a45ab-67db-4ef0-bb5a-20b3f04a80d6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:08.296559Z", + "iopub.status.busy": "2026-07-27T10:46:08.296464Z", + "iopub.status.idle": "2026-07-27T10:46:08.301432Z", + "shell.execute_reply": "2026-07-27T10:46:08.301092Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([ 0.0040901 , 0.0052492 , -0.00473426, ..., 0.00062202,\n", + " -0.00271347, 0.00045253], shape=(1000000,))" + ] + }, + "execution_count": 87, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "output = np.zeros(n_elems, dtype=np.float64)\n", + "quadratic_formula_nanobind.run(a, b, c, output)\n", + "output" + ] + }, + { + "cell_type": "code", + "execution_count": 88, + "id": "8f718827-d128-4272-b543-170ccf1cab88", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:08.302214Z", + "iopub.status.busy": "2026-07-27T10:46:08.302123Z", + "iopub.status.idle": "2026-07-27T10:46:10.008548Z", + "shell.execute_reply": "2026-07-27T10:46:10.008138Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.09 ms ± 9.28 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n" + ] + } + ], + "source": [ + "nanobind_time = %timeit -o quadratic_formula_nanobind.run(a, b, c, output)" + ] + }, + { + "cell_type": "markdown", + "id": "4bf7ce2e-046f-45bb-8b2c-8d9c99a1573e", + "metadata": {}, + "source": [ + "Leaving Python, writing C++ code, and then importing it is fine for a long-term project, like a library that will be used many times. But, it's inconvenient in the middle of a data analysis." + ] + }, + { + "cell_type": "markdown", + "id": "de872785-68be-471c-b89b-90c8430203a2", + "metadata": {}, + "source": [ + "Note: if we change the C++, recompile, and do\n", + "\n", + "```python\n", + "import quadratic_formula_nanobind\n", + "```\n", + "\n", + "again, we will _not_ get the new version. We would still have the old version, with no error messages or warnings!" + ] + }, + { + "cell_type": "markdown", + "id": "f663096e", + "metadata": {}, + "source": [ + "### cppjit" + ] + }, + { + "cell_type": "code", + "execution_count": 89, + "id": "14d9977e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:10.009544Z", + "iopub.status.busy": "2026-07-27T10:46:10.009447Z", + "iopub.status.idle": "2026-07-27T10:46:10.036484Z", + "shell.execute_reply": "2026-07-27T10:46:10.036084Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 89, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import cppjit\n", + "cppjit.cppdef('''\n", + "void run(double* a, double* b, double* c, double* output, int n_elems) {\n", + " for (int i = 0; i < n_elems; i++) {\n", + " output[i] = (-b[i] + sqrt(b[i]*b[i] - 4*a[i]*c[i])) / (2*a[i]);\n", + " }\n", + "}\n", + "''')" + ] + }, + { + "cell_type": "code", + "execution_count": 90, + "id": "0c89979f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:10.037344Z", + "iopub.status.busy": "2026-07-27T10:46:10.037234Z", + "iopub.status.idle": "2026-07-27T10:46:10.039281Z", + "shell.execute_reply": "2026-07-27T10:46:10.038931Z" + } + }, + "outputs": [], + "source": [ + "# double * from NumPy\n", + "x = a.ctypes.data_as(ctypes.POINTER(ctypes.c_double))\n", + "y = b.ctypes.data_as(ctypes.POINTER(ctypes.c_double))\n", + "z = c.ctypes.data_as(ctypes.POINTER(ctypes.c_double))\n", + "cppjit_out = output.ctypes.data_as(ctypes.POINTER(ctypes.c_double))" + ] + }, + { + "cell_type": "code", + "execution_count": 91, + "id": "2d5f9214", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:10.040122Z", + "iopub.status.busy": "2026-07-27T10:46:10.040022Z", + "iopub.status.idle": "2026-07-27T10:46:11.862406Z", + "shell.execute_reply": "2026-07-27T10:46:11.862014Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.2 ms ± 17.7 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n" + ] + } + ], + "source": [ + "cppjit_time = %timeit -o cppjit.gbl.run(x, y, z, cppjit_out, n_elems)" + ] + }, + { + "cell_type": "code", + "execution_count": 92, + "id": "a64b4c10-de0a-4a58-a092-ba28293f9c12", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:11.863394Z", + "iopub.status.busy": "2026-07-27T10:46:11.863294Z", + "iopub.status.idle": "2026-07-27T10:46:11.992077Z", + "shell.execute_reply": "2026-07-27T10:46:11.991586Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "peak memory: 322.06 MiB, increment: 0.00 MiB\n" + ] + } + ], + "source": [ + "cppjit_mem = %memit -o cppjit.gbl.run(x, y, z, cppjit_out, n_elems)" + ] + }, + { + "cell_type": "code", + "execution_count": 93, + "id": "0a57b52d-40e9-4276-b6b5-f708179e571d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:11.993157Z", + "iopub.status.busy": "2026-07-27T10:46:11.993012Z", + "iopub.status.idle": "2026-07-27T10:46:11.995453Z", + "shell.execute_reply": "2026-07-27T10:46:11.995102Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 93, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cppjit_mem" + ] + }, + { + "cell_type": "code", + "execution_count": 94, + "id": "14664481", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:11.996341Z", + "iopub.status.busy": "2026-07-27T10:46:11.996217Z", + "iopub.status.idle": "2026-07-27T10:46:11.998960Z", + "shell.execute_reply": "2026-07-27T10:46:11.998541Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "array([ 0.0040901 , 0.0052492 , -0.00473426, ..., 0.00062202,\n", + " -0.00271347, 0.00045253], shape=(1000000,))" + ] + }, + "execution_count": 94, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# NumPy from double *\n", + "np.ctypeslib.as_array(cppjit_out, shape = (n_elems,))" + ] + }, + { + "cell_type": "code", + "execution_count": 95, + "id": "c0903b85", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:11.999829Z", + "iopub.status.busy": "2026-07-27T10:46:11.999709Z", + "iopub.status.idle": "2026-07-27T10:46:12.304164Z", + "shell.execute_reply": "2026-07-27T10:46:12.303753Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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znS7C/2eBgUFaezYsZKkXbt2uXI697Opc+fO2rRpk5KTk/X333/r9ttvz9BXgwYNNG/ePMXExCguLk6LFy/O9Ha5unXr6tdff1ViYqL27t2rl19+OdPftYYNG2r+/Pk6evSoEhMTtXPnTk2ePPmCYwX8F3CFBwAAlwFfX1+VLl1aHh4eKlOmjJ599lkVK1ZMX331lVu7Tz/9VD179lRwcLA++OADVa1aVf/73/900003yd/f361AUr16dX3//feaPHmypk6dqscee0xTpkzR2rVrXX/5LVq0qJYtW6Y6dero888/17p163T11VerU6dOqlixottfmIcMGaLTp09r7Nix8vX11QsvvKBp06apadOmbjmWLFlSc+fO1YwZM/Tdd9/p6aef1owZM9S9e3e99957mjBhgqZPn67Bgwfr+++/V6VKlVy3MzRq1Ei33nqrZsyYoX379um6667T008/rd9//11169bN8Ffxjz/+WEePHtXIkSNVtGjRTMe2WrVq+u2333TixAm1a9fO7Zgu9H6czcx04sQJSWfmArj77rs1efJk1a9fX/Hx8Wrfvr369OmjV155RRs3bpQktWrVynW1yKuvvqrTp0+rV69e+u2333Tbbbdp9erVkqRrr71Wf/75p0qUKKHPPvtMW7duVYUKFXT//ferSJEiObp8vn///ho/frzi4+P1xhtvSJIOHz6cZfvAwEBNmTJFf/75p4YOHaqyZcuqX79+8vf310033eTWt5eXlxYsWKBVq1Zp0KBBatu2rQYNGqSIiAhNmDAh2zlejG+++UZhYWEaMmSIOnbsqGHDhunEiRN68skn9dtvv+nFF19U9+7d9c4772j16tVatmyZpDNFwp9++knNmzfXZ599prCwMNWvX18DBgxQzZo1de+992bZZ//+/dWjRw/dd999rltM0t/boKAgjRgxQosWLdInn3yiWrVq6emnn1ajRo0y/C6WLl1av/zyi2bMmKGvvvrqvO/HhXz55Zd68skn1b59ey1evDjD+rCwMD3yyCMaN26c9u3b57p1Zf369XrkkUf01VdfaeHChfriiy9c2xQuXFhLlixRhQoV9Omnn2rPnj269dZbNWrUKF177bUaMGCAWx+9evXSVVddpc8++0wpKSk6ceKE6tatqxUrVmj//v166623lJCQoAceeECzZ89Wly5dNHv2bLd9jB8/XlFRUXr11Vd13XXXqX///vrwww/14IMPusY+J+fx2aZPn673339fRYsWVUJCgry8vNS1a1e9++67uuqqqzK0z87v6Q8//KCaNWvq4YcfVv/+/XXs2DFJ0tGjR137ad68ue677z59/PHHiouL03PPPaeZM2eqcuXKrs+OunXratmyZYqNjdXo0aN16tQpPfnkk/r9998VEBCgP//8U5JUtmxZhYSEyNvb2zWeffr0yfAZeM0112jhwoU6evSo3nrrLUVHR+u6667Tfffdl62xAv4LjCAIgiCI/InAwEDLTFJSkvXo0cOtrb+/v5mZPfTQQ27L27dvn2F5ZGSkmZk1b97ctezqq6+2pKQkGzNmjGvZiBEjzMzsnnvuyTLHgIAAMzPbvHmzFShQwLX82WefNTOzevXquZaFhISYmdmDDz7oWlazZk0zM0tNTbXGjRu7lrdr187MzAIDA13Lrrrqqgz9N2nSxMzMHnnkkQzjtnTpUvP09HRrHxQUZGZmpUuXtlq1atm+ffts1apVVqJEiYt+P9Lfk7Pb1qtXz5KTk+2zzz4zX19f27t3r/3555/m5eXlarNt2zb75Zdf3La76qqrLCIiwhYsWOBaNmXKFEtNTbWGDRtmmVv6cWWVc5UqVVzLNm3aZCEhIVm+lwEBASbJvL297dChQ7Zx40YrVKiQq92dd95pZmYjRoxwLQsODjYzs1deecVtn2vXrrXVq1fn6LyPi4uz4ODgTNeZmQUFBWU47gkTJriWeXp62p49eywtLc1eeOEF13JfX19LSEhw23f37t0tNTXV/P393frp06ePmZk1a9bsvLmefT6d/buUnJxs8+fPNw8PD9fyvn37mplZz549M/xO9OnTJ1tjk1l/Z4evr6+Zmc2cOdPtvYmMjHRrFxkZaXPmzMl0fMePH++27OWXX7a4uDirXr262/I333zTTp06ZRUrVjRJVqVKFTMzi46Otquvvtqt7aJFi+yvv/6yggULui1fvny5bdu2LcP5unDhQrd277zzjp06dcqKFy9+wfM4q0g/thIlSlhycrJ1797dJNkdd9xhaWlpVrly5UzHN7u/pwMHDszwu3Z238nJyVatWjXXsvr165uZ2TPPPONa9sMPP1hycrJVrVrVtaxcuXIWExNjv//+u2vZu+++a2ZmjRo1cjvvoqKi3HLo3Lmzmdl5PzsI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P//88wX3cbn79ttvVbFiRT3xxBMZ1l111VUqUqRIjve5ePFipaSk6LnnnnNb3rt3b5UoUSLPxu2hhx7S448/rpUrV7rmIMkN3377rW699Va1b98+wzpfX98LXkV09OhRhYSE6Mknn1S5cuUyrD/7cbM5kd3zOCtjx47ViBEj9Nprr2XZJru/p+n5SLronE6fPq2FCxeqc+fOboXKMmXK6OGHH9by5ctdtyrNmzdPzZo1U6NGjdxyOfcKv8xySb9SpVChQheVJ+AkXOEBAMBl4I477lDt2rUl/fOP35o1a2rUqFGufwAvXbpUEyZM0EsvvaQGDRpo4cKFrrk9unbtqn79+mV7Po50Y8aM0f3336/vvvtOn3/+udauXatSpUqpU6dOeuqpp1yP4Py3zJ07V48++qhiYmK0ZcsWNWvWTG3bts1yboELMTM98sgjmj17tr799lvdeeedGa5kyMzZ78fZVq5cqcjISNWuXVuvvfaagoODNXfuXElSz549tWHDBn388cfq1q2bzEyPP/64fvnlF23evFnBwcHav3+/KlSooFatWik2NladOnWSJL300ktq3769lixZ4np06rXXXquuXbuqefPmiomJ0cKFC7V7925NnjxZY8aMUVpamh577DEdPXo0w1Uea9eu1dNPP62XX35ZO3bs0JEjRzI97tTUVL344ouaMmWKlixZoq+//tr1WNrIyEiNGzfuYob9svLll1/qgQce0IQJE9SqVSutWLFCXl5eql27th544AHdfvvtOZ4s9tixYxo1apRGjBih+fPn66efflKtWrXUt29f/fnnnxkeJ30x7r//fsXHx6tgwYKqUKGCbr/9djVv3lwbNmxQ165dL3n/ZxszZow6deqkuXPnuh5dXbRoUdWvX1/333+/rrvuugs+zvmZZ57R8uXLtWnTJk2cOFE7d+5U2bJl1axZM1WsWFENGjTIcV7ZPY+zsnHjxgt+huXk9zT9PHnjjTc0Y8YMnTp1SnPmzFFiYmK2c3rllVfUrl07LV++XB9//LFSU1P15JNPqlChQm7zjIwePVqPPvqo5s+fr/fff9/1WNrdu3e7FTkCAwPVt29fzZo1SxEREfLx8dETTzyhmJgYzZs3L9t5AU6W74+KIQiCIIj/amT2GNTExERbt26dPfnkk5lu8/jjj9vq1astISHBYmJi7K+//rK33nrL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5curcOHC+u6771S4cGHt3LlTn332me655x63210++ugjtW3b9oK5hoaGZnidnsO/bePGja6fDx48KEkqU6ZMnu6jfv368vb21vbt293GOiAgwDVHhySlpKS47TsrZcqU0Weffabt27crOjpasbGxKlasmCpXrixJatCggfbt26fw8PBMt/fz81PPnj3dclmwYIG8vLxUtWrVDO1HjRql4sWLu6JChQoXzBEAAAAAzsakpf9BSUlJF7Xd0aNHNWfOHPXq1UuRkZG644471LJlyxzto1ixYgoKCtIPP/yQYV1ycrL27dunWrVqqW3btmrXrp0+/vhjDR48WAEBAUpNTb2ovM91+vRpSWfmMUlXoECBXNl3Zk6dOuX6OX1ODE/PnNUac7qPYsWKKTU1VQ0bNlRaWprbuvj4eNfP2T0Xpk6dqtKlS6tfv37avXu3UlJSFBoaqoIFC2ZrP8WKFdOnn36qDz74IMO6PXv2ZFh28uRJnTx5Mlu5AQAAAEBmKHj8B4WHhysxMVFt2rTR5MmTM6xP/6J59pUV6SZNmqSvv/5a+/btU0REhFauXOm2vkCBArrllltcV3nUrFlTJUuWVFhYmCRp3bp1qlWrliIiIrLMLzk5WXPnztXcuXP10Ucfadu2bapfv36mj4HNyrkTgzZt2tSVw9GjRyVJ1157rTZs2CDpzBUKOXHy5MlMxyc/ZJbL+vXr5e3trTJlymj58uWX3Ie/v7/69u3rekRtxYoVdc0117jWb9y4URUrVlSNGjUyvcpj3bp1qlu37nnfdwAAAADITRQ8/oNSUlL09ttva/To0Tp58qRWrFiha665RvXq1dPnn3+uI0eOKDExUR06dNC+ffuUnJys2NhYSdKCBQsUGxurV155RcOHD8+w75MnT2r8+PF67rnnlJqaqg8//FChoaGuAsjIkSM1d+5c7dmzR99//71Onz4tPz8/3XDDDRo2bJgCAwPl5eWlVatWKTExUY888ogSExO1e/duSdIzzzyje++994K3tfj7+2vw4MGaPXu22rVrp65du7qeGJKcnKzQ0FANGTJEkZGRKlOmjF5//fUcjeGuXbtUtWpV+fn5ad++fYqLi8u3KxJ27dolHx8ftW7dWn/99ZcSExMVHh6ur776Sl988YUGDhyo9evX65prrlGbNm20ceNGzZs3L0d9hIeH69FHH9WaNWtU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PlNgYKCioqJ09913a/PmzUpJSVHlypV1yy23aOHChTp69Kiio6P1+++/66abbnLta/LkyZozZ47b/r29vXX48GE99thjWeZZokQJTZ06VSdOnFBCQoLmzZun6tWru9Znlc+5t7R4eHhoyJAh2rlzpxITE7VhwwZ16dLFtT4gIEBmptatW2v16tVKSEjQihUrVLNmTVc/I0aMUIMGDVxX3gQGBkqSfH19NXHiRB05ckQxMTH69ddfdeONN7odx4svvqhDhw4pNjZWkyZN0lVXXXXe96dgwYLy8fFxCwAAAADICQoeyFRaWppeeuklPfvss6pQocIl7at169YqX768WrRooeeff14jR47U3LlzFRUVpSZNmmjChAn69NNPz9tPUlKSpDNfhL/77jt5eXmpU6dOrvXXXHONOnbsqM8//1xVqlSRmSkgICDL/RUrVkzz5s1TmzZtdNNNN2n+/PmaM2eOKlWq5NZu0KBB+uuvv3TTTTfptddekyQVKVJEL774oh5//HHVq1dPR44ckY+Pj6ZOnarmzZuradOmCg8P17x581SsWDFJ0qRJk9ShQweVK1fOte+77rpLRYoU0TfffJNlnlOmTNEtt9yiTp06qVmzZvLw8NC8efPk7e3tapNZPucaOnSoevTooaeeekr16tXTuHHj9NVXX6lFixZu7d544w0NHDhQt9xyi1JTU/X5559Lkr755huNHTtWf//9t8qVK6dy5cq58v7uu+9UpkwZ3XHHHWrYsKHWrVunX3/9VSVLlpQkde3aVSNGjNBLL72kW265RQcPHlTfvn2zPOb0fGNjY12xf//+87YHAAAAgMwYQZwdwcHBNmvWLJNkK1eutEmTJpkk69y5s9mZSTxMkgUFBdn69evdtu3Xr59FRka67SsyMtI8PDxcy8LCwmzJkiWu156enhYXF2fdunVzLTMz69y5s0mywoUL24cffminTp2y+vXrmyT76KOP7Oeff3a1HzBggO3YscMkWfny5S0sLMwaNWqUo+PetGmTPfPMM67XkZGR9sMPP7i1CQwMNDOzG2+88bz78vDwsJiYGOvYsaNr2d9//22DBw92vf7xxx/t888/z3If1atXNzOzZs2auZaVKlXKEhIS7P777z9vPme/hwULFrT4+Hhr2rSpW5uJEyfatGnTTJIFBASYmVnr1q1d6++44w4zMytUqFCW77e/v79FR0dbwYIF3ZaHh4fbE088YZJsxYoV9uGHH7qtDw0NzbCvs6NgwYLm4+PjivLly5uZmY+PmUQQBEEQBEEQxL8X+f8d9ezw8fH5/+8GPhdsm+MrPIYNG6bChQtnWH7VVVdp2LBhOd0dLnMvvviiAgMDVbt27Yvex+bNm3X2ZKeHDx/Wpk2bXK9Pnz6t48ePq0yZMm7bff3114qLi1NcXJy6dOmi3r17u7abOHGi2rdvr/Lly0s6M9/HlClTJEkHDhxQnTp1tHr16ixzKlq0qMaMGaMtW7YoKipKcXFxqlOnjipXruzWbs2aNRm2TUlJ0caNG92WlSlTRp999pm2b9+u6OhoxcbGqlixYm77mzRpknr16uVqf8cdd7iuoPjkk09cxxoXFydJqlOnjk6dOqVVq1a59nHixAlt27ZNderUOW8+Z6tevbqKFi2qRYsWufXRo0cPXX/99W5tz97PwYMHXblmxc/PT8WKFdPx48fd9l21alXXvuvUqeN2DJIUGhqa5T4l6eTJk277Sx8TAAAAAMgu7ws3cRcUFKQJEya4bjFIV6RIEQUFBbku+4czLFu2TAsWLNCoUaNcBYV0p0+fzjBhZ2YTe546dcrttZlluszT073+NmDAAC1evFgxMTE6duyY27oNGzbor7/+Uo8ePbRw4ULVq1dPHTt2zPZxjR07Vu3atdOgQYO0Y8cOJSUl6fvvv88wL0hCQkKGbc899yVp6tSpKl26tPr166fdu3crJSVFoaGhbvv74osv9NZbb6lp06a69dZbFRkZqeXLl0uShg8frrFjx2Y7/wvlc7b022o6duyY4daQlJQUt9dnvy/pRapz35dz933w4EG1bNkyw7ro6Ojz5gUAAAAAeSnHBQ8PDw+3v9an8/Pz04kTJ3IlKVxehgwZog0bNmjbtm1uy48ePeo2J4UkNWjQINf6PXTokCIiIrJcP2nSJPXv318VKlTQ4sWLtW/fvmzv29/fX1OmTNHs2bMlnbni47rrrrvoXP39/dW3b1/98ssvkqSKFSvqmmuucWtz4sQJzZ49W7169VKzZs0UHBzsWnf06FEdPXrUrX1YWJgKFCigJk2auK6IKFWqlGrVqqUtW7ZkO7ctW7YoOTlZlStX1tKlSy/2EHXy5El5eXm5LVu3bp3KlSun1NRU7d69O9PtwsLC1KRJE3355ZeuZU2bNr3oPAAAAAAgO7J9S8uJEyd0/PhxmZm2b9+u48ePuyI6OlqLFi3St99+m5e5Ip/8/fffmjZtmp577jm35b///ruuueYavfDCC6pWrZr69u2rO+6441/La/r06apYsaKeeOIJ160hklS+fHmFhYWpUaNGWW4bHh6u++67T35+frrxxhs1ffr0817JcCHh4eF69NFHVbt2bTVu3FjTpk1TYmJihnaTJk1SYGCg6tSpo6lTp553nzt27NDs2bM1ceJE+fv768Ybb9RXX32l/fv368cff8x2bvHx8Ro7dqzGjRunHj16qFq1arrpppv0v//9Tz169Mj2fnbt2qWqVavKz89PpUuXVsGCBbV48WKFhoZq9uzZateunapUqaJmzZrp9ddfV8OGDSVJ77//vh577DH17NlTNWrU0IgRI1SvXr1s9wsAAAAAFyPb3/D69++v559/Xh4eHgoKCtKAAQNc8dRTT6l58+b63//+l5e5Ih8NHz48Q0Fg69at6tu3r5555hn99ddfaty48UXflnExYmNjNXPmTMXHx7uu1JDO3FZTu3ZtFSlSJMttn3/+eUVFRWnlypWaM2eOFixYoHXr1l10Lr1791bJkiW1bt06ffnll/rggw8yfVrK4sWLdfDgQS1YsMA1R8b59OrVS2vXrtXcuXMVGhoqDw8P3XnnnUpNTc1RfsOGDdNrr72moUOHKiwsTPPnz1fHjh3dHiF8ITNnztT8+fMVEhKiY8eO6aGHHpIk3XnnnVq6dKmCg4O1fft2zZgxQ1WqVNHhw4clSd9++61ee+01jR49WmvXrlWVKlX0ySef5Ch/AAAAAMgpD52ZvTTbWrRooRUrVigtLS2PUgKyb/Hixdq8ebP69euX36lkS9GiRbV//3716tVLs2bNyu90rhg+Pj6KjY1V8eIS85cCAAAA/yaPCzf5F/3z3aD4BR9ukONr+NOfZpGuU6dOmjVrlt54441MJ6wE8kKJEiV0zz33qGXLlvroo4/yO50L8vDw0DXXXKNhw4YpOjpaP/30U36nBAAAAACOluOCx6effqqaNWtKkqpWrapvvvlGiYmJ6tq1q0aPHp3rCQKZWb9+vaZMmaIXX3xR27dvz+90Lqhy5co6cuSIHn74YT322GNcIQUAAAAAeSzHt7RER0fr5ptv1s6dO/XCCy+odevW6tChg2699VbNmDFDlStX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FscXPFauXKly5copJiZGkvTNN9+oZs2arvVBQUFav379JfUxZMgQpaamatCgQRe1fUhIiMaNG3dJOeSm4OBgzZo1y23Z3r17Va5cOf3999+52tf111+vzz//XHv37lVycrJ27typ6dOnq2HDhrnaT3adWxA7+3VISIjMLMsICQm55P7PPV9zu0BXtmxZffDBB4qIiFBycrL27Nmjn376Sa1bt861PgAAAADgcuD4gsepU6d0+PBh1+vk5GQdPXo0V/t47LHHNHr0aD322GO5ut/LyenTp3X48GGlpaXl2j4bNmyotWvXqmbNmnryySdVt25d3Xvvvdq6daveeeedTLdJv9Iku4KCghQcHJwr+d53330qV66cypUrp0aNGkmS2rRp41p23333XXIf556vualKlSpau3atWrdurcGDB6t+/frq0KGDQkJC9NFHH+VJnwAAAACQnyy/wsPDwwYPHmzh4eGWnJxsu3fvtpdeeskkWZUqVczMrFu3brZixQpLSkqyTZs2WYsWLVzbBwQEmJnZnXfeaX/99ZclJSVZaGio1atXL0MbX19fk2SBgYEWFRXl+vlcgYGBOTqGFi1a2N69e83b29v27dtnzZo1c1sfHBxss2bNcls2btw4CwkJca0/V5UqVVz7XrVqlSUnJ9uBAwds1KhR5uXl5dpPSEiIffDBBzZu3Dg7ceKEHTp0yB5//HErUqSIff755xYbG2vh4eHWoUMH1zaenp42adIk27lzpyUmJtrWrVvtueeec60PCgrKkE9AQIDr/fDz83O1rVu3rs2ZM8diYmIsNjbWli5datWqVcv22G3atMlWr15tHh4eGdalv1/nRnoe2e0jKCjIgoODs93+7PMjs9fn5nH2eGQW9erVs7S0NLv66qtNkpUsWdLS0tLs66+/drV5+eWXbdmyZRnO1/SfzxYUFGSSLDIy0oYOHWqTJ0+22NhY2717tz3xxBPnzeXnn3+2vXv3WpEiRc473mZmnTt3dr1+6623bNu2bZaQkGARERE2cuRI8/b2dhvj9evX2yOPPGKRkZEWHR1tX3/9tRUrVszVplixYvbVV19ZfHy8HThwwPr3728hISE2bty4bL0vPj4+Zmbm42MmEQRBEP/tyL9/uxIEQRD5H/98N/C5YNt8vcJj1KhRGjJkiF577TXVrVtXDz/8cIa/bo8ZM0bvvPOObrrpJoWGhmrOnDkqVapUhjYDBw5Uo0aNdPToUc2ZM0fe3t4X7P+bb77R2LFj9ffff7v+Sv/NN99IOnNbR3ZuUejdu7e+/vprpaam6uuvv1bv3r1zMAJSv379tHLlSn322WeuHPbu3avy5ctr3rx5Wr16tfz8/PT000+rd+/eeuWVV9y2DwwM1LFjx9S4cWONHz9en3zyib777jutXLlSN998sxYuXKgvv/xShQsXliR5enpq37596tq1q+rWrauRI0fqzTffVNeuXSVJY8eO1TfffKNffvnFlc/KlSsz5F2+fHktXbpUKSkpat26tRo2bKjPP/88W+MuSQ0aNNANN9ygd955R5ldsZF+S8eVbvPmzTp+/LgCAgIkSbfddpvba+nMvB2///57hm1Xrlypfv36KSYmxvVejB071rV+4MCBWrNmjW666SZ9/PHH+uSTT9xu1zpbyZIl1aFDB3300UdKTEzMsP584x0XF6eePXuqbt266tevn5544gkNGDDArc3111+ve+65R3fddZfuuusuBQQEaMiQIa717777rvz9/dWpUye1a9dOt912m26++eYs+yxYsKB8fHzcAgAAAAByKl+qMsWKFbOkpCTr3bt3puvT/4L+wgsvuJZ5eXnZnj17bPDgwSb989fwBx54wNWmZMmSlpCQYF27dnVrk9kVHtI/f50+t/8333zTpk6desHKUkJCgt14440myfz8/Cw2NtaKFi3qanOhKzwkZfqX7tdff93CwsLclj399NMWGxvruiIiJCTEli5d+k/1ytPT4uLi3PIuW7asmZk1adIky+MYP368fffdd+fN+dwrGt544w2LiIhw+0t/TqJr165mZtagQYMcbXelXeEhyb7//nsbP368SbJ3333X3n77bTt+/LjVqlXLvL29LT4+3tq2bZut8zU9IiMj7YsvvnBbdujQIXvyySczzaFRo0ZmZnbPPfdcMN9zr/A4NwYOHGirV692G+P4+Hi3KzrefvttCw0NNenM73pKSop16dLFtb548eIWHx+f5RUemV1pxBUeBEEQxJm4uH97EgRBEM6IK+IKjzp16uiqq67Sr7/+et52oaGhrp/T0tK0Zs0a1alTJ8s2UVFR2rZtW4Y2OfXSSy8pMDDwvG0eeughRUREaOPGjZKkv/76S7t371a3bt0uqW/pzPicfVyStGLFCvn4+KhixYquZel9S2fm2Th+/Lg2bdrkWpZ+xUyZMmVcy/r27as1a9boyJEjiouLU58+fVS5cuUc5degQQMtW7ZMqampOdounYeHR7bb/v3334qLi1NcXJw2b94sSa7XcXFxmjdvnqtt8+bN3da99NJL6t69u9uyhx9++KJyvlhLlixRy5YtJZ25muO3337T0qVL1bJlSzVq1EgFChTQihUrcrzfs997STp06JDb+3y2nIz3uR544AEtX75cBw8eVFxcnF5//fUM58uuXbsUHx/ven3w4EFXLtWqVVPBggX1559/utbHxsZq27ZtWfY5atQoFS9e3BUVKlS46PwBAAAA/Ddl7/6DPJCUlJRfXeea3r17q169ejp16pRrmaenpx577DF9/vnnks4UIc79slmgQIFcy+HsviXJzDIsS89Lkrp166axY8dq4MCBCg0NVVxcnAYPHqwmTZrkqN9Lff+2b98uSapdu7Y2bNhw3rZ33nmna8wqVKigJUuWqEGDBpnmsmbNGrd1zz33nCpUqKAXX3zRtSyvJgXNyu+//6733ntP1atXV926dbV8+XLVrl1bLVu2VMmSJbVmzZqLGs/M3vv09/lc4eHhOn36tGrXrp2jPpo2bapp06YpKChICxYsUExMjB588EENHDjwonPJjpMnT+rkyZMXvT0AAAAA5NsVHuHh4UpMTFSbNm3O265p06aun728vNSwYUOFhYVl2aZEiRKqWbNmhjZZOXnypLy8vHKQ+Rk33HCDbrnlFrVs2VINGjRwRcuWLdWsWTPVqlVLknT06FFde+21btue/YU8qxzCwsLUrFkzt2X+/v6KjY3Vvn37cpzv2ftYuXKlPvnkE23YsEERERG6/vrrL5jPuTZu3Kjbbrst23N2nGvDhg3avHmzBg4cmOnVB76+vq6f9+zZo4iICEVERGj37t2S5HodERGhAwcOuNomJye7rTtx4oTi4uLclp19JcK/YdOmTYqKitIrr7yiDRs2KCEhQb///rsCAgLUsmXLTOfvSHex5+e5oqKitGDBAj3zzDMqUqRIhvVnj/fZbr31Vu3evVtvvvmm1q5dqx07dqhKlSo56nvnzp06efKk68k2klS8ePEs5xsBAAAAgNyQbwWPlJQUvf322xo9erQeffRRVatWTU2aNMnwaNdnnnlG99xzj2rVqqWPPvpIJUuWdF09kW748OFq3bq16tWrpylTpujYsWOaPXt2tvLYtWuXqlatKj8/P5UuXVoFCxaUJL355puaOnVqltv17t1bf/75p5YtW6bNmze7YtmyZVq9erVr8tLffvtNt9xyix599FFVr15dI0aM0A033JAhhyZNmqhKlSoqXbq0PDw89PHHH6tSpUoaP368atWqpU6dOunVV1/Vu+++q8wm+cyu8PBw3XLLLWrfvr1q1KihkSNHun0RTc/nxhtvVM2aNVW6dOlMixoffvihihcvrhkzZqhhw4aqXr26HnnkkRx9ie3Vq5dq1qypZcuW6Y477lDVqlVVv359vfTSS/rxxx8v+hgvR0uXLlX37t1dxY2NGzeqUKFCatOmjZYsWZLldrt27ZKPj49at26t0qVLuyafvRjPPPOMvLy89Oeff+q+++5T9erVVbt2bT377LMZbp9KFx4ersqVK6tbt26qVq2ann32Wd1777056jc+Pl5Tp07VmDFj1LJlS9WtW1eTJ0/W6dOnL+lcBgAAAIDzydentLz22mt65513NHLkSIWFhembb77JMAfBkCFDNGTIEP31119q3ry5OnXqpOPHj2do8/7772vt2rUqV66c7r777kxv68jMzJkzNX/+fIWEhOjYsWN66KGHJEnXXnttlvNaFChQQI888ohmzpyZ5T579Oghb29vLVy4UK+99ppGjx6t1atXy8fHR1988YVb+7FjxyotLU1btmzRsWPHVLlyZR04cEB33nmnGjdurL/++ksTJkzQ5MmT9frrr2fruLLy6aef6ocfftA333yjVatWqXTp0vr444/d2kycOFHbtm3TmjVrdOzYMfn7+2fYz4kTJ9S6dWsVK1ZMS5Ys0dq1a/XEE0+4xj0gIEBmdt6rAVavXq1bbrlFO3bs0MSJExUWFqaffvpJ9erVU//+/S/pOC+Wp6fnRc9Lcj5LliyRt7e3q+BhZlq6dKnM7Lzzd4SGhuqTTz7RN998o2PHjumFF1646BwiIyN18803KyQkRO+8847+/vtvLVq0SG3atNHTTz+d6TZz5szRuHHj9OGHH2rDhg269dZb9dprr+W47+eff16hoaGaO3euFi9erBUrVigsLEzJyckXfTwAAAAAcCH5PstqZpGdp2Cc+0SLzKJ9+/aWlpZmBQoUyPdj+i9Fz549bfv27Rf9FJf8ihdffNE2bdqU73k4PYoUKWJR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IiJ04sQJ12s/Pz8VK1ZMx48fd9t34cKFdf3115/32AcMGKDFixe7LTtw4IDr5z/++ENjx47V8OHD9dZbb2nFihVu/fr7++vll192LfPy8lLhwoVVuHBhNWjQQPv27VN4ePh5c7hUderUUWhoqNuyFStWyMfHRxUrVtTevXslSRs3bnRrc/DgQZUpUyZXc9m8ebPMzPX68OHD+vvvv12vT58+rePHj7v69fPzU/Xq1RUXF+e2n6uuukrXX399hnNKOvO+SlJycrJr2V9//aXFixdr06ZNWrBggRYuXKjvv//ebT6WpKQkSVKRIkUu/UABIB9Q8AAAAMih3r17q0CBAm5f9D08PJSSkqL//e9/io2N1bRp07RkyRJdc801ateunZKSklyTfRYrVkyS1LFjR+3fv99t3ykpKW6vz53kcuzYsWrXrp0GDRqkHTt2KCkpSd9//70KFiyY7fyLFSumgwcPqmXLlhnWXWgC0kOHDikiIiLL9R4eHvL391dqaqqqV6+eod+goCD98MMPGbZLTk52fcHOyunTp+Xh4eG2rECBAufd5lKcOnXK7bWZydPTM8/7OF+/xYoV09q1a9W9e/cM+zp69GimfRw7dkySVLJkSdfPp0+fVrt27XTrrbeqffv2evbZZ/XGG2+oSZMm2rVrlySpVKlS590vAFzuKHgAAADkgJeXl3r06KHnn39eCxcudFs3e/ZsPfTQQ/r0008VGhqqvXv3qlu3brrjjjv03XffKTU1VZK0ZcsWJScnq3Llylq6dGmO+vf399eUKVM0e/ZsSWeu+Ljuuutc67dt26YCBQropptucl3lcf3117u+vErSunXrVK5cOaWmpmr37t0XMQpZGzx4sGrXrq2AgAAtWLBAPXv21JQpU1z91qpVK8uCycaNG1WxYkXVqFEj06s8jh49qnLlyrkta9CggdvrkydPup6WkpWwsDB16dLFbZm/v79iY2O1b9++Cxxh/lq3bp26deumI0eOZLjKIysRERGKiYlR3bp1M4zrypUrtXLlSo0cOVK7d+/Wvffeq3HjxkmSbrjhBp08eVKbN2/O9eMAgH9D7paoAQAAHO6uu+5SyZIlNXnyZG3evNktZs6cqd69e7vaTp8+XU899ZTatWunadOmuZbHx8dr7NixGjdunHr06KFq1arppptu0v/+9z/16NHjvP2Hh4frvvvuk5+fn2688UZNnz7d7aqDbdu2adGiRfrss8/UqFEjNWjQQJ999pkSExNdt04sXrxYoaGhmj17ttq1a6cqVaqoWbNmev3119WwYcPz9l+iRAmVLVvWLdJveWjQoIFGjhzpegTq888/r/fff19Vq1aVJI0cOVI9evTQ8OHDVbduXdWuXVvdunXTa6+9JklaunSpli5dqpkzZ6pt27a67rrr1KFDB91+++2SpN9//13XXHONXnjhBVWrVk19+/bVHXfc4Zbfrl27dOONN6pmzZoqXbq0vL0z/n3v448/VqVKlTR+/HjVqlVLnTp10quvvqp3333X7faSy9G0adN07Ngx/fjjj2revLmuu+46BQQE6P3331eFChUy3cbMtHjxYjVv3ty1rHHjxho6dKgaNmyoSpUq6b777tM111yjsLAwV5vbbrtNy5Ytc7sVBgCuNPk+kQhBEARBEMSVEj/99JPNnTs303WNGjUyM7P/a++OWVKLwziOP7elIZcgqGgImlxO78AhBJFeQWsgCg3SFr2FQMTNhkhocatFpEFbJGrNwAZBTjlkLaHYwSF+LfceMopu3EvRn+8HfnDAR86fP04P8jye58nMFI1GJWlsqObLZLNZtVotjUYj9Xo9VatVxWIxmb0/+HJxcVG1Wk3D4VC+72tjY0MnJyfK5/NhzdzcnCqVioIgUKfT0dramm5vb5VOp8OaSCSiQqGgbrer0Wgk3/d1cHAwNtjydd6ztbWlyclJXV5eqlgsjn3n6OhIjUZDExMTMjMlEgk1Gg0Nh0M9PDzo7OxMqVQqrJ+entbe3p7u7+/1+Pioi4sLra6uhp9nMhn5vq/BYKBSqaTt7e2x+52ZmdHx8bH6/f4/r6V9eadmpsPDQ+3v73/4G/nsWtqXNW+99/VK4dnZWZVKJd3d3SkIArXbbe3u7obbcN5KMpnUzc1NOCA1Go2qWq2q1+spCAJdXV2Fg2//pNVqhcNSCSHkJ+bX7wcAAAA4amFhwbrdrsXjcavX6999HHyT8/Nzy+fzVi6XP6xNJpOWy+VseXnZnp6evuB0APD/McMDAADAMSsrKxaJRKzZbNr8/Lzt7OxYp9P59LwQuCWdTpvneX9VOzU1Zevr6zQ7APxo/MMDAADAMYlEwnK5nC0tLdlgMLDT01Pb3Ny06+vr7z4aAABfhoYHAAAAAABwDltaAAAAAACAc2h4AAAAAAAA59DwAAAAAAAAzqHhAQAAAAAAnEPDAwAAAAAAOIeGBwAAAAAAcA4NDwAAAAAA4BwaHgAAAAAAwDnPNVLIaeGXcNEAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "def avg(lst): \n", + " return sum(lst.timings) / len(lst.timings) \n", + "\n", + "test_names = [\n", + " #'Python \"for\" loops: imperative', # commented out because it's much slower anyway\n", + " 'ctypes: builtin interface',\n", + " 'NumPy: array-oriented',\n", + " 'nanobind: imperative in C++',\n", + " \"cppjit: Automatic, C++ JIT with Clang\"\n", + "]\n", + "test_results = np.array([\n", + " #avg(py_time),\n", + " avg(ctypes_time),\n", + " avg(np_time),\n", + " avg(nanobind_time),\n", + " avg(cppjit_time)\n", + "])\n", + "\n", + "# Creating the plot\n", + "plt.figure(figsize=(10, 6))\n", + "plt.barh(test_names, test_results, color='blue')\n", + "plt.xlabel('Average Execution Time (s)')\n", + "plt.ylabel('Benchmark Test')\n", + "plt.title('Benchmark Execution Time for Different Methods')\n", + "plt.gca().invert_yaxis() # Invert y-axis to have the fastest method at the top\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "fad0bb58-251e-4667-ad69-d989db5830fa", + "metadata": {}, + "source": [ + "### Exercise 4\n", + "\n", + "Not all ways to interoperate with C/C++ code gave us the same runtime performance here. Where do the differences come from?" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/09__swe__jax__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/09__swe__jax__SOLUTION.ipynb new file mode 100644 index 00000000..e9c815d3 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/09__swe__jax__SOLUTION.ipynb @@ -0,0 +1,846 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8701ec32", + "metadata": {}, + "source": [ + "## SWE - JAX - SOLUTION\n", + "\n", + "JAX takes a different angle from NumPy and Numba. You write\n", + "the kernel as a **pure function over whole arrays**, decorate it with\n", + "`@jax.jit`, and the XLA compiler produces a single GPU program for the\n", + "entire time loop, running every step on the device without returning to Python.\n", + "\n", + "**SOLUTION**\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Imports and reference setup](#sec1)\n", + "2. [The step as a pure function](#sec2)\n", + "3. [Fuse the time loop with `lax.scan`](#sec3)\n", + "4. [Acceptance gate](#sec4)\n", + "5. [Limitation: shape-rigidity](#sec5)\n", + "6. [Fixed cost: when does N start to matter?](#sec6)\n", + "7. [Cache residency](#sec7)\n", + "\n", + "### 1. Imports and reference setup\n", + "\n", + "We re-import `swe_core` for the float64 reference field, then load JAX." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "5990bfb4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:15.001244Z", + "iopub.status.busy": "2026-07-27T10:46:15.001152Z", + "iopub.status.idle": "2026-07-27T10:46:20.812379Z", + "shell.execute_reply": "2026-07-27T10:46:20.811778Z" + } + }, + "outputs": [], + "source": [ + "import time\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "import swe_core\n", + "\n", + "# Shared problem parameters\n", + "N, N_STEPS = swe_core.canonical_size()\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "dx = L / N\n", + "DT = swe_core.fixed_dt(H0 + AMP, dx, cfl=CFL, g=G)\n", + "\n", + "# Float64 NumPy reference for validation\n", + "h_ref, _ = swe_core.solve_numpy(N, N_STEPS)\n", + "\n", + "import jax\n", + "import jax.numpy as jnp\n", + "from jax import lax" + ] + }, + { + "cell_type": "markdown", + "id": "8a50f5d5", + "metadata": {}, + "source": [ + "### 2. The step as a pure function\n", + "\n", + "Let's look at the same operation shape as notebook 08, expressed in JAX:\n", + "\n", + "- **Read**. `h[:-1]`, `h[1:]` for interface states, same as NumPy-style slicing.\n", + "- **Compute**. Rusanov formula unchanged: `F_h = 0.5*(huL+huR) - 0.5*a*(hR-hL)`.\n", + "- **Update**. JAX arrays are immutable, so we produce a new array with `h.at[1:-1].set(...)` instead of `h[1:-1] = ...`.\n", + "\n", + "The function signature `(state, _) -> ((new_state), None)` is the shape `lax.scan` expects in the next section.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `jax.jit(fn)`: just-in-time compile a Python function on its first call\n", + " with a given input shape/dtype. Subsequent calls reuse the compiled code.\n", + "- `jax.numpy` mirrors NumPy with immutable arrays.\n", + "- `jnp.maximum`, `jnp.abs`, `jnp.sqrt`: elementwise math.\n", + "- `jnp.array.at[idx].set(value)`: functional update for an immutable array, returns a *new* array with the slot set.\n", + "- `jax.lax.scan(body, init, xs)`: a structured loop the compiler can fuse.\n", + " Equivalent to a Python `for x in xs: carry, y = body(carry, x)`, but\n", + " inside a single XLA program.\n", + "- `arr.block_until_ready()`: synchronise; required before stopping any GPU\n", + " timer." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "99c48795", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:20.814180Z", + "iopub.status.busy": "2026-07-27T10:46:20.813988Z", + "iopub.status.idle": "2026-07-27T10:46:20.818147Z", + "shell.execute_reply": "2026-07-27T10:46:20.817653Z" + } + }, + "outputs": [], + "source": [ + "@jax.jit\n", + "def step_jax(state, _):\n", + " '''One Rusanov-flux step, fully functional. Returns (new_state, None).\n", + " Designed to be called by jax.lax.scan.'''\n", + " h, hu = state\n", + " DRY = jnp.float32(swe_core.DRY_TOL)\n", + "\n", + " # Reflective BCs via functional updates.\n", + " h = h.at[0].set(h[1]).at[-1].set(h[-2])\n", + " hu = hu.at[0].set(-hu[1]).at[-1].set(-hu[-2])\n", + "\n", + " # Interface states at every face i+1/2 for i = 0 .. N. Shape (N+1,).\n", + " hL, hR = h[:-1], h[1:]\n", + " huL, huR = hu[:-1], hu[1:]\n", + "\n", + " h_safe_L = jnp.maximum(hL, DRY)\n", + " h_safe_R = jnp.maximum(hR, DRY)\n", + " uL = huL / h_safe_L\n", + " uR = huR / h_safe_R\n", + " cL = jnp.sqrt(G * h_safe_L)\n", + " cR = jnp.sqrt(G * h_safe_R)\n", + " a = jnp.maximum(jnp.abs(uL) + cL, jnp.abs(uR) + cR)\n", + "\n", + " F_h = 0.5 * (huL + huR) - 0.5 * a * (hR - hL)\n", + " F_hu = 0.5 * (huL*uL + 0.5*G*hL*hL + huR*uR + 0.5*G*hR*hR) - 0.5 * a * (huR - huL)\n", + "\n", + " # Interior update for cells i = 1 .. N.\n", + " h_new = h.at[1:-1].set(h[1:-1] - (DT / dx) * (F_h[1:] - F_h[:-1]))\n", + " hu_new = hu.at[1:-1].set(hu[1:-1] - (DT / dx) * (F_hu[1:] - F_hu[:-1]))\n", + " return (h_new, hu_new), None" + ] + }, + { + "cell_type": "markdown", + "id": "3367bcd4", + "metadata": {}, + "source": [ + "### 3. Fuse the time loop with `lax.scan`\n", + "\n", + "`for _ in range(N_STEPS): state = step_jax(state, ...)` would\n", + "*work*, but each call would round-trip through the Python interpreter\n", + "between steps, preventing XLA from fusing them.\n", + "\n", + "`jax.lax.scan(body, init, xs)` collapses the entire loop into one device\n", + "program. The cost is that `step_jax` must already be jit-able, and\n", + "the scan body must accept a `(state, x)` signature.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `jax.lax.scan(body, init, xs)`: walks the leading axis of `xs`,\n", + " threading `state` through `body`. We do not need the per-step `x` value\n", + " (we use a fixed `DT`), so we pass `jnp.arange(N_STEPS)` and ignore it\n", + " inside the body.\n", + "- `jax.tree_util.tree_map(lambda a: a.block_until_ready(), pytree)`:\n", + " synchronise every leaf in a pytree of arrays. We use it as the sync\n", + " before stopping a timer." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "3adecd2b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:20.819133Z", + "iopub.status.busy": "2026-07-27T10:46:20.819006Z", + "iopub.status.idle": "2026-07-27T10:46:20.821738Z", + "shell.execute_reply": "2026-07-27T10:46:20.821324Z" + } + }, + "outputs": [], + "source": [ + "setup_ic = swe_core.by_size(lambda n: tuple(\n", + " jnp.asarray(a, jnp.float32)\n", + " for a in swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)))\n", + "\n", + "\n", + "def run_jax(N_local: int):\n", + " '''One full N_STEPS simulation at a given grid size, returns (h, hu) on device.'''\n", + " final, _ = jax.lax.scan(step_jax, setup_ic(N_local), jnp.arange(N_STEPS))\n", + " return jax.tree_util.tree_map(lambda a: a.block_until_ready(), final)" + ] + }, + { + "cell_type": "markdown", + "id": "b28a5d77", + "metadata": {}, + "source": [ + "### 4. Acceptance gate\n", + "\n", + "Cold time captures the first call (XLA compile + execute). Warm time is\n", + "the steady-state, after a couple of warmups. We also check the JAX\n", + "float32 result against the NumPy float64 reference.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- A float32 trajectory accumulates round-off of order `n·ε` over the run,\n", + " which the cell below reports. The acceptance tolerance is `1e-4` here.\n", + "- `swe_core.report_and_verify(warm, diff, tol, ...)`: verify the result is\n", + " within the tolerance and emit one timing and acceptance record." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "2503813a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:20.856891Z", + "iopub.status.busy": "2026-07-27T10:46:20.856749Z", + "iopub.status.idle": "2026-07-27T10:46:22.720994Z", + "shell.execute_reply": "2026-07-27T10:46:22.720407Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[09_jax] N=4194304 steps=47 | cold 1800.4 ms (incl. XLA compile) | warm 5.8 ms | max_diff 1.22e-07 < tol 1e-04 | PASS\n" + ] + } + ], + "source": [ + "# Cold capture.\n", + "t0 = time.perf_counter()\n", + "h_f, hu_f = run_jax(N)\n", + "cold_s = time.perf_counter() - t0\n", + "\n", + "# Warm timing\n", + "warm = swe_core.timed_run(run_jax, N, warmup=2, repeats=5, label='09_jax')\n", + "\n", + "# Acceptance.\n", + "diff = swe_core.max_diff(h_ref, np.asarray(h_f))\n", + "swe_core.report_and_verify(warm, diff, tol=1e-4, cold_s=cold_s,\n", + " n=N, steps=N_STEPS, cold_note='incl. XLA compile')\n", + "\n", + "swe_core.save_timing(\n", + " warm, grid_str=f'N={N}', tool='jax', hardware='gpu',\n", + " dtype='float32', steps=N_STEPS,\n", + " cold_s=cold_s, max_diff_vs_numpy=diff,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "70205dfa", + "metadata": {}, + "source": [ + "### 5. Limitation: shape-rigidity\n", + "\n", + "The XLA compile happens **per input shape**.\n", + "\n", + "The chart runs each shape once and splits that first (cold) call into the\n", + "one-time compile and the recurring execution.\n", + "\n", + "What makes the warm runs faster, and why does a new `N` require repaying the compilation cost?\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- JAX requires the same shape and dtype for a warm run." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "95e3b113", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:22.722528Z", + "iopub.status.busy": "2026-07-27T10:46:22.722400Z", + "iopub.status.idle": "2026-07-27T10:46:24.808101Z", + "shell.execute_reply": "2026-07-27T10:46:24.807529Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N= 262,144: cold 122.4 ms warm 1.5 ms compile ~ cold-warm = 121 ms\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N= 1,048,576: cold 123.9 ms warm 2.1 ms compile ~ cold-warm = 122 ms\n", + " N= 4,194,304: cold 5.9 ms warm 5.8 ms compile ~ cold-warm = 0 ms\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N=16,777,216: cold 311.3 ms warm 20.3 ms compile ~ cold-warm = 291 ms\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N=67,108,864: cold 905.9 ms warm 76.5 ms compile ~ cold-warm = 829 ms\n" + ] + }, + { + "data": { + "image/png": "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Kr4bRER56+s4rahD+Dzc7lvUERDRF0YLWffhIhUZGxsjLi4OJiYmhTJelldMZvkcr5hUB/Zvli+1fy2nHSjqED4Lj//8MhNZHr//86Uew+zjLIXVv6rkWCqfkf2QkZERtLVlh9nyIigiIiIiKgwqX+xVtWpVnDp1CgkJCYiNjUVMTAxiYmLw7t07xMTEFESMRERERERyVD4ju2vXLmhpaeGHH35AZGQkhODIBCIiIiIqfConsvXr10fjxo3x8OHDgoiHiIiIiEgpKg8tuHXrFr766quCiIWIiIiISGkqn5H98ccfsX79elSqVAl3795FWlqazPTAwEC1BUdEREREpIjKiWy5cuVgZWWFrVu3SnVCCGhpaUEIAV3dT7oRAhERERGRUlTOOrds2QI/Pz/079+fF3sRERERUZFROZG1sLBA9+7dERoaWhDxEBEREREpReWLvS5duoT69esXRCxEREREREpT+YzsyZMnsXLlStStWxeBgYFyF3udPHlSbcERERERESmiciK7fv16AMCcOXPkpvFiLyIiIiIqLCpnnTo6OgURBxERERGRSlQeI0tERERE9DlQ6ozs2LFjsWHDBqSkpGDs2LG5tl29erVaAiMiIiIiyo1SiezEiROxe/dupKSkYOLEiQrbCSGYyBIRERFRoVAqkbW0tMzx/0RERERERUXlMbKzZ89GsWLF5OoNDQ0xe/ZstQRFRERERJQXlRNZZ2dnlChRQq6+ePHicHZ2VktQRERERER5UTmR1dLSghBCrr5+/fqIjo5WS1BERERERHlR+j6y0dHREEJACIGHDx/KJLM6OjooUaKE9LAEIiIiIqKCpnQiO2HCBGhpaWHLli1wdnZGbGysNC01NRXh4eG4ceNGgQRJRERERPQxpRPZHTt2AADCwsLg5eWFjIyMAguKiIiIiCgvKj+i1sPDoyDiICIiIiJSCR9RS0REREQaiYksEREREWkkJrJEREREpJGYyBIRERGRRlLqYq/Dhw8rvcDevXvnOxgiIiIiImUplch+eM9YIiIiIqLPgVKJ7A8//FDQcRARERERqYRjZImIiIhIIyl1RtbX1xdCCKUW2Lhx408KiIiIiIhIGUolsseOHSvgMIiIiIiIVKNUIjt//vyCjoOIiIiISCUcI0tEREREGkmpM7If0tbWxsSJE9G3b19UqVIF+vr6MtPLlCmjtuCIiIiIiBRR+Yyss7MzJk2ahP3798PU1BQrVqzAkSNHkJmZiblz5xZAiERERERE8lROZAcOHIiRI0dixYoVSE9Px969ezFy5EjMnz8fzZs3L4gYiYiIiIjkqJzImpmZITAwEACQkJAAU1NTAMCpU6fQtWtX9UZHRERERKSAyonss2fPULFiRQBAaGgoOnbsCABo2rQpUlJS1BsdEREREZECKieyR48eRfv27QEAq1evxoIFC/Dw4UPs2LEDW7ZsUXuAREREREQ5UfmuBTNmzJD+f+DAAURERKBly5Z49OgRTp06pdbgiIiIiIgUUTmR/Zi3tze8vb3VEQsRERERkdJUHlowffp0DB8+XK5++PDhmDp1qlqCIiIiIiLKi8qJ7OjRo/HgwQO5+nv37sHJyUktQRERERER5SVft996+fKlXP2bN2+kuxkQERERERU0lRPZp0+folWrVnL1rVq1wosXL9QSFBERERFRXlS+2Gvjxo1wcXGBnp4eLl26BABo3749lixZguXLl6s9QCIiIiKinKicyC5duhRlypTB33//DX19fQBAcnIy/vzzTyxevFjtARIRERER5SRft9+aPn06FixYAFtbWyQlJeHRo0dITU1Vd2xERERERAqpPEY22/v372FtbY2wsLACTWLDwsIghJAra9asAQC4u7vLTVu3bp00f6lSpXDixAnEx8fD19cXDRo0kFn+mjVrMGnSpAKLn4iIiIgKRr4TWQD4559/UKFCBXXFkqOmTZvCzMxMKo6OjgCAgwcPSm02bNgg0+bD+9n+9ttvMDY2RqNGjXD58mVs3LhRmmZnZwc7Ozu4uLgU6DYQERERkfp90pO9tLS01BWHQlFRUTKvp0+fjpCQEFy5ckWqS0xMRGRkZI7z29raYt++fXj06BE2bNiAUaNGAQB0dXWxfv16/Pjjj8jMzCy4DSAiIiKiAvFJZ2QLm56eHgYNGoQtW7bI1A8cOBBv3rxBYGAgFi5ciGLFiknT/P390a5dO+jo6KBTp04ICAgAAEydOhWXL1/G7du3lVq3vr4+jI2NZQoRERERFZ1PSmS7dOmC58+fqyuWPH333XcoWbIktm3bJtXt2bMHgwYNQtu2bbFo0SIMHjwYu3btkqYvXrwY6enpCA0NRc+ePTFixAhYW1tj6NChWLBgAdatW4fQ0FDs378fJiYmCtc9Y8YMxMXFSaUwt5uIiIiI5KmcyLq5ucHU1BQA4OXlJV3oZWxsDDc3N/VG95ERI0bg7NmzMk8W27hxI1xdXXH37l3s2bMHQ4YMQa9evWBpaQkAiIuLw8CBA1G1alW0adMG9+/fxz///INff/0VAwcOhKWlJWxsbJCYmIg5c+YoXPeiRYtgYmIilUqVKhXothIRERFR7lROZNu0aSPdP/ZDhoaGsLe3V0tQOalSpQocHR2xadOmXNt5e3sDAKytrXOcPmzYMLx79w4nTpxAmzZtcOzYMaSnp+PgwYNo06aNwuWmpqYiPj5ephARERFR0VH6Yq+6detK/69Vqxaio6Ol1zo6OujcuXOB/tw+fPhwvH79GqdPn861XfbttT48a5utbNmymDNnDlq3bg0gK249PT0AWeNvdXR01Bs0ERERERUYpRPZO3fuSPdpzX407YeSkpIwduxYtQaXTUtLC8OHD8f27duRkZEh1VtaWmLAgAE4c+YM3r59i3r16mHlypW4cuUKAgMD5Zbj4uKC5cuX48WLFwCyhkYMHjwYrq6uGDVqFLy8vAokfiIiIiJSP6UT2WrVqkFLSwuPHz9Gs2bN8ObNG2laamoqXr9+XWC3sXJ0dISFhYXc3QpSU1Ph6OiICRMmwMjICE+fPsXhw4fx+++/yy2jY8eOsLa2xuDBg6W6NWvWoEmTJvD29sbNmzcxb968AomfiIiIiNRP6UT2yZMnAFAkP79fuHAhx3vWPnv2LNdxrR9ydXWFq6urTF1SUhL69eunjhCJiIiIqJCpfLHXkCFD8M0330iv//zzT8TExMDLywtVqlRRa3BERERERIqonMjOnDkTSUlJAIDmzZvjl19+wdSpUxEVFYWVK1eqPUAiIiIiopyo/Ijar776CiEhIQCyHlBw6NAhbNy4EV5eXrh8+bK64yMiIiIiypHKZ2QTEhJQpkwZAFkXUF24cAEAkJycLPNoWCIiIiKigqTyGdkLFy5g06ZN8PPzQ40aNXDmzBkAQO3atREeHq7u+IiIiIiIcqTyGdkxY8bg2rVrKFeuHHr37i09GKFx48bYu3ev2gMkIiIiIsqJSmdkdXR0MG7cOPz5559yT/GaO3euOuMiIiIiIsqVSmdkMzIyMHXqVOjqqjwigYiIiIhIrVQeWuDm5gYHB4eCiIWIiIiISGkqn1o9e/YsFi9ejLp16+L27dt4//69zPSTJ0+qLTgiIiIiIkVUTmT//vtvAMCkSZPkpgkhOOyAiIiIiAqFylmnjo5OQcRBRERERKQSlcfIEhERERF9DpQ6Izt27Fhs2LABKSkpGDt2bK5tV69erZbAiIiIiIhyo1QiO3HiROzevRspKSmYOHGiwnZCCCayRERERFQolEpkGzRogLi4OACApaVlgQZERERERKQMpcbIRkdHo1y5cgCy7iNrampaoEEREREREeVFqUQ2ISEBZcqUAQC0adMGenp6BRoUEREREVFelBpacPHiRbi7u+P+/fsAgKNHjyI1NTXHtu3bt1dfdERERERECiiVyA4aNAhDhw6FlZUVHBwccO/ePSQmJhZ0bERERERECimVyCYnJ+Off/4BADRp0gTTpk1DbGxsgQZGRERERJQblZ/s1a5du4KIg4iIiIhIJXyyFxERERFpJCayRERERKSRmMgSERERkUZiIktEREREGilfiWzr1q2xc+dOXLt2Debm5gCybtHVqlUrtQZHRERERKSIyolsr169cP78eSQlJaFhw4YwMDAAAJiammLmzJlqD5CIiIiIKCcqJ7KzZs2Ck5MTRo0ahbS0NKney8sLjRo1UmtwRERERESKqJzI2tjYwMPDQ64+NjYWJUuWVEdMRERERER5UjmRffXqFaytreXqW7dujcePH6slKCIiIiKivKicyG7cuBGrVq1Cs2bNIISAubk5BgwYgGXLlmHdunUFESMRERERkRyVH1G7ePFiaGtrw83NDcWLF4eHhwdSUlKwbNkyrFmzpiBiJCIiIiKSo3IiCwALFy7E0qVLYW1tjRIlSiAoKAjv379Xd2xERERERArlK5EFgLS0NNy/f1+dsRARERERKU3lRLZ48eKYPn062rdvj/Lly0NbW3aYrZWVldqCIyIiIiJSROVEdtOmTXBwcMDOnTvx8uVLCCEKIi4iIiIiolypnMh26dIFXbt2xbVr1woiHiIiIiIipah8+62YmBhER0cXRCxEREREREpTOZGdPXs25s+fj2LFihVEPERERERESlFqaIGvr6/MWFhra2tERkYiPDwcaWlpMm0bN26s3giJiIiIiHKgVCJ77NixAg6DiIiIiEg1SiWy8+fPL+g4iIiIiIhUovIY2dDQUJQuXVqu3tTUFKGhoWoJioiIiIgoLyonslWrVoWOjo5cvYGBASpXrqyW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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Fresh shapes: each pays one XLA compile on its cold call.\n", + "shape_data = []\n", + "for N_local in [n for n, _ in swe_core.sweep_points()]:\n", + " t0 = time.perf_counter()\n", + " run_jax(N_local)\n", + " cold = time.perf_counter() - t0\n", + " warm = swe_core.timed_run(run_jax, N_local, warmup=2, repeats=3)['median_s']\n", + " shape_data.append((N_local, cold, warm))\n", + " print(f' N={N_local:>10,}: cold {cold*1e3:8.1f} ms warm {warm*1e3:8.1f} ms '\n", + " f'compile ~ cold-warm = {(cold - warm)*1e3:6.0f} ms')\n", + "\n", + "swe_core.plot_compile_share([f'N={n:,}' for n, _, _ in shape_data],\n", + " [c for _, c, _ in shape_data],\n", + " [w for _, _, w in shape_data],\n", + " 'Breakdown of runtime with growing N')" + ] + }, + { + "cell_type": "markdown", + "id": "sec6-fixed-cost-md", + "metadata": {}, + "source": [ + "### 6. Fixed cost: when does N start to matter?\n", + "\n", + "Looking at the warm bars in Sec. 5: at the smallest sweep size the run is\n", + "dominated by a fixed per-step cost, the kernel launch and scan-step dispatch in\n", + "the XLA program. As `N` grows that cost is amortised and the time becomes\n", + "proportional to the cells.\n", + "\n", + "We can explain the curve with a two-term model:\n", + "\n", + "$$t_\\mathrm{warm}(N) \\approx n_\\mathrm{steps} \\cdot (t_0 + N/B)$$\n", + "\n", + "where $t_0$ is the fixed per-step overhead and $B$ is the streaming rate.\n", + "Below the crossover $N^* = t_0 \\cdot B$ the linear term is invisible; above\n", + "it, doubling `N` doubles the time. On GPUs with a large L2 cache there is a third\n", + "regime in between (Sec. 7).\n", + "\n", + "**Q:** at what `N` do you expect the linear term to take over on this GPU?\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "sec6-fixed-cost-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:24.809322Z", + "iopub.status.busy": "2026-07-27T10:46:24.809197Z", + "iopub.status.idle": "2026-07-27T10:46:25.541672Z", + "shell.execute_reply": "2026-07-27T10:46:25.541171Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N= 262,144: warm 1.5 ms 8249 Mcells/s\n", + " N=1,048,576: warm 2.2 ms 22751 Mcells/s\n", + " N=4,194,304: warm 5.8 ms 33919 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N=16,777,216: warm 20.3 ms 38842 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N=67,108,864: warm 76.5 ms 41214 Mcells/s\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "t0 = 31.8 us/step crossover N* ~ 1,336,910 cells\n" + ] + } + ], + "source": [ + "# Warm sweep over a wide range of N.\n", + "SWEEP_N = [n for n, _ in swe_core.sweep_points()]\n", + "\n", + "sweep_t = []\n", + "for N_local in SWEEP_N:\n", + " r = swe_core.timed_run(run_jax, N_local, warmup=2, repeats=3)\n", + " sweep_t.append(r['median_s'])\n", + " print(f' N={N_local:>9,}: warm {r[\"median_s\"]*1e3:8.1f} ms '\n", + " f'{N_local * N_STEPS / r[\"median_s\"] / 1e6:8.0f} Mcells/s')\n", + "\n", + "Ns = np.array(SWEEP_N, dtype=float)\n", + "ts = np.array(sweep_t)\n", + "\n", + "# Two-term fit: t0 from the small-N plateau, slope from the two largest sizes.\n", + "t0_step = ts[0] / N_STEPS\n", + "slope = (ts[-1] - ts[-2]) / (Ns[-1] - Ns[-2]) / N_STEPS\n", + "n_star = t0_step / slope # crossover N* = t0 / slope\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 4))\n", + "ax.loglog(Ns, ts * 1e3, 'o-', label='warm median')\n", + "ax.loglog(Ns, (t0_step + slope * Ns) * N_STEPS * 1e3, '--', color='#888',\n", + " label=r'model $n_\\mathrm{steps}\\,(t_0 + N/B)$')\n", + "ax.axvline(n_star, color='#c33', ls=':', label=f'crossover N* ~ {n_star:,.0f}')\n", + "ax.set(xlabel='N (cells)', ylabel=f'time per {N_STEPS}-step run [ms]',\n", + " title='Fixed per-step cost dominates until N is large enough')\n", + "ax.legend(); ax.grid(alpha=0.3, which='both')\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "print(f't0 = {t0_step*1e6:.1f} us/step crossover N* ~ {n_star:,.0f} cells')\n" + ] + }, + { + "cell_type": "markdown", + "id": "l2wall-md", + "metadata": {}, + "source": [ + "### 7. Cache residency\n", + "\n", + "Throughput in the sweep above rises with `N`, then flattens. What sets the ceiling?\n", + "\n", + "Query the L2 size, read XLA's working-set footprint from\n", + "`compiled.memory_analysis()`, and plot throughput against it.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `jax.jit(fn).lower(x).compile().memory_analysis()`: static buffer sizes of the\n", + " compiled program; `temp_size_in_bytes` is the intermediate working set.\n", + "- `cupy.cuda.runtime.getDeviceProperties(0)['l2CacheSize']`: this GPU's L2 size." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "l2wall-measure", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:25.542726Z", + "iopub.status.busy": "2026-07-27T10:46:25.542614Z", + "iopub.status.idle": "2026-07-27T10:46:30.568206Z", + "shell.execute_reply": "2026-07-27T10:46:30.567583Z" + } + }, + "outputs": [], + "source": [ + "import cupy\n", + "\n", + "# Query L2 size. Sweep grid sizes with increasing XLA buffer footprint timing a fixed-length scan on each.\n", + "L2_BYTES = int(cupy.cuda.runtime.getDeviceProperties(0)['l2CacheSize'])\n", + "EC_STEPS = 400\n", + "scan_solve = jax.jit(lambda s: lax.scan(step_jax, s, jnp.arange(EC_STEPS))[0])\n", + "\n", + "def measure(N_local):\n", + " \"\"\"Return working-set footprint [MB], best throughput [Gcells/s] for N.\"\"\"\n", + " h_np, hu_np = swe_core.bump_ic(N_local, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + " s = (jnp.asarray(h_np, jnp.float32), jnp.asarray(hu_np, jnp.float32))\n", + " buf = scan_solve.lower(s).compile().memory_analysis()\n", + " foot = swe_core.to_mib(buf.temp_size_in_bytes + buf.argument_size_in_bytes\n", + " + buf.output_size_in_bytes)\n", + " t = swe_core.timed_run(lambda: jax.block_until_ready(scan_solve(s)), warmup=1, repeats=5)\n", + " return foot, N_local * EC_STEPS / t['min_s'] / 1e9\n", + "\n", + "# Footprint is ~24 bytes/cell of XLA buffers. The sweep spans ~0.5x - ~6x the L2 size.\n", + "L2_SWEEP = [int(m * L2_BYTES / 24) for m in (0.5, 0.75, 1, 1.5, 2, 3, 4.5, 6)]\n", + "foot_mb, gcs = [], []\n", + "for N_local in L2_SWEEP:\n", + " foot, gcell = measure(N_local)\n", + " foot_mb.append(foot)\n", + " gcs.append(gcell)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "l2wall-plot", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:30.569887Z", + "iopub.status.busy": "2026-07-27T10:46:30.569658Z", + "iopub.status.idle": "2026-07-27T10:46:30.680439Z", + "shell.execute_reply": "2026-07-27T10:46:30.680036Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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WURERETUklzKMq+++uqWjoOIiIiIqEEuJa47duxw/BwTE1PvWq0xMTHNExURERER0QXcntWRmpqKsLCwWv0dOnRAampqswRFRERERHQhtxPXc7WsF/L394fJZGqWoIiIiIiILuTyTKr33nsPACCEwKuvvgqDweA4JpfLMWLECBw6dKjZAySi2uwQ2Kkvg1ICt3wlIqJ2w+XEdfDgwQCqRlz79+8Ps9nsOGY2m5GYmIh33323+SMkolrMQmBuegq3fCUionbF5cT1mmuuAQB88cUXeOqpp6DX61ssKCIiIiKiC7m96OrMmTNbIg4iIiIiogZxr0giL+QjyfBH30FY36M/t3wlIqJ2g9tcEXkpX5nc0yEQERG1Kg7VEBEREZFXYOJKRERERF6BiSsREREReQUmrkRERETkFZi4EhEREZFX4KoCRF5IQCChQg+FJMHOnbOIiKidYOJK5IUqhcBjqSe55SsREbUrLBUgIiIiIq/AxJWIiIiIvIJHE9crr7wS69evR1ZWFoQQmDx5cq1zFi5ciOzsbBgMBmzevBlxcXEeiJSobfGRZNjUewC+696PW74SEVG74dF/8TQaDRITE/H444/XefyZZ57Bk08+iUcffRQjRoxARUUFNm3aBLVa3cqRErU9wQolghQsUyciovbDo//qbdy4ERs3bqz3+NNPP43XXnsN69evBwBMmzYNeXl5mDJlCtasWdNaYRIRERFRG9Bmv2OMjY1FVFQUtmzZ4ujT6XTYt28fRo0aVe/zVCoVtFqtUyMiIiIi79dmE9fIyEgAQF5enlN/Xl6e41hdnn/+eeh0OkfLyspq0TiJiIiIqHW02cS1sd58800EBAQ4WseOHT0dEhERERE1gzabuObm5gIAIiIinPojIiIcx+piNpuh1+udGhERERF5vzabuKampiInJwfjx4939Gm1WowYMQJ79uzxYGREnicgcNxQgWSjgVu+EhFRu+HRVQU0Go3TuqyxsbEYOHAgiouLcfbsWbz//vuYP38+Tp06hdTUVLz66qvIzs7GTz/95LmgidqASiHwwJkT3PKViIjaFY8mrsOGDcP27dsdj5csWQIAWLFiBWbMmIG3334bGo0G//73vxEUFISdO3fihhtuQGVlpYciJiIiIiJPkQBc0sM1Wq0WOp0OAQEBrVPvKgHwAWDHJf7OkqfJJFSNuBoF7Pys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DhyIl19+2el5n3/+OaKjo+Hr63vReBvSp08fJCYmwmAwOPp27doFuVyOXr16QavVomPHjnW+v3369Lno9RMTE51qPPfs2QOtVusoxQCAhIQEl2Jt6H1vLr/88gv8/f0xduxYzJw5E1988UW9577zzjsYNGgQBgwYgGuuucbx/JqfOSLyDgpPB0BE3m379u2YOXMmBg4cCIvFguTkZGzfvh1XXXUVgoOD8ccff7h9zYqKijr7f/31V9x3330YNWqUoz6zIRaLxfGzEAIAXE5WsrOzMWjQIMfjcxOkZs6ciQ8//BA33HADpk6ditdeew3XXXcd9u3bB39/fyxYsABr166tdT2TyeTSfduy+v5eLtSU991VNpsNq1atwsKFCzFixAjceuut9Z5bWFiI06dPAwBSUlLw9NNPY+/evbj66quxdevWZo2LiFoWf90koib5888/odVqMWfOHEeSei5xveqqq7B9+3bHuUlJSRg9erTT80ePHo2TJ0/Cbrdf9F6ffPIJnnvuOaxfvx5jx45tUtzJyckYPny4U1/NxzabDadPn3a0mpO2Dh06hLfeegujR4/G0aNHcc899wAADhw4gF69ejk971w7l8CZzWbHZC13JCUlYeDAgfDz83P0jR49GjabDcnJydDr9cjKyqrz/T1+/Ljj3gDqvP/AgQPh4+PjeDxy5Ejo9XqcPXvW7Vgb0tjXX5cvvvgCV111FdatW4fS0lKXn2ez2QCgyaPgRNT6OOJKRE1SWlqKw4cP495778Xs2bMBADt27MC3334LlUrlNOL63nvv4e+//8b8+fOxZs0ajBo1CrNnz8asWbNcvt+yZcsgl8vx888/Y+LEibW+GnfV0qVLsWPHDsyZMwcbNmzANddcg4kTJzoSzLp07doVDz/8MNavX4/s7Gz06tULPXr0wJdffgkAWLRoEX7++WdkZGTg+++/h91ux8CBA9GvXz+89NJLAKpm9o8fPx67du1CZWWlywnX119/jYULF2LlypV45ZVXEBYWhqVLl2LVqlXIz88HUPWV+MKFC3H69GkcOnQIM2bMwKBBg3DvvfcCAPLz82EwGHDDDTcgMzMTJpPJUXKhUqmwfPlyvPbaa+jatSsWLlyIZcuWNfh+NEZaWhq0Wi2uueYaR+lDY5ehOnHiBEJCQpzKJ+qi1WoREREBSZIQExODt99+G/n5+di9e3ej7ktEnuXxQls2NjbvbkuWLHGagANUTS7Kzs6ude5tt90mjh49KiorK0VaWppjYte5duEscKDuSUVz5swRZWVlYtSoUQKoe5JQzfMDAwOFEEKMGzfO0ffQQw+Js2fPioqKCrF27Vrxwgsv1BnzuRYeHi7Wrl0rsrKyhMlkEqmpqeKVV15xTGACICZMmCB27twpKioqRGlpqdi7d6946KGHHMdvuukmcfLkSWE2m0Vqamq997pwchYA0a9fP7F161ZhMBhEYWGh+Oyzz5xm5UuSJF5++WVx9uxZUVlZKQ4ePCiuv/56p2s8+OCDIj09XVitVrFt2zYBnJ+U9Morr4iCggKh0+nEZ599JlQqleN527ZtE0uWLKkVZ2Pe948//lgUFBQIIUSt13iuXWxyVn3vW12Ts2rKy8sTP//8c60JajXfB0//98TGxlZ/k6p/ICJq9/7973+jd+/eTS5D8Dbx8fEICgpqsE60tS1YsABTpkzB4MGDW+2ebfF9ICJnrHElonZr3rx5GDBgALp3747Zs2dj+vTpWLlypafDomr9+/eHXq/HY4891qL3GTNmDPR6vaOkgojaLo64ElG7tWbNGlx11VXQarU4c+YMli5dis8++8zTYbW6tjjSGBwcjA4dOgAACgoKXFr+rLF8fHzQsWNHAEB5eTny8vJa7F5E1DRMXImIiIjIK7BUgIiIiIi8AhNXIiIiIvIKTFyJiIiIyCswcSUiIiIir8DElYiIiIi8AhNXIiIiIvIKTFyJiIiIyCswcSUiIiIir8DElYiIiIi8wv8Hm9s28T6qfUMAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DRAM plateau: 39.6 Gcells/s -> 634 GB/s of useful traffic\n" + ] + } + ], + "source": [ + "L2_MB = swe_core.to_mib(L2_BYTES)\n", + "pk = int(np.argmax(gcs))\n", + "peak, floor = gcs[pk], gcs[-1]\n", + "fig, ax = plt.subplots(figsize=(7, 4))\n", + "ax.semilogx(foot_mb, gcs, 'o-', color='#27a')\n", + "ax.axvline(L2_MB, color='#c33', ls='--', label=f'L2 = {L2_MB:.0f} MB')\n", + "ax.axvspan(foot_mb[0], L2_MB, alpha=0.05, color='green')\n", + "ax.axvspan(L2_MB, foot_mb[-1], alpha=0.06, color='#c33')\n", + "ax.set_ylim(0, peak * 1.28)\n", + "ax.annotate(f'{peak:.0f} Gcells/s', xy=(foot_mb[pk], peak), xytext=(0, 8),\n", + " textcoords='offset points', ha='center', va='bottom', fontsize=9, color='#27a',\n", + " bbox=dict(boxstyle='round,pad=0.3', fc='white', ec='#27a', lw=0.8))\n", + "ax.annotate(f'~{floor:.0f} Gcells/s\\nDRAM-bound, {peak/floor:.1f}x lower', xy=(foot_mb[-1], floor),\n", + " xytext=(foot_mb[-1], floor + peak * 0.2), color='#c33', fontsize=9, ha='right')\n", + "ax.set_xlabel('working-set footprint [MB]')\n", + "ax.set_ylabel('throughput [Gcells/s]')\n", + "ax.set_title('Throughput peak')\n", + "ax.legend(loc='center right', fontsize=8)\n", + "plt.tight_layout(); plt.show()\n", + "\n", + "# Device-only DRAM bandwidth\n", + "print(f'DRAM plateau: {gcs[-1]:.1f} Gcells/s -> {gcs[-1] * 16:.0f} GB/s of useful traffic')" + ] + }, + { + "cell_type": "markdown", + "id": "357c92e0", + "metadata": {}, + "source": [ + "**EXTRA CREDIT: inspecting intermediate states**\n", + "\n", + "`lax.scan` runs the whole loop as one compiled program, so there is no Python\n", + "loop to `print` from. Inside it `h` is a placeholder (a *tracer*), not real\n", + "numbers, so `float(h[i])` fails. To watch the trajectory, have the scan body\n", + "return a value each step, and `scan` collects them into an array you get back\n", + "at the end.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `lax.scan(body, init, xs)`: the second element of `body`'s return is\n", + " stacked across steps and returned as the scan's second output." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2c3309e8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:30.681449Z", + "iopub.status.busy": "2026-07-27T10:46:30.681330Z", + "iopub.status.idle": "2026-07-27T10:46:31.003688Z", + "shell.execute_reply": "2026-07-27T10:46:31.003033Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "mid-loop inspection fails: ConcretizationTypeError\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "recovered 47 per-step volume samples from one fused scan\n" + ] + } + ], + "source": [ + "# EXTRA CREDIT: collect the trajectory by returning h from the scan body.\n", + "state0 = (jnp.asarray(swe_core.bump_ic(N, L=L, h0=H0, amplitude=AMP, sigma=SIG)[0], jnp.float32),\n", + " jnp.zeros(N + 2, jnp.float32))\n", + "\n", + "# Naive: read a value mid-loop. h is a tracer, so this raises.\n", + "try:\n", + " def peek(state, x):\n", + " new_state, _ = step_jax(state, x)\n", + " return new_state, float(jnp.sum(new_state[0])) # float() on a traced array\n", + " jax.lax.scan(peek, state0, jnp.arange(5))\n", + "except Exception as e:\n", + " print(f'mid-loop inspection fails: {type(e).__name__}')\n", + "\n", + "# Fix: return the diagnostic as a traced value, which scan stacks into ys.\n", + "def body(state, x):\n", + " new_state, _ = step_jax(state, x)\n", + " return new_state, jnp.sum(new_state[0][1:-1]) # per-step total water volume\n", + "\n", + "_, volume_hist = jax.lax.scan(body, state0, jnp.arange(N_STEPS))\n", + "volume_hist.block_until_ready()\n", + "print(f'recovered {volume_hist.shape[0]} per-step volume samples from one fused scan')" + ] + }, + { + "cell_type": "markdown", + "id": "sec-fp64-sweep", + "metadata": {}, + "source": [ + "**Float64 for the synthesis comparison.** Notebook 14 compares every tool at\n", + "matched precision. The cell below runs the same scanned solve in float64 and\n", + "records rates across the shared size range." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "jax-step-file", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:31.004937Z", + "iopub.status.busy": "2026-07-27T10:46:31.004825Z", + "iopub.status.idle": "2026-07-27T10:46:31.009657Z", + "shell.execute_reply": "2026-07-27T10:46:31.009251Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing swe_jax_step.py\n" + ] + } + ], + "source": [ + "%%writefile swe_jax_step.py\n", + "# The float64 step lives in a file so this notebook and the profiler's\n", + "# subprocess run the same code. Re-run this cell and the next one after\n", + "# any edit here, or the imported copy stays stale.\n", + "from functools import partial\n", + "\n", + "import jax\n", + "import jax.numpy as jnp\n", + "import swe_core\n", + "\n", + "jax.config.update('jax_enable_x64', True)\n", + "G = 9.81\n", + "\n", + "\n", + "@jax.jit\n", + "def step64(state, _, dx_, dt_):\n", + " h, hu = state\n", + " h = h.at[0].set(h[1]).at[-1].set(h[-2])\n", + " hu = hu.at[0].set(-hu[1]).at[-1].set(-hu[-2])\n", + " hL, hR = h[:-1], h[1:]\n", + " huL, huR = hu[:-1], hu[1:]\n", + " hsL = jnp.maximum(hL, swe_core.DRY_TOL)\n", + " hsR = jnp.maximum(hR, swe_core.DRY_TOL)\n", + " uL, uR = huL / hsL, huR / hsR\n", + " cL, cR = jnp.sqrt(G * hsL), jnp.sqrt(G * hsR)\n", + " a = jnp.maximum(jnp.abs(uL) + cL, jnp.abs(uR) + cR)\n", + " F_h = 0.5 * (huL + huR) - 0.5 * a * (hR - hL)\n", + " F_hu = 0.5 * (huL*uL + 0.5*G*hL*hL + huR*uR + 0.5*G*hR*hR) - 0.5 * a * (huR - huL)\n", + " h_new = h.at[1:-1].set(h[1:-1] - (dt_/dx_) * (F_h[1:] - F_h[:-1]))\n", + " hu_new = hu.at[1:-1].set(hu[1:-1] - (dt_/dx_) * (F_hu[1:] - F_hu[:-1]))\n", + " return (h_new, hu_new), None\n", + "\n", + "\n", + "# Wrapping the scan in jit traces the body once. Calling lax.scan directly\n", + "# re-traces it on every call.\n", + "@partial(jax.jit, static_argnames=('n_steps',))\n", + "def solve64(state, dx_, dt_, n_steps):\n", + " final, _ = jax.lax.scan(lambda s, x: step64(s, x, dx_, dt_), state,\n", + " jnp.arange(n_steps))\n", + " return final[0]\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "sweep-for-synthesis", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:31.010599Z", + "iopub.status.busy": "2026-07-27T10:46:31.010502Z", + "iopub.status.idle": "2026-07-27T10:46:34.324668Z", + "shell.execute_reply": "2026-07-27T10:46:34.323969Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[09_jax_fp64] N=4194304 steps=47 | warm 10.9 ms | max_diff 0.00e+00 < tol 1e-12 | PASS\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DRAM-resident fp64/fp32: 2.0x\n" + ] + } + ], + "source": [ + "# Float64 rates across the shared size range, for the synthesis notebook (14).\n", + "jax.config.update('jax_enable_x64', True)\n", + "\n", + "# Written by the cell above; reload so an edit there takes effect here.\n", + "import importlib\n", + "import swe_jax_step\n", + "solve64 = importlib.reload(swe_jax_step).solve64\n", + "\n", + "_setup64 = swe_core.by_size(lambda n: tuple(\n", + " jnp.asarray(a) for a in swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)))\n", + "_setup32 = swe_core.by_size(lambda n: tuple(\n", + " jnp.asarray(a, jnp.float32)\n", + " for a in swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)))\n", + "\n", + "\n", + "def run_jax64(n_cells, n_steps):\n", + " dx_ = L / n_cells\n", + " dt_ = swe_core.fixed_dt(H0 + AMP, dx_, cfl=CFL, g=G)\n", + " return jax.block_until_ready(solve64(_setup64(n_cells), dx_, dt_, n_steps))\n", + "\n", + "warm64 = swe_core.timed_run(run_jax64, N, N_STEPS, label='09_jax_fp64')\n", + "diff64 = swe_core.max_diff(h_ref, np.asarray(run_jax64(N, N_STEPS)))\n", + "swe_core.report_and_verify(warm64, diff64, tol=1e-12, n=N, steps=N_STEPS)\n", + "swe_core.save_timing(warm64, grid_str=f'N={N}', tool='jax_fp64', hardware='gpu',\n", + " dtype='float64', steps=N_STEPS, max_diff_vs_numpy=diff64)\n", + "swe_core.save_sweep('09_jax_fp64', run_jax64)\n", + "\n", + "# Matched fp32 point at the largest size: the DRAM-resident precision gap.\n", + "def run_jax32(n_cells, n_steps):\n", + " dx_ = L / n_cells\n", + " dt_ = swe_core.fixed_dt(H0 + AMP, dx_, cfl=CFL, g=G)\n", + " return jax.block_until_ready(solve64(_setup32(n_cells), dx_, dt_, n_steps))\n", + "\n", + "BN, BSTEPS = swe_core.SWEEP_SIZES[-1]\n", + "t64 = swe_core.timed_run(run_jax64, BN, BSTEPS, warmup=1, repeats=3)\n", + "t32 = swe_core.timed_run(run_jax32, BN, BSTEPS, warmup=1, repeats=3)\n", + "print(f\"DRAM-resident fp64/fp32: {t64['median_s'] / t32['median_s']:.1f}x\")" + ] + }, + { + "cell_type": "markdown", + "id": "8f09f703", + "metadata": {}, + "source": [ + "**Recap.**\n", + "\n", + "- We expressed our computation as a **pure function** of `(h, hu)` and let\n", + " `@jax.jit + lax.scan` fuse the whole time loop into one device program.\n", + "- **Shape-rigidity**: changing `N` is not free.\n", + "- Below the crossover **N\\*** (Sec. 6) a fixed per-step cost dominates:\n", + " the grid is too small for the GPU to matter. Above it, time scales with `N`.\n", + "- JAX is float32-first: the docs call single precision 'the desired\n", + " behavior for many machine-learning applications'; float64 is opt-in\n", + " (`jax_enable_x64`) and pays the throughput hit measured above.\n", + "\n", + "Next: `10__swe__pyomp.ipynb` uses OpenMP's standard pragma-based API for shared-memory parallelism from Python." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/10__swe__pyomp__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/10__swe__pyomp__SOLUTION.ipynb new file mode 100644 index 00000000..36a7d8f8 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/10__swe__pyomp__SOLUTION.ipynb @@ -0,0 +1,674 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a8f93a1b", + "metadata": {}, + "source": [ + "## SWE - PyOMP - SOLUTION\n", + "\n", + "PyOMP brings the **OpenMP programming model** into Python. Inside a\n", + "`@numba.openmp.njit` function you write `with openmp(\"parallel for\"):`\n", + "and the compiler emits the same kind of parallel-region IR that\n", + "`#pragma omp parallel for` produces in C.\n", + "\n", + "This differs from `@njit(parallel=True)`, which is **Numba's own parallel\n", + "mode**. PyOMP is OpenMP itself, exposed as a\n", + "Python decorator. The directives port to\n", + "C / C++ / Fortran without translation.\n", + "\n", + "**SOLUTION**\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Imports and the OpenMP runtime](#sec1)\n", + "2. [The threading probe](#sec2)\n", + "3. [The step kernel with `openmp(\"parallel for\")`](#sec3)\n", + "4. [Acceptance and timing](#sec4)\n", + "5. [Thread scaling](#sec5)\n", + "6. [Scaling with data size](#sec6)\n", + "\n", + "### 1. Imports and the OpenMP runtime\n", + "\n", + "`numba.openmp` exposes both the decorator and the runtime helpers. Set\n", + "the thread count once with `omp_set_num_threads(N)` before the first\n", + "parallel region.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- Use the PyOMP-aware decorator:\n", + " ```python\n", + " from numba.openmp import (\n", + " njit,\n", + " openmp_context as openmp,\n", + " omp_set_num_threads,\n", + " omp_get_thread_num, omp_get_num_threads,\n", + " )\n", + " ```\n", + "- `with openmp(\"...\"):` accepts any OpenMP construct (`parallel for`,\n", + " `reduction(+:x)`, `target teams distribute`, …)\n", + " at JIT time.\n", + "- `omp_set_num_threads(N)` sets the thread team size. This is equivalent to\n", + " `OMP_NUM_THREADS=N` in C." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "fa81a40e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:37.618699Z", + "iopub.status.busy": "2026-07-27T10:46:37.618605Z", + "iopub.status.idle": "2026-07-27T10:46:43.668008Z", + "shell.execute_reply": "2026-07-27T10:46:43.667339Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# Pin OpenMP threads to cores before the runtime starts\n", + "os.environ.setdefault(\"OMP_PROC_BIND\", \"close\")\n", + "os.environ.setdefault(\"OMP_PLACES\", \"cores\")\n", + "\n", + "import time\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import numba\n", + "import psutil\n", + "\n", + "# PyOMP exposes its own njit decorator at numba.openmp.njit\n", + "from numba.openmp import njit\n", + "from numba.openmp import openmp_context as openmp\n", + "from numba.openmp import (\n", + " omp_get_thread_num,\n", + " omp_get_num_threads,\n", + " omp_set_num_threads,\n", + ")\n", + "\n", + "import swe_core\n", + "\n", + "\n", + "# Shared problem parameters\n", + "N, N_STEPS = swe_core.canonical_size()\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "dx = L / N\n", + "DT = swe_core.fixed_dt(H0 + AMP, dx, cfl=CFL, g=G)\n", + "\n", + "# Float64 NumPy reference for validation\n", + "h_ref, _ = swe_core.solve_numpy(N, N_STEPS)" + ] + }, + { + "cell_type": "markdown", + "id": "c282cc1b", + "metadata": {}, + "source": [ + "### 2. The threading probe\n", + "\n", + "Confirm we got the requested team size by asking the OpenMP runtime\n", + "inside a `parallel` region. We request one thread per physical core and\n", + "expect to observe that same count inside the region.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `omp_get_thread_num()` / `omp_get_num_threads()`: this thread's id and\n", + " the team size, valid inside a `parallel` region." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "1ed7e81b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:43.669808Z", + "iopub.status.busy": "2026-07-27T10:46:43.669579Z", + "iopub.status.idle": "2026-07-27T10:46:43.976645Z", + "shell.execute_reply": "2026-07-27T10:46:43.976031Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "threads: requested 288, observed inside parallel: 288\n" + ] + } + ], + "source": [ + "N_THREADS = psutil.cpu_count(logical=False) or os.cpu_count()\n", + "omp_set_num_threads(N_THREADS)\n", + "\n", + "@njit\n", + "def probe_threading():\n", + " '''Inside an `openmp(\"parallel\")` region, ask how many threads we got.'''\n", + " n_seen = 0\n", + " with openmp(\"parallel\"):\n", + " if omp_get_thread_num() == 0:\n", + " n_seen = omp_get_num_threads()\n", + " return n_seen\n", + "\n", + "n_observed = probe_threading()\n", + "print(f'threads: requested {N_THREADS}, observed inside parallel: {n_observed}')" + ] + }, + { + "cell_type": "markdown", + "id": "00669ddf", + "metadata": {}, + "source": [ + "### 3. The step kernel with `openmp(\"parallel for\")`\n", + "\n", + "Now let's implement the same **Read / Compute / Update** shape in PyOMP:\n", + "\n", + "- **Read**. Inside a `parallel for`, each thread reads `h[i-1]`, `h[i]`, `h[i+1]` for its slice of cells.\n", + "- **Compute**. `rusanov_face(...)` is called twice per cell, for the west and east face.\n", + "- **Update**. `h_new[i] = h[i] - (dt/dx) * (Fh_e - Fh_w)`. Each thread writes its own range of `h_new`, so no synchronisation is needed.\n", + "\n", + "The per-face flux lives in its own `@njit` helper so the parallel-loop body stays compact. The code shape mirrors what you would write in C-OpenMP.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `with openmp(\"parallel for\"):` parallelises the immediately-following `for` loop. Iterations are distributed across the thread team.\n", + "- `with openmp(\"parallel for reduction(+:total)\"):` would parallelise a reduction. We don't use one here: the SWE step writes one output per cell, with no cross-cell accumulation." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "dfc3a14f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:43.978013Z", + "iopub.status.busy": "2026-07-27T10:46:43.977768Z", + "iopub.status.idle": "2026-07-27T10:46:43.982990Z", + "shell.execute_reply": "2026-07-27T10:46:43.982568Z" + } + }, + "outputs": [], + "source": [ + "DRY_TOL_F = 1e-6\n", + "\n", + "@njit\n", + "def rusanov_face(hL, hR, huL, huR, g):\n", + " '''Scalar Rusanov face flux. Pulled into its own @njit function so the\n", + " parallel-loop body stays compact.'''\n", + " hL_s = hL if hL > DRY_TOL_F else DRY_TOL_F\n", + " hR_s = hR if hR > DRY_TOL_F else DRY_TOL_F\n", + " uL = huL / hL_s\n", + " uR = huR / hR_s\n", + " cL = np.sqrt(g * hL_s)\n", + " cR = np.sqrt(g * hR_s)\n", + " a = max(abs(uL) + cL, abs(uR) + cR)\n", + " Fh = 0.5 * (huL + huR) - 0.5 * a * (hR - hL)\n", + " Fhu = 0.5 * (huL * uL + 0.5*g*hL*hL +\n", + " huR * uR + 0.5*g*hR*hR) - 0.5 * a * (huR - huL)\n", + " return Fh, Fhu\n", + "\n", + "@njit\n", + "def step_pyomp(h, hu, h_new, hu_new, dx, dt, g):\n", + " '''One Rusanov-flux step. The per-cell loop is wrapped in\n", + " `with openmp(\"parallel for\")`.'''\n", + " Np2 = h.shape[0]\n", + " N = Np2 - 2\n", + " # Carry ghost cells through (the caller reapplies BCs between steps).\n", + " h_new[0] = h[0]; hu_new[0] = hu[0]\n", + " h_new[-1] = h[-1]; hu_new[-1] = hu[-1]\n", + " inv = dt / dx\n", + " with openmp(\"parallel for\"):\n", + " for i in range(1, N + 1):\n", + " Fh_w, Fhu_w = rusanov_face(h[i-1], h[i], hu[i-1], hu[i], g)\n", + " Fh_e, Fhu_e = rusanov_face(h[i], h[i+1], hu[i], hu[i+1], g)\n", + " h_new[i] = h[i] - inv * (Fh_e - Fh_w)\n", + " hu_new[i] = hu[i] - inv * (Fhu_e - Fhu_w)\n", + "\n", + "@njit\n", + "def _fill(dst_h, dst_hu, src_h, src_hu, s1, s2, n):\n", + " with openmp(\"parallel for\"):\n", + " for i in range(n):\n", + " dst_h[i] = src_h[i]\n", + " dst_hu[i] = src_hu[i]\n", + " s1[i] = 0.0\n", + " s2[i] = 0.0\n", + "\n", + "\n", + "def _setup_ic(n):\n", + " src_h, src_hu = swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + " m = n + 2\n", + " h, hu, h2, hu2 = (np.empty(m) for _ in range(4))\n", + " _fill(h, hu, src_h, src_hu, h2, hu2, m)\n", + " return h, hu, h2, hu2, src_h, src_hu\n", + "\n", + "\n", + "setup_ic = swe_core.by_size(_setup_ic)\n" + ] + }, + { + "cell_type": "markdown", + "id": "efb0af0a", + "metadata": {}, + "source": [ + "### 4. Acceptance and timing\n", + "\n", + "We use two pre-allocated buffers, swapped after each step so the loop allocates nothing. Wall-\n", + "clock is measured by `swe_core.timed_run`. Correctness against the\n", + "float64 NumPy reference uses `swe_core.max_diff`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "3f1997cf", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:43.984453Z", + "iopub.status.busy": "2026-07-27T10:46:43.984248Z", + "iopub.status.idle": "2026-07-27T10:46:44.382165Z", + "shell.execute_reply": "2026-07-27T10:46:44.381715Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[10_pyomp] N=4194304 steps=47 | cold 309.4 ms (incl. JIT + OMP region lowering) | warm 9.8 ms | max_diff 0.00e+00 < tol 1e-12 | PASS\n" + ] + } + ], + "source": [ + "def run_pyomp() -> tuple[np.ndarray, np.ndarray]:\n", + " h, hu, h2, hu2, src_h, src_hu = setup_ic(N)\n", + " _fill(h, hu, src_h, src_hu, h2, hu2, N + 2) # reset, in parallel\n", + " for _ in range(N_STEPS):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " step_pyomp(h, hu, h2, hu2, dx, DT, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " return h, hu\n", + "\n", + "# Cold capture (includes Numba + PyOMP compile).\n", + "t0 = time.perf_counter()\n", + "h_pyomp, _ = run_pyomp()\n", + "cold_s = time.perf_counter() - t0\n", + "\n", + "# Check now, because the timed runs below overwrite this buffer.\n", + "diff = swe_core.max_diff(h_ref, h_pyomp)\n", + "\n", + "warm = swe_core.timed_run(run_pyomp, warmup=2, repeats=5, label='10_pyomp')\n", + "swe_core.report_and_verify(warm, diff, tol=1e-12, cold_s=cold_s,\n", + " n=N, steps=N_STEPS,\n", + " cold_note='incl. JIT + OMP region lowering')\n", + "\n", + "swe_core.save_timing(\n", + " warm, grid_str=f'N={N}', tool='pyomp', hardware='cpu',\n", + " dtype='float64', steps=N_STEPS,\n", + " cold_s=cold_s, max_diff_vs_numpy=diff,\n", + " threads_observed=int(n_observed),\n", + " numba_version=numba.__version__,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "68cda77c", + "metadata": {}, + "source": [ + "### 5. Thread scaling\n", + "\n", + "The same kernel scales with `omp_set_num_threads(n)`, with no recompile and no\n", + "Python-level thread orchestration. The sweep below runs up to the logical-core count.\n", + "The dotted line in the plot marks the physical-core count.\n", + "\n", + "**Predict before run:** sketch speedup against thread count.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "8acef27f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:46:44.384253Z", + "iopub.status.busy": "2026-07-27T10:46:44.384118Z", + "iopub.status.idle": "2026-07-27T10:47:05.885270Z", + "shell.execute_reply": "2026-07-27T10:47:05.884432Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " threads = 1: 2106.9 ms 93.6 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " threads = 2: 1057.4 ms 186.4 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " threads = 4: 546.1 ms 361.0 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " threads = 8: 276.3 ms 713.5 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " threads = 16: 141.3 ms 1394.9 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " threads = 32: 71.3 ms 2763.0 Mcells/s\n", + " threads = 64: 39.5 ms 4989.3 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " threads = 128: 20.7 ms 9509.9 Mcells/s\n", + " threads = 256: 11.0 ms 17934.0 Mcells/s\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAArIAAAFJCAYAAABnxM7HAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAAlkxJREFUeJzs3Xd8U9X7wPHPTZp000EppUArUDay9xCQoQxBQRBBARkKuFgyBBUQQdnK8id7CAoqfkGQvYcgG8qGsgotLd27Te7vj5ZIaIEG0jaF5/165UVyc3Luk6ctfXpy7jkKoCKEEEIIIUQ+o8nrAIQQQgghhHgSUsgKIYQQQoh8SQpZIYQQQgiRL0khK4QQQggh8iUpZIUQQgghRL4khawQQgghhMiXpJAVQgghhBD5khSyQgghhBAiX5JCVgghhBBC5EtSyAohBNC4cWNUVaVjx455HUq2BAUFsWjRorwOIxNVVfnqq69Mj3v06IGqqvj7++dhVEKIZ5VdXgcghBA5RVWztwN3kyZNcjYQIYQQOUIKWSHEM+udd94xe9y9e3datmyZ6fjZs2cpX758bob23Fi2bBm//PILycnJeR2KEOIZJIWsEOKZ9fPPP5s9rlu3Li1btsx0HHjqQtbR0ZHExMSn6uNZZDQapYgVQuQYmSMrhBD30Wg0fP7559y4cYPExES2bt1KqVKlzNrs2LGDU6dOUb16dXbt2kV8fDwTJkwAQK/XM2bMGC5evEhSUhLXr1/nu+++Q6/Xm/XRs2dPtm3bRmhoKElJSQQGBtKvX78sYxo1ahQ3btwgPj6e7du3U6FChWy/n7feeovDhw8TExNDdHQ0J0+e5JNPPjFr4+bmxrRp0wgKCiIpKYkbN26wZMkSChYsCIBOp2Ps2LEcPnyYqKgo4uLi2L17d7amZGQ1RzYoKIh169bRoEEDDh48SGJiIpcvX+bdd9/N9PoXX3yRnTt3kpCQwI0bNxg1ahQ9e/aUebdCCEBGZIUQwsyIESMwGo1MmTIFNzc3hg0bxs8//0zdunXN2hUsWJC///6bX375heXLlxMaGoqiKKxdu5aGDRvy008/cfbsWV588UUGDRpEmTJleOONN0yv79+/P4GBgaxdu5a0tDRee+015s6di0ajYc6cOaZ248aN44svvmD9+vVs2LCB6tWrs3nz5kyFcVaaN2/OL7/8wtatWxk+fDiQPvLcoEEDfvjhBwCcnZ3Zs2cP5cuXZ+HChRw9ehQvLy/atWtHsWLFuHv3LgUKFKBPnz6sXLmSefPm4erqSu/evdm0aRO1a9fmxIkTFuc5ICCA3377jQULFrBkyRJ69erF4sWLOXLkCGfOnAHA19eXHTt2oKoqEydOJD4+nj59+sgIrxDCjCo3uclNbs/DbebMmaqafgVYplvjxo1VVVXVwMBAVafTmY5//PHHqqqqasWKFU3HduzYoaqqqr7//vtmfXTr1k1NS0tTGzRoYHb8/fffV1VVVevVq2c65uDgkCmGv//+W7106ZLpsZeXl5qUlKSuW7fOrN348eNVVVXVRYsWPfL9Tp8+XY2KilI1Gs1D24wZM0ZVVVV9/fXXH9pGo9GY5QRQ3dzc1Nu3b6vz5883O66qqvrVV1+ZHvfo0UNVVVX19/c3HQsKClJVVVUbNmxo9l4TExPVyZMnm459//33qsFgUKtUqWI65uHhoYaHh2fqU25yk9vzeZOpBUIIcZ9FixaRmppqerxnzx4ASpYsadYuKSkp0/JXnTp14uzZs5w7d46CBQuabtu3bwegadOmZq+/p0CBAhQsWJBdu3ZRqlQpChQoAKSPqNrb2zNz5kyz88yYMSNb7yUqKgpnZ2datGjx0DYdO3bk+PHj/Pnnnw9tYzQaTTlRFAUPDw/s7Ow4fPgw1atXz1YsDwoMDGTv3r2mx+Hh4Zw/f94sz6+++ioHDhwwG/GNjIzMco6zEOL5JIWsEELc5/r162aPIyMjAfDw8DA7HhwcbFbwApQuXZpKlSoRHh5udrt48SIA3t7eprb169dny5YtxMXFER0dTXh4OBMnTgTS56wCpjmg915/T3h4OBEREY99L3PmzOHChQts3LiRGzdusGDBAl555RWzNqVKleL06dOP7at79+6cOHGCpKQkIiIiCA8Pp23btqZYLfVgniE91/fn2d/fn0uXLmVql9UxIcTzSebICiHEfQwGQ5bHFUUxe5zVCgUajYaTJ08yePDgLPu4ceMGkD66u23bNs6dO8fgwYO5ceMGKSkptG7dmsGDB6PRWGeMISwsjKpVq/LKK6/QqlUrWrVqRa9evViyZAk9e/bMdj/dunVjyZIlrFmzhsmTJ3Pnzh0MBgMjR47MdCFcdmU3z0II8ShSyAohhJVcvnyZKlWqsG3btke2e+2113BwcKBdu3am4hbMpx4AXLt2DUgf6Q0KCjId9/LywtPTM1sxpaam8tdff/HXX3+hKApz5syhX79+fP3111y+fJnLly9TqVKlR/bx5ptvcvnyZTp06GB2fOzYsdmK4Uldu3aNgICATMezOiaEeD7J1AIhhLCSVatWUaxYMfr27ZvpOQcHB5ycnID/RiPvH30sUKAA7733ntlrtm7dSkpKCh9//LHZ8YEDB2YrngeLXVVVOXnyJAD29vYA/P7771StWpXXX3/9of1kFW/t2rWpV69etuJ4Ups2baJevXpUqVLFdMzDw4Nu3brl6HmFEPmHjMgKIYSVLFu2jM6dO/Pjjz/StGlT9u3bh1arpVy5cnTu3JlXXnmFI0eOsHnzZpKTk1m3bh3/93//h4uLC3379uXOnTv4+vqa+gsPD2fKlCl8/vnn/PXXX2zYsIFq1arRqlUrwsLCHhvP/Pnz8fT0ZPv27dy8eRN/f38+/vhjjh07xtmzZwGYPHkyb775JqtXr2bhwoUcOXIET09P2rVrR79+/Th58iR//fUXHTt2ZM2aNaxfv54SJUrQr18/zpw5g4uLS47lc9KkSbzzzjts2bKFmTNnmpbfun79OgULFsz2FsRCiGeXFLJCCGElqqry+uuvM2jQILp3784bb7xBQkICV65c4fvvv+fChQsAXLhwgTfffJPx48czZcoUQkJCmDt3LmFhYZlWQhg9ejRJSUn069ePpk2bcvDgQVq2bMn69esfG8/y5ct5//33GTBgAO7u7oSEhPDrr78yZswYUxEYHx9Po0aNGDt2LG+88QY9evTgzp07bNu2jZs3bwKwePFifHx8+OCDD3jllVc4c+YM77zzDp06dcrWpghP6ubNmzRt2pQffviBzz//nLCwMGbPnk18fDwzZ840W/lBCPF8Ukhfh0sIIYTIF6ZPn84HH3yAi4sLRqMxr8MRQuQhmSMrhBDCZjk4OJg99vT05N1332Xv3r1SxAohZGqBEEII23XgwAF27tzJ2bNnKVy4ML1796ZAgQJ8/fXXeR2aEMIGSCErhBDCZm3YsIE333yT999/H1VVOXr0KL179zbtuCaEeL7JHFkhhBBCCJEvyRxZIYQQQgiRL8nUggy+vr7ExsbmdRhCCCGEEM89V1dXbt269dh2UsiSXsQGBwfndRhCCCGEECJD0aJFH1vMSiELppHYokWL5sqorFarpUWLFmzZssW09ePzTPJhTvKRmeTEnOTDnOQjM8mJOcmHOUvy4aDRsCljm+hXTpwgKReWvXN1dSU4ODhbNZkUsveJjY3NtUI2MTGR2NhY+YFC8vEgyUdmkhNzkg9zko/MJCfmJB/mLMlHLPDy/v0ARKWl5UJ0lpFCVgghhBBCPJQtFrD3yKoFQgghhBAiX5IRWSGEEEIIkSU7RaF74cIALA0NJU21re0HpJDNJjs7O4oUKYJG8/SD2FqtFi8vL/z9/WWuDpKPB+VlPlRVJTw8nISEhFw9rxBCCNtkpygMKFoUgBV37kghmx95e3szfvx4HBwcrNano6MjL7/8stX6y+8kH+byOh87d+5k0aJFqDb2H5YQQojcZVBV1oSFme7bGilkH0NRFPr06UNcXBxTpkwhOTnZKv26urrKBgz3kXyYy6t82NnZUa5cOTp37gzAwoULcz0GIYQQtiNVVfnm+vW8DuOhpJB9DHd3d8qVK8ecOXO4cOGC1fp1c3MjOjraav3ld5IPc3mZj8uXLwPw1ltv8csvv8g0AyGEEDZLVi14DFdXVwDu3LmTx5EIkXvOnTsHgJeXVx5HIoQQwtoURcnrEKxGCtnHuPfFtsZFN87ubnj4+uDs7vbUfQmRk9Iy1gx8lv6zE0IIkf7/+ldffUX16tWxt7d/bHsHjYY9Vauyp2pVHKxwwbu1ydSCHObg6kKtdq1p2LUTXn7FTMcjbt5i9/Jf+XftBpJi4/IwQiGEEEI8L1q0aEHZsmVJSUnBwcEhW9PHHLXaXIjsyeRpaT1ixAgOHTpETEwMoaGhrFmzhjJlypi12bFjB6qqmt3mzp1r1qZ48eL89ddfxMfHExoayqRJk9DaQNLL1q/Dl1v/R/thn+JZzNfsOXdfH9oP+5Qvt/6PsvXr5FGEQgghhHheuLm58fbbbwNw7NixbF2LkWw08tqpU7x26hTJRmNOh2ixPC1kGzduzOzZs6lbty4tWrRAp9OxefNmnJyczNr99NNP+Pj4mG7Dhg0zPafRaFi/fj16vZ769evTo0cPevbsybhx43L77ZgpW78OfeZMRWdvj6LRZFp/VqPRoGg06Ozt6TNnqhSzVta4cWNUVcXNTaZxCCGEEADdu3fH2dmZK1euZPsCdhW4nZLC7ZQUbG/xrTwuZFu1asWSJUs4c+YMJ0+epGfPnvj7+1OjRg2zdgkJCYSGhppu9y9L1LJlSypUqMA777zDiRMn2LhxI1988QUffvghOp0ut98SkD6doMf0CQBoHjMyfO/5HtMn4ODqkuOxCSGEEOL5U7FiRRo1aoTRaGTBggXPzDrhNjVH9t7oWUREhNnxbt268c477xASEsK6dev4+uuvSUxMBKBevXqcOnXKbFWBTZs28eOPP1KxYkWOHz+e6Tx6vd5sgvO9lQm0Wm2mKQlPMkWhVrvW6B0cULI5KVqj1aJ3cKDma63Yu2K1xefL7+5dUKQoSqYfLK1W+9zt9vWofOS2rH4m8ioOjUZjE7HYAsmHOclHZpITc897Puzs7OjTpw8AW7du5dq1a5QvXz5b+dACnQoVAmB1WBi58RvZkq+TzRSyiqIwY8YM9u7dS2BgoOn4ihUruHbtGrdu3aJy5cp89913lC1blo4dOwLg4+NDaGioWV/3Hvv4+GR5rpEjRzJmzJhMx1u0aGEqkO/x8vLC0dERV1fXbH9M/dI7b2Wr3f1UoPG7XTi1frPFr83KX3/9xZkzZzAYDLz99tukpKQwfvx4fvvtNyZPnky7du0ICwtj2LBhbN26FYDy5cszbtw46tWrR0JCAjt27GDkyJGmPyyaNWvG0KFDqVChAgaDgUOHDjFixAiuXr0KgE6n45tvvqFdu3a4u7sTFhbGwoULmT59On5+fpw8eZJGjRpx6tQpIP0Pl2vXrtG2bVuOHTtGw4YN+euvv3jzzTcZPXo0FSpUoEOHDuzbt4+BAwfSs2dPvL29uXz5MpMmTWLt2rWm99uiRQsmTpxI0aJFOXz4MCtXrjSdIz9ydnbO0/O7urri6OjISy+9RHh4eJ7GAun/qVWvXh1FUZ67P2yyIvkwJ/nITHJi7nnPh6Io3LhxA2dnZ+7evUurVq2ynQ+d0cigjLoqoXp1UnNh5QJHR8dst7WZQnb27NlUqlSJhg0bmh2fN2+e6f7p06e5ffs227dvp2TJkly5cuWJzjVx4kSmTZtmeuzq6kpwcDBbtmzJtJuSv78/L7/8MrGxsaZJ0QN/WYirV8Es+1YUBTfvQhbHpNFo8Czmy6d/LHvoKFxs+F1mdOmVrf7S0tLo0qULkyZNolatWrz11ltMmzaNV199lTVr1jBmzBgGDRrEjz/+iJ+fH3q9nv/973/Mnz+fjz/+GEdHR7777jvmz59Ps2bNTP1OnjyZkydP4uLiwrhx41i6dClVq1ZFVVWGDBnCq6++SqdOnbh+/TrFixenePHiREdHExMTk/4e7svjPfHx8aYbwBdffMHQoUO5cuUKkZGRDBgwgM6dO/P+++9z8eJFXnrpJX766SeuXbvG7t27KVasGMuWLWP27Nn89NNP1KxZk6lTpwIQHR2d7zZauDciGxMTk2cjsu7u7iQmJrJ7926uXbuWJzHcT6vVoqoqGzdufC5/CT1I8mFO8pGZ5MSc5CPdvU/6LMmHXlGoUbw4ABtPnCAlF34v3fukPDtsopCdOXMmbdu25aWXXiI4OPiRbQ8ePAhAQEAAV65cISQkhNq1a5u1KVy4MAAhISFZ9pGSkkJKSkqm4waDIdMXNKsvsKtXQdwLez8yzif1JEXww5w4cYJvvvkGSC/eR4wYQXh4OPPnzwdg3LhxDBgwgMqVK9O8eXOOHTvGqFGjTK/v1asXN2/epHTp0ly8eJE//vjDrP9evXoRHh5OhQoVCAwMxM/Pj4sXL7J3714ArmdzS7t7xdq9f7/88kvTKLFer+fzzz+nefPm/PPPPwAEBQXRsGFDPvjgA3bv3k3//v25fPkyQ4cOBeDChQu8+OKLjBgx4onyltcezEdeyupnIq8YjUabiievST7MST4yk5yYe17zodPpSE1NzXQ8u/lIBEYHBeVQdFmz5GuU54XszJkzeeONN2jSpInpI+pHqVq1KgC3b98G4MCBA4waNYpChQoRFhYGpH/MHB0dzZkzZ3Ik5tjwuw997klHZO+JvhP2yBFZS5w8edJ032g0cvfuXdPH+vDfFAxvb2+qVKlC06ZNM41IA5QqVYqLFy8SEBDAuHHjqFOnDl5eXqaVGPz8/AgMDGTx4sVs2bKF8+fPs3HjRv766y+2bNliUcwAhw8fNt0PCAjA2dk5Uz96vZ5jx44B6VMi7v2Bc8+BAwcsPq8QQgjxLKlduzbdu3dnwYIFpt+Zz5o8LWRnz55N165dad++PbGxsaaR1OjoaJKSkihZsiRdu3Zlw4YN3L17l8qVKzN9+nR27dplKsg2b97MmTNnWLZsGcOGDcPHx4fx48cze/bsLEddreFxH++PXL8az2K+mZbcehSj0UjEzVtMbNPpacMzefAvMFVVs/yrTKPR4OLiwrp16xg+fHim5+/90bBu3TquXbtG3759uXXrFhqNhsDAQPR6PZC+Jl2JEiVo1aoVzZs3Z9WqVWzdupVOnTphzFh77v6doh62qsS9KQYALi7pKzm0adMm02h9cnLyY3MghBBCPI/s7e3p2bMnXl5elClTRgrZnDBgwAAAdu3aZXa8Z8+eLFmyhJSUFJo3b87AgQNxdnbmxo0b/P7774wfP97U1mg00rZtW+bOncuBAweIj49nyZIlfPnll7n6Xu63d8Vq2g/71KLXKMCen1flTEDZcPToUTp27MjVq1ezHNL39PSkXLly9O3b1zR1oEGDBpnaxcbGsmrVKlatWsVvv/3Gpk2b8PDwMI2WFylSxLSSxL3R9Uc5c+YMSUlJ+Pn5sXv37izbnD17lnbt2pkdq1u37mP7FkIIIZ5VnTp1wsvLi9DQ0ExTAy3hoNGwrlIlAF47fZokG9sUIU8L2cft437z5k2aNGny2H6uX79OmzZtrBTV0/t37QZaffIBOnv7x64jC2A0GEhNTubwur9zIbqszZ49m759+7Jy5UomTZpEREQEAQEBdOnShT59+hAZGUl4eDjvv/8+t2/fxs/Pj2+//dasj0GDBnH79m2OHTuG0WikU6dO3L59m6ioKFRV5cCBA4wYMYKgoCC8vb3N/iB5mLi4OKZMmcL06dPRaDTs3bsXNzc3GjRoQExMDEuXLuXHH39kyJAhTJo0ifnz51OjRg169uyZQ5kSQgghbFvx4sVNddHChQuz/DTWEh55tC5/duTphgjPqqTYOJYM+hxIL1If5d7ziweOJCk2Lsdje5jbt2/ToEEDtFotmzdv5tSpU8yYMYOoqCiMRiOqqtKlSxdq1KjB6dOnmT59Op999plZH7GxsQwbNozDhw/z77//8sILL9C6dWvTnN9evXphZ2fHkSNHmDFjBqNHj85WbF988QVff/01I0eO5OzZs2zcuJE2bdoQlDH5/MaNG3Ts2JHXX3+dEydO0K9fPz7//HPrJkgIIYTIBxRFoW/fvmi1Wg4ePPjUUwqSjUY6BwbSOTDQJreozfOLvZ5V5/cfZP6AIfSYPgG9gwMqmM2ZNRqNKEBqcjKLB47kwoFDVj1/06ZNMx0rUaJEpmP3j4pfunTJtD5vVrZt20bFihUf+vr58+ebVkTIyrlz5zJNR7j3ejc3N3bt2vXQUfoffviBH3744aF9r1+/nvXr15sdW7x48UPbCyGEEM+iJk2aUK5cOZKSkqzye1AFriQlPXU/OUUK2Rx0fv9BxjVvT83XWtGoW2e8/IqZnou6FcKuZb9weO0GkuLiH9GLEEIIIUT2lClTBoBVq1Zx965lqx3lR1LI5rCk2Dj2rljN3hWrcXIrgL2zE8nxCehQ8t1C/UIIIYSwbf/3f//H/v37rbYEqRZ4zcsLgHXh4bmyRa0lpJDNRQnRMSREp+9wlV+3ThVCCCGEbbt/zfinpdNoGO3vD8DGiAgMNjZPVi72EkIIIYTIx7RaLW+//XaODJIZVZWdUVHsjIrCaAM7Tj5IRmSFEEIIIfKxV199lTfeeIN69erx6aefWnWL8xRVZejly1brz9pkRFYIIYQQIp/y9PSkc+fOAKxZs8aqRWx+IIWsEEIIIUQ+1bNnTxwdHTl37hw7d+7M63BynRSyQgghhBD5UNWqValbty4Gg4H58+fnyGisvaKwtlIl1laqhP1jdmTNCzJHVgghhBAin9HpdPTq1QuADRs2cP369Rw5j6Io+Nrbm+5jY1MXZET2GbVjxw6mT5/+yDZBQUF8+umnVj1vdvrU6XRcvHiRevXqAeDv709UVBRVqlQBoHHjxqiqajNLlK1cuZLBgwfndRhCCCGESdu2bfHx8SE8PJxVq1bl2HlSjEa6nz1L97NnSbGxpbdARmSfWR06dCA1NTWvw8hSv379CAoK4sCBA1k+v3//fnx8fGxmw4jx48eze/du5s+fT0xMTF6HI4QQQrBx40bc3NwIDAwkOTk5x85jBM4kJORY/09LRmSfUZGRkcTFxeV1GFn66KOPWLBgwUOfT01NJTQ0NBcjyppOpwMgMDCQy5cv88477+RxREIIIUS6xMREFi9ezL///pvXoeQpKWSfkL29/UNv9wqgR7XV6/XZbmufMTfFEg9OLShUqBBr164lISGBK1eu0LVr10yvcXNzY968edy5c4fo6Gi2bdtG5cqVTc+XLFmSP//8k5CQEGJjYzl06BDNmjWzKK4aNWpQqlQp1q9f/9A2D04t6NGjB5GRkbRs2ZIzZ84QGxvL33//jY+Pj9nrevfuzZkzZ0hMTOTs2bP079/f7Plvv/2W8+fPEx8fz+XLlxk3bhx2dv99KPHVV19x7NgxevfuzZUrV0hKSjI9t27dOrp06WLRexVCCCGsrVChQrl6Pi3wqqcnr3p6os3VM2ePTC14QsuWLXvoc0ePHuXbb781PZ43bx4ODg5Ztg0MDGTs2LGmx7Nnz6ZAgQKZ2t1bI+5JLV68GF9fX5o2bUpqaio//PAD3t7eZm1Wr15NYmIirVq1Ijo6mg8++IBt27ZRpkwZIiMjcXFxYcOGDYwaNYrk5GS6d+/OunXrKFu2LDdu3MhWHI0aNeLChQsWjxY7OTkxdOhQ3n33XYxGI8uXL2fKlCmmUdKuXbsybtw4PvroI44dO0a1atWYN28e8fHxLF26FIDY2Fh69uzJrVu3ePHFF5k3bx6xsbFMnjzZdJ6AgAA6duxIhw4dMBj+21H60KFDjBo1Cr1eT0pKikWxCyGEENbg5OTEN998Q3BwMDNmzMiVKXg6jYbxJUoAsDMqyua2qJVC9jlQunRpWrduTa1atTh8+DCQPnp57tw5U5sGDRpQu3ZtvL29TYXaZ599xuuvv86bb77JvHnzOHnyJCdPnjS95ssvv+SNN96gXbt2zJ49O1ux+Pv7c+vWLYvfg16vp1+/fly5cgWAWbNm8eWXX5qeHzt2LEOGDGHNmjUAXL16lQoVKvDBBx+YCtlvvvnG1P7atWtMmTKFLl26mBWyer2e7t27Ex4ebnb+W7duYW9vj4+PT45dGSqEEEI8SpcuXXB3dyc+Pp74+PhcOaeqqhzMuD7EFjdbkEL2Cb377rsPfc74wF8rffv2zdSmQIECxMTEZGr74YcfWifA+5QvX57U1FSOHDliOnb+/HkiIyNNj6tUqYKLiwt37941e62joyOlSpUCwNnZmTFjxtCmTRuKFCmCnZ0djo6O+Pn5ZTsWR0dHs4/ssys+Pt5UxALcvn3bNKLs5OREQEAACxYsYN68eaY2dnZ2Zn+tdu7cmU8++YRSpUrh4uKCnZ1dpou3rl27lqmIhfS5SPfOJYQQQuS2kiVL0rJlSwDmz59PWlparpw3WVX58OLFXDnXk5BC9glZcoVgVm1TUlKyPJ6TVx4+iouLC7dv36ZJkyaZnouKigJgypQptGjRgqFDh3Lp0iUSExP57bff0Ov12T5PeHg4L774osXxPbgCg6qqaDQaU+yQ/gfDwYMHzdrdmx5Qt25dfv75Z7766is2bdpEdHQ0Xbp0YciQIWbtH/YXrqenJwBhYWEWxy6EEEI8DUVR6Nu3LxqNhj179hAYGJjXIdkMKWSfA+fOnUOn01GjRg3T1IIyZcrg4eFhanP06FF8fHxIS0vj2rVrWfbToEEDFi9ezJ9//gmkj9C+8MILFsVy7NixTBdhPa07d+4QHBxMyZIlWbFiRZZt6tevz7Vr15gwYYLpmL+/f7bPUalSJW7cuJFpxFoIIYTIaS1btqRUqVJm132IdFLIPgcuXLjA33//zf/93//Rv39/0tLSmDFjBgn3rQu3detWDhw4wJ9//smwYcO4cOECvr6+tGnThjVr1nDkyBEuXrxIhw4dWLduHaqq8vXXX5tGRbNrx44duLi4ULFiRav+RfnVV1/xww8/EB0dzcaNG7G3t6dmzZp4eHgwffp0Ll68iJ+fH2+99Rb//vsvbdq04Y033sh2/40aNWLz5s1Wi1cIIYTIDjc3N95++20Afvnll1xfY91eUVhavjwA3c+eJdnG5snK8lvPiffee49bt26xa9cu/vjjD3766Sfu3Llj1qZ169bs3r2bRYsWceHCBX755Rf8/f1Na7oOHjyYyMhI9u/fz7p169i0aRNHjx61KI6IiAjWrFlDt27drPbeABYsWECfPn147733OHXqFLt27aJnz54EBQUB6ctnTZ8+nVmzZnH8+HHq16/P119/na2+7e3tef31183m3wohhBC5wdXVlfDwcC5dupQnAyqKolDK0ZFSjo7pW9TaIPV5v7m6uqqqqqqurq6ZnvP391eXLl2q+vv7W/Wcbm5uef6+8+r24osvqiEhIaqzs3O+yEe/fv3UTZs25eo58zofOfV9/6Q3rVartm3bVtVqtXkeiy3cJB+SD8nJ85UPrVarenh45Ek+NKDWcHFRa7i4qJpcer+PqsuyiE+I3HXq1CmGDx9OiYx16WxdamoqH3/8cV6HIYQQ4jllMBjMVhrKTUbgSFwcR+LisK0VZNPJHFmRJ5YsWZLXIWTbo7bTFUIIIXLCa6+9hp2dHevWrcu1pbbyIylkhRBCCCFsiLe3N2+99RZ6vZ7g4GAOHTqUZ7FogYYZW8bvjY7G8OjmuU4KWSGEEEIIG9KrVy/0ej0nT57M0yIW0reonRoQAEDDY8dki1ohhBBCCJG12rVrU716dVJTU21iapuqqpyIizPdtzVSyAohhBBC2AB7e3t69uwJwNq1a7l9+3beBkT6FrW9z5/P6zAeSlYtEEIIIYSwAZ07d8bLy4vQ0FD++OOPvA4nX8jWiOxrr72W7Q7XrVv3xMEIIYQQQjyPnJ2dadGiBQALFy4kNTU1jyPKH7JVyP75559mj1VVNdvd4f45E3Z2MltBCCGEEMIS8fHxDBs2jHr16nHs2LG8DsfEXlH4qWxZAN4/fz5/blGr1WpNt5YtW3L8+HFatWqFu7s77u7utG7dmqNHj/Lqq6/mdLzCioKCgvj0009zrP8dO3Ywffp0q/W3aNEi1qxZY7X+8pKnpyehoaH4+/vn+rlXrlzJ4MGDc/28QgghHi0kJMTmfs8pikJFZ2cqOjvb5Ba1Fs+RnTFjBp9++imbN28mNjaW2NhYNm/ezODBg/nhhx8s6mvEiBEcOnSImJgYQkNDWbNmDWXKlDFrY29vz6xZswgPDyc2NpbffvsNb29vszbFixfnr7/+Ij4+ntDQUCZNmoRWq7X0rQkr69ChA1988UVeh2GTRo0axf/+9z+uXbsGQOXKlVmxYgXXr18nISGBgwcP8sknn2R6XdeuXTl+/Djx8fHcunWLBQsW4Onpadbm008/5dy5cyQkJHD9+nWmTZuGvb296fnx48czatQoChQokLNvUgghxGO5uroSkLG8lS1KNRr59OJFPr14kVQbW3oLnqCQLVWqFFFRUZmOR0dH88ILL1jUV+PGjZk9ezZ169alRYsW6HQ6Nm/ejJOTk6nN9OnTee211+jUqRONGzfG19fXbAK0RqNh/fr16PV66tevT48ePejZsyfjxo2z9K0JK4uMjCQuY8mO/Con/iBydHSkd+/eZsuq1KhRgzt37vDOO+9QsWJFpk6dysSJE/nwww9NberXr8/SpUtZsGABFStWpFOnTtSuXZt58+aZ2rz99tt8++23jB07lvLly9O7d2/eeustJkyYYGoTGBjI5cuXeeedd6z+3oQQQlima9eujB8/nvbt2+d1KFkyAPtiYtgXE2NzmyHco1py27Vrl7pp0ybV29vbdMzb21vduHGjunPnTov6evDm5eWlqqqqNmrUSAXUAgUKqMnJyWrHjh1NbcqWLauqqqrWqVNHBdRXX31VTUtLM4vngw8+UKOiolSdTpflefR6verq6mq6+fr6qqqqqu7u7qpWqzW7lSxZUl26dKnq7+9v1oeDRqM6aDRmx+wURXXQaFSdomTZVsl4rCiK6unmpjpqtar+MW0BVfsEudyxY4c6c+ZMdebMmWpUVJQaFhamjhs3zqxNUFCQOnLkSHXBggVqTEyMeu3aNbVv376m57dt26bOnDkz09coOTlZffnll1VA7d+/v3rhwgU1MTFRDQkJUVevXm0Ww/Tp083y/u2336rXr19Xk5KS1IsXL6q9evVSFUVRPTw81AULFqhXrlxRExIS1HPnzqmffPKJ2bkXLVqkrlmz5pHvu379+uqOHTvU+Ph4NSIiQt24caPq7u5uOv/333+vhoaGqomJieqePXvUmjVrml7buHFjVVVV9dVXX1UPHz6sJicnq40bN1YVRVFHjBhhiu348eNm35Pu7u7q8uXL1Tt37qgJCQnqhQsX1J49ez40xo4dO6qhoaEPfV5RFNXNzU2dPXu2um3bNtPxIUOGqJcuXTJr+9FHH6k3btwwPZ45c6a6detWszZTpkxR9+zZY3bsiy++UHfv3v3QGPz9/dWlS5eqJUuWzPQzkRc3vV6vtmvXTtXr9Xkeiy3cJB+SD8nJs5GP8uXLq6tWrVJXrVqlli9fPtfO6+5dSO3U/V3V3btQnucgy/jc3VVVVVVXV9fH1jsWX5nVq1cv1qxZw/Xr17lx4waQ/tH+xYsXef311y3tzoxbxhZoERERQPoolV6vZ+vWraY258+f59q1a9SrV4+DBw9Sr149Tp06xZ07d0xtNm3axI8//kjFihU5fvx4pvOMHDmSMWPGZDreokULEhMTzY55eXnh6OiIq6urKT6AbRkfA3S4coXojKH2rh4e9C5YkPXR0UwLCzO1/atkSRw1GrpevUpoxn7JnQsVok9AAFtjY5kYGmpq+3uJErhrtfS6fp1rKSkAtC5QgA0xMY9P4H3s7Ozo0aMHy5cvp1mzZlSrVo0ZM2YQFhbG0qVLgfTR7KFDh/LNN98wc+ZM2rdvz9y5czl69CiXLl1ixYoVTJ48mXHjxpGSEUvfvn25ffs2R44coXHjxvzwww988MEHHDp0CA8PD+rVq2fKk52dHfb29qbHCxcupFatWowcOZJTp07h7+9PwYIFKVCgAK6uroSFhfHee+8RGRlJ7dq1mTFjBtHR0aaLDfV6PTqdzuzrcL8XX3yRLVu2sHz5ckaPHk1aWhqNGjXCw8MDVVX59ttvadeuHQMGDODGjRumKTLVqlUjKioKFxcXACZPnszo0aO5evUqUVFRjB07ls6dOzNkyBAuX75MgwYNWL58OYmJiezbt4/vvvuOF198kU6dOhEREUGJEiVwdHR8aJzNmzfnxIkTD30e0q9eLViwILGxsaZ2p06donjx4rz55pts2bKFQoUK0aVLF7Zu3Wpqc/z4cd59912aNm3K0aNH8ff3p23btvz6669m5wsMDGTUqFEUKlTI9LW9n6urK46Ojrz00kuEh4c/NM7cotVqqV69OoqiYDDY6phA7pF8mJN8ZCY5MWeL+VAUhbZt2wJw8eJFSpUqRalSpXLsfHYO9vhUr0zR+jVxKpg+Ja3uZwNIuBtB8P7DhBw9SVpScuY4VZUSGb8ngvR61FyYJ+vo6JjtthYXspcvX6Zy5cq0aNGCcuXKAXD27FmzYvNJKIrCjBkz2Lt3L4GBgQD4+PiQnJxMdHS0WdvQ0FB8fHxMbULvKwTvPX/vuaxMnDiRadOmmR67uroSHBzMli1biI2NNWvr7+/Pyy+/TGxsbKY4AGJiY4nOKE6TMxKfkpJi3jbjCr/Y2FiiU1JQFIXkjKIi9YG2qtEIWi1xsbFEJyUBkKjTZXnuR0lLS+PGjRsMGDAAgCNHjlCqVCn69evHzJkzATAajaxfv96Ui+PHj9O/f39q1qzJkSNHWL58OZMmTaJJkyasXr0agLfeeouFCxcSHR2Np6cn8fHxrFq1yjSFYM+ePWYx3Pv6lS5dmg4dOtC8eXO2bdsGwMmTJwFMk8dHjhxpWgHj5MmTVKlShbZt27JkyRJTXlNTUx+ai/79+3P48GH69u1rOnbw4EEAnJyc6NWrFz179uT3338HoEePHly9epVOnToxZcoU03sYNWoUa9euBdKL58GDB9O8eXP++ecfU2zVq1enW7dubNiwAR8fHw4fPsyuXbuA9ILzUXx8fLh+/fpD34eiKNSuXZsOHTrQpk0bU7vNmzfTrVs3Fi5ciIODAzqdjrVr19K3b1/SMr4HFyxYgJOTExs3bkRRFHQ6HXPnzuWrr74yO8fFixext7fH0dGRsPv+6LrH3d2dxMREdu/ebZrHm5e0Wi2qqrJx40ab+SWUlyQf5iQfmUlOzNliPtq0aYOHhwexsbFMmjQpR6filalXm3enjkfv4ID6wHMOHu4EtGmOX7OGLBsymgsHzLfEddBo2FW5MgCNT54kKRfmybq6uma77ROvlbVlyxa2bNnypC/PZPbs2VSqVImGDRtarc+HSUlJyXIUymAwZPoGf9g3fMOMpTHu/4IuDQ1lxZ07GB5YmqJFRsGWnNFWVVX+Fx3Nyps3MT7Q9rXTp83aAqx7whGxe4XXPQcOHGDIkCFoNBqMGf3fKybvCQkJMV1Ml5yczLJly+jVqxerV6+mWrVqVKpUiXbt2gHp3wPXrl3jypUrbNy4kY0bN7JmzZpMo9oAVatWJS0tzVTs3e9e8dq/f3969eqFn58fjo6O6PX6LEfUH6Zq1aqmgvtBpUqVQq/Xs2/fPtOxtLQ0Dh06RPny5c3aHj582HQ/ICAAZ2fnTN/rer3etDzK3Llz+f3336levTqbN2/mzz//5MCBAw+N09HRkaSMP1KyUqFCBVasWMHYsWPNzlu+fHm+//57xo0bx6ZNmyhSpAiTJ0/mxx9/pE+fPkD6vPPPP/+cAQMGcPDgQQICAvj+++8ZPXo048ePN/V172t0/3z0rGT1M5FXjEajTcWT1yQf5iQfmUlOzNlSPgoWLMibb74JwPLlyy0erLJE2fp16DVrMgCKRsOD46kaTfrlUjp7e3rNmsz8AUM4v/+g6fk0o5HzCQnp99PSMtU4OcGSr9ETFbJOTk40btwYPz8/9Hq92XP3RvssMXPmTNq2bctLL71EcHCw6XhISIjpo+n7v8iFCxcmJCTE1KZ27dpm/RUuXNj0XE7J6i+SNFUlLYsvcFZtDQ85/rC2OeXBBZdVVTV9UwPMnz+f48ePU7RoUd577z22b9/O9evXAYiLi6N69eo0adKEli1bMm7cOMaMGUOtWrUy/VBmVdzer0OHDkyZMoUhQ4Zw4MABYmNj+eyzz6hTp06238vjzpFd8fHxpvv3phu0adPG7HsT0gt9gI0bN+Lv70/r1q1p0aIF27ZtY/bs2Xz22WdZ9h8eHo6Hh0eWz5UvX55t27axePFivvnmG7PnRo4cyb59+5gyZQqQPvIbHx/P3r17GT16NCEhIXz99dcsW7bMdCHZ6dOncXZ25qeffuKbb74x/dFwb6WDrEZjhRBC5KyePXvi4ODAuXPn2LlzZ46dx8HVhR7T0y/21Tzm4mWNVovRYKDH9AmMa96epNj0EeJkVaXb2bM5FuPTsnjVgqpVq3Lp0iVWrlzJrFmzGD16NDNmzGDChAkMHDjQ4gBmzpzJG2+8wcsvv8zVq1fNnjty5AgpKSk0a9bMdKxMmTL4+/ubRrwOHDjAiy++SKFChUxtWrRoQXR0NGfOnLE4nmfJg0Vg3bp1uXjxomk0NjtOnz5t+ri+a9euLFy40Ox5g8HAtm3bGD58OJUrV+aFF17g5ZdfztTPqVOn0Gg0NG7cOMvz1K1bl/379zN37lyOHz/O5cuXLZ4rdPLkSbPvlftdvnyZ5ORkGjRoYDpmZ2dHrVq1Hvl9cubMGZKSkvDz8+Py5ctmt5s3b5rahYeHs3TpUt59910GDhzI+++//9A+jx07RoUKFTIdr1ChAjt27GDJkiVmo6f3ODk5Zfra3fur9d70jOy0AahUqRI3btzg7t27D41TCCFEzjh69ChRUVHMnz/fbFMpa6vVrjV6B4fHFrH3aLRa9A4O1HytVY7FZG0Wj8hOnz6ddevW0a9fP6Kjo6lbty6pqaksX76c77//3qK+Zs+eTdeuXWnfvj2xsbGmkdTo6GiSkpKIiYlhwYIFTJs2jYiICGJiYpg5cyb79+83zX3cvHkzZ86cYdmyZQwbNgwfHx/Gjx/P7Nmzs5w+8Dzx8/Nj6tSp/N///R/Vq1fn448/ZsiQIRb3M3/+fGbNmkV8fLzZQs1t2rShZMmS7N69m8jISFq3bo1Go+H8+fOZ+rh27RpLlixh4cKFfPLJJ5w4cQJ/f3+8vb1ZvXo1ly9f5q233qJly5YEBQXx7rvvUqtWLYKCgrId58SJEzl16hSzZ8/mxx9/JCUlhaZNm7J69Wru3r3L3LlzmTx5MhEREVy/fp1hw4bh5ORktgzWg+Li4pgyZQrTp09Ho9Gwd+9e3NzcaNCgATExMSxdupSxY8dy5MgRAgMDsbe3p23btpx9xF+vmzZtYuLEibi7u5uWsqtYsSLbt29n06ZNTJs2DW9vbxwcHDAYDKaLrdatW8e8efPo16+faWrBjBkzOHjwILdv3za1GTx4MMeOHTNNLfj6669Zt26dWYHbqFEjNm/enO3cCiGEsJ4dO3awd+/eHN+GtmHXTqiQaTrBo6hAo26d2bsi66l6tsbiQrZq1ap88MEHqKqKwWDA3t6eoKAghg0bxpIlSyzakeLehUgPzpvs2bOn6QKfQYMGYTQa+f3337G3t2fTpk2m10H6nJe2bdsyd+5cDhw4QHx8PEuWLOHLL7+09K09c5YuXYqjoyOHDh3CYDDw/fff89NPP1ncz8qVK5kxYwYrV640fZwOEBUVRYcOHRgzZgwODg5cvHiRt99++6EjnP3792fChAnMmTOHggULcv36ddP6posWLaJcuXL8+uuvqKrKypUrmTNnDq1aZf+vwosXL9KyZUsmTJjAoUOHSExM5ODBg6xcuRJI34BDo9GwbNkyXF1dOXz4MK+88kqW6yLf74svviAsLIyRI0dSsmRJoqKiOHr0qCn2lJQUJk6cyAsvvEBiYiJ79uyhS5cuD+3v9OnTHD16lM6dO5u+Hm+++Sbe3t68++67vPvuu6a2V69epUSJEgAsWbIEV1dXPvroI6ZOnUpUVBTbt29n+PDhpvbjx49HVVXGjx9P0aJFCQsLY926dYwaNcrUxt7entdff1124hNCiFymKIppBDani1hndze8/IpZ/DqNRoOXXzGc3AqQEB2DvaLwQ+nSAHxy8aLNbVELFq5PeufOHTUgIEAF1PPnz6stW7ZUyVjfNS4uzuL1Tm3h5urq+tD1yu6tp/ngOrJPe3Nzc8vR9/TgGq5Pc/P391fT0tLUatWq5Vi8OZ0PW7u1bt1aDQwMVJUH1hHOjXz069dP3bRp02O/5jnxff+kN61Wq7Zt21bVarV5Host3CQfkg/JSf7Lh6+vr/rDDz+Y1sHP6ZuHr4869dSBJ755+PqokL6+/eEaNdTDNWpkWj8/p26PqssevFk8Invs2DFq1arFpUuX2LVrF+PGjcPLy4t3332X0xlX3Itng52dHQULFmT8+PH8888/pqv0xdPbsGEDpUuXpmjRomZzbXNDamoqH3/8ca6eUwghnnd9+vTBx8eHpk2bmqZH5qSUhKe7ADo5Pn2lglSjkeGXL5vu2xqLL/b6/PPPTfPxRo0aRWRkJHPnzqVQoUKPvMBF5D8NGjQgJCSEWrVq0a9fv7wO55nz/fff53oRC+lrzV64cCHXzyuEEM+rhg0bUqlSJVJSUjJdNJ1T4qOiCb9+06ILvCF9ymb49ZskRKdvxGQAtkVFsS0qyia3qLV4RPbIkSOm+2FhYRbNYRS5p2nTpk/dx65du8yudBdCCCGEZZycnOjevTsAv//+u9lOpDlt74rVtB/2qUWvUYA9P6/KmYBygMUjspC+Q0azZs14//33TetsFilSBGdnZ6sGJ4QQQgiRn3Xp0gV3d3eCg4NZt25drp7737UbSElKzvYSX0aDgZSkJA6v+9t0TANUcXamirPzkxWNOcziEVk/Pz82btyIn58f9vb2bNmyhbi4OIYPH469vT39+/fPiTjzzL0vvp3dE2+CJkS+Y29vD1i2u4oQQghzJUuWpGXLlkD6Upb3thPPLamJSYRfu0HR8mUe29aY8f/94oEjTZshAOg1GhaUKwek72qaG1vUWsLi6uz777/n8OHDVKlSxWwx9TVr1jBv3jyrBmcLwsLCSE1N5Y033mDNmjVW+yZ0dXXF3d3dKn09CyQf5vIqH1qtFm9vbzp37kxSUlKO7o4nhBDPuho1aqDRaNizZw+BgYG5fv5OY0aailhVVUFVUcFsB0+j0YgCpCYns3jgSC4cOGTWh6qqXM/YVj0nN294UhYXso0aNaJ+/fqZ1j+7evUqRYsWtVpgtiIxMZHp06czaNAgKleubLV+HR0drbal6rNA8mEur/Nx7tw5Jk6cmOujB0II8SxZvXo1Fy5cyLRzaW54ZUAfarVvDUBqUjLzPx6KT8kSNOrW2Wx92Yibt9jz8yoOr91AUlx8pn6SVZUOeVCEZ5fFhaxGo0GbxVZnxYoVIzY21ipB2ZrTp0/z0UcfUahQIatc/KTVannppZfYvXu3fHSL5ONBeZkPVVWJiYkhOjraJv/yFkKI/ObEiRO5fs5ar7ehZf/eQPqI688jvuLSP4e59M9h9q5YjaunB6+0bs2mDRuIjYjM9fisyeJCdvPmzQwcOJAPPvgASP/F5+zszNixY9mwYYPVA7QViYmJXL9+3Sp9abVawsPDuXbtmhRuSD4eJPkQQoj87ZVXXuHQoUNERuZ+kVi6bi06fTnC9HjdlJmc2ma+g2pCdAxJUdGmJbbyM4svQBs6dCgNGjQgMDAQBwcHVqxYYZpWcP9WmUIIIYQQz5tKlSrRu3dvpk6dmuurOfmULkWPaRPQ6tLHKff8vIrdy355qj71isKMgABmBASgt8ElOS0ekb158yZVqlThrbfeokqVKri4uLBgwQJ+/vlnkjImAwshhBBCPG/s7Ozo06cPAHv27CE+PvOc05xSwLsQfedMxdE1fVnU09t38b9J3z91vxpFoaGbm+k+NjbtzKJC1s7OjnPnztG2bVtWrFjBihUrciouIYQQQoh8pV27dvj6+hIZGckvvzzdSKgl7J2c6DN7Cu4+hQG4fuoMy4d/hWqFpbJSjUbGZFysZotb1FpUyKalpeHg4JBTsQghhBBC5Eve3t506NABgKVLl+bayjMaOy3vTh1P0XLpy2zdvRnMgo+HkpqUbJX+DcBf9y23amssniM7e/Zshg8fnuXKBUIIIYQQz6NevXqh1+s5efIk+/bty7Xzdhg1lPIN6wHpF3HNHzCEuLv5eyUCS1g8R7ZWrVo0a9aMli1bcurUqUzzPzp27Gi14IQQQgghbF2tWrWoXr06qampLFiwINfO+3Lv7tR783UA0lJSWPTpcO4EXbPqOTRAgKMjAJcSE7G1yQUWF7JRUVH8/vvvORGLEEIIIUS+c/LkSdatW0diYiK3b9/OlXNWa92SNgP7mx7/Mno8V44ct/p59BoNKypUAJ6RLWp79eqVE3EIIYQQQuRLycnJLFu2LNfOV7JGVbp8Pcr0eP2MORz7e0uOnEtVVe6kpJju2xqLC1khhBBCCAEuLi7Ex8fnaoHnXcKf9374Dju9HoADq/9k+4KcK6KTVZXWp07lWP9Py+KLvby9vVm6dCnBwcGkpqaSlpZmdhNCCCGEeNYpisLw4cMZO3YsPj4+uXJOl4Ie9JkzDacCBQA4u2c/f3wzJVfObassHpFdvHgxfn5+fP3119y+fdsmh5mFEEIIIXJS06ZNKVu2LImJiaRkfPSek/SODvSeNYWCxXwBuHnmPMuGfoHxOd/K3OJCtmHDhjRq1IgTJ07kRDxCCCGEEDbN1dWVbt26AbBq1SoiIiJy9HyKRsM7343Dr1L6RVeRt0NY8NFQkhMScvS8kL5F7bgSJQD4MiiIFBsbwLR4asGNGzdQbHCvXSGEEEKI3NCtWzdcXV25evUqf//9d46f7/XhA6nYtBEAibFxzB8whJiw8Bw/L6RvS9vcw4PmHh7pW9TaGIsL2YEDB/Ltt9/i7++fE/EIIYQQQtissmXL8vLLLwMwf/58jDm8HNVL3bvQsGsnAAypaSwZNJKQS1dy9Jz3SzUa+e76db67fj3/blEbERFhNhfW2dmZy5cvk5CQQGpqqlnbggULWjdCIYQQQggboNVq6du3LwDbtm3jwoULOXq+F5s34bUhH5serxozkYsHD+foOR9kAFaHheXqOS2RrUJ24MCBORyGEEIIIYRtc3Nzw2AwEBMTw88//5yj5/KvUoluE8eg0aR/eL5pznwOr92Qo+fMj7JVyC5dujSn4xBCCCGEsGkRERGMHDmSIkWKEBcXl2PnKVi8GL1+mITOwR6Af/+3ns1zc2/r2/spQDH79DhuJidjW5d6PcEc2bS0NAoVKpTpuKenp6wjK4QQQohnmtFoJDg4OMf6d3Z3o+/cabh4egBw8Z/DrB7zbY6d73HsNRrWVKrEmkqVsNdYXDbmOIsjetiKBfb29rmyjpoQQgghRG6qWrUqnTp1QqfT5eh57Oztee+HSRTyLw7A7YuXWTxoBIY8HiiMTUsj1kYHK7O9juzHH6dPNlZVlT59+pgNqWu1Wl566SXOnTtn/QiFEEIIIfKIXq+nT58+eHt7k5aWxpo1a3LkPIqi0HXCl5SoVhmA6DthzO8/mKS4+Bw5X3YlGY00teG9A7JdyA4aNAhIT3S/fv0w3LeTREpKClevXqVfv37Wj1AIIYQQIo906NABb29vwsPD2bAh5y62ajv4I6q0TF/WKyk+ngUfDiUq9E6One9Zke1CtmTJkgBs376dDh06EBUVlVMxCSGEEELkuaJFi9KuXTsAFi1aRHJyco6cp0GXjjTp2RUAQ1oay4aOJvhczi7t9ayweIvae4sACyGEEEI8y3r37o2dnR1Hjhzh33//zZFzVGjckNdHDDI9/uObKZzb+0+OnOtJ6BSFz/38AJhw/Tqp+X2LWmtq1KgRa9euJTg4GFVVad++vdnzixYtQlVVs9uDW8F5eHiwfPlyoqOjiYyMZP78+Tg7O+fm2xBCCCHEM6Zhw4ZUqlSJ5ORkFi5cmCPnKFahHO9MGodGqwVg2/yl/PPb/3LkXE9Kqyi85uXFa15eaG1wi1qLR2StydnZmRMnTrBw4cKHTp7++++/ee+990yPHxzW//nnnylSpAgtWrRAp9OxaNEifvrpJ7p165ajsQshhBDi2aQoCl26dAHgjz/+ICwHdrby8PWh9+wp2Ds5AnBsw2b+/uFHq5/naaWpKt/fvGm6b2vytJDduHEjGzdufGSb5ORkQkNDs3yuXLlytGrVipo1a3LkyBEgfXWFDRs2MHToUG7fvm31mIUQQgjxbFNVlbFjx9KuXTvWrl1r9f4dC7jSZ840CngVBODykWOsHD0e1QYLxTRVZdlD6jBbkKeFbHY0adKE0NBQIiMj2b59O6NHjyYiIgKAevXqERkZaSpiAbZu3YrRaKROnTr8+eefWfap1+uxz9ilAsDV1RVIX0ZMmzG8n5O0Wi0ajSZXzpUfSD7MST4yk5yYk3yYk3xkJjkx9yT5iIiIYPHixabXWy0WnY73vv8On1IlALgTdI2lgz4HozHXvl62/v1hSVxWK2SdnJyoUaMGe/bssVaXbNy4kT/++IOgoCBKlSrFhAkT+Pvvv6lXrx5GoxEfHx/u3DFfmsJgMBAREYGPj89D+x05ciRjxozJdLxFixYkJiZaLf6H0Wq1VK9eHUVRzJYxe15JPsxJPjKTnJiTfJiTfGQmOTGX3XwoioKnpyd3797NsVjKd26HT7UXAUiJi+fy6r9o0qBhjp0vK5Z8fyiqiovRCECcRoOaC/NkHR0ds93WaoVsQEAAO3bswM7OeoO8v/76q+n+6dOnOXnyJFeuXKFJkyZs3779ifudOHEi06ZNMz12dXUlODiYLVu2EBsb+1QxZ4dWq0VVVTZu3Cj/wSD5eJDkIzPJiTnJhznJR2aSE3PZzUfLli1p06YNGzZsYNmyZVaP45UP+/5XxCYm8X/9BnLj9Fmrn+dxLPn+cNBo2FU5fZOGxidPkpRR1Oake5+UZ4fNTy24X1BQEGFhYQQEBLB9+3ZCQkLw9vY2a6PVavH09CQkJOSh/aSkpGS5na7BYMi1H3ij0Zir57N1kg9zko/MJCfmJB/mJB+ZSU7MPS4f7u7uvPXWWwDcunXL6nmr0+E1mvXtYYpl+fAvuXritFXPYYnsfn8YVNV0kZfBYMCQC4WsJbnPdiH7uGH23JhnUbRoUQoWLGi6iOvAgQN4eHhQvXp1jh49CqSvc6vRaDh48GCOxyOEEEKIZ0P37t1xcnLi0qVLbNmyxap9l61fh45fDDM9/t930wncYb2pmDkpyWikbkaNZYuyXcja29szd+5cTp06leXz/v7+fPXVVxad3NnZmYCAANPjEiVKUKVKFSIiIoiIiOCrr77i999/JyQkhFKlSjFp0iQuXbrEpk2bADh37hx///038+bNo1+/fuh0OmbNmsUvv/wiKxYIIYQQIlsqVapEw4YNMRqNzJs3z6qrB/iWLU33ad+gzZh6uWvpSvau+M1q/T/vsl3IHj9+nBs3brB06dIsn69cubLFhWzNmjXZuXOn6fH06dMBWLx4Mf3796dy5cr06NEDd3d3bt26xebNm/niiy/MpgV069aNWbNmsW3bNoxGI7///juffPKJRXEIIYQQ4vlkZ2dHnz59ANi0aRNBQUFW69u9sDd9Zk/FIWOjppNbdrBuykyr9S8sKGTXr1+Pu7v7Q5+PiIh4aJH7MLt27UJ5xNVvr7766mP7iIyMlM0PhBBCCPFE2rVrh6+vL5GRkfzyyy9W69fBxZnec6biVrgQAFdPnOLnkWNtcq3YR9EpCoOKFQNg+s2bNrdFbbYL2YkTJz7y+Zs3b9KrV6+nDkgIIYQQIreEhoYSHR3NkiVLrLYEp8ZOS/ep3+BbJn36ZPj1myz8eBhpD+xOmh9oFYXOGRfW/xAcnH8LWSGEEEKIZ82+ffs4evSoVdeR7/TlCMrWrwNAfFQ08wYMJj4yymr956Y0VeWnW7dM922NFLJCCCGEeK5Zs4ht/sF71H6jLQCpycks/HgY4dduWK3/3JamqvxkwxfQa/I6ACGEEEKI3OTg4MCECROoV6+eVfut0fZVWn30vunxis/HcfX4SaueQ5iTQlYIIYQQz5VOnToREBDA22+/jU6ns0qfAbVr0Hnc56bH66bO4uTmJ9+F1Ja4aLW45MJ+AU9CphYIIYQQ4rnh5+dH69atAViwYAGpqalP3WfhUiXoOX0idhlF8b5ffmfn4p+ful9b4KDRsLNqVQAaHjuWK1vUWsLiEdlixYpRtGhR0+NatWoxffp0+vbta9XAhBBCCCGsSVEU+vbti1ar5cCBA5w4ceKp+3T1KkifOVNxLOAKQODOvfz57fSn7ldkj8WF7IoVK2jatCkAhQsXZsuWLdSuXZtvvvmGL774wuoBCiGEEEJYQ5MmTShbtiyJiYksXrz4qfvTOzrSe/YUPH2LAHAj8CzLh32B0WB46r5tRZLRSJ0jR6hz5IjNjcbCExSylSpV4tChQwB07tyZ06dP06BBA7p160bPnj2tHZ8QQgghxFOzt7fn7bffBmDVqlVERkY+VX8arZZ3J39N8QrlAIgIvs2CD4eSkpj01LHaGkPGzRZZXMjqdDqSMxb0bd68OWvXrgXg3LlzFClSxLrRCSGEEEJYQfHixXF1deXq1av8/fffT93fGyMHU6FxAwASY2KZP2AwsXcjnrpfYRmLL/YKDAykX79+rF+/nhYtWpimE/j6+nL37l2rByiEEEII8bQuXbrEtm3biI+Px/iUH5E3fa8b9d/qAEBaaiqLBo4g9MpVK0Rpe+wUhQG+vgDMuXXL5jZFsHhEdvjw4XzwwQfs3LmTlStXcvJk+vpo7dq1M005EEIIIYSwBdr7lo06c+YMV65cear+qr7SjLaDPzI9/vXLb7j879Gn6tOW2SkK3X186O7jg52i5HU4mVg8Irtr1y68vLwoUKAAUVFRpuM//fQTCQkJ1oxNCCGEEOKJValShT59+jBx4kSr9FeiWmXenvCl6fHfM/+Po39tskrftipNVVkaEmK6b2ssLmRHjRrFzz//zNWrV82OX7t2zVoxCSGEEEI8lbZt2/LOO++g0Wh4/fXXuX79+lP1V+gFP977YRJ2ej0AB39fy9afFlshUtuWpqr8EByc12E8lMVTCzp16sSlS5fYt28f/fv3p2DBgjkRlxBCCCGExXQ6HR9++CHdu3dHo9Gwfft2FixY8FR9unh60GfOVJzd3QA4v+8ffhs/yRrhiqdkcSFbtWpVKleuzM6dOxk6dCi3bt3ir7/+4u2338bR0TEnYhRCCCGEeCwPDw/Gjh1L48aNMRgMLFy4kB9//JG0tLQn7lPnYE+vHybhVbwYALfOX2TJkFEY02x1QSrr02bcbJHFhSykT5YeNWoUpUqVomnTply9epUZM2YQkjGHQgghhBAiNxUpUoSJEycSEBBAbGws33zzDRs3bnyqPhWNhq4Tx+BfpRIAUaF3mP/hEJLjn59rghw0Gg7WqMHBGjVw0DxR2ZijLJ4j+6D4+HgSExNJSUnB1dXVGjEJIYQQQlgkPDyc8PBw4uLimDRpEnfu3HnqPl8b+jGVmzcBICkunvkDhhAdGvbU/QrreaJC9oUXXqBr16507dqVsmXLsmvXLr766it+++03a8cnhBBCCJEljUaDqqqoqkpqaiqTJ08mOTmZpKSn312rYddONH63CwCGtDSWDP6c2xcuPXW/+U2S0UiT48dN922NxYXsgQMHqFWrFidPnmTRokWsXLmSW7du5URsQgghhBBZcnZ2ZuDAgVy5coWVK1cCEB0dbZW+K738Eu2HDzQ9/m3sd1w48PyulR9nsN35wBYXstu2baNXr16cPXs2J+IRQgghhHikYsWKMWzYMHx8fChbtiwbN24kMjLSKn37vViBd74bhyZjPuiW/1vEoT//skrfwvosLmRHjx6dE3EIIYQQQjxWjRo1+OSTT3B0dOTOnTtMmjTJakVswWJF6TVzMjoHewAOr/ubjbN+skrf+ZWdotDLxweAhSEhNrcpwlNf7CWEEEIIkRveeOMN3nrrLTQaDYGBgUybNo3Y2Fir9O3kVoA+c6biWtATgEuHjrDqywlW6Ts/s1MU3vf1BWBpaKgUskIIIYQQlurfvz9NmzYFYNOmTSxevBiDleZu2un1vPf9d3iX8Acg5HIQiweNxPAU688+KwyqyqqMFSAMNlbEghSyQgghhMgHTp48SaNGjViwYAHbtm2zWr+KotBl/GhK1qgKQEz4XeYPGExijHVGevO7VFVl0o0beR3GQ0khK4QQQgibpNPpSE1NBWDfvn2cP3+e8PBwq56j9af9qNaqBQDJCYks+HAokbdkg6f84okKWXd3d3r37k358uUBOHv2LAsXLrTaZGshhBBCPN+aNWvGG2+8wejRo4mKigKwehFbt9PrvNy7OwBGg4Fln33BzTPnrHoOkbMs3musUaNGBAUF8cknn+Dh4YGHhwcff/wxQUFBNGrUKCdiFEIIIcRzQqvV0rt3bz744AO8vb1p3rz5U/fp5O6Gg7sbTu5upmPlGtWj46ihpsdrJk7j7O59T32uZ42DRsM/1avzT/Xqz8YWtbNnz2bVqlX0798fY8YODxqNhjlz5jB79mwqV65s9SCFEEII8exzdXVl8ODBVKxYEaPRyK+//sqaNWueqC8HVxdqtWtNw66d8PIrBkC94R8Rfv0mp7btpP5bHdBotQDsWLic/b/+YbX38ayxU5S8DuGhLC5kAwICePPNN01FLIDRaGTatGl0797dqsEJIYQQ4vng7+/PZ599hre3NwkJCcycOZMjR448UV9l69ehx/QJ6B0cePA6e89ivjTp2Q0lozg7vnEr62fMecron13JRiOtTp403bc1FheyR48epXz58ly4cMHsePny5Tlx4oTVAhNCCCHE86Fs2bKMGjUKBwcHQkJC+O677wgODn6yvurXoc+cqQAoGg0PjiVq7vt4XFVVjvy1EdUGl5WyFSoQlnHBnS2yuJD94Ycf+P777wkICOCff/4BoG7dunz44YeMGDGCF1980dT21KlT1otUCCGEEM+k69evExYWRkREBDNmzCA+Pv6J+nFwdaHH9PRNDO5NG3gU1ajyzqRxjGvenqTYuCc6p8hbFheyK1euBGDSpElZPqeqKoqioKoqdnayupcQQgghMrt/aa3ExETGjRtHbGys2dRFS9Vq1xq9gwNKNi9K0mg16B0cqPlaK/auWP3E532W2SkKb3t7A7Dyzh2b29nL4svPSpQo8chbyZIlTf8+TqNGjVi7di3BwcGoqkr79u0ztRk7diy3bt0iISGBLVu2EBAQYPa8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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "N_LOGICAL = os.cpu_count()\n", + "THREAD_COUNTS = [1]\n", + "while THREAD_COUNTS[-1] * 2 <= N_LOGICAL:\n", + " THREAD_COUNTS.append(THREAD_COUNTS[-1] * 2)\n", + "\n", + "sweep = []\n", + "for nthreads in THREAD_COUNTS:\n", + " omp_set_num_threads(nthreads)\n", + " r = swe_core.timed_run(run_pyomp, warmup=2, repeats=3)\n", + " rate = N * N_STEPS / r['median_s'] / 1e6\n", + " sweep.append((nthreads, r['median_s'], rate))\n", + " print(f' threads = {nthreads:>2}: {r[\"median_s\"]*1000:6.1f} ms '\n", + " f'{rate:7.1f} Mcells/s')\n", + "\n", + "ns = [s[0] for s in sweep]\n", + "ts = [s[1] for s in sweep]\n", + "speedups = [ts[0] / t for t in ts]\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 3.4))\n", + "ax.plot(ns, speedups, 'o-', linewidth=2, markersize=10, label='measured')\n", + "ax.plot(ns, ns, '--', color='#aaa', label='ideal (linear)')\n", + "ax.axvline(N_THREADS, color='#c33', linestyle=':',\n", + " label=f'physical cores ({N_THREADS})')\n", + "ax.set_xscale('log', base=2)\n", + "ax.set_xticks(ns); ax.set_xticklabels([str(n) for n in ns])\n", + "ax.set_xlabel('OMP_NUM_THREADS'); ax.set_ylabel('speedup vs 1 thread')\n", + "ax.set_title('Thread scaling')\n", + "ax.legend(); ax.grid(alpha=0.3); plt.tight_layout(); plt.show()\n", + "\n", + "omp_set_num_threads(N_THREADS) # restore the notebook default for later cells\n" + ] + }, + { + "cell_type": "markdown", + "id": "sec5-reveal-md", + "metadata": {}, + "source": [ + "Speedup is near-linear while each new thread lands on an idle core." + ] + }, + { + "cell_type": "markdown", + "id": "ed4dfcc7", + "metadata": {}, + "source": [ + "### 6. Scaling with data size\n", + "\n", + "Let's now look at throughput against working-set size. Here one fused loop runs per step, so the working set is just the four\n", + "`float64` state arrays. The last-level cache boundary is marked in the plot.\n", + "\n", + "A new `N` triggers no recompile here, unlike JAX. Numba specialises on `dtype` and rank, not shape, so there is no cold spike to chart.\n", + "\n", + "**Q:** throughput is flat while the working set is cache-resident, from L2\n", + "through L3. What limits the kernel there? (Measured in notebook 14)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "6256f3b6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:05.887573Z", + "iopub.status.busy": "2026-07-27T10:47:05.887433Z", + "iopub.status.idle": "2026-07-27T10:47:10.165085Z", + "shell.execute_reply": "2026-07-27T10:47:10.163838Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N= 16,384: working set 0.5 MiB 714.2 Mcells/s\n", + " N= 32,768: working set 1.0 MiB 1244.1 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N= 65,536: working set 2.0 MiB 2699.3 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N= 131,072: working set 4.0 MiB 4471.1 Mcells/s\n", + " N= 262,144: working set 8.0 MiB 7720.7 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N= 524,288: working set 16.0 MiB 11656.4 Mcells/s\n", + " N=1,048,576: working set 32.0 MiB 12481.9 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N=2,097,152: working set 64.0 MiB 17096.8 Mcells/s\n", + " N=4,194,304: working set 128.0 MiB 16331.1 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N=8,388,608: working set 256.0 MiB 19575.4 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N=16,777,216: working set 512.0 MiB 13365.7 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N=33,554,432: working set 1024.0 MiB 12112.0 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N=67,108,864: working set 2048.0 MiB 10384.2 Mcells/s\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Sweep across cache regimes. Few steps at large N keeps runtime bounded, throughput is step count-invariant.\n", + "N_MAX = max(n for n, _ in swe_core.sweep_points())\n", + "SWEEP = [(n, max(20, min(1000, 200_000_000 // n)))\n", + " for n in (1 << k for k in range(14, N_MAX.bit_length()))]\n", + "\n", + "grid_data = []\n", + "for N_local, steps_local in SWEEP:\n", + " L_local = 10.0\n", + " dx_local = L_local / N_local\n", + " DT_local = swe_core.fixed_dt(H0 + AMP, dx_local, cfl=CFL, g=G)\n", + "\n", + " # Run the IC outside timed function: only time update step.\n", + " h0, hu0 = swe_core.bump_ic(N_local, L=L_local, h0=H0, amplitude=AMP, sigma=SIG)\n", + " s0, s1 = np.empty_like(h0), np.empty_like(hu0)\n", + "\n", + " def run_at(h=h0, hu=hu0, h2=s0, hu2=s1, dx_=dx_local, dt_=DT_local, s_=steps_local):\n", + " for _ in range(s_):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " step_pyomp(h, hu, h2, hu2, dx_, dt_, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + "\n", + " r = swe_core.timed_run(run_at, warmup=2, repeats=5)\n", + " cells_per_s = N_local * steps_local / r['median_s']\n", + " ws_mb = swe_core.to_mib(swe_core.working_set_bytes(N_local + 2, n_arrays=4)) # The fused kernel stores 4 float64 arrays\n", + " grid_data.append((N_local, cells_per_s, ws_mb))\n", + " print(f' N={N_local:>9,}: working set {ws_mb:8.1f} MiB '\n", + " f'{cells_per_s/1e6:6.1f} Mcells/s')\n", + "\n", + "LLC_MIB = swe_core.llc_mib() # CPU last-level cache, the wall to watch\n", + "swe_core.plot_rate_sweep([d[2] for d in grid_data],\n", + " {f'PyOMP warm @ {N_THREADS} threads': [d[1] / 1e6 for d in grid_data]},\n", + " 'working-set footprint [MiB]',\n", + " 'Throughput across cache and DRAM',\n", + " boundaries=[(LLC_MIB, f'last-level cache = {LLC_MIB:.0f} MiB')] if LLC_MIB else (),\n", + " logy=False, from_zero=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "sweep-for-synthesis", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:10.167774Z", + "iopub.status.busy": "2026-07-27T10:47:10.167594Z", + "iopub.status.idle": "2026-07-27T10:47:11.778896Z", + "shell.execute_reply": "2026-07-27T10:47:11.777739Z" + } + }, + "outputs": [], + "source": [ + "# Rates across the shared size range, for the synthesis notebook (14).\n", + "def run_pyomp_at(n_cells, n_steps):\n", + " dx_ = 10.0 / n_cells\n", + " dt_ = swe_core.fixed_dt(H0 + AMP, dx_, cfl=CFL, g=G)\n", + " h, hu, h2, hu2, src_h, src_hu = setup_ic(n_cells)\n", + " _fill(h, hu, src_h, src_hu, h2, hu2, n_cells + 2)\n", + " for _ in range(n_steps):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " step_pyomp(h, hu, h2, hu2, dx_, dt_, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " return h\n", + "\n", + "swe_core.save_sweep('10_pyomp', run_pyomp_at)" + ] + }, + { + "cell_type": "markdown", + "id": "ecd0b992", + "metadata": {}, + "source": [ + "**Recap.**\n", + "\n", + "- Same `#pragma omp parallel for` directives as C, exposed via a\n", + " Python context manager.\n", + "- Thread scaling is near-linear while each thread has a core of its own\n", + " (Sec. 5); the kernel handles any `N` with no recompile (Sec. 6).\n", + "\n", + "Next: `11__swe__nanobind.ipynb` drops into C++. We compile a\n", + "hand-written kernel and call it from Python through a binding library.\n" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/11__swe__nanobind__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/11__swe__nanobind__SOLUTION.ipynb new file mode 100644 index 00000000..341c99d2 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/11__swe__nanobind__SOLUTION.ipynb @@ -0,0 +1,549 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "84637cd3", + "metadata": {}, + "source": [ + "## SWE - nanobind - SOLUTION\n", + "\n", + "One way to get more performance is to write the kernel in C++ and bind it back to Python. This notebook takes the binding-library and FFI (foreign function interface) approach with **nanobind**, the modern successor to pybind11.\n", + "\n", + "**SOLUTION**\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Imports and source layout](#sec1)\n", + "2. [The C++ source](#sec2)\n", + "3. [Build with nanobind + CMake](#sec3)\n", + "4. [Acceptance and timing](#sec4)\n", + "5. [nanobind vs pybind11](#sec5)\n", + "6. [Limitation: binding and build glue](#sec6)\n", + "\n", + "### 1. Imports and source layout\n", + "\n", + "The C++ source lives at `notebooks/swe_step.cpp`. We invoke `cmake` and\n", + "the system C++ compiler via `subprocess`. The resulting shared library is\n", + "imported back into Python.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `nanobind.cmake_dir()`: path to nanobind's CMake config files. We pass\n", + " it via `-Dnanobind_DIR=` so `find_package(nanobind CONFIG REQUIRED)`\n", + " succeeds.\n", + "- `nanobind_add_module(target source)`: nanobind's CMake helper that\n", + " creates a Python extension target with the right include paths,\n", + " link flags, and visibility settings.\n", + "- `nb::ndarray>` (on the C++ side): a typed NumPy view (`double*` + `shape`).\n", + " No copy unless the layout demands one." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "2f5d84c4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:14.644815Z", + "iopub.status.busy": "2026-07-27T10:47:14.644723Z", + "iopub.status.idle": "2026-07-27T10:47:19.892151Z", + "shell.execute_reply": "2026-07-27T10:47:19.891568Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "import numpy as np\n", + "import nanobind\n", + "import psutil\n", + "\n", + "import swe_core\n", + "\n", + "# One OpenMP thread per physical core, pinned, as in NB 10.\n", + "os.environ.setdefault('OMP_NUM_THREADS', str(psutil.cpu_count(logical=False)))\n", + "os.environ.setdefault('OMP_PROC_BIND', 'close')\n", + "os.environ.setdefault('OMP_PLACES', 'cores')\n", + "\n", + "# Shared problem parameters.\n", + "N, N_STEPS = swe_core.canonical_size()\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "dx = L / N\n", + "DT = swe_core.fixed_dt(H0 + AMP, dx, cfl=CFL, g=G)\n", + "\n", + "# CMakeLists.txt + swe_step.cpp live next to swe_core.py; build artifacts go in build/.\n", + "CPP_DIR = Path(swe_core.SWE_STEP_CPP).parent\n", + "BUILD_DIR = CPP_DIR / 'build'\n", + "\n", + "# Float64 NumPy reference for validation\n", + "h_ref, _ = swe_core.solve_numpy(N, N_STEPS)\n", + "\n", + "def _setup_ic(n):\n", + " h, hu = swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + " return (h, hu, np.empty_like(h), np.empty_like(hu),\n", + " np.empty(n + 1), np.empty(n + 1))\n", + "\n", + "setup_ic = swe_core.by_size(_setup_ic)\n" + ] + }, + { + "cell_type": "markdown", + "id": "ff37df55", + "metadata": {}, + "source": [ + "### 2. The C++ source\n", + "\n", + "Same **Read / Compute / Update** shape as NB 08, expressed in C++ via nanobind:\n", + "\n", + "- **Read**. The `double*` pointer nanobind hands us, a zero-copy view of the NumPy buffer.\n", + "- **Compute**. One flux per interface, written into a caller-provided face buffer, no temporaries.\n", + "- **Update**. Writes into `double*`. No Python objects on the hot path.\n", + "\n", + "From `swe_step.cpp`:\n", + "\n", + "```cpp\n", + "#include \n", + "#include \n", + "#include \n", + "#include \n", + "\n", + "namespace nb = nanobind;\n", + "\n", + "inline void rusanov_face(double hL, double hR, double huL, double huR,\n", + " double g, double& Fh, double& Fhu) {\n", + " /* ...same arithmetic as swe_core.step_numpy... */\n", + "}\n", + "\n", + "// Each interface flux is computed exactly once, as in swe_core.step_numpy.\n", + "void cpp_step(\n", + " nb::ndarray, nb::c_contig> h_in,\n", + " nb::ndarray, nb::c_contig> hu_in,\n", + " nb::ndarray, nb::c_contig> h_out,\n", + " nb::ndarray, nb::c_contig> hu_out,\n", + " nb::ndarray, nb::c_contig> Fh_buf,\n", + " nb::ndarray, nb::c_contig> Fhu_buf,\n", + " double dx, double dt, double g)\n", + "{\n", + " const double* h = h_in.data();\n", + " /* ... */\n", + " // Pass 1: one flux per interface i+1/2, i = 0..N.\n", + " #pragma omp parallel for\n", + " for (size_t f = 0; f <= N; ++f)\n", + " rusanov_face(h[f], h[f + 1], hu[f], hu[f + 1], g, Fh[f], Fhu[f]);\n", + "\n", + " // Pass 2: difference the stored fluxes over the interior.\n", + " #pragma omp parallel for\n", + " for (size_t i = 1; i <= N; ++i) { /* ... */ }\n", + "}\n", + "\n", + "NB_MODULE(swe_step, m) {\n", + " m.def(\"cpp_step\", &cpp_step,\n", + " nb::arg(\"h\"), nb::arg(\"hu\"), nb::arg(\"h_new\"), nb::arg(\"hu_new\"),\n", + " nb::arg(\"Fh\"), nb::arg(\"Fhu\"),\n", + " nb::arg(\"dx\"), nb::arg(\"dt\"), nb::arg(\"g\") = 9.81);\n", + "}\n", + "```\n", + "\n", + "Things to note:\n", + "- `nb::ndarray, nb::c_contig>` is a zero-copy typed view into the NumPy buffer; `nb::c_contig` rejects strided views.\n", + "- The face-flux buffers are pre-allocated by NumPy and passed in, so the kernel allocates nothing.\n", + "- `NB_MODULE`'s name must match the compiled library filename (`swe_step.cpython-...so`).\n", + "- `nb::arg` carries Python keyword names and defaults into the generated signature.\n", + "- Both passes run under `#pragma omp parallel for`, the same directives used with PyOMP. Each thread writes its own index range, no synchronisation needed." + ] + }, + { + "cell_type": "markdown", + "id": "6c713090", + "metadata": {}, + "source": [ + "### 3. Build with nanobind + CMake\n", + "\n", + "nanobind ships with a CMake helper, `nanobind_add_module(...)`, that\n", + "sets up include paths, link flags, RPATH, and visibility for you. Let's\n", + "write a small `CMakeLists.txt` and then configure and build the library.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `cmake -B build -S .` configures (out-of-source build directory).\n", + "- `-DCMAKE_BUILD_TYPE=Release` is essential for the compiler to emit optimisation flags (`-O3 -DNDEBUG`).\n", + "- `-DPython_EXECUTABLE=` ensures we correctly link against the Python executable in our venv.\n", + "- `cmake --build build --config Release -j` compiles our code into a shared library." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "613ae7b5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:19.893923Z", + "iopub.status.busy": "2026-07-27T10:47:19.893739Z", + "iopub.status.idle": "2026-07-27T10:47:23.138025Z", + "shell.execute_reply": "2026-07-27T10:47:23.137452Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "-- Build files have been written to: /accelerated-computing-hub/tutorials/pyhpc/notebooks/build\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[100%] Built target swe_step\n" + ] + } + ], + "source": [ + "# locate nanobind's CMake config\n", + "NANOBIND_CMAKE_DIR = nanobind.cmake_dir()\n", + "\n", + "CMAKELISTS = '''\n", + "cmake_minimum_required(VERSION 3.20)\n", + "project(swe_step LANGUAGES CXX)\n", + "set(CMAKE_CXX_STANDARD 17)\n", + "find_package(Python 3.12 REQUIRED COMPONENTS Interpreter Development.Module)\n", + "find_package(nanobind CONFIG REQUIRED)\n", + "find_package(OpenMP REQUIRED)\n", + "nanobind_add_module(swe_step swe_step.cpp)\n", + "target_compile_options(swe_step PRIVATE -O3)\n", + "target_link_libraries(swe_step PRIVATE OpenMP::OpenMP_CXX)\n", + "'''\n", + "(CPP_DIR / 'CMakeLists.txt').write_text(CMAKELISTS)\n", + "\n", + "# Configure and build.\n", + "swe_core.run_cmd(['cmake', '-B', 'build', '-S', '.',\n", + " '-DCMAKE_BUILD_TYPE=Release',\n", + " f'-DPython_EXECUTABLE={sys.executable}',\n", + " f'-Dnanobind_DIR={NANOBIND_CMAKE_DIR}'], cwd=CPP_DIR)\n", + "swe_core.run_cmd(['cmake', '--build', 'build', '--config', 'Release', '-j'], cwd=CPP_DIR)" + ] + }, + { + "cell_type": "markdown", + "id": "e82729d0", + "metadata": {}, + "source": [ + "Now that we have our shared library, let's import it on the Python side:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "930ea37e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:23.139260Z", + "iopub.status.busy": "2026-07-27T10:47:23.139146Z", + "iopub.status.idle": "2026-07-27T10:47:23.146475Z", + "shell.execute_reply": "2026-07-27T10:47:23.146095Z" + } + }, + "outputs": [], + "source": [ + "sys.path.insert(0, str(BUILD_DIR))\n", + "import swe_step as cpp_swe" + ] + }, + { + "cell_type": "markdown", + "id": "4822549e", + "metadata": {}, + "source": [ + "### 4. Acceptance and timing\n", + "\n", + "The time loop reuses pre-allocated state and face-flux buffers, swapping\n", + "input and output each step (double buffering), so the timing measures the\n", + "kernel call, not per-step allocation. Boundary conditions are re-applied on\n", + "the Python side (`swe_core.apply_bc_reflective`) between steps, so the C++\n", + "kernel does only the step." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "ac0a257d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:23.147424Z", + "iopub.status.busy": "2026-07-27T10:47:23.147323Z", + "iopub.status.idle": "2026-07-27T10:47:23.946180Z", + "shell.execute_reply": "2026-07-27T10:47:23.945675Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[11_nanobind] N=4194304 steps=47 | warm 92.1 ms | max_diff 1.11e-15 < tol 1e-12 | PASS\n", + "NumPy baseline 4808.3 ms -> 52.21x\n" + ] + } + ], + "source": [ + "# Buffers are allocated once, so the timed region is the step loop.\n", + "_IC = swe_core.bump_ic(N, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + "_STATE = (np.empty_like(_IC[0]), np.empty_like(_IC[1]))\n", + "_BUF = (np.empty_like(_IC[0]), np.empty_like(_IC[1]),\n", + " np.empty(N + 1), np.empty(N + 1)) # one flux slot per interface\n", + "\n", + "def run_nanobind() -> tuple[np.ndarray, np.ndarray]:\n", + " h, hu = _STATE\n", + " np.copyto(h, _IC[0])\n", + " np.copyto(hu, _IC[1])\n", + " h2, hu2, Fh, Fhu = _BUF\n", + " for _ in range(N_STEPS):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " cpp_swe.cpp_step(h, hu, h2, hu2, Fh, Fhu, dx, DT, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " return h, hu\n", + "\n", + "# No cold/warm split here: compile cost was paid at build time and the dlopen\n", + "# at import, so every function call runs warm.\n", + "h_nb, _ = run_nanobind()\n", + "\n", + "# Check now, because the timed runs below overwrite this buffer.\n", + "diff = swe_core.max_diff(h_ref, h_nb)\n", + "\n", + "warm = swe_core.timed_run(run_nanobind, warmup=2, repeats=5, label='11_nanobind')\n", + "swe_core.report_and_verify(warm, diff, tol=1e-12, n=N, steps=N_STEPS)\n", + "\n", + "npy_ms = 1e3 * next(r['median_s'] for r in swe_core.load_timings() if r['tool'] == 'numpy')\n", + "print(f\"NumPy baseline {npy_ms:.1f} ms -> {npy_ms / (1e3 * warm['median_s']):.2f}x\")\n", + "\n", + "swe_core.save_timing(\n", + " warm, grid_str=f'N={N}', tool='nanobind', hardware='cpu',\n", + " dtype='float64', steps=N_STEPS,\n", + " max_diff_vs_numpy=diff, binding='nanobind',\n", + " flags='-O3',\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "97b5f1bb", + "metadata": {}, + "source": [ + "### 5. nanobind vs pybind11\n", + "\n", + "pybind11 is nanobind's predecessor and remains the most widely used C++↔Python binding library (PyTorch, SciPy, and much of the HPC ecosystem). The ergonomics are nearly identical: in pybind11 the array type is `py::array_t` and the entry point is `PYBIND11_MODULE`. nanobind's published benchmarks report up to ~10× lower per-call overhead, ~4× faster compilation, and ~5× smaller binaries ([nanobind](https://github.com/wjakob/nanobind))." + ] + }, + { + "cell_type": "markdown", + "id": "a8b1306d", + "metadata": {}, + "source": [ + "### 6. Limitation: binding and build glue\n", + "\n", + "The trade-off is everything around the kernel:\n", + "\n", + "- **The bindings track the C++ API.** `NB_MODULE`, `m.def`, and `nb::arg` are a separate layer that has to be hand-edited whenever the C++ source evolves. The kernel is also no longer regular C++, it carries nanobind types like `nb::ndarray`.\n", + "- **Build system.** Building the module requires working with CMake and a C++ toolchain.\n", + "- **Recompile-on-edit cycle.** Editing the source leads to recompilation, restarting the kernel and re-importing the module.\n", + "- **FFI boundary cost.** Each call from Python into C++ pays a small fixed overhead for argument conversion. At ~1000 calls per run it stays well under a millisecond, negligible against the kernel time.\n", + "\n", + "With `-O3` the compiler still emits scalar code for this\n", + "loop, yet Sec. 4 lands well ahead of single-threaded NumPy: no Python\n", + "overhead, no temporaries, and both passes are multi-threaded.\n", + "\n", + "**EXTRA CREDIT:** make the kernel even faster by changing only compile flags. The cell\n", + "below rebuilds the same source as module `swe_step_fast`. Edit\n", + "its `target_compile_options` line and re-run. The first build imports cleanly; a loaded C extension cannot be\n", + "reloaded without a kernel restart. Run `swe_core.report_and_verify` to ensure correctness. What is the largest gain you can reach, and which\n", + "flag does the work?\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `target_compile_options(swe_step_fast PRIVATE )`: the line to\n", + " extend. Candidates: `-ffast-math`, `-fno-math-errno`, `-funroll-loops`." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ec-flags", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:23.947431Z", + "iopub.status.busy": "2026-07-27T10:47:23.947319Z", + "iopub.status.idle": "2026-07-27T10:47:35.059808Z", + "shell.execute_reply": "2026-07-27T10:47:35.058845Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "-- Build files have been written to: /accelerated-computing-hub/tutorials/pyhpc/notebooks/build\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[100%] Built target swe_step_fast\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[11_nanobind] N=4194304 steps=47 | warm 89.5 ms | max_diff 1.11e-15 < tol 1e-12 | PASS\n", + "1.0x vs the -O3 build\n" + ] + } + ], + "source": [ + "# EXTRA CREDIT: rebuild with extra flags and measure the gain vs -O3.\n", + "assert 'swe_step_fast' not in sys.modules, 'restart the kernel before rebuilding with new flags'\n", + "\n", + "# Same source, second module name, extra flags.\n", + "(CPP_DIR / 'swe_step_fast.cpp').write_text(\n", + " open(swe_core.SWE_STEP_CPP).read().replace('NB_MODULE(swe_step,', 'NB_MODULE(swe_step_fast,'))\n", + "\n", + "CMAKELISTS = '''\n", + "cmake_minimum_required(VERSION 3.20)\n", + "project(swe_step_fast LANGUAGES CXX)\n", + "set(CMAKE_CXX_STANDARD 17)\n", + "find_package(Python 3.12 REQUIRED COMPONENTS Interpreter Development.Module)\n", + "find_package(nanobind CONFIG REQUIRED)\n", + "find_package(OpenMP REQUIRED)\n", + "nanobind_add_module(swe_step_fast swe_step_fast.cpp)\n", + "# -fno-math-errno alone also vectorises, and stays bit-exact\n", + "target_compile_options(swe_step_fast PRIVATE -O3 -mcpu=native -ffast-math)\n", + "target_link_libraries(swe_step_fast PRIVATE OpenMP::OpenMP_CXX)\n", + "'''\n", + "(CPP_DIR / 'CMakeLists.txt').write_text(CMAKELISTS)\n", + "\n", + "swe_core.run_cmd(['cmake', '-B', 'build', '-S', '.',\n", + " '-DCMAKE_BUILD_TYPE=Release',\n", + " f'-DPython_EXECUTABLE={sys.executable}',\n", + " f'-Dnanobind_DIR={NANOBIND_CMAKE_DIR}'], cwd=CPP_DIR)\n", + "swe_core.run_cmd(['cmake', '--build', 'build', '--config', 'Release', '-j'], cwd=CPP_DIR)\n", + "\n", + "import swe_step_fast as cpp_fast\n", + "\n", + "def run_fast(n_cells=N, n_steps=N_STEPS):\n", + " dx_ = L / n_cells\n", + " dt_ = swe_core.fixed_dt(H0 + AMP, dx_, cfl=CFL, g=G)\n", + " ic_h, ic_hu, h2, hu2, Fh, Fhu = setup_ic(n_cells)\n", + " h, hu = ic_h.copy(), ic_hu.copy()\n", + " for _ in range(n_steps):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " cpp_fast.cpp_step(h, hu, h2, hu2, Fh, Fhu, dx_, dt_, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " return h, hu\n", + "\n", + "h_fast, _ = run_fast()\n", + "warm_fast = swe_core.timed_run(run_fast, warmup=2, repeats=5, label='11_nanobind')\n", + "diff_fast = swe_core.max_diff(h_ref, h_fast)\n", + "swe_core.report_and_verify(warm_fast, diff_fast, tol=1e-12, n=N, steps=N_STEPS)\n", + "print(f\"{warm['median_s'] / warm_fast['median_s']:.1f}x vs the -O3 build\")\n", + "\n", + "# The synthesis notebook compares each tool at its best: replace the row.\n", + "if diff_fast < 1e-12:\n", + " swe_core.save_timing(\n", + " warm_fast, grid_str=f'N={N}', tool='nanobind', hardware='cpu',\n", + " dtype='float64', steps=N_STEPS, max_diff_vs_numpy=diff_fast,\n", + " binding='nanobind', flags='-O3 -mcpu=native -ffast-math')\n", + " swe_core.save_sweep('11_nanobind', run_fast)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "sweep-for-synthesis", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:35.062147Z", + "iopub.status.busy": "2026-07-27T10:47:35.062004Z", + "iopub.status.idle": "2026-07-27T10:47:43.903144Z", + "shell.execute_reply": "2026-07-27T10:47:43.902240Z" + } + }, + "outputs": [], + "source": [ + "# Rates across the shared size range, for the synthesis notebook (14).\n", + "# Pick up faster implementation if EXTRA CREDIT was done.\n", + "_mod = cpp_fast if 'cpp_fast' in globals() else cpp_swe\n", + "\n", + "def run_nanobind_at(n_cells, n_steps):\n", + " dx_ = L / n_cells\n", + " dt_ = swe_core.fixed_dt(H0 + AMP, dx_, cfl=CFL, g=G)\n", + " ic_h, ic_hu, h2, hu2, Fh, Fhu = setup_ic(n_cells)\n", + " h, hu = ic_h.copy(), ic_hu.copy()\n", + " for _ in range(n_steps):\n", + " swe_core.apply_bc_reflective(h, hu)\n", + " _mod.cpp_step(h, hu, h2, hu2, Fh, Fhu, dx_, dt_, G)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " return h\n", + "\n", + "swe_core.save_sweep('11_nanobind', run_nanobind_at)" + ] + }, + { + "cell_type": "markdown", + "id": "recap-07", + "metadata": {}, + "source": [ + "---\n", + "\n", + "**Recap.**\n", + "\n", + "- A short `CMakeLists.txt` + `nb::ndarray<...>` exposes a C++ kernel to Python with zero-copy NumPy arrays.\n", + "- The cost is the glue around the kernel: a bindings layer to maintain as the C++ evolves, plus build and FFI overhead.\n", + "- The kernel computes each face once, as `step_numpy` does, with both passes under `#pragma omp parallel for`. `-O3 -mcpu=native` is still scalar yet well ahead of NumPy; one more flag vectorises it further (EXTRA CREDIT).\n", + "\n", + "Next: `12__swe__cppjit__cub.ipynb` uses JIT-compiled C++/CUDA, with no CMake, recompilation, or separate shared library. It provides automatic Python bindings and runs the whole solve on the GPU using the CUB library." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/12__swe__cppjit__cub__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/12__swe__cppjit__cub__SOLUTION.ipynb new file mode 100644 index 00000000..82e3ef49 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/12__swe__cppjit__cub__SOLUTION.ipynb @@ -0,0 +1,929 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "nb05-title", + "metadata": {}, + "source": [ + "## SWE - CppJIT - CUB - SOLUTION\n", + "\n", + "NumPy, JAX, PyOMP, and nanobind each rewrote the same step in a different way,\n", + "but nanobind required a separate build: edit the C++, run CMake, restart the\n", + "kernel, re-import. CppJIT removes that round-trip: hand it C++ or CUDA source\n", + "with `cppjit.cppdef`, and the declared functions are callable from Python\n", + "straight away. NumPy arrays can be passed as pointers with no wrapper code.\n", + "In this notebook we write the step as a CUB kernel and run the whole solve on the GPU.\n", + "\n", + "**SOLUTION**\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Imports and activation](#sec1)\n", + "2. [Solve on the GPU with CUB in C++](#sec2)\n", + "3. [Acceptance and timing](#sec3)\n", + "4. [CuPy baseline](#sec4)\n", + "5. [Fusing the CuPy step](#sec5)\n", + "6. [CUDA C++ kernel](#sec6)\n", + "7. [Scaling: fused vs drop-in vs CPU](#sec7)\n", + "8. [Limitations and recap](#sec8)\n", + "\n", + "### 1. Imports and activation\n", + "\n", + "`import cppjit` starts a live CUDA-enabled clang-repl interpreter, so the kernels below run on the GPU.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `cppjit.cppdef(src)`: compile a block of C++ or CUDA source and link it into\n", + " the session. The functions it declares become callable from Python automatically through proxies.\n", + "- `cppjit.gbl.(...)`: call a declared C++ function. A contiguous `float64`\n", + " array can be passed through as a `double*`, zero-copy.\n", + "- `cppjit.CUDA_ENABLED`: returns `True` if the C++ interpreter was created with CUDA support.\n", + "\n", + "---\n", + "\n", + "**Note.** CppJIT is the successor project to the [cppyy](https://cppyy.readthedocs.io/en/latest/) Python/C++ bindings generator that originated from the field of high-energy physics.\n", + "Based on the LLVM compiler infrastructure and the [CppInterOp](https://github.com/compiler-research/CppInterOp) library, CppJIT enables automatic interoperability paradigms and advanced features such as GPU and C++23 support.\n", + "This tutorial image bootstraps an alpha version of this package, built from source. A beta release on PyPI is planned for late August 2026." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "nb05-imports", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:46.509617Z", + "iopub.status.busy": "2026-07-27T10:47:46.509522Z", + "iopub.status.idle": "2026-07-27T10:47:54.505448Z", + "shell.execute_reply": "2026-07-27T10:47:54.504902Z" + } + }, + "outputs": [], + "source": [ + "import time\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import swe_core\n", + "import cppjit\n", + "\n", + "assert cppjit.CUDA_ENABLED, \\\n", + " 'This notebook needs the CUDA-enabled tutorial image.'\n", + "\n", + "# Shared problem parameters.\n", + "N, N_STEPS = swe_core.canonical_size()\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "\n", + "# Float64 NumPy reference for validation.\n", + "h_ref, _ = swe_core.solve_numpy(N, N_STEPS)\n", + "\n", + "# Host-side setup is built once per size, so the timed region is the solve.\n", + "def _setup_ic(n):\n", + " h, hu = swe_core.bump_ic(n, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + " return np.ascontiguousarray(h), np.ascontiguousarray(hu)\n", + "\n", + "setup_ic = swe_core.by_size(_setup_ic)\n" + ] + }, + { + "cell_type": "markdown", + "id": "nb05-sec2", + "metadata": {}, + "source": [ + "### 2. Solve on the GPU with CUB in C++\n", + "\n", + "Let's solve the same Rusanov step with the CUB library in C++. CUB is a library of architecture-tuned CUDA C++ building blocks: warp-, block-,\n", + "and device-level algorithm primitives, shipped with the CUDA Toolkit as part\n", + "of CCCL.\n", + "The kernel lives in `swe_cub_solver.cpp`. `cppjit.cppdef` compiles it into the session.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `cub::DeviceFor::Bulk(n, op)`: run `op` on the GPU for each index in\n", + " `0..n-1`, so we walk the cells without building an index array.\n", + "- `[=] __device__ (long i) { ... }`: a CUDA lambda. It captures the field\n", + " pointers by value and runs on the device.\n", + "- `cudaMalloc` / `cudaMemcpy`: explicit device allocation and host-device\n", + " copies." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "nb05-gpu", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:54.507752Z", + "iopub.status.busy": "2026-07-27T10:47:54.507559Z", + "iopub.status.idle": "2026-07-27T10:47:57.203061Z", + "shell.execute_reply": "2026-07-27T10:47:57.202460Z" + } + }, + "outputs": [], + "source": [ + "cppjit.cppdef(open(swe_core.SWE_CUB_CPP).read())\n", + "\n", + "def run_swe_gpu(n_cells=N, n_steps=N_STEPS):\n", + " \"\"\"Time-step on the GPU. The state stays in device memory.\"\"\"\n", + " cell_dx = L / n_cells\n", + " step_dt = swe_core.fixed_dt(H0 + AMP, cell_dx, cfl=CFL, g=G)\n", + " h, hu = setup_ic(n_cells)\n", + " cppjit.gbl.gpu_swe_init(h, hu, h.shape[0]) # no-op once resident\n", + " cppjit.gbl.gpu_swe_steps(cell_dx, step_dt, G, n_steps)\n", + "\n", + "\n", + "def fetch_swe_gpu(n_cells=N):\n", + " \"\"\"Final (h, hu) of the last solve, copied to the host.\"\"\"\n", + " h_out, hu_out = np.empty(n_cells + 2), np.empty(n_cells + 2)\n", + " cppjit.gbl.gpu_swe_fetch(h_out, hu_out)\n", + " return h_out, hu_out" + ] + }, + { + "cell_type": "markdown", + "id": "nb05-sec3", + "metadata": {}, + "source": [ + "### 3. Acceptance and timing\n", + "\n", + "We run the full solve, check it against the float64 NumPy reference, then\n", + "report a time. Cold call cost: clang-repl emits PTX, and the CUDA driver compiles PTX to SASS for this GPU at first launch. Both the cold call and the warm median are saved to `timings.json`." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "nb05-timing", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:57.204635Z", + "iopub.status.busy": "2026-07-27T10:47:57.204522Z", + "iopub.status.idle": "2026-07-27T10:47:57.494345Z", + "shell.execute_reply": "2026-07-27T10:47:57.493853Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[12_cppjit_gpu_cub] N=4194304 steps=47 | cold 212.2 ms (incl. JIT + first PTX->SASS) | warm 5.5 ms | max_diff 1.11e-15 < tol 1e-12 | PASS\n" + ] + } + ], + "source": [ + "t0 = time.perf_counter()\n", + "run_swe_gpu()\n", + "cold_s = time.perf_counter() - t0\n", + "h_gpu, _ = fetch_swe_gpu()\n", + "\n", + "warm = swe_core.timed_run(run_swe_gpu, warmup=2, repeats=5, label='12_cppjit_gpu_cub')\n", + "diff = swe_core.max_diff(h_ref, h_gpu)\n", + "swe_core.report_and_verify(warm, diff, tol=1e-12, cold_s=cold_s,\n", + " n=N, steps=N_STEPS,\n", + " cold_note='incl. JIT + first PTX->SASS')\n", + "\n", + "swe_core.save_timing(\n", + " warm, grid_str=f'N={N}', tool='cppjit_gpu_cub', hardware='gpu',\n", + " dtype='float64', steps=N_STEPS, cold_s=cold_s, max_diff_vs_numpy=diff)" + ] + }, + { + "cell_type": "markdown", + "id": "sec4-cupy-md", + "metadata": {}, + "source": [ + "### 4. CuPy baseline\n", + "\n", + "Since we are now running on GPU, let's use CuPy as a baseline. It runs the same vectorised step as `swe_core.step_numpy`, but on the device, as a drop-in replacement (`cp.op` for `np.op`).\n", + "\n", + "**Q:** the warm time below lands far behind the CUB solve, on the same GPU\n", + "with the same arithmetic. Where does the time go? Count the kernel launches\n", + "per step with `nsys` below." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "sec4-cupy-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:57.495753Z", + "iopub.status.busy": "2026-07-27T10:47:57.495638Z", + "iopub.status.idle": "2026-07-27T10:47:58.268975Z", + "shell.execute_reply": "2026-07-27T10:47:58.268445Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[12_cupy] N=4194304 steps=47 | cold 87.6 ms (incl. NVRTC kernel compiles) | warm 54.1 ms | max_diff 0.00e+00 < tol 1e-12 | PASS\n" + ] + } + ], + "source": [ + "import cupy as cp\n", + "\n", + "def step_cupy(h, hu, cell_dx, step_dt, g=G, tol=swe_core.DRY_TOL):\n", + " \"\"\"swe_core.step_numpy, slice for slice, on the device.\"\"\"\n", + " hL, hR = h[:-1], h[1:]\n", + " huL, huR = hu[:-1], hu[1:]\n", + " h_safe_L = cp.maximum(hL, tol)\n", + " h_safe_R = cp.maximum(hR, tol)\n", + " uL, uR = huL / h_safe_L, huR / h_safe_R\n", + " cL, cR = cp.sqrt(g * h_safe_L), cp.sqrt(g * h_safe_R)\n", + " a = cp.maximum(cp.abs(uL) + cL, cp.abs(uR) + cR)\n", + " F_h = 0.5 * (huL + huR) - 0.5 * a * (hR - hL)\n", + " F_hu = 0.5 * (huL*uL + 0.5*g*hL*hL\n", + " + huR*uR + 0.5*g*hR*hR) - 0.5 * a * (huR - huL)\n", + " h_new, hu_new = h.copy(), hu.copy()\n", + " h_new[1:-1] = h[1:-1] - (step_dt / cell_dx) * (F_h[1:] - F_h[:-1])\n", + " hu_new[1:-1] = hu[1:-1] - (step_dt / cell_dx) * (F_hu[1:] - F_hu[:-1])\n", + " return h_new, hu_new\n", + "\n", + "setup_ic_dev = swe_core.by_size(lambda n: tuple(cp.asarray(a) for a in setup_ic(n)))\n", + "\n", + "\n", + "def run_swe_cupy(n_cells=N, n_steps=N_STEPS):\n", + " \"\"\"Full solve with CuPy. The state is already on the device.\"\"\"\n", + " cell_dx = L / n_cells\n", + " step_dt = swe_core.fixed_dt(H0 + AMP, cell_dx, cfl=CFL, g=G)\n", + " h0, hu0 = setup_ic_dev(n_cells)\n", + " h, hu = h0.copy(), hu0.copy()\n", + " for _ in range(n_steps):\n", + " h[0] = h[1]; h[-1] = h[-2]\n", + " hu[0] = -hu[1]; hu[-1] = -hu[-2]\n", + " h, hu = step_cupy(h, hu, cell_dx, step_dt)\n", + " cp.cuda.Device().synchronize()\n", + " return h, hu\n", + "\n", + "t0 = time.perf_counter()\n", + "h_cupy, _ = run_swe_cupy()\n", + "cold_cupy_s = time.perf_counter() - t0\n", + "\n", + "warm_cupy = swe_core.timed_run(run_swe_cupy, warmup=2, repeats=5, label='12_cupy')\n", + "diff_cupy = swe_core.max_diff(h_ref, cp.asnumpy(h_cupy))\n", + "swe_core.report_and_verify(warm_cupy, diff_cupy, tol=1e-12, cold_s=cold_cupy_s,\n", + " n=N, steps=N_STEPS,\n", + " cold_note='incl. NVRTC kernel compiles')\n", + "\n", + "swe_core.save_timing(\n", + " warm_cupy, grid_str=f'N={N}', tool='cupy', hardware='gpu',\n", + " dtype='float64', steps=N_STEPS, cold_s=cold_cupy_s,\n", + " max_diff_vs_numpy=diff_cupy)\n" + ] + }, + { + "cell_type": "markdown", + "id": "nvrtc-cache-note", + "metadata": {}, + "source": [ + "**Note.** The cold call doesn't actually compile anything. NVRTC builds CuPy's\n", + "own operator kernels (such as `cupy_sqrt__float64...`), and the cubins land\n", + "in an on-disk cache (`CUPY_CACHE_DIR`) that persists across sessions, so a\n", + "warm cache hides the compile cost. To record it, point `CUPY_CACHE_DIR` at an\n", + "empty directory before the `cupy` import and re-run." + ] + }, + { + "cell_type": "markdown", + "id": "ec-nsys-md", + "metadata": {}, + "source": [ + "**Count the kernel launches.** This tutorial image ships Nsight Systems. The cell\n", + "below profiles a short solve of each GPU path and sums kernel instances\n", + "per step.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `nsys profile -o python script.py`: record a timeline;\n", + " `--python-sampling=true` adds interpreter backtraces.\n", + "- `nsys stats --report cuda_gpu_kern_sum .nsys-rep`: kernel\n", + " instance counts per name." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "nsys-writefile", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:58.270291Z", + "iopub.status.busy": "2026-07-27T10:47:58.270107Z", + "iopub.status.idle": "2026-07-27T10:47:58.275131Z", + "shell.execute_reply": "2026-07-27T10:47:58.274762Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing swe_launch_probe.py\n" + ] + } + ], + "source": [ + "%%writefile swe_launch_probe.py\n", + "import sys\n", + "import time\n", + "\n", + "import cupy as cp\n", + "import swe_core\n", + "\n", + "mode, N, steps = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])\n", + "L = 10.0; dx = L / N; dt = swe_core.fixed_dt(1.1, dx)\n", + "h, hu = swe_core.bump_ic(N, L=L)\n", + "\n", + "if mode in ('cub', 'raw'):\n", + " import cppjit\n", + " assert cppjit.CUDA_ENABLED\n", + " cppjit.cppdef(open(swe_core.SWE_CUB_CPP if mode == 'cub'\n", + " else swe_core.SWE_RAW_CPP).read())\n", + " tag = '' if mode == 'cub' else '_raw'\n", + " getattr(cppjit.gbl, 'gpu_swe_init' + tag)(h, hu, h.shape[0])\n", + " step = getattr(cppjit.gbl, 'gpu_swe_steps' + tag)\n", + " solve = lambda: step(dx, dt, 9.81, steps)\n", + "elif mode == 'jax':\n", + " import jax\n", + " import jax.numpy as jnp\n", + " from swe_jax_step import solve64\n", + " st = tuple(jnp.asarray(a) for a in (h, hu))\n", + "\n", + " def solve():\n", + " jax.block_until_ready(solve64(st, dx, dt, steps))\n", + "elif mode == 'fused':\n", + " import swe_fused_kernel as k\n", + " step = cp.ElementwiseKernel(\n", + " 'raw float64 h, raw float64 hu, int64 n, float64 inv, float64 g',\n", + " 'raw float64 h_new, raw float64 hu_new',\n", + " k.BODY, 'swe_step_fused', preamble=k.PREAMBLE)\n", + " a0, b0 = cp.asarray(h), cp.asarray(hu)\n", + " a, b = cp.empty_like(a0), cp.empty_like(b0)\n", + " c, d = cp.empty_like(a0), cp.empty_like(b0)\n", + " inv = dt / dx\n", + "\n", + " def solve():\n", + " p, q, r, s = a, b, c, d\n", + " cp.copyto(p, a0); cp.copyto(q, b0)\n", + " for _ in range(steps):\n", + " step(p, q, N, inv, 9.81, r, s, size=N)\n", + " p, q, r, s = r, s, p, q\n", + " cp.cuda.Device().synchronize()\n", + "else:\n", + " gh0, ghu0 = cp.asarray(h), cp.asarray(hu)\n", + "\n", + " def solve():\n", + " gh, ghu = gh0.copy(), ghu0.copy()\n", + " for _ in range(steps):\n", + " gh[0] = gh[1]; gh[-1] = gh[-2]; ghu[0] = -ghu[1]; ghu[-1] = -ghu[-2]\n", + " hL, hR = gh[:-1], gh[1:]; huL, huR = ghu[:-1], ghu[1:]\n", + " hsL = cp.maximum(hL, 1e-6); hsR = cp.maximum(hR, 1e-6)\n", + " uL, uR = huL / hsL, huR / hsR\n", + " cL, cR = cp.sqrt(9.81 * hsL), cp.sqrt(9.81 * hsR)\n", + " a = cp.maximum(cp.abs(uL) + cL, cp.abs(uR) + cR)\n", + " F_h = 0.5 * (huL + huR) - 0.5 * a * (hR - hL)\n", + " F_hu = (0.5 * (huL*uL + 0.5*9.81*hL*hL + huR*uR + 0.5*9.81*hR*hR)\n", + " - 0.5 * a * (huR - huL))\n", + " gh2, ghu2 = gh.copy(), ghu.copy()\n", + " gh2[1:-1] = gh[1:-1] - (dt/dx) * (F_h[1:] - F_h[:-1])\n", + " ghu2[1:-1] = ghu[1:-1] - (dt/dx) * (F_hu[1:] - F_hu[:-1])\n", + " gh, ghu = gh2, ghu2\n", + " cp.cuda.Device().synchronize()\n", + "\n", + "# One warm call pays the compile and the allocation, then profile the next one.\n", + "solve()\n", + "cp.cuda.profiler.start()\n", + "t0 = time.perf_counter()\n", + "solve()\n", + "cp.cuda.Device().synchronize()\n", + "print(f'WALL_S {time.perf_counter() - t0:.6f}')\n", + "cp.cuda.profiler.stop()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "nsys-profile", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:47:58.276048Z", + "iopub.status.busy": "2026-07-27T10:47:58.275953Z", + "iopub.status.idle": "2026-07-27T10:48:20.034451Z", + "shell.execute_reply": "2026-07-27T10:48:20.033942Z" + } + }, + "outputs": [], + "source": [ + "PROF_STEPS = 20\n", + "MODES = ('cub', 'raw', 'cupy')\n", + "STAGE = {'cub': '12_cppjit_gpu_cub', 'raw': '12_cppjit_gpu_raw',\n", + " 'cupy': '12_cupy', 'fused': '12_cupy_fused',\n", + " 'jax': '09_jax_fp64'}\n", + "walls = {}\n", + "\n", + "# Node tracing records kernels inside CUDA graphs, which XLA uses.\n", + "for mode in MODES:\n", + " out = !nsys profile -c cudaProfilerApi --capture-range-end=stop --cuda-graph-trace=node --force-overwrite true -o launch_{mode} python swe_launch_probe.py {mode} {N} {PROF_STEPS}\n", + " walls[mode] = float(next(l for l in out if 'WALL_S' in l).split()[1])\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "nsys-count", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:48:20.036295Z", + "iopub.status.busy": "2026-07-27T10:48:20.036186Z", + "iopub.status.idle": "2026-07-27T10:48:20.758777Z", + "shell.execute_reply": "2026-07-27T10:48:20.758369Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " cub: 40 kernel launches over 20 steps = 2.0 kernels/step\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " raw: 40 kernel launches over 20 steps = 2.0 kernels/step\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " cupy: 862 kernel launches over 20 steps = 43.1 kernels/step\n" + ] + } + ], + "source": [ + "for mode in MODES:\n", + " rows = !nsys stats --force-export=true --report cuda_gpu_kern_sum launch_{mode}.nsys-rep\n", + " kern = [ln for ln in rows if ln.strip()[:1].isdigit()]\n", + " total = sum(int(ln.split()[2]) for ln in kern)\n", + " if total == 0:\n", + " raise RuntimeError(f'no kernels recorded for {mode}; re-run the profile cell')\n", + " print(f' {mode:>6}: {total:>4} kernel launches over {PROF_STEPS} steps '\n", + " f'= {total / PROF_STEPS:5.1f} kernels/step')\n" + ] + }, + { + "cell_type": "markdown", + "id": "sec5-fused-md", + "metadata": {}, + "source": [ + "### 5. Fusing the CuPy step\n", + "\n", + "`cupy.ElementwiseKernel` fuses the whole step into one CUDA C kernel,\n", + "one thread per cell. Each thread derives its ghost values, computes\n", + "its two face fluxes, and writes its update. This gives us one launch\n", + "per step, with no Python round-trip. Since neighbouring cells share faces,\n", + "every interior flux is computed twice. Storing them instead would require a\n", + "second pass and launch, as in the earlier CUB case.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `cp.ElementwiseKernel(in_params, out_params, operation, name, preamble=...)`:\n", + " compiles a CUDA C elementwise kernel on first call (NVRTC). `raw float64 x`\n", + " passes an array with explicit indexing (`x[j]`) instead of an implicit\n", + " per-element view; `size=` sets the launch size.\n", + "- `preamble`: pass CUDA C `__device__` helpers." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "sec5-fused-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:48:20.760220Z", + "iopub.status.busy": "2026-07-27T10:48:20.760115Z", + "iopub.status.idle": "2026-07-27T10:48:20.894268Z", + "shell.execute_reply": "2026-07-27T10:48:20.893850Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[12_cupy_fused] N=4194304 steps=47 | cold 91.0 ms (incl. NVRTC compile of fused kernel) | warm 3.5 ms | max_diff 1.11e-15 < tol 1e-12 | PASS\n" + ] + } + ], + "source": [ + "_RUSANOV_DEV = r'''\n", + "__device__ void rusanov_face(double hL, double hR, double huL, double huR,\n", + " double g, double& Fh, double& Fhu) {\n", + " const double DRY = 1e-6;\n", + " const double hL_s = hL > DRY ? hL : DRY;\n", + " const double hR_s = hR > DRY ? hR : DRY;\n", + " const double uL = huL / hL_s, uR = huR / hR_s;\n", + " const double cL = sqrt(g * hL_s), cR = sqrt(g * hR_s);\n", + " const double a = fmax(fabs(uL) + cL, fabs(uR) + cR);\n", + " Fh = 0.5 * (huL + huR) - 0.5 * a * (hR - hL);\n", + " Fhu = 0.5 * (huL * uL + 0.5 * g * hL * hL + huR * uR + 0.5 * g * hR * hR)\n", + " - 0.5 * a * (huR - huL);\n", + "}\n", + "'''\n", + "\n", + "_FUSED_BODY = r'''\n", + " const long j = i + 1; // interior cell\n", + " const double hW = (j == 1) ? h[1] : h[j - 1]; // reflective ghosts,\n", + " const double huW = (j == 1) ? -hu[1] : hu[j - 1]; // derived in-kernel\n", + " const double hE = (j == n) ? h[n] : h[j + 1];\n", + " const double huE = (j == n) ? -hu[n] : hu[j + 1];\n", + " double Fw_h, Fw_hu, Fe_h, Fe_hu;\n", + " rusanov_face(hW, h[j], huW, hu[j], g, Fw_h, Fw_hu);\n", + " rusanov_face(h[j], hE, hu[j], huE, g, Fe_h, Fe_hu);\n", + " h_new[j] = h[j] - inv * (Fe_h - Fw_h);\n", + " hu_new[j] = hu[j] - inv * (Fe_hu - Fw_hu);\n", + " if (j == 1) { h_new[0] = h[1]; hu_new[0] = -hu[1]; }\n", + " if (j == n) { h_new[n + 1] = h[n]; hu_new[n + 1] = -hu[n]; }\n", + "'''\n", + "\n", + "step_cupy_fused = cp.ElementwiseKernel(\n", + " 'raw float64 h, raw float64 hu, int64 n, float64 inv, float64 g',\n", + " 'raw float64 h_new, raw float64 hu_new',\n", + " _FUSED_BODY, 'swe_step_fused', preamble=_RUSANOV_DEV)\n", + "\n", + "# The profiler runs this kernel in its own process, so write the two sources\n", + "# out rather than keeping a second copy of them.\n", + "open('swe_fused_kernel.py', 'w').write(\n", + " f\"PREAMBLE = r'''{_RUSANOV_DEV}'''\\nBODY = r'''{_FUSED_BODY}'''\\n\")\n", + "\n", + "# Device buffers are allocated once and reset to the initial condition per call.\n", + "def _setup_ic_fused(n):\n", + " h0, hu0 = setup_ic_dev(n)\n", + " return h0, hu0, *(cp.empty_like(h0) for _ in range(4))\n", + "\n", + "setup_ic_fused = swe_core.by_size(_setup_ic_fused)\n", + "\n", + "\n", + "def run_swe_cupy_fused(n_cells=N, n_steps=N_STEPS):\n", + " \"\"\"Full solve with hand-fused CuPy kernel: one launch per step.\"\"\"\n", + " cell_dx = L / n_cells\n", + " step_dt = swe_core.fixed_dt(H0 + AMP, cell_dx, cfl=CFL, g=G)\n", + " h0, hu0, h, hu, h2, hu2 = setup_ic_fused(n_cells)\n", + " cp.copyto(h, h0)\n", + " cp.copyto(hu, hu0)\n", + " inv = step_dt / cell_dx\n", + " for _ in range(n_steps):\n", + " step_cupy_fused(h, hu, n_cells, inv, G, h2, hu2, size=n_cells)\n", + " h, hu, h2, hu2 = h2, hu2, h, hu\n", + " cp.cuda.Device().synchronize()\n", + " return h, hu\n", + "\n", + "t0 = time.perf_counter()\n", + "h_fused, _ = run_swe_cupy_fused()\n", + "cold_fused_s = time.perf_counter() - t0\n", + "\n", + "warm_fused = swe_core.timed_run(run_swe_cupy_fused, warmup=2, repeats=5,\n", + " label='12_cupy_fused')\n", + "diff_fused = swe_core.max_diff(h_ref, cp.asnumpy(h_fused))\n", + "swe_core.report_and_verify(warm_fused, diff_fused, tol=1e-12, cold_s=cold_fused_s,\n", + " n=N, steps=N_STEPS,\n", + " cold_note='incl. NVRTC compile of fused kernel')\n", + "\n", + "swe_core.save_timing(\n", + " warm_fused, grid_str=f'N={N}', tool='cupy_fused', hardware='gpu',\n", + " dtype='float64', steps=N_STEPS, cold_s=cold_fused_s,\n", + " max_diff_vs_numpy=diff_fused)\n" + ] + }, + { + "cell_type": "markdown", + "id": "sec6-raw-md", + "metadata": {}, + "source": [ + "### 6. CUDA C++ kernel\n", + "\n", + "CppJIT is not tied to CUB: the same session can declare a `__global__`\n", + "kernel and launch it directly. `swe_raw_cuda_solver.cpp` rewrites our GPU solver in CUDA C++.\n", + "\n", + "The raw launch comes with trade-offs: you have to size the launch yourself, write the\n", + "index guard, and give up CUB's algorithm libraries, autotuning and abstractions.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `__global__ void kernel(...)`: a CUDA C++ kernel; each thread computes its index\n", + " from `blockIdx`/`blockDim`/`threadIdx` and guards the range.\n", + "- `kernel<<>>(args)`: kernel launch syntax. cppjit\n", + " compiles and registers the kernel live (same clang-repl session as CUB).\n", + "- Launch shape: 256-thread blocks, `grid = ceil(N / 256)`, try tuning if time permits." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "sec6-raw-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:48:20.895621Z", + "iopub.status.busy": "2026-07-27T10:48:20.895516Z", + "iopub.status.idle": "2026-07-27T10:48:21.092480Z", + "shell.execute_reply": "2026-07-27T10:48:21.091969Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[12_cppjit_gpu_raw] N=4194304 steps=47 | cold 50.9 ms (incl. JIT + first PTX->SASS compilation) | warm 5.3 ms | max_diff 1.11e-15 < tol 1e-12 | PASS\n" + ] + } + ], + "source": [ + "# Load the CUDA C++ kernel into our Python session.\n", + "cppjit.cppdef(open(swe_core.SWE_RAW_CPP).read())\n", + "\n", + "def run_swe_gpu_raw(n_cells=N, n_steps=N_STEPS):\n", + " \"\"\"Time-step with CUDA C++, two launches per step.\"\"\"\n", + " cell_dx = L / n_cells\n", + " step_dt = swe_core.fixed_dt(H0 + AMP, cell_dx, cfl=CFL, g=G)\n", + " h, hu = setup_ic(n_cells)\n", + " cppjit.gbl.gpu_swe_init_raw(h, hu, h.shape[0])\n", + " cppjit.gbl.gpu_swe_steps_raw(cell_dx, step_dt, G, n_steps)\n", + "\n", + "\n", + "def fetch_swe_gpu_raw(n_cells=N):\n", + " \"\"\"Final (h, hu) of the last raw solve.\"\"\"\n", + " h_out, hu_out = np.empty(n_cells + 2), np.empty(n_cells + 2)\n", + " cppjit.gbl.gpu_swe_fetch_raw(h_out, hu_out)\n", + " return h_out, hu_out\n", + "\n", + "t0 = time.perf_counter()\n", + "run_swe_gpu_raw()\n", + "cold_raw_s = time.perf_counter() - t0\n", + "h_raw, _ = fetch_swe_gpu_raw()\n", + "\n", + "warm_raw = swe_core.timed_run(run_swe_gpu_raw, warmup=2, repeats=5,\n", + " label='12_cppjit_gpu_raw')\n", + "diff_raw = swe_core.max_diff(h_ref, h_raw)\n", + "swe_core.report_and_verify(warm_raw, diff_raw, tol=1e-12, cold_s=cold_raw_s,\n", + " n=N, steps=N_STEPS,\n", + " cold_note='incl. JIT + first PTX->SASS compilation')\n", + "\n", + "swe_core.save_timing(\n", + " warm_raw, grid_str=f'N={N}', tool='cppjit_gpu_raw', hardware='gpu',\n", + " dtype='float64', steps=N_STEPS, cold_s=cold_raw_s,\n", + " max_diff_vs_numpy=diff_raw)\n" + ] + }, + { + "cell_type": "markdown", + "id": "nb05-sec4", + "metadata": {}, + "source": [ + "### 7. Scaling: fused vs drop-in vs CPU\n", + "\n", + "The sweep below times all five paths across grid sizes and plots throughput." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "nb05-sweep", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:48:21.093579Z", + "iopub.status.busy": "2026-07-27T10:48:21.093464Z", + "iopub.status.idle": "2026-07-27T10:49:54.957318Z", + "shell.execute_reply": "2026-07-27T10:49:54.956835Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "N= 262,144 steps= 762 CppJIT raw 28936 CppJIT CUB 28222 CuPy fused 36091 CuPy drop-in 721 NumPy (CPU) 71 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "N= 1,048,576 steps= 190 CppJIT raw 40382 CppJIT CUB 36556 CuPy fused 56120 CuPy drop-in 2910 NumPy (CPU) 63 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "N= 4,194,304 steps= 47 CppJIT raw 37167 CppJIT CUB 35894 CuPy fused 57715 CuPy drop-in 3643 NumPy (CPU) 38 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "N=16,777,216 steps= 20 CppJIT raw 38685 CppJIT CUB 37057 CuPy fused 58225 CuPy drop-in 4088 NumPy (CPU) 31 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "N=67,108,864 steps= 20 CppJIT raw 39068 CppJIT CUB 38291 CuPy fused 56873 CuPy drop-in 4223 Mcells/s\n" + ] + }, + { + "data": { + "image/png": "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kIDTp09bbXV1dU73saKiAq+88gpuu+02HD9+HHPmzDEfGzRoELRardPPLici8mdMyImIgtDx48exefNmpx4dWFpaavfM75UrV2L27NkoLi5GZmYmRo4ciaeffhoAcOrUKbz77rt455138POf/xzp6enIzc3FM888g2nTpvXYXnp6Ol5++WXceuutSE1NRUFBAYYOHYoTJ06Yy0yaNAmnT582rzEnIgpkTMiJiILUCy+84NQnb27evBkjRoxARkaGed+ePXtw//33Y/r06aioqMCuXbswfvx48/F58+bhnXfewauvvop//etf2LJlC3Jzc3H+/Pke22tqakJmZiY++ugjnDx5Ehs2bMD69evxpz/9yVxm9uzZ2Lhxo4tnTETknwQAPX9EGxERBbU1a9YgKioKCxYsULsrGD58OHbt2oWMjAw0NDSo3R0iol7jDDkREfXopZdewrlz5yAIgtpdQVJSEh566CEm40R00+AMORERERGRijhDTkRERESkIibkREREREQqYkJORERERKQiJuRERERERCpiQk5EREREpCIm5EREREREKmJCTkRERESkIibkREREREQqYkJORERERKSi/wN0/3lpmiXsuQAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Sweep the grid size and compare all five paths on throughput.\n", + "rates = swe_core.sweep_table({\n", + " 'CppJIT raw': run_swe_gpu_raw, 'CppJIT CUB': run_swe_gpu,\n", + " 'CuPy fused': run_swe_cupy_fused, 'CuPy drop-in': run_swe_cupy,\n", + " 'NumPy (CPU)': swe_core.solve_numpy})\n", + "\n", + "swe_core.plot_rate_sweep([n for n, _ in swe_core.sweep_points()], rates,\n", + " 'N (cells)', 'Throughput vs grid size (float64)',\n", + " logy=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "sweep-for-synthesis", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:49:54.958716Z", + "iopub.status.busy": "2026-07-27T10:49:54.958598Z", + "iopub.status.idle": "2026-07-27T10:49:58.855821Z", + "shell.execute_reply": "2026-07-27T10:49:58.855317Z" + } + }, + "outputs": [], + "source": [ + "# Rates across the shared size range, for the synthesis notebook (14).\n", + "for _stage, _fn in (('12_cppjit_gpu_cub', run_swe_gpu),\n", + " ('12_cppjit_gpu_raw', run_swe_gpu_raw),\n", + " ('12_cupy', run_swe_cupy),\n", + " ('12_cupy_fused', run_swe_cupy_fused)):\n", + " swe_core.save_sweep(_stage, _fn)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "de294015", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:49:58.857613Z", + "iopub.status.busy": "2026-07-27T10:49:58.857501Z", + "iopub.status.idle": "2026-07-27T10:50:11.801617Z", + "shell.execute_reply": "2026-07-27T10:50:11.801160Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " cub: kernels 91.5% copies 1.7% host 6.8%\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " raw: kernels 91.7% copies 1.7% host 6.6%\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " cupy: kernels 88.9% copies 3.8% host 7.2%\n", + " fused: kernels 87.4% copies 2.6% host 9.9%\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " jax: kernels 40.1% copies 44.0% host 15.9%\n" + ] + } + ], + "source": [ + "# The fused kernel is defined above, so it is profiled here rather than with\n", + "# the others, then every path's wall clock is split the same way.\n", + "for extra in ('fused', 'jax'):\n", + " out = !nsys profile -c cudaProfilerApi --capture-range-end=stop --cuda-graph-trace=node --force-overwrite true -o launch_{extra} python swe_launch_probe.py {extra} {N} {PROF_STEPS}\n", + " walls[extra] = float(next(l for l in out if 'WALL_S' in l).split()[1])\n", + "\n", + "for mode in (*MODES, 'fused', 'jax'):\n", + " bd = swe_core.nsys_breakdown(f'launch_{mode}.nsys-rep', walls[mode])\n", + " swe_core.save_breakdown(STAGE[mode], bd, N, PROF_STEPS)\n", + " print(f\" {mode:>6}: kernels {100 * bd['kernel_s'] / bd['total_s']:4.1f}% \"\n", + " f\"copies {100 * bd['memcpy_s'] / bd['total_s']:4.1f}% \"\n", + " f\"host {100 * bd['host_s'] / bd['total_s']:4.1f}%\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "sec7-takeaways", + "metadata": {}, + "source": [ + "Takeaways:\n", + "\n", + "- CuPy drop-in beats NumPy at every size and still trails the fused kernel.\n", + " Each of its 43 kernels per step re-reads the arrays from memory; the\n", + " fused kernel reads them once.\n", + "- The raw CUDA dispatch and the CUB path run within a few percent of each\n", + " other. Both trail the fused CuPy kernel, which stores no fluxes." + ] + }, + { + "cell_type": "markdown", + "id": "nb05-sec5", + "metadata": {}, + "source": [ + "### 8. Limitations and recap\n", + "\n", + "CppJIT gives us C++ and CUDA as a notebook language: edit a cell, run it,\n", + "call it from Python. The main cost is compilation at first use: `cppdef`\n", + "parses the source when it is declared, and the first call includes JIT\n", + "compilation of both host and device code.\n", + "\n", + "**Recap.**\n", + "\n", + "- `cppjit.cppdef` compiles C++ and CUDA in the running session; `cppjit.gbl`\n", + " allows function dispatch with NumPy arguments as pointers.\n", + "- The same Rusanov step runs on the GPU with CUB, the data kept resident\n", + " across the time loop.\n", + "- The drop-in CuPy path beats NumPy at every size and still trails the fused\n", + " paths. The win over drop-in is memory traffic: dozens of kernels per step\n", + " each re-read the arrays, against one or two that read them once.\n", + "- The fused CuPy kernel is a source fragment compiled inside CuPy's generated\n", + " wrapper; CppJIT compiles standard C++ translation units.\n", + "\n", + "Next: `13__swe__mpi4py.ipynb` distributes the same solve across MPI ranks:\n", + "slab decomposition and halo exchange." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/13__swe__mpi4py__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/13__swe__mpi4py__SOLUTION.ipynb new file mode 100644 index 00000000..334c29dc --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/13__swe__mpi4py__SOLUTION.ipynb @@ -0,0 +1,978 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "43e247f6", + "metadata": {}, + "source": [ + "## SWE - mpi4py - SOLUTION\n", + "\n", + "MPI is a distributed computing model: `mpirun` starts many copies of your\n", + "program as independent processes that share no memory and communicate by\n", + "explicit messages, instead of the single accelerated process we saw with\n", + "JAX, PyOMP, nanobind, and CppJIT. mpi4py brings that model to Python.\n", + "\n", + "Here that means splitting the channel into contiguous **slabs**, one per\n", + "process, each holding its slab plus one **ghost cell** per side; neighbours\n", + "swap edge cells before every step (the **halo exchange**). The kernel is\n", + "untouched: each rank advances its slab with `swe_core.step_numpy`. This\n", + "notebook is about the decomposition and what the communication costs.\n", + "\n", + "**SOLUTION**\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Imports and the launch pattern](#sec1)\n", + "2. [The decomposition probe](#sec2)\n", + "3. [The halo exchange](#sec3)\n", + "4. [Acceptance and timing](#sec4)\n", + "5. [Rank scaling](#sec5)\n", + "6. [Overlapping communication and computation](#sec6)\n", + "\n", + "### 1. Imports and the launch pattern\n", + "\n", + "An MPI program is `size` identical copies of one script started together;\n", + "each copy asks for its **rank** to find its role. The notebook kernel is\n", + "not one of those copies, so every program below is written out with\n", + "`%%writefile` and launched with `run_mpi`.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `MPI.COMM_WORLD`: the communicator spanning every rank of the launch;\n", + " `Get_rank()` / `Get_size()` return this copy's id and the total count.\n", + "- `run_mpi(ranks, script, *args)`: launch under the detected MPI\n", + " implementation. `MPI.get_vendor()` selects the matching launcher, so the\n", + " same cell works against Open MPI and MPICH.\n", + "- `mpi_result(...)`: the same launch, returning the `RESULT` record rank 0\n", + " prints. Timing inside the script keeps launcher and `MPI_Init` startup\n", + " out of the numbers." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "5c59b191", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:50:14.606061Z", + "iopub.status.busy": "2026-07-27T10:50:14.605961Z", + "iopub.status.idle": "2026-07-27T10:50:15.145840Z", + "shell.execute_reply": "2026-07-27T10:50:15.145339Z" + } + }, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "import shutil\n", + "import subprocess\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import mpi4py\n", + "import psutil\n", + "\n", + "import swe_core\n", + "\n", + "# Install Open MPI and mpi4py if running in Google Colab.\n", + "colab_marker = Path(\"/tmp/accelerated-computing-hub-mpi4py-installed\")\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not colab_marker.exists():\n", + " print(\"Installing Open MPI and mpi4py.\")\n", + " subprocess.run([\"apt-get\", \"-qq\", \"update\"], check=True)\n", + " subprocess.run(\n", + " [\"apt-get\", \"-qq\", \"install\", \"-y\", \"openmpi-bin\", \"libopenmpi-dev\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " subprocess.run(\n", + " [sys.executable, \"-m\", \"pip\", \"install\", \"mpi4py\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " colab_marker.touch()\n", + " print(\"Open MPI and mpi4py installed.\")\n", + "\n", + "# Open MPI protects against accidental root launches. Colab and the tutorial\n", + "# container are isolated environments where explicitly allowing this is safe.\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT\", \"1\")\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT_CONFIRM\", \"1\")\n", + "\n", + "# Match the launcher to the MPI library against which mpi4py was built.\n", + "vendor_result = subprocess.run(\n", + " [\n", + " sys.executable,\n", + " \"-c\",\n", + " \"from mpi4py import MPI; print(MPI.get_vendor()[0])\",\n", + " ],\n", + " check=True,\n", + " capture_output=True,\n", + " text=True,\n", + ")\n", + "MPI_VENDOR = vendor_result.stdout.strip()\n", + "\n", + "if MPI_VENDOR == \"MPICH\":\n", + " mpi_launcher = (\n", + " shutil.which(\"mpirun.mpich\")\n", + " or shutil.which(\"mpiexec.mpich\")\n", + " or shutil.which(\"mpiexec\")\n", + " )\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No MPICH launcher was found\")\n", + " # Do not delegate this nested launch back to Slurm on CSCS.\n", + " MPI_LAUNCHER = [mpi_launcher, \"-launcher\", \"fork\"]\n", + "elif MPI_VENDOR == \"Open MPI\":\n", + " mpi_launcher = shutil.which(\"mpirun.openmpi\") or shutil.which(\"mpirun\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No Open MPI launcher was found\")\n", + " MPI_LAUNCHER = [mpi_launcher, \"--oversubscribe\"]\n", + "else:\n", + " mpi_launcher = shutil.which(\"mpiexec\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(f\"No launcher was found for {MPI_VENDOR}\")\n", + " MPI_LAUNCHER = [mpi_launcher]\n", + "\n", + "\n", + "def run_program(command):\n", + " '''Run a child program, display its output, and fail on errors.'''\n", + " result = subprocess.run(\n", + " command,\n", + " capture_output=True,\n", + " text=True,\n", + " timeout=180,\n", + " )\n", + " print(result.stdout, end=\"\")\n", + " if result.stderr:\n", + " print(result.stderr, end=\"\", file=sys.stderr)\n", + " result.check_returncode()\n", + "\n", + "\n", + "def run_mpi(rank_count, script):\n", + " '''Run a Python script under the selected MPI implementation.'''\n", + " command = [\n", + " *MPI_LAUNCHER,\n", + " \"-n\",\n", + " str(rank_count),\n", + " sys.executable,\n", + " \"-u\",\n", + " script,\n", + " ]\n", + " run_program(command)\n", + "\n", + "\n", + "def run_python(script):\n", + " '''Run a serial Python reference with the notebook's interpreter.'''\n", + " run_program([sys.executable, \"-u\", script])\n", + "\n", + "\n", + "def mpi_result(rank_count, script, *args):\n", + " '''Launch a script and return the RESULT record rank 0 prints.'''\n", + " result = subprocess.run(\n", + " [*MPI_LAUNCHER, \"-n\", str(rank_count),\n", + " sys.executable, \"-u\", script, *map(str, args)],\n", + " capture_output=True,\n", + " text=True,\n", + " timeout=900,\n", + " )\n", + " if result.stderr:\n", + " print(result.stderr, end=\"\", file=sys.stderr)\n", + " result.check_returncode()\n", + " line = next((l for l in result.stdout.splitlines()\n", + " if l.startswith(\"RESULT \")), None)\n", + " assert line, result.stdout\n", + " return json.loads(line[len(\"RESULT \"):])\n", + "\n", + "\n", + "# Use a teaching-scale rank count; large shared nodes may expose hundreds of cores.\n", + "N_RANKS = min(32, psutil.cpu_count(logical=False) or os.cpu_count())\n", + "\n", + "# Shared problem identity, for reporting. The solver script restates the\n", + "# full configuration: each MPI process is its own interpreter.\n", + "N, N_STEPS = swe_core.canonical_size()" + ] + }, + { + "cell_type": "markdown", + "id": "e05b77ed", + "metadata": {}, + "source": [ + "### 2. The decomposition probe\n", + "\n", + "`N` interior cells split into `size` contiguous slabs, remainder cells\n", + "going to the lowest ranks. Each rank stores `local_N + 2` cells: its slab\n", + "plus one ghost per side. Five ranks does not divide `N = 16384` evenly, so\n", + "the probe shows the uneven split.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `divmod(N, size)`: base slab length and remainder; the first\n", + " `remainder` ranks take one extra cell." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "b3ca356a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:50:15.147474Z", + "iopub.status.busy": "2026-07-27T10:50:15.147318Z", + "iopub.status.idle": "2026-07-27T10:50:15.151462Z", + "shell.execute_reply": "2026-07-27T10:50:15.151145Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing swe_mpi_decomp.py\n" + ] + } + ], + "source": [ + "%%writefile swe_mpi_decomp.py\n", + "\"\"\"Slab decomposition probe: every rank reports the cells it owns.\"\"\"\n", + "from mpi4py import MPI\n", + "\n", + "N = 16384 # a fixed, readable grid: this probe illustrates the split\n", + "\n", + "comm = MPI.COMM_WORLD\n", + "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "\n", + "# Split N interior cells into `size` contiguous slabs\n", + "# ranks below N % size take one extra cell.\n", + "local_N = N // size + (1 if rank < N % size else 0)\n", + "offset = rank * (N // size) + min(rank, N % size)\n", + "\n", + "rows = comm.gather((rank, offset, local_N), root=0)\n", + "if rank == 0:\n", + " for r, o, n in rows:\n", + " print(f\"rank {r}/{size}: interior cells [{o:>6}, {o + n:>6}) local_N = {n:,}\")\n", + " assert sum(n for _, _, n in rows) == N, \"cells lost or duplicated\"" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a0e0e594", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:50:15.152320Z", + "iopub.status.busy": "2026-07-27T10:50:15.152224Z", + "iopub.status.idle": "2026-07-27T10:50:15.474009Z", + "shell.execute_reply": "2026-07-27T10:50:15.473573Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "rank 0/5: interior cells [ 0, 3277) local_N = 3,277\n", + "rank 1/5: interior cells [ 3277, 6554) local_N = 3,277\n", + "rank 2/5: interior cells [ 6554, 9831) local_N = 3,277\n", + "rank 3/5: interior cells [ 9831, 13108) local_N = 3,277\n", + "rank 4/5: interior cells [ 13108, 16384) local_N = 3,276\n" + ] + } + ], + "source": [ + "run_mpi(5, \"swe_mpi_decomp.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "8d884386", + "metadata": {}, + "source": [ + "### 3. The halo exchange\n", + "\n", + "Each update reads a cell's two neighbours. Interior cells of a slab find\n", + "both locally; the first and last cell each need one cell owned by the\n", + "neighbouring rank, delivered into the ghosts before every step:\n", + "\n", + "- **Exchange**: `Sendrecv` copies each neighbour's edge cell into the\n", + " ghost, in both directions.\n", + "- **Walls**: rank 0 mirrors its left ghost, the last rank its right\n", + " ghost.\n", + "- **Step**: `swe_core.step_numpy` on the local array, unchanged.\n", + "\n", + "Every interior cell then sees the same neighbour values as the serial\n", + "solve, so the gate expects `max_diff` exactly zero.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `comm.Sendrecv(sendbuf, dest=.., sendtag=.., recvbuf=.., source=..,\n", + " recvtag=..)`: one paired send and receive. The runtime schedules the\n", + " pair, so neighbour chains cannot deadlock.\n", + "- `MPI.PROC_NULL`: the no-op peer. A transfer with it completes\n", + " immediately; the wall ranks use it as their missing neighbour.\n", + "- Uppercase methods (`Sendrecv`, `Isend`, `Gatherv`) move NumPy buffers\n", + " zero-copy; any contiguous slice like `h[-2:-1]` is a valid message\n", + " buffer.\n", + "- `comm.Gatherv(sendbuf, (recvbuf, counts), root=0)`: concatenate\n", + " unequal-length slabs on one rank. The acceptance gate uses it." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "77c1ea48", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:50:15.475140Z", + "iopub.status.busy": "2026-07-27T10:50:15.475036Z", + "iopub.status.idle": "2026-07-27T10:50:15.479908Z", + "shell.execute_reply": "2026-07-27T10:50:15.479503Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing swe_mpi_solver.py\n" + ] + } + ], + "source": [ + "%%writefile swe_mpi_solver.py\n", + "\"\"\"Distributed 1D SWE solver: slab decomposition + halo exchange.\n", + "\n", + "Rank 0 prints one result: max-over-ranks wall times and the\n", + "max_diff.\n", + "\"\"\"\n", + "import os\n", + "os.environ.setdefault(\"OMP_NUM_THREADS\", \"1\") # pure MPI: one thread per rank\n", + "\n", + "import json, sys\n", + "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", + "import swe_core\n", + "\n", + "# Shared problem parameters; sweep runs pass N and N_STEPS on the CLI.\n", + "_args = [a for a in sys.argv[1:] if not a.startswith('-')]\n", + "N = int(_args[0]) if _args else 16384\n", + "L = 10.0\n", + "H0 = 1.0\n", + "AMP = 0.1\n", + "SIG = 0.5\n", + "CFL = 0.4\n", + "G = 9.81\n", + "N_STEPS = int(_args[1]) if len(_args) > 1 else 1000\n", + "dx = L / N\n", + "DT = swe_core.fixed_dt(H0 + AMP, dx, cfl=CFL, g=G)\n", + "\n", + "comm = MPI.COMM_WORLD\n", + "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "left = rank - 1 if rank > 0 else MPI.PROC_NULL\n", + "right = rank + 1 if rank < size - 1 else MPI.PROC_NULL\n", + "\n", + "# Slab partition (Sec. 2).\n", + "local_N = N // size + (1 if rank < N % size else 0)\n", + "offset = rank * (N // size) + min(rank, N % size)\n", + "\n", + "\n", + "def halo_exchange(h, hu):\n", + " \"\"\"Fill the ghost cells from the neighbours' edge interior cells.\"\"\"\n", + " # Shift east: last interior cell -> right neighbour's left ghost.\n", + " comm.Sendrecv(h[-2:-1], dest=right, sendtag=0, recvbuf=h[:1], source=left, recvtag=0)\n", + " comm.Sendrecv(hu[-2:-1], dest=right, sendtag=1, recvbuf=hu[:1], source=left, recvtag=1)\n", + " # Shift west: first interior cell -> left neighbour's right ghost.\n", + " comm.Sendrecv(h[1:2], dest=left, sendtag=2, recvbuf=h[-1:], source=right, recvtag=2)\n", + " comm.Sendrecv(hu[1:2], dest=left, sendtag=3, recvbuf=hu[-1:], source=right, recvtag=3)\n", + "\n", + "\n", + "def exchange_and_bc(h, hu):\n", + " \"\"\"Ghost cells from the neighbours. Physical walls on the end ranks.\"\"\"\n", + " halo_exchange(h, hu)\n", + " if rank == 0:\n", + " h[0] = h[1]; hu[0] = -hu[1]\n", + " if rank == size - 1:\n", + " h[-1] = h[-2]; hu[-1] = -hu[-2]\n", + "\n", + "\n", + "# This rank's slab, cut once from the global IC at import.\n", + "_h_g, _hu_g = swe_core.bump_ic(N, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + "IC = (_h_g [offset : offset + local_N + 2].copy(),\n", + " _hu_g[offset : offset + local_N + 2].copy())\n", + "del _h_g, _hu_g\n", + "\n", + "\n", + "def solve_local():\n", + " \"\"\"N_STEPS of exchange / BC / step on the local slab.\"\"\"\n", + " h, hu = IC[0].copy(), IC[1].copy()\n", + " for _ in range(N_STEPS):\n", + " exchange_and_bc(h, hu)\n", + " h, hu = swe_core.step_numpy(h, hu, dx, DT, g=G)\n", + " return h, hu\n", + "\n", + "\n", + "def timed(fn):\n", + " \"\"\"Max-over-ranks wall time of one fn() call: the slowest rank sets it.\"\"\"\n", + " comm.Barrier()\n", + " t0 = MPI.Wtime()\n", + " out = fn()\n", + " return comm.allreduce(MPI.Wtime() - t0, op=MPI.MAX), out\n", + "\n", + "\n", + "def benchmark(solve):\n", + " \"\"\"Cold pass + one warm-up, then 5 timed repeats; gate and report.\"\"\"\n", + " cold_s, _ = timed(solve) # first pass: first-touch + numpy warm-up\n", + " timed(solve)\n", + " ts, h = [], None\n", + " for _ in range(5):\n", + " dt_s, (h, _hu) = timed(solve)\n", + " ts.append(dt_s)\n", + "\n", + " # Acceptance: gather the interior slabs to rank 0, compare to the reference.\n", + " counts = [N // size + (1 if r < N % size else 0) for r in range(size)]\n", + " h_all = np.empty(N) if rank == 0 else None\n", + " comm.Gatherv(h[1:-1], (h_all, counts) if rank == 0 else None, root=0)\n", + " if rank == 0:\n", + " h_ref, _ = swe_core.solve_numpy(N, N_STEPS)\n", + " print(\"RESULT \" + json.dumps({\n", + " \"ranks\": size, \"cold_s\": cold_s,\n", + " \"median_s\": float(np.median(ts)), \"min_s\": float(np.min(ts)),\n", + " \"max_s\": float(np.max(ts)), \"repeats\": len(ts),\n", + " \"max_diff\": swe_core.max_diff(h_ref[1:-1], h_all)}))\n", + "\n", + "\n", + "def halo_cost(reps=20_000, step_reps=500):\n", + " \"\"\"Per-step cost of the exchange alone vs the local compute alone.\"\"\"\n", + " h_g, hu_g = swe_core.bump_ic(N, L=L, h0=H0, amplitude=AMP, sigma=SIG)\n", + " h = h_g [offset : offset + local_N + 2].copy()\n", + " hu = hu_g[offset : offset + local_N + 2].copy()\n", + "\n", + " def exchanges():\n", + " for _ in range(reps):\n", + " exchange_and_bc(h, hu)\n", + "\n", + " def steps():\n", + " for _ in range(step_reps):\n", + " swe_core.step_numpy(h, hu, dx, DT, g=G)\n", + "\n", + " t_x = min(timed(exchanges)[0] for _ in range(3)) / reps # min of 3: the floor\n", + " t_c = min(timed(steps)[0] for _ in range(3)) / step_reps\n", + " if rank == 0:\n", + " print(f\"halo exchange: {t_x * 1e6:6.2f} us/step (4 messages, 8 B each)\")\n", + " print(f\"local step : {t_c * 1e6:6.2f} us/step (local_N = {local_N:,})\")\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " halo_cost() if \"--halo-cost\" in sys.argv else benchmark(solve_local)" + ] + }, + { + "cell_type": "markdown", + "id": "7895b056", + "metadata": {}, + "source": [ + "### 4. Acceptance and timing\n", + "\n", + "Timing lives inside the script: `Barrier`, then `MPI.Wtime` around a full\n", + "solve, then a max over ranks (the slowest rank sets the wall clock).\n", + "Rank 0 gathers the slabs with `Gatherv`, computes `max_diff` against the\n", + "float64 reference, and prints one `RESULT` line for the notebook to\n", + "record." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "0ba6b32f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:50:15.480793Z", + "iopub.status.busy": "2026-07-27T10:50:15.480697Z", + "iopub.status.idle": "2026-07-27T10:50:24.070081Z", + "shell.execute_reply": "2026-07-27T10:50:24.069462Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[13_mpi4py] N=4194304 steps=47 | cold 210.3 ms (first solve pass; excl. mpirun + MPI_Init) | warm 208.9 ms | max_diff 0.00e+00 < tol 1e-12 | PASS\n" + ] + } + ], + "source": [ + "res = mpi_result(N_RANKS, \"swe_mpi_solver.py\", N, N_STEPS)\n", + "\n", + "warm = {'label': '13_mpi4py', 'median_s': res['median_s'],\n", + " 'min_s': res['min_s'], 'max_s': res['max_s'], 'repeats': res['repeats']}\n", + "swe_core.report_and_verify(warm, res['max_diff'], tol=1e-12,\n", + " cold_s=res['cold_s'], n=N, steps=N_STEPS,\n", + " cold_note='first solve pass; excl. mpirun + MPI_Init')\n", + "\n", + "swe_core.save_timing(\n", + " warm, grid_str=f'N={N}', tool='mpi4py', hardware='cpu',\n", + " dtype='float64', steps=N_STEPS,\n", + " cold_s=res['cold_s'], max_diff_vs_numpy=res['max_diff'],\n", + " ranks=res['ranks'], mpi4py_version=mpi4py.__version__,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "72b883c0", + "metadata": {}, + "source": [ + "### 5. Rank scaling\n", + "\n", + "The same script scales with `mpirun -n`. The sweep\n", + "runs powers of two up to the physical-core count.\n", + "\n", + "**Predict before run:** each rank steps `N / ranks` cells but pays the\n", + "same four messages per step however small the slab gets. Sketch\n", + "throughput against rank count." + ] + }, + { + "cell_type": "markdown", + "id": "0a309bec", + "metadata": {}, + "source": [ + "The halo payload is 8 bytes per message, so the exchange cost is\n", + "latency. Measure it against the local step alone, at 2 ranks:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "fe0fb509", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:50:24.071314Z", + "iopub.status.busy": "2026-07-27T10:50:24.071205Z", + "iopub.status.idle": "2026-07-27T10:51:40.130543Z", + "shell.execute_reply": "2026-07-27T10:51:40.129998Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "halo exchange: 4.59 us/step (4 messages, 8 B each)\n", + "local step : 50282.25 us/step (local_N = 2,097,152)\n" + ] + } + ], + "source": [ + "run_program([*MPI_LAUNCHER, \"-n\", \"2\", sys.executable, \"-u\",\n", + " \"swe_mpi_solver.py\", str(N), str(N_STEPS), \"--halo-cost\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d243acdc", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:51:40.131736Z", + "iopub.status.busy": "2026-07-27T10:51:40.131628Z", + "iopub.status.idle": "2026-07-27T10:53:20.804832Z", + "shell.execute_reply": "2026-07-27T10:53:20.804355Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ranks = 1: 5204.4 ms 37.9 Mcells/s max_diff 0.0e+00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ranks = 2: 2074.5 ms 95.0 Mcells/s max_diff 0.0e+00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ranks = 4: 1073.3 ms 183.7 Mcells/s max_diff 0.0e+00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ranks = 8: 579.4 ms 340.2 Mcells/s max_diff 0.0e+00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ranks = 16: 313.6 ms 628.7 Mcells/s max_diff 0.0e+00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ranks = 32: 203.3 ms 969.8 Mcells/s max_diff 0.0e+00\n" + ] + }, + { + "data": { + "image/png": "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CQkJSuHChZUNGzYoS5YsyVQ/43WrVq2UxMRE5euvv1YApXnz5oqiKIqrq2u211QURZk5c+ZjY5szZ46ybt06o2v7+fkpWq1WLVuzZo2yatUq9bWfn58ybNgwZdq0aUpgYKBSrVo1dV+dOnUURVGUUqVKmf37Lptssj0fmwycEkKIfBAeHs7WrVvp378/Go2GrVu3cu/evSzrduzYkdjYWKytrdFqtaxcuZKJEyeadL3jx49nKvvggw8YMGAApUqVwt7eHhsbG06fPm1U58KFCxgMBvV1cHAwNWvWNKozatQoHB0dqVevHn5+fmr5mTNn2LNnD+fOnWPnzp3s2rWL3377jaioKJNiF0KInJKhBUIIkU8WL15M//796devH4sXL8623r59+6hduzYVK1bE3t6e/v37k5CQYNK14uPjjV737NmTb7/9lkWLFtG2bVtq167NkiVLMg1ZSE1NNXqtKAparfGvigMHDqDT6ejRo4dRucFgoE2bNrRv356LFy8yZMgQrly5QpkyZUyKXQghckoSWSGEyCc7duzAxsYGa2trdu7cmW29+Ph4bty4we3bt9Hr9bly7RdffJFDhw4xf/58Tp8+zY0bNyhfvvwTnevo0aO0b9+eTz75hFGjRmXaf+jQISZOnEidOnVISUnhtddee9rwhRAiSzK0QAgh8onBYKBq1arq1/np2rVr9O3bl7Zt2+Ln58ebb75J/fr1jYYGmOLw4cO88sorbN++nbS0NL7//nsaNGhAq1at2LVrF3fv3qVhw4YUKVKES5cu5fLdCCFEOklkhRAiH8XGxprluj/99BN16tRhzZo1KIrCqlWr+OGHH2jfvv0Tn/Off/6hQ4cObNu2Db1ez549e2jWrBnDhw/HxcUFf39/Ro0axY4dO3LxToQQ4j4N6U99CSGEEEIIYVFkjKwQQgghhLBIksgKIYQQQgiLJImsEEIIIYSwSJLICiGEEEIIiySJrBBCCCGEsEiSyAohhBBCCIskiawQQgghhLBIksgKIYQQQgiLJImsEEIIIYSwSJLICiGEEEIIiySJrBBCCCGEsEj/BysRL9vDvcShAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "RANK_COUNTS = [1]\n", + "while RANK_COUNTS[-1] * 2 <= N_RANKS:\n", + " RANK_COUNTS.append(RANK_COUNTS[-1] * 2)\n", + "\n", + "sweep = []\n", + "for n_ranks in RANK_COUNTS:\n", + " r = mpi_result(n_ranks, \"swe_mpi_solver.py\", N, N_STEPS)\n", + " assert r['max_diff'] < 1e-12, f'gate failed at {n_ranks} ranks'\n", + " rate = N * N_STEPS / r['median_s'] / 1e6\n", + " sweep.append((n_ranks, r['median_s'], rate))\n", + " print(f' ranks = {n_ranks}: {r[\"median_s\"]*1000:6.1f} ms '\n", + " f'{rate:7.1f} Mcells/s max_diff {r[\"max_diff\"]:.1e}')\n", + "\n", + "ranks = [s[0] for s in sweep]\n", + "rates = [s[2] for s in sweep]\n", + "\n", + "fig, ax = plt.subplots(figsize=(7, 3.4))\n", + "ax.plot(ranks, rates, 'o-', linewidth=2, markersize=10, label='measured')\n", + "ax.plot(ranks, [rates[0] * n for n in ranks], '--', color='#aaa', label='ideal (linear)')\n", + "ax.set_xscale('log', base=2)\n", + "ax.set_xticks(ranks); ax.set_xticklabels([str(n) for n in ranks])\n", + "ax.set_xlabel('MPI ranks'); ax.set_ylabel('throughput [Mcells/s]')\n", + "ax.set_title('Rank scaling')\n", + "ax.legend(); ax.grid(alpha=0.3); plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "333244ae", + "metadata": {}, + "source": [ + "**Takeaways:**\n", + "\n", + "- Speedup can exceed the rank count, because adding ranks shrinks each slab\n", + " until it fits in cache.\n", + "- The exchange is cheap next to the step itself. The floor is the NumPy\n", + " kernel's fixed per-step cost, paid by every rank on an ever-smaller slab.\n", + "- Halving the slab halves the compute but not the fixed cost: the\n", + " surface-to-volume effect." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "swpmpi01", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:53:20.806054Z", + "iopub.status.busy": "2026-07-27T10:53:20.805937Z", + "iopub.status.idle": "2026-07-27T10:54:59.964630Z", + "shell.execute_reply": "2026-07-27T10:54:59.964144Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "N= 262,144 steps= 762: 2112 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "N= 1,048,576 steps= 190: 1395 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "N= 4,194,304 steps= 47: 948 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "N=16,777,216 steps= 20: 590 Mcells/s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "N=67,108,864 steps= 20: 550 Mcells/s\n" + ] + }, + { + "data": { + "text/plain": [ + "8915" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Rates across the shared size range, for the synthesis notebook (14).\n", + "from pathlib import Path\n", + "\n", + "sweep = []\n", + "for n_, steps_ in swe_core.SWEEP_SIZES:\n", + " r = mpi_result(N_RANKS, \"swe_mpi_solver.py\", n_, steps_)\n", + " assert r['max_diff'] < 1e-12, (n_, r)\n", + " sweep.append({'n': n_, 'steps': steps_, 'median_s': r['median_s']})\n", + " print(f\"N={n_:>10,} steps={steps_:>5}: {n_ * steps_ / r['median_s'] / 1e6:7.0f} Mcells/s\")\n", + "\n", + "records = swe_core.load_timings()\n", + "row = next(rr for rr in records if rr.get('stage') == '13_mpi4py')\n", + "row['sweep'] = sweep\n", + "Path(swe_core.TIMINGS_PATH).write_text(json.dumps(records, indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "8f0fb225", + "metadata": {}, + "source": [ + "### 6. Overlapping communication and computation\n", + "\n", + "The blocking loop serialises exchange and compute, yet only the first\n", + "and last interior cell of a slab read the ghosts. A non-blocking\n", + "pattern like:\n", + "\n", + "- post `Irecv`/`Isend`\n", + "- update the interior\n", + "- `Waitall`\n", + "- update the two edge cells\n", + "\n", + "steps every other cell while the messages are being passed.\n", + "\n", + "Sec. 5 measured the exchange cost. Predict: what fraction of a step can hiding\n", + "it save? The two edge cells are advanced from their own faces. A second\n", + "`step_numpy` call would cost more than the exchange it hides, because an\n", + "array-wide call is priced per call, not per cell.\n", + "\n", + "---\n", + "\n", + "**Quick Docs**\n", + "\n", + "- `comm.Irecv(buf, source=..)` / `comm.Isend(buf, dest=..)`: post\n", + " nonblocking transfers, each returning a `Request`.\n", + "- `MPI.Request.Waitall(reqs)`: block until every request completes." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "b66cf0a9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:54:59.965994Z", + "iopub.status.busy": "2026-07-27T10:54:59.965877Z", + "iopub.status.idle": "2026-07-27T10:54:59.970226Z", + "shell.execute_reply": "2026-07-27T10:54:59.969850Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing swe_mpi_overlap.py\n" + ] + } + ], + "source": [ + "%%writefile swe_mpi_overlap.py\n", + "# EXTRA CREDIT: hide the halo exchange behind the interior computation.\n", + "\"\"\"Non-blocking halo exchange overlapped with the interior update.\n", + "\n", + "Only the first and last interior cell of a slab read the ghosts. Post the\n", + "exchange, step every other cell while the messages fly, wait, then update\n", + "those two. Reuses the decomposition and harness of swe_mpi_solver.\n", + "\"\"\"\n", + "import math\n", + "\n", + "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", + "import swe_core\n", + "import swe_mpi_solver as s\n", + "\n", + "\n", + "def rusanov_face(hL, hR, huL, huR):\n", + " \"\"\"Rusanov flux (Fh, Fhu) at one interface, in scalars.\"\"\"\n", + " hsL, hsR = max(hL, swe_core.DRY_TOL), max(hR, swe_core.DRY_TOL)\n", + " uL, uR = huL / hsL, huR / hsR\n", + " cL, cR = math.sqrt(s.G * hsL), math.sqrt(s.G * hsR)\n", + " a = max(abs(uL) + cL, abs(uR) + cR)\n", + " return (0.5 * (huL + huR) - 0.5 * a * (hR - hL),\n", + " 0.5 * (huL*uL + 0.5*s.G*hL*hL + huR*uR + 0.5*s.G*hR*hR)\n", + " - 0.5 * a * (huR - huL))\n", + "\n", + "\n", + "def edge_cell(h, hu, i):\n", + " \"\"\"Advance cell i from its two faces, without an array-wide kernel call.\n", + "\n", + " Reads through float() because arithmetic on numpy scalars is several\n", + " times slower than on Python floats, and the result is identical.\"\"\"\n", + " hm, h0, hp = float(h[i-1]), float(h[i]), float(h[i+1])\n", + " qm, q0, qp = float(hu[i-1]), float(hu[i]), float(hu[i+1])\n", + " Fh_w, Fhu_w = rusanov_face(hm, h0, qm, q0)\n", + " Fh_e, Fhu_e = rusanov_face(h0, hp, q0, qp)\n", + " r = s.DT / s.dx\n", + " return h0 - r * (Fh_e - Fh_w), q0 - r * (Fhu_e - Fhu_w)\n", + "\n", + "\n", + "def step_overlap(h, hu):\n", + " \"\"\"One step: Isend/Irecv, interior update, Waitall, edge update.\"\"\"\n", + " reqs = [\n", + " s.comm.Irecv(h[:1], source=s.left, tag=0),\n", + " s.comm.Irecv(hu[:1], source=s.left, tag=1),\n", + " s.comm.Irecv(h[-1:], source=s.right, tag=2),\n", + " s.comm.Irecv(hu[-1:], source=s.right, tag=3),\n", + " s.comm.Isend(h[-2:-1], dest=s.right, tag=0),\n", + " s.comm.Isend(hu[-2:-1], dest=s.right, tag=1),\n", + " s.comm.Isend(h[1:2], dest=s.left, tag=2),\n", + " s.comm.Isend(hu[1:2], dest=s.left, tag=3),\n", + " ]\n", + " # Interior cells 2 .. local_N-1 read no ghosts: step them during the exchange.\n", + " h_mid, hu_mid = swe_core.step_numpy(h[1:-1], hu[1:-1], s.dx, s.DT, g=s.G)\n", + " MPI.Request.Waitall(reqs)\n", + " # Ghosts are now valid: physical walls first, then the two edge cells.\n", + " if s.rank == 0:\n", + " h[0] = h[1]; hu[0] = -hu[1]\n", + " if s.rank == s.size - 1:\n", + " h[-1] = h[-2]; hu[-1] = -hu[-2]\n", + "\n", + " h_new, hu_new = np.empty_like(h), np.empty_like(hu)\n", + " h_new[0], hu_new[0] = h[0], hu[0]\n", + " h_new[-1], hu_new[-1] = h[-1], hu[-1]\n", + " h_new[2:-2], hu_new[2:-2] = h_mid[1:-1], hu_mid[1:-1]\n", + " h_new[1], hu_new[1] = edge_cell(h, hu, 1)\n", + " h_new[-2], hu_new[-2] = edge_cell(h, hu, -2)\n", + " return h_new, hu_new\n", + "\n", + "\n", + "def solve_local_overlap():\n", + " h, hu = s.IC[0].copy(), s.IC[1].copy()\n", + " for _ in range(s.N_STEPS):\n", + " h, hu = step_overlap(h, hu)\n", + " return h, hu\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " s.benchmark(solve_local_overlap)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "6a0283a5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:54:59.971139Z", + "iopub.status.busy": "2026-07-27T10:54:59.971041Z", + "iopub.status.idle": "2026-07-27T10:55:09.413889Z", + "shell.execute_reply": "2026-07-27T10:55:09.413277Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "blocking (Sec. 4): 208.9 ms\n", + "overlapped (Sec. 6): 230.7 ms max_diff 0.0e+00\n" + ] + } + ], + "source": [ + "r_ov = mpi_result(N_RANKS, \"swe_mpi_overlap.py\", N, N_STEPS)\n", + "assert r_ov['max_diff'] < 1e-12, r_ov\n", + "\n", + "print(f'blocking (Sec. 4): {res[\"median_s\"]*1e3:6.1f} ms')\n", + "print(f'overlapped (Sec. 6): {r_ov[\"median_s\"]*1e3:6.1f} ms max_diff {r_ov[\"max_diff\"]:.1e}')" + ] + }, + { + "cell_type": "markdown", + "id": "f251c0fa", + "metadata": {}, + "source": [ + "**Recap.**\n", + "\n", + "- We decompose our working set across nodes. Halo exchange: a standard pattern\n", + " of distributed stencil codes.\n", + "- One node scales until the per-step fixed cost dominates the shrinking slab.\n", + "- Overlap (Sec. 6) can win at most the exchange time, so the split has to cost\n", + " less than that. Here the extra non-blocking calls alone exceed it, and the\n", + " overlapped loop stays behind the blocking one. The prize grows with rank\n", + " count, since the slab shrinks while the exchange stays latency-bound, and on\n", + " a real network.\n", + "\n", + "Next: `14__swe__synthesis.ipynb` collects every row from `timings.json` and\n", + "compares the tools on compile cost and float64 throughput across the memory\n", + "hierarchy." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/14__swe__synthesis__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/14__swe__synthesis__SOLUTION.ipynb new file mode 100644 index 00000000..86077049 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/14__swe__synthesis__SOLUTION.ipynb @@ -0,0 +1,348 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ffe28a76", + "metadata": {}, + "source": [ + "## SWE - Synthesis - SOLUTION\n", + "\n", + "We solved the same 1D bump-pulse step with multiple tools: NumPy, JAX, PyOMP,\n", + "nanobind, CuPy, CppJIT, and mpi4py. This notebook reads `timings.json` and compares them on first-call\n", + "compile cost and on a matched-float64 throughput sweep, from cache-resident to DRAM-resident sizes. It\n", + "closes with a summary table of programming models and limitations.\n", + "\n", + "**SOLUTION**\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Read the timings](#sec1)\n", + "2. [First-call compile cost](#sec2)\n", + "3. [Matched-precision comparison across the memory hierarchy](#sec3)\n", + "4. [Programming models and limitations](#sec4)\n", + "\n", + "### 1. Read the timings\n", + "\n", + "Let's load every row from `timings.json` (written by notebooks 08-13). Each row\n", + "carries the warm wall-clock (`median_s`), the first-call cost where the\n", + "tool JIT-compiles (`cold_s`), the hardware and dtype it ran on, and the\n", + "max-diff against the float64 NumPy reference." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "18601eeb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:55:12.069806Z", + "iopub.status.busy": "2026-07-27T10:55:12.069716Z", + "iopub.status.idle": "2026-07-27T10:55:12.351734Z", + "shell.execute_reply": "2026-07-27T10:55:12.351256Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "stage tool hardware dtype median_s cold_s max_diff\n", + "--------------------------------------------------------------------------------------------\n", + "08_numpy numpy cpu float64 4.8083 nan ref\n", + "09_jax jax gpu float32 0.0058 1.800 1.2e-07\n", + "09_jax_fp64 jax_fp64 gpu float64 0.0109 nan 0.0e+00\n", + "10_pyomp pyomp cpu float64 0.0098 0.309 0.0e+00\n", + "11_nanobind nanobind cpu float64 0.0895 nan 1.1e-15\n", + "12_cppjit_gpu_cub cppjit_gpu_cub gpu float64 0.0055 0.212 1.1e-15\n", + "12_cupy cupy gpu float64 0.0541 0.088 0.0e+00\n", + "12_cupy_fused cupy_fused gpu float64 0.0035 0.091 1.1e-15\n", + "12_cppjit_gpu_raw cppjit_gpu_raw gpu float64 0.0053 0.051 1.1e-15\n", + "13_mpi4py mpi4py cpu float64 0.2089 0.210 0.0e+00\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import swe_core\n", + "\n", + "rows = swe_core.load_timings()\n", + "by_tool = {r[\"tool\"]: r for r in rows}\n", + "\n", + "# timings.json is written by notebooks 08 to 13\n", + "expected = [\"numpy\", \"cupy\", \"cupy_fused\", \"jax\", \"jax_fp64\", \"pyomp\",\n", + " \"nanobind\", \"cppjit_gpu_cub\", \"cppjit_gpu_raw\", \"mpi4py\"]\n", + "missing = [t for t in expected if t not in by_tool]\n", + "if missing:\n", + " raise SystemExit(\n", + " \"timings.json has no rows for: \" + \", \".join(missing) + \". \"\n", + " \"Run notebooks 08 through 13 first, then re-run this notebook.\"\n", + " )\n", + "\n", + "hdr = (f'{\"stage\":<24}{\"tool\":>18}{\"hardware\":>9}{\"dtype\":>9}'\n", + " f'{\"median_s\":>11}{\"cold_s\":>9}{\"max_diff\":>12}')\n", + "print(hdr)\n", + "print(\"-\" * len(hdr))\n", + "for r in rows:\n", + " cold = r.get(\"cold_s\", float(\"nan\"))\n", + " md = r.get(\"max_diff_vs_numpy\")\n", + " md_s = f\"{md:.1e}\" if md is not None else (\"ref\" if r[\"tool\"] == \"numpy\" else \"n/a\")\n", + " print(f'{r[\"stage\"]:<24}{r[\"tool\"]:>18}{r[\"hardware\"]:>9}{r[\"dtype\"]:>9}'\n", + " f'{r[\"median_s\"]:>11.4f}{cold:>9.3f}{md_s:>12}')" + ] + }, + { + "cell_type": "markdown", + "id": "114ab6fd", + "metadata": {}, + "source": [ + "### 2. First-call compile cost\n", + "\n", + "Cold time is the first call, where CuPy, JAX, PyOMP, and CppJIT pay a compile\n", + "cost before they execute. Warm is the steady-state median over repeated runs\n", + "at the canonical size from notebook 08. The JAX bars are its native float32 run;\n", + "the rest are float64. nanobind compiles at build time, not at first\n", + "call, so it has no cold bar." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "3a21b26e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:55:12.353059Z", + "iopub.status.busy": "2026-07-27T10:55:12.352919Z", + "iopub.status.idle": "2026-07-27T10:55:12.558332Z", + "shell.execute_reply": "2026-07-27T10:55:12.557985Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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WL16NXr06IG7d+/qvGAiIiIiIqAIUwtOnjyJCxcuoG7durC3t4e9vT3ee+89/Pnnn9i/fz/q1auHS5cuwd3dvTjqJSIiIiICUIQjsrNnz0b//v0hl8uVy1JSUuDm5obz589j3bp1WLx4Mc6fP6/TQomIiEiabnTooO8SipV9QIC+Syi3tD4iW61aNdSuXVttea1atVC1alUAQFJSEipUqPDu1RERERER5aNIUwt27NiBYcOGoV69eqhXrx6GDRuG7du348SJEwCATp06ITw8XNe1EhEREREpaT214JtvvoG7uzsOHDgAY+PXd8/OzsauXbswc+ZMAEBYWBgmTZqk20p1yMXFBVOnToWhIa8+RkRERCRVBgBEUe5oZmaGRo0aAQAePnyI1NRUXdZVIszNzZGSkoKqVauqzPklkpKyPvcM4PyzksZ9inStrO9T3J90S5t8pvUR2VypqalISEhQ/puIiIiIqCRp/d26gYEBFixYgKSkJERGRiIyMhKJiYmYP38+DAwMiqNGIiIiIiI1Wh+RXbZsGSZOnIi5c+fiypUrAIAePXrAzc0NFStWxPz583VeJBERERHR27QOshMmTMCkSZPg5eWlXBYUFITo6Gh4eHgwyBIRERFRidB6aoGlpSXCwsLUloeFhcHS0lInRRERERERFUbrIBsYGIhp06apLZ82bRoCAwN1UhQRERERUWG0nlrw3//+F6dPn0a/fv1w9epVAEDXrl1Rv359DBo0SOcFEhERERHlResjspcuXYKtrS2OHz8OCwsLWFhY4NixY2jWrBkuX75cHDUSEREREakp0nVknz17xpO6iIiIiEivNAqybdq00XjAoKCgIhdDRERERKQpjYLs7du3IYQo9AcPhBAwNi7yj4UREREREWlMo9TZsGHD4q6DiIiIiEgrGgXZx48fF3cdRERERERa0fqqBUREREREpQGDLBERERFJEoMsEREREUkSgywRERERSRKDLBERERFJkk6D7MOHD7Ft2zbUqVNHl8MSEREREanRaZDdtWsXjIyMcOXKFV0OS0RERESkRqc/w7Vo0SJdDkdERERElK93PiJraGgIOzs7WFhY6KAcIiIiIiLNaB1k3d3d4ezs/PrOhobw8/PDzZs3ERUVBQcHB50XSERERESUF62D7MiRIxEYGAgA+OSTT9CwYUM0b94c7u7uWLZsmc4L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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Cold vs warm for the tools that JIT-compile on the first call.\n", + "jit = [\"cupy\", \"jax\", \"pyomp\", \"cppjit_gpu_cub\"]\n", + "jit_labels = {\"cupy\": \"CuPy\", \"jax\": \"JAX\", \"pyomp\": \"PyOMP\",\n", + " \"cppjit_gpu_cub\": \"CppJIT\"}\n", + "swe_core.plot_cold_warm([jit_labels[t] for t in jit],\n", + " [by_tool[t][\"cold_s\"] for t in jit],\n", + " [by_tool[t][\"median_s\"] for t in jit],\n", + " \"First-call compile cost (N = 16384, 1000 steps)\")" + ] + }, + { + "cell_type": "markdown", + "id": "nb06-cmp-md", + "metadata": {}, + "source": [ + "### 3. Matched-precision comparison across the memory hierarchy\n", + "\n", + "This is the primary benchmark: every tool runs float64 over the sizes\n", + "notebook 08 fixed for this machine. The working set is six fp64 arrays,\n", + "48 B per cell, so the sweep runs from cache into DRAM on both devices.\n", + "Throughput is the solve loop for one call from Python; step counts shrink\n", + "with `N`.\n", + "\n", + "The fused GPU kernels are bandwidth-bound at these sizes, so a float32\n", + "solve gains exactly the bytes it saves.\n", + "\n", + "The plot's cache boundaries use this 48 B model. NumPy's temporaries double\n", + "its bytes per cell, so its curve leaves cache earlier. PyOMP's fused kernel\n", + "streams only the four state arrays, so its curve leaves later. The GPU curve\n", + "runs flat through its L2 boundary: a working set that fits in L2 measures no\n", + "faster than one that does not." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "nb06-cmp-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:55:12.559398Z", + "iopub.status.busy": "2026-07-27T10:55:12.559291Z", + "iopub.status.idle": "2026-07-27T10:55:13.602017Z", + "shell.execute_reply": "2026-07-27T10:55:13.601496Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " N NumPy CuPy drop-in CuPy fused JAX CppJIT CUB CppJIT raw PyOMP mpi4py nanobind Mcells/s\n", + " 262,144 69 737 37760 9773 28150 28597 7690 2112 172\n", + " 1,048,576 63 2986 56535 19125 36478 40003 13744 1395 681\n", + " 4,194,304 38 3642 58102 18211 35760 37115 19111 948 2189\n", + " 16,777,216 31 4089 59204 19637 37701 38672 20774 590 3153\n", + " 67,108,864 - 4223 56324 20317 38298 38708 10229 550 2583\n" + ] + }, + { + "data": { + "image/png": "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kkAgQN544w3yy1/+kjz//PPE6XSSjRs3iv8f0f/LvXv3ktOnT5Mnn3yS3H///eTkyZPE4XCQG264gRw7dow88sgj5JFHHiE2m40cOHCAMAwjtj///PNJIBAg+/btI7/97W/JH/7wB1JdXU0sFgvp1q2bWO+pp54ihBCye/du8vnnn5M5c+aQu+66i2g0GuJ2u8lf//rXRvdh9+7d5LvvvmvyXkXtbt26lXz22Wdkzpw55M033xTvVVPvW6VSSXbs2EFqamrIn/70J/LLX/6SvPvuu4TnefLyyy+L9bp16yaO/dChQ+SRRx4h999/P+natSsxm83k9OnT5P/+7//Ir371K/Lb3/6W7N27l/j9/rg53JSNwYMHk7q6OlJTU0Oee+45cuedd5IXXniB7Ny5M+3r/MUvfkEIIWTQoEFx137uuecSQgi56aabcv63kx70yOGR8wHQgx70aINj8uTJhBBCZs+eHVc+b948Qgghr776alz5woULic/nI2azmQAgDzzwACGEkJqaGrJhwwZy/fXXkzlz5pCzZ88Si8VCysrK4tr/5S9/IYcPHyYymYwAqQvyyy+/nBBCyMiRI5PWmTVrFiGEkMcffzyu/JNPPiE8z5OePXuKZQ2FzXPPPUf8fj8xGAximVQqJVarlfz73/8Wy9566y1y+vRpYjKZ4vr48MMPic1mIwqFosnrSHR+8eLF5NChQ+JrnU5H7HY7Wb9+PZHL5UltLV++nBBCyC9/+cu48vvuu48QQsgNN9wglkkkErJ27VricDiIRqMhAMjLL79M6urqCMuySfvYvn17Sv8/DY+oIF+8eHFc+dq1awnP82Tu3LliGcuy5MSJE2T58uVi2YQJEwghhFx//fVx7S+88MJG5UePHiWEEDJ27FixbPr06YQQQtxuN+natatYfueddxJCCJk8ebJYtm3bNlJZWUmMRqNYNnjwYBIKhci7774rlkUF5QcffNDoej/44ANy6tSpOKE/bNgwQgght9xyS5P3Kmo39n0GgCxYsIDU1NTElTV83z7xxBPE6XSS3r17x9X785//TILBICkvLydAvZiuq6sjRUVFcXVZliVSqTSuTK/Xk7Nnz8aNqSkbK1asIHa7Pe5et/Q6dTod8Xg85Pnnn4+r98orrxCn00lUKlXa70d60KO9HDRkhULpoPzjH/9o9Foul4shCxqNBgBACMH555+Pjz76CK+//jquuOIKmEwm3HPPPWLbPn364P7778fDDz+cdixsNMZ95syZkEgSL2u55JJLEAqF8Nprr8WV/+1vfwPLspgxY0ZS+x9//DFkMhmuvPJKsezCCy+E0WjExx9/LJbNnj0bCxcuBMMwMJvN4rF06VIYDAaMGDGiyevw+Xzic51OB7PZjJUrV6JXr17Q6XQAgOnTp0On0+GFF16A3+9v1l7DEIZLLrkEZ8+exUcffSSWRe+LVqsVQznq6uqgVqsxffr0pPbr6uowaNAg9O7du8lxJKNhSNHGjRvBsmxcuSAI2LJlC3r27CmWXX311airq8OyZcvi7vPWrVvhdDoxderUOLs//fQTNmzYENcPAPzwww84efJko/JoX2VlZRg+fDjeffdd2Gw2sd6PP/6IZcuW4ZJLLml0Ta+//nqjsvfeew9dunSJG9eNN94Ij8eDBQsWNHGHkttdvXo1ioqKoNVqk7a5+uqrsXr1athstrj79N1330EikeC8886Lq79gwQLU1tbGlQmCIMaAMwwDo9EIiUSCLVu2JHw/N7RRVFSEyZMn45133om71y29TofDgS+//DIuPItlWVx77bX44osvshqzT6HkG1SQUygdEJ7nceTIkbiyAwcOAAC6d+8OAOICwoULF8Ltdov1Nm7ciCNHjmD8+PFi2auvvop169bhf//7X9pjWblyJT777DM8/fTTqK2txRdffIFbb701Ll69W7duOHPmDFwuV1zbvXv3iueTsWvXLuzduxfXXnutWHbttdeipqYGP/zwA4BwXLbRaMSvfvUr1NbWxh3vvvsugPpFsskYP348li1bBpfLBbvdjtraWjz//PMAwrHAQDjzDQDs3r272fty+vTpRgvqunXrhoMHD4IQElfe8D7MnTsXBw4cwJIlS3Dy5Em8/fbbuOiii+LaPPnkkzAYDDh48CB27dqFv/zlLxg8eHCz44py4sSJuNd2ux0AGgk3u90Oo9Eovu7Tpw8MBgNqamoa3WutVtvoPjfsx+FwJO0HgNhX9F7s37+/0dj37t2L4uJiqFSquPKjR482qrts2TKcOXMGN954I4CwsL3++uvx5ZdfNno/JqPhNUS/IMTel4b06dMHM2bMaHSPvv/+ewCN34+Jxg4AN998M3bu3Amfzwer1Yra2lrMnDlTfE82ZSP65SaV9yuQ2nW+99576NatGyZNmgQAuOCCC1BWVob3338/pT4olPYKzbJCoVAScubMGQBAVVVVo3PV1dXih+zUqVMxY8YM/OxnP4sTxhKJBEqlEt26dYPVaoXT6Uza19VXX40xY8bgsssuw0UXXYR58+bhN7/5DcaOHRv3ZaClfPzxx3jiiSdgNpvhdDoxa9YsfPTRR+B5HkDYSwcA77//PubPn5/Qxq5du5La79mzJ77//nvs27cPDz30EE6ePIlAIIBLLrkEDz30kGg/HVqTUaWmpgbDhg3DRRddhBkzZmDGjBm4/fbbMX/+fNx6660Awt7LXr164fLLL8eFF16IO+64Aw8++CDmzJmTUprM6L1LpTx2ESLLsqiqqhIFbqKxt7Sfhn2lS6J7LggCPvzwQ9x55524++67MWHCBHTp0gX/+c9/UrbbkrGyLItvv/02aUaj6BfopsZ+4403Yv78+fj888/x17/+FdXV1eB5Ho899pj45bA5G+mQynUuXboUlZWVuOmmm7B69WrcdNNNOHv2LL777rtW9U2hFDpUkFMoHRCO49CzZ08cPHhQLOvbty8AiOkNt27dCgDo0qVLo/adO3fGvn37AAAVFRUAgM8//7xRvfLychw7dgwPPPAAXn311SbHtHHjRmzcuBG///3vcf311+PDDz/Eddddh7fffhvHjx/HBRdcAI1GE+eVjGbLaG7Do48//hhPP/00Zs+ejaqqKuj1evz3v/8Vz9fU1MDhcIDjONEDmQ6XXXYZFAoFZs2aFee5bRh+cfjwYQDAOeecIz5Ph+PHj2PIkCFgGCbOS57oPgSDQSxatAiLFi0CwzCYO3cu5syZgz/+8Y9i3zabDe+++y7effddqNVqrFq1Ck8//XSL89anwuHDh3HBBRdg7dq1cWE+mSZ6L/r169foXP/+/cWMPqnw3nvv4be//S0uu+wyzJgxA9XV1Vi6dGlGx9uQw4cPQ6PRtOj9GOWqq67C4cOH48K1AOCZZ55JqX30V7RzzjmnxWNoSPQLzq233orf/e53uOKKK/DWW2/RHOyUDg8NWaFQOii//vWvG70OBAKiADhw4AB27NiByy+/PG5L7OnTp6OiogLLli0DEI7lveKKKxod1dXV2Lx5M6644oomd+xsmOoOgJgaTS6XAwC++eYbSCSSRmN+8MEHIQgCFi9e3OS17tu3D7t27cK1116La6+9FmfOnBHTQgJhkbBgwQLMnj0bgwYNatS+qKioSftRz2CsJ1Cn0zVKmfjtt9/C4XDgscceE68tHb755ht06tQpLvyG4zjce++9cDqdWLlyJQDAZDLFtSOEiB7+aL8N67jdbhw6dKhF40qHTz75BBKJpFGaTSB8LYlCKVpCZWUltm/fjltuuSXO5qBBg3DhhRfim2++SdnWjz/+iJ07d+KOO+7A7Nmz8d///jepNzhTfPLJJxg/fjwuvPDCRuf0ej04jmvWRqL35ejRozFu3LiUxlBbW4uVK1fi9ttvR9euXVMcefO8//77MJlMeOONN6DVatP6tYFCaa9QDzmF0gHxer24+OKL8e6772Ljxo2YMWMGZs6cieeeey5uUdeDDz6IZcuWYc2aNXjjjTeg1+vx0EMPYf/+/fjXv/4FIBzLm2jB1yuvvIKqqip8+eWXTY7llltuwd13343PP/8chw8fhlarxZ133gm73S6KpoULF+KHH37Ac889h+7du2Pnzp248MILccUVV+Dll19uFA+fiI8//hjPPvssfD4f3n777UZx2I8++iimTp2KjRs34q233sKePXtgMpkwYsQIXHDBBXFfShry7bffwu/3Y+HChXjjjTeg0Whw5513orq6Oi5PtdPpxIMPPoi3334bmzdvxocffgibzYahQ4dCpVKJ4STJePPNN/GrX/0K7777LkaOHIljx47hqquuwsSJE3H//feLvx78+9//hslkwg8//IBTp06hW7duuPfee7F9+3Yx3nzPnj1YsWIFtm7dCqvVinPPPRdXXXVVo8W+mWbVqlV4/fXX8fjjj2PYsGH49ttvEQwG0adPH1x99dW4//77U14s2RwPP/wwFi9ejPXr1+Ptt9+GUqnEvffeC7vdjqeffjotW++99x7+9re/AUBWBORf//pXzJo1C4sWLcK7776LrVu3Qq1WY/DgwbjqqqvQvXt3WCyWJm0sWrQIs2fPxueff46vv/4aPXr0wJw5c7Bnzx5x0XZz3HfffVizZg22bduGN998E0ePHkX37t1x6aWXYvjw4S26th07duDHH3/ENddcgz179mD79u0tskOhtDdynuqFHvSgR+aPptIeOp1O0qNHD7JkyRLicrnI2bNnyVNPPRWX2i16nH/++WTdunXE4/GQ2tpaMn/+fFJaWtps/6mmPRw2bBj54IMPyLFjx4jX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ulDZMD93RHR7XMl/tUj3mmlcadu2LYQYcplMhq5du+LkyZMF6SGncaX50XdHn+vptlEqlVCpVFAqldRDngJ0rudX3/k237M11zMeQx4LwzBi3PUFF1yARYsWAQBOnjyJoqKidM3ljFzEkPcdrcKka01gWYDlwl9CWI5Fl556vLLwqia/mAg8gdMajuV2WgToFGXYs+MY6qoDcFh48MFoaIkagBo6dIKuG9CvW2avgcaV0hhyGkOePdpzXGlHn+vptpHL5TAYDLjoootoDHkK0LmeX33n23zP1lxv0xjyLVu24Pe//z2+++47TJ48GXfddReAcOx3w3zh+Uy2Y8h7DSrCr167EoSQRsI79vXZ43acOGir93afCHu5a864wIfCorut47ibgsaV0hhyAGBZFkL9Lk0FA40rzZ++O/pcT7dNLv/uFyJ0rudX3/k239tFDPkDDzyADz74AFdccQWee+45HD58GEA49/i6devSNZc3tHXs1IFdVTiwsxq9BhWBkzT2hPO8gMO7a/Cbn/0vJXvtOdYs3+LMWmujvcWQA+nFxeUbNK40f/ru6HM93TaF9t7NNYV2v9rzXG+LPjpsDHmPHj1w9OhR/PjjjxgyZEij8w8//HDBvOlzxX9eapygPgrHsfjP3zZleUQUSvpotVqMHTsWGzZsKLidOimUQkWr1cJoNEKr1WY1IxiFQskOKQvyXbt24dixY/jqq6/wxRdfYPPmzXHnCz2mLRuLOnetO4ODu6oTbuN6eHctdq0/m/Iigfa6+CPfFn601kZ7XNTJcZy4SUmhLJiKQhd65U/fHX2up9uG4zhxU65Cef/mEjrX86vvfJvvBb2os6ioCNOnT8fll1+Or776CoQQLFq0CF999RWWLVtWcII8VxsDHVjDoM8QNq6M41gcXBve4TQV2vPij3xb+NFaG+1xUWeUiRMnZrW/TEAXeuVP3x19rqfbhuM49OrVCxMnTiyI926uoXM9v/rOt/le0Is6/X4/Fi1aJGZVGTduHGbNmoUXX3wRH330Eb777jt89dVXWLhwIWpra1MeQK7I2cZAi4HvlpobbeN6dI8lZRPtefFHvi38aK2N9rqos1AptPtF53p27eX7os5Ceu/mmkK7X+15rrdFH3RRZwzr16/H+vXr8dhjj6F3796YNWsWbr31VvzrX//CQw89hLlz57bUdE7I5kKK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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Matched-precision (float64) comparison, read from timings.json.\n", + "import cupy as cp\n", + "\n", + "NAMES = {'08_numpy': 'NumPy', '12_cupy': 'CuPy drop-in',\n", + " '12_cupy_fused': 'CuPy fused', '09_jax_fp64': 'JAX',\n", + " '12_cppjit_gpu_cub': 'CppJIT CUB', '12_cppjit_gpu_raw': 'CppJIT raw',\n", + " '10_pyomp': 'PyOMP', '13_mpi4py': 'mpi4py',\n", + " '11_nanobind': 'nanobind'}\n", + "by_stage = {r['stage']: r for r in rows if 'sweep' in r}\n", + "swe_core.require_stages(by_stage, NAMES)\n", + "\n", + "# Each tool carries its own (N, rate) pairs, since a slow tool stops earlier.\n", + "Ns, rate = swe_core.sweep_series(by_stage, NAMES)\n", + "\n", + "# Where the fp64 working set outgrows each device's last-level cache.\n", + "bpc = swe_core.working_set_bytes(1)\n", + "llc = swe_core.llc_total_mib()\n", + "l2 = swe_core.device_l2_mib()\n", + "realms = ([(l2 * 2**20 / bpc, 'GPU L2')] if l2 else []) + \\\n", + " ([(llc * 2**20 / bpc, 'CPU LLC')] if llc else [])\n", + "swe_core.plot_rate_sweep(Ns, rate, 'N (cells)',\n", + " 'fp64 solve across memory hierarchy',\n", + " boundaries=realms)" + ] + }, + { + "cell_type": "markdown", + "id": "e1a1e4e8", + "metadata": {}, + "source": [ + "#### Where end-to-end time is spent\n", + "\n", + "A wall-clock number cannot say where the time went. The bars below come from\n", + "Nsight Systems: one warm call per tool, with a capture range so the report\n", + "covers that call and nothing around it. GPU kernels and memory copies are what\n", + "the profiler measured. The host bar is the remainder, which is Python\n", + "assembling launches and any wait the device did not fill.\n", + "\n", + "Kernels dominate every path here, so what separates them is work on the\n", + "device, not host overhead. JAX is the exception on copies: its scan carries\n", + "state between steps as device-to-device transfers." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "84c78a8f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-27T10:55:13.603217Z", + "iopub.status.busy": "2026-07-27T10:55:13.603067Z", + "iopub.status.idle": "2026-07-27T10:55:13.677986Z", + "shell.execute_reply": "2026-07-27T10:55:13.677496Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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jcL8isK2S3oNtmnTBitXroSamho0NTXx559/Ijg4uFzXozze1u+byMvLw7Bhw7Bx40asXbsW2dnZGDFiBDIyMjBy5Ej897//lT6Rnzt3LtLS0nDx4kXMmTMHgYGBuHnzZpGfs/nz52Pr1q1YvHgxAgICyjRPAgCWL1+OnTt3IiwsDImJibhx4waqVKnyWue1a9cujB07Fp6entI6V1dX/Oc//4G/vz+OHTuGffv2lbj/06dPMW7cOOzbtw+amppITk7GyJEjXysWoneFYGFheTuKiYmJ2LdvnzAxMVF5LCwsb1PZtGmTWLRoUYW3K4QQBgYGKj8/lreznDp1SowfP16lMfDvAsu7VDgUioiI3lr169dHaGgobG1tX9mrQVRR7OzsEBkZCblc/srJ30T0PxwKRUREb634+Hjpuf+VQU1NrdLapneXr6+v9BQzIio79lgQvUUKx/3zySBERAT87+/Bq55uRfQ24LsXordIYmIi8vLyMHToULi6uiI/P1/VIRERkYpoampi6NChyMvLQ2JioqrDIXolNTyfbEFEb4nWrVtj9uzZ0NLSUnUoRESkYnl5eVi3bp30JXtEbzMmFkRvIR0dHdSpU4fjv4mIPmBCCCQmJiIrK0vVoRCVCRMLIiIiIiJSGidvExERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0jRVHQCVT4MGDSCTyVQdBhERERFVEH19fTx69EjVYSiNicU7pEGDBoiLi1N1GERERERUwRo2bPjOJxdMLN4hhT0VDRs2ZK8FERER0XtAX18fcXFx78V7OyYW7yCZTPZevPiIiIiI6P3BydtERERERKQ0JhZERERERKQ0JhZERERERKQ0JhZERERERKQ0JhZERERERKQ0PhXqHdR09l6k5+SrOgwiIiKicvFp9lmlth/jZFep7VcGdT09VYdQYdhjQURERERESmNiQURERERESmNiQURERERESmNiQURERERESmNiQURERERESmNiQURERERESmNiQURERERESmNiQURERERESmNiQURERERESnuvEwsHBwcIIWBgYKCS4zs7O8PV1VUlxyYiIiIiepM0y7tDvXr1sHDhQgwYMAANGzbEkydP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+HjEx8dLyw8fPlTY/uIc5tImgL9KuR43GxsbC1tbW1y+fBlr167F7du3cf78efTs2RNTp06V6n3xxRfQ1NSEr68v1q9fj0WLFr1WcIWTUcLCwuDt7V1q3QcPHmD48OEYMmQIAgMD8fXXX+OHH34o03EmTpwIX19f/PPPP/Dw8ICamhr69++vkPFlZmZi9erV2L9/P9zd3ZGeno5Ro0a91nkREREREb1v1ABUzrfDVZDIyEhs3boV69atU1kMjo6OWL9+PQwNDVUWA/D8i0/S0tJgtdQV6TnKdXMRERERvWk+zT6r1PZjnOwqtf3KoK6nB5urV1G9enXIZDJVh6OUcg2FepNq166N0aNHw8jIqMTvriAiIiIiorfDW5tYJCYmIjExEVOmTEFKSoqqwyEiIiIiolK8tYlFZXxvxuvau3cv9u7dq+owiIiIiIjeWuWavE1ERERERFQcJhZERERERKQ0JhZERERERKQ0JhZERERERKS0t/57LOh/Cr/H4n14zjERERERvV/v79hjQURERERESmNiQURERERESmNiQURERERESmNiQURERERESmNiQURERERESmNiQURERERESmNiQUREREREStNUdQBUfk1n70V6Tr6qwyAiIiICAPg0+0zpNmKc7CogknePup6eqkOoMOyxICIiIiIipTGxICIiIiIipTGxICIiIiIipTGxICIiIiIipTGxICIiIiIipTGxICIiIiIipTGxICIiIiIipTGxICIiIiIipTGxICIiIiIipX2wicVHH32EoKAg5ObmwtXVVWVxxMbGYtasWSo7PhERERG9ndTV1dG2bVt06tQJ9vb2sLGxgY6OjqrDKlGlJhb16tXDxo0bER0djezsbNy/fx8nT55Ejx49ytyGiYkJhBBSefr0Kdzc3GBtba1UbL///jsCAgLQtGlTfP7550q1RURERERUGeLi4uDh4QEvLy8kJiaiZcuWqg6pRJqV1bCJiQnc3d2RkpKCuXPnIjg4GFpaWujbty+2bNkCS0vLcrXXs2dP3LlzB40aNcLGjRtx5swZtGjRAqmpqa8Vn6mpKbZt24a4uLjX2p+IiIiIqDLJ5XI8e/ZMWk5NTYWJiUmxdevXrw8jIyPk5eWhWrVqyM/PR2hoKExNTaGnp4fs7GwEBQWhoKAAtWvXhqmpKQBATU0N0dHRSExMVDreSuux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dXzxxRfYvXu3tK5GjRrYsWMHVqxYATU1NWzduhVXr17Fs2fPVBE2EREREb0DwsLCYGlpiebNm0MIgbCwMOTk5CjVZsuWLaGlpQUhBHJzcxESElIhsTKxUELr1q3Rrl07dOvWDUlJSdL6mjVr4sqVK7CwsEB4eDgAYNOmTXj8+DF++eUXAMDgwYOxZcsWjB49WiWxExEREdHbLysrC35+fq+s9/IDgV5+LG1oaKj0f19f34oJ7iVMLJQwadIkeHt7KzwCrNCtW7cwadIkzJs3D0OGDMHIkSNhZ2cnTY5xdHSEj48Phg0bhmPHjr3p0ImIiIiIKhTnWLwGdXV1yOVyjB8/HkePHi22ztGjRzFhwgTUrl0b27Ztw48//qjwLYm3b9/Gjz/+iK1bt0rj5oiIiIiI3lVqAPjtbOV05swZREVFYebMmW/0uPr6+khLS4PVUlek5+S/0WMTker4NPtM1SEQ0XskxslO1SHQC9T19GBz9SqqV6/+zj+ohz0W5VCjRg0MGDAA3bp1w4ULF1QdDhERERHRW4NzLMph9+7daN++PdauXYsTJ06oOhwiIiIiorcGE4tyGDZsmKpDICIiIiJ6K3EoFBERERERKY2JBRERERERKY2JBRERERERKY2JBRERERERKY3fY/EOKfwei/fhOcdERERE9H69v2OPBRERERERKY2JBRERERERKY2JBRERERERKY2JBRERERERKY2JBRERERERKY2JBRERERERKU1T1QFQ+TWdvRfpOfmqDoOIiIjojfJp9lmZ6sU42VVyJBVHXU9P1SFUGPZYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0phYEBERERGR0vjN20REREREbyF1dXW0bt0aenp6kMvlyM3NRVhYGLKyslQdWrEqtceiXr162LhxI6Kjo5GdnY379+/j5MmT6NGjR4Uex8TEBEIIWFlZFVleunQphBClltJ8+eWX8PT0hEwmQ3JyMm7duoVZs2ZBR0cHAODs7AxXV9ci+zk4OEAIAQMDAwCAo6OjwjFlMhl8fHwwdOjQCr0WRERERPT+iIuLg4eHB7y8vJCYmIiWLVuqOqQSVVqPhYmJCdzd3ZGSkoK5c+ciODgYWlpa6Nu3L7Zs2QJLS8vK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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Composition of one profiled solve call, recorded by each tool's own\n", + "# notebook (swe_core.nsys_breakdown + save_breakdown).\n", + "BSTAGES = ['12_cupy', '12_cupy_fused', '09_jax_fp64',\n", + " '12_cppjit_gpu_cub', '12_cppjit_gpu_raw']\n", + "swe_core.require_stages(by_stage, BSTAGES, key='breakdown')\n", + "\n", + "PARTS = [('kernel_s', 'GPU kernels', '#27a'),\n", + " ('memcpy_s', 'memory copies', '#e90'),\n", + " ('host_s', 'host and Python', '#c33')]\n", + "fig, ax = plt.subplots(figsize=(8, 3.2))\n", + "ys, left = np.arange(len(BSTAGES)), np.zeros(len(BSTAGES))\n", + "for key, lbl, color in PARTS:\n", + " frac = np.array([by_stage[s]['breakdown'][key]\n", + " / by_stage[s]['breakdown']['total_s']\n", + " for s in BSTAGES]) * 100\n", + " ax.barh(ys, frac, left=left, color=color, label=lbl)\n", + " left += frac\n", + "ax.set_yticks(ys); ax.set_yticklabels([NAMES[s] for s in BSTAGES])\n", + "ax.invert_yaxis(); ax.set_xlim(0, 100)\n", + "ax.set_xlabel('share of one solve call [%]')\n", + "bd0 = by_stage[BSTAGES[0]]['breakdown']\n", + "ax.set_title(f\"Composition of one solve call at N = {bd0['n']:,}, {bd0['steps']} steps\", pad=30)\n", + "for s, y in zip(BSTAGES, ys):\n", + " ax.text(101, y, f\"{by_stage[s]['breakdown']['total_s'] * 1e3:,.0f} ms\",\n", + " va='center', fontsize=8, color='#444')\n", + "ax.legend(ncols=4, fontsize=8, loc='lower center', bbox_to_anchor=(0.5, 1.04))\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "2b9e9456", + "metadata": {}, + "source": [ + "### 4. Programming models and limitations\n", + "\n", + "The plots rank by throughput. The table below is the qualitative counterpart: the programming model each tool exposes, and where that model stops.\n", + "\n", + "| Tool | Programming model | Limitation |\n", + "|---|---|---|\n", + "| **NumPy** | Array programming on the host | Single-threaded, no GPU |\n", + "| **CuPy** | Array programming on the device, NumPy API | One kernel per elementwise op and Python in the loop, unless hand-fused via `ElementwiseKernel` |\n", + "| **JAX** | Pure functions traced and compiled to one device program; autodiff | Shape-rigidity: each new input shape pays a fresh XLA compile; float32 is the default and float64 pays the bandwidth its extra bytes cost |\n", + "| **PyOMP** | OpenMP directives inside a JIT-compiled Python function | Scaling stops at physical cores; scalar per-core rate is arithmetic bound at cache-resident sizes |\n", + "| **mpi4py** | Explicit messages between independent processes | Per-step fixed costs dominate as slabs shrink; one NumPy rank per core on a single node |\n", + "| **nanobind** | Hand-written bindings to a compiled C++ extension | Build-system glue and FFI cost, plus a recompile-on-edit cycle |\n", + "| **CppJIT** | C++ and CUDA compiled into the live session, with bindings generated automatically | First-call compile cost, high memory utilisation and parsing cost (JIT overhead) with large translation units |" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/solutions/swe_core.py b/tutorials/pyhpc/notebooks/solutions/swe_core.py new file mode 120000 index 00000000..0008ff85 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/swe_core.py @@ -0,0 +1 @@ +../swe_core.py \ No newline at end of file diff --git a/tutorials/pyhpc/notebooks/solutions/swe_cub_solver.cpp b/tutorials/pyhpc/notebooks/solutions/swe_cub_solver.cpp new file mode 120000 index 00000000..cb063522 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/swe_cub_solver.cpp @@ -0,0 +1 @@ +../swe_cub_solver.cpp \ No newline at end of file diff --git a/tutorials/pyhpc/notebooks/solutions/swe_raw_cuda_solver.cpp b/tutorials/pyhpc/notebooks/solutions/swe_raw_cuda_solver.cpp new file mode 120000 index 00000000..df50932d --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/swe_raw_cuda_solver.cpp @@ -0,0 +1 @@ +../swe_raw_cuda_solver.cpp \ No newline at end of file diff --git a/tutorials/pyhpc/notebooks/solutions/swe_step.cpp b/tutorials/pyhpc/notebooks/solutions/swe_step.cpp new file mode 120000 index 00000000..136f7609 --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/swe_step.cpp @@ -0,0 +1 @@ +../swe_step.cpp \ No newline at end of file diff --git a/tutorials/pyhpc/notebooks/swe_core.py b/tutorials/pyhpc/notebooks/swe_core.py new file mode 100644 index 00000000..058e7244 --- /dev/null +++ b/tutorials/pyhpc/notebooks/swe_core.py @@ -0,0 +1,723 @@ +# SPDX-License-Identifier: Apache-2.0 +"""1D Shallow Water Equations reference solver in NumPy. + +We solve + + ∂h/∂t + ∂(hu)/∂x = 0 (water height) + ∂(hu)/∂t + ∂(hu²/h + ½ g h²)/∂x = 0 (momentum) + +on a domain [0, L] split into N cells of width dx = L/N, with one ghost +cell on each side. State arrays have shape (N+2,); the interior is [1:-1]. + +Time discretisation: forward Euler. +Space discretisation: finite volume with the Rusanov interface flux. + +The discrete update at cell i is + + q_new[i] = q[i] - (dt/dx) (F[i+½] - F[i-½]) + +where q = (h, hu) is the conserved state. The Rusanov flux at face i+½ +(between cells i and i+1) is + + F[i+½] = ½ (F(q_L) + F(q_R)) - ½ a (q_R - q_L) + a = max(|u_L| + √(g h_L), |u_R| + √(g h_R)) + +with u = hu/h the velocity. + +`step_numpy` is the reference implementation. Each notebook in the +tutorial replaces it with a different tool and validates the result +against this one via `max_diff(h_tool, h_numpy) < TOL`. +""" + +import json, os, time +from pathlib import Path +import numpy as np + +os.environ.setdefault("CPPINTEROP_EXTRA_INTERPRETER_ARGS", "-O3") + +g = 9.81 # gravitational acceleration, m/s² +DRY_TOL = 1e-6 # depth below this is clamped (to avoid division by 0) + +# As this is a canonical asset, allow arbitrary callers to resolve this file's location +HERE = Path(__file__).resolve().parent +TIMINGS_PATH = str(HERE / "timings.json") +SWE_STEP_CPP = str(HERE / "swe_step.cpp") +SWE_CUB_CPP = str(HERE / "swe_cub_solver.cpp") +SWE_RAW_CPP = str(HERE / "swe_raw_cuda_solver.cpp") + + +# --- Initial / boundary conditions ----------------------------------------- + +def bump_ic(N, L=10.0, h0=1.0, amplitude=0.1, sigma=0.5): + """1D Gaussian-bump pulse: + h(x, 0) = h0 + amplitude * exp(-((x - L/2) / sigma)**2) + hu(x, 0) = 0 + The bump splits into two counter-propagating wave packets at + c = sqrt(g * h0). In the linear regime (amplitude << h0) they + stay smooth indefinitely. + """ + dx = L / N + xs = (np.arange(N + 2) - 0.5) * dx + h = h0 + amplitude * np.exp(-((xs - L / 2) / sigma) ** 2) + hu = np.zeros_like(h) + return h, hu + + +def apply_bc_reflective(h, hu): + """Reflective walls: mirrors ghost cells and flips momentum sign.""" + h[0] = h[1]; h[-1] = h[-2] + hu[0] = -hu[1]; hu[-1] = -hu[-2] + + +# --- Time step (forward Euler + Rusanov flux) ------------------------------- + +def fixed_dt(h_max, dx, cfl=0.4, g=g): + """One-shot dt from the max wave speed in the IC (CFL stability). + h_max is the peak h value in the initial condition.""" + return float(cfl * dx / np.sqrt(g * h_max)) + + +def step_numpy(h, hu, dx, dt, g=g, tol=DRY_TOL): + """One forward-Euler step with Rusanov flux. Returns (h_new, hu_new).""" + # Left/right states at every interface i+½ (length N+1). + hL, hR = h[:-1], h[1:] + huL, huR = hu[:-1], hu[1:] + + # Velocities and wave speeds at the face. + h_safe_L = np.maximum(hL, tol) + h_safe_R = np.maximum(hR, tol) + uL, uR = huL / h_safe_L, huR / h_safe_R + cL, cR = np.sqrt(g * h_safe_L), np.sqrt(g * h_safe_R) + a = np.maximum(np.abs(uL) + cL, np.abs(uR) + cR) + + # Rusanov interface flux: average of physical fluxes − stabilising diffusion. + F_h = 0.5 * (huL + huR) - 0.5 * a * (hR - hL) + F_hu = 0.5 * (huL*uL + 0.5*g*hL*hL + huR*uR + 0.5*g*hR*hR) - 0.5 * a * (huR - huL) + + # Divergence: update interior cells, ghost cells unchanged. + h_new, hu_new = np.empty_like(h), np.empty_like(hu) + h_new[0], h_new[-1] = h[0], h[-1] + hu_new[0], hu_new[-1] = hu[0], hu[-1] + h_new[1:-1] = h[1:-1] - (dt / dx) * (F_h[1:] - F_h[:-1]) + hu_new[1:-1] = hu[1:-1] - (dt / dx) * (F_hu[1:] - F_hu[:-1]) + return h_new, hu_new + + +def solve_numpy(N, n_steps, L=10.0, h0=1.0, amplitude=0.1, sigma=0.5, + cfl=0.4, g=g): + """NumPy reference solver: bump IC, run n_steps of boundary conditions and update. + Returns the final (h, hu)""" + dx = L / N + dt = fixed_dt(h0 + amplitude, dx, cfl=cfl, g=g) + h, hu = bump_ic(N, L=L, h0=h0, amplitude=amplitude, sigma=sigma) + for _ in range(n_steps): + apply_bc_reflective(h, hu) + h, hu = step_numpy(h, hu, dx, dt, g=g) + return h, hu + + +# --- Validation ------------------------------------------------------------- + +def max_diff(a, b): + """Max |a - b| as a float - the cross-tool acceptance metric.""" + return float(np.max(np.abs(np.asarray(a) - np.asarray(b)))) + + +def report_and_verify(warm, diff, tol, cold_s=None, + n=None, steps=None, cold_note=""): + """Verify results are within the defined error threshold, and emit timing and acceptance record. + `warm`: dict returned by `timed_run`; `diff`: `max_diff(...)` against the fp64 NumPy reference. + `cold_note`: append what the cold call includes. FAIL raises AssertionError + """ + title = warm.get("label") or "run" + ok = diff < tol + ctx = f" N={n} steps={steps}" if n is not None and steps is not None else "" + note = f" ({cold_note})" if cold_note else "" + cold = f" | cold {cold_s * 1e3:7.1f} ms{note}" if cold_s is not None else "" + record = (f"[{title}]{ctx}{cold} | warm {warm['median_s'] * 1e3:7.1f} ms" + f" | max_diff {diff:.2e} {'<' if ok else '>='} tol {tol:.0e}" + f" | {'PASS' if ok else 'FAIL'}") + print(record) + assert ok, record + + +# --- Timing harness --------------------------------------------------------- + +def timed_run(fn, *args, warmup=2, repeats=5, label=""): + """Time fn(*args) with warmups + repeats. Returns median / min / max [s]. + + fn must not return until its work is done: every runner in this tutorial + is responsible for handling synchronization and returns host arrays. + """ + for _ in range(warmup): + fn(*args) + ts = [] + for _ in range(repeats): + t0 = time.perf_counter() + fn(*args) + ts.append(time.perf_counter() - t0) + return { + "label": label, + "median_s": float(np.median(ts)), + "min_s": float(np.min(ts)), + "max_s": float(np.max(ts)), + "repeats": repeats, + "samples_s": [float(t) for t in ts], + } + + +def save_timing(result, grid_str, tool, hardware, dtype, steps=None, **extra): + """Write a timing record to timings.json""" + path = Path(TIMINGS_PATH) + records = load_timings() if path.exists() else [] + record = { + "stage": result.get("label", tool), + "grid": grid_str, + "steps": steps, + "median_s": result["median_s"], + "min_s": result["min_s"], + "max_s": result["max_s"], + "tool": tool, + "hardware": hardware, + "dtype": dtype, + } + record.update(extra) + # Keep one row per stage: a re-run replaces its previous entry. + records = [r for r in records if r.get("stage") != record["stage"]] + records.append(record) + path.write_text(json.dumps(records, indent=2)) + + +# --- Problem sizing ----------------------------------------------------------- +# +# Sizes are derived from the machine: core count, cache sizes, free memory. + +CANONICAL_WORK = 200_000_000 # cells x steps in the headline run +SWEEP_WORK = 200_000_000 # cells x steps at each sweep point +CELLS_PER_CORE = 15_000 # enough work per thread to amortise a fork/join +MIN_STEPS = 20 # below this a step-rate measurement is noise +MAX_STEPS = 1000 + + +def _pow2_at_least(x): + return max(1 << max(int(max(x, 1) - 1).bit_length(), 0), 1024) + + +def _pow2_nearest(x): + up = _pow2_at_least(x) + down = max(up // 2, 1024) + return down if (x - down) < (up - x) else up + + +def canonical_size(): + """Headline (n_cells, n_steps): the value NB 08 recorded, else derived.""" + fixed = load_machine().get("canonical") + return tuple(fixed) if fixed else _derive_canonical() + + +def _derive_canonical(): + """Headline (n_cells, n_steps): enough cells per core to amortise a + parallel region, capped to keep the NumPy reference quick.""" + n = _pow2_nearest(physical_cores() * CELLS_PER_CORE) + n = min(n, _pow2_at_least(_cell_budget())) + steps = int(min(MAX_STEPS, max(MIN_STEPS, CANONICAL_WORK // n))) + return n, steps + + +def _derive_sweep(): + """[(n_cells, n_steps)] spanning cache-resident to DRAM-resident. + + Runs from an eighth of the smaller cache boundary to eight times the + larger one, with `n * steps` held near constant.""" + per_cell = working_set_bytes(1) + boundaries = [m * 2**20 / per_cell + for m in (device_l2_mib(), llc_total_mib()) if m] + lo = _pow2_at_least(min(boundaries) / 8) if boundaries else 16_384 + hi = _pow2_at_least(max(boundaries) * 8) if boundaries else 16_777_216 + hi = min(hi, _pow2_at_least(_cell_budget())) + sizes, n = [], lo + while n <= max(hi, lo): + sizes.append((n, int(min(MAX_STEPS, max(MIN_STEPS, SWEEP_WORK // n))))) + n *= 4 + return tuple(sizes) + + +def _cell_budget(fraction=0.4, bytes_per_cell=200): + """Cells that fit in `fraction` of the tighter of host and device memory. + + The 200 B/cell allowance covers `step_numpy`'s temporaries.""" + host, dev = free_bytes() + limits = [b for b in (host, dev) if b] + return (min(limits) * fraction / bytes_per_cell) if limits else 16_777_216 + + +def free_bytes(): + """(host, device) available memory in bytes; either is None if unknown.""" + host = dev = None + try: + for line in open("/proc/meminfo"): + if line.startswith("MemAvailable:"): + host = int(line.split()[1]) * 1024 + break + except OSError: + pass + try: + import cupy + dev = int(cupy.cuda.runtime.memGetInfo()[0]) + except Exception: + pass + return host, dev + + +def device_l2_mib(): + """GPU L2 cache in MiB; None when no CUDA device is visible.""" + try: + import cupy + return cupy.cuda.runtime.getDeviceProperties(0)["l2CacheSize"] / 2**20 + except Exception: + return None + + +def llc_total_mib(): + """Last-level cache summed over every instance, in MiB; None if unknown.""" + import glob + scale = {"K": 2**10, "M": 2**20, "G": 2**30} + found = [] + for d in glob.glob("/sys/devices/system/cpu/cpu*/cache/index*/"): + try: + level = int(open(d + "level").read()) + shared = open(d + "shared_cpu_list").read().strip() + size = open(d + "size").read().strip() + except (OSError, ValueError): + continue + found.append((level, shared, + float(size.rstrip("KMG")) * scale.get(size[-1], 1) / 2**20)) + if not found: + return None + top = max(f[0] for f in found) + return sum({shared: mib for level, shared, mib in found + if level == top}.values()) + + +_SWEEP_SIZES = None + + +def sweep_points(): + """Sweep points: the list NB 08 recorded, else derived once and cached.""" + fixed = load_machine().get("sweep") + if fixed: + return tuple(tuple(p) for p in fixed) + global _SWEEP_SIZES + if _SWEEP_SIZES is None: + _SWEEP_SIZES = _derive_sweep() + return _SWEEP_SIZES + + +def sweep_sizes(): + """Derive sweep points from the machine, ignoring any recorded list.""" + return _derive_sweep() + + +def __getattr__(name): + # Deriving SWEEP_SIZES imports CuPy, so resolve it on first use. + if name == "SWEEP_SIZES": + return sweep_points() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def by_size(build): + """Memoise `build(n_cells)` so a timed loop excludes the setup it returns. + + The warmup call pays for the setup; the timed calls reuse it.""" + cache = {} + + def get(n): + if n not in cache: + cache[n] = build(n) + return cache[n] + + get.cache = cache + return get + + +def timed_sweep(fn, warmup=1, repeats=3, budget_s=120.0): + """Rate fn(n_cells, n_steps) at every sweep point, for timings.json. + + Every tool runs the identical (cells, steps) at each point. Do not vary the + step count per tool: throughput is N / (fixed/steps + per_step), so fewer + steps reads slower for a reason unrelated to the kernel. + + A tool that cannot reach the larger sizes inside `budget_s` stops early and + the skipped sizes are named.""" + out, dropped, spent = [], [], 0.0 + points = sweep_points() + for i, (n, steps) in enumerate(points): + if out: + last = out[-1] + per_run = last["median_s"] * (n * steps) / (last["n"] * last["steps"]) + if spent + per_run * (warmup + repeats) > budget_s: + dropped = [m for m, _ in points[i:]] + break + t0 = time.perf_counter() + r = timed_run(fn, n, steps, warmup=warmup, repeats=repeats) + spent += time.perf_counter() - t0 + out.append({"n": n, "steps": steps, "median_s": r["median_s"]}) + if dropped: + print(f" sweep stopped after N = {out[-1]['n']:,}; " + f"{', '.join(f'{m:,}' for m in dropped)} would overrun the " + f"{budget_s:.0f} s budget") + return out + + +def sweep_table(runners, budget_s=60.0, warmup=1, repeats=3): + """Rate every {label: fn(n_cells, n_steps)} at each sweep point, printing a + row per size and returning {label: (n_cells, Mcells/s)}. + + A runner that has spent `budget_s` sits out the remaining sizes, so a slow + tool cannot stall the sweep.""" + rates = {k: ([], []) for k in runners} + spent = dict.fromkeys(runners, 0.0) + for n, steps in sweep_points(): + row = [] + for name, fn in runners.items(): + if spent[name] > budget_s: + continue + t0 = time.perf_counter() + r = timed_run(fn, n, steps, warmup=warmup, repeats=repeats) + spent[name] += time.perf_counter() - t0 + rate = n * steps / r["median_s"] / 1e6 + rates[name][0].append(n) + rates[name][1].append(rate) + row.append(f"{name} {rate:7.0f}") + print(f"N={n:>10,} steps={steps:>4} " + " ".join(row) + " Mcells/s") + return rates + + +def sweep_series(by_stage, names): + """Read the recorded sweeps into {label: (n_cells, Mcells/s)}, print the + table, and return (sorted sizes, series).""" + rate = {names[s]: ([p["n"] for p in by_stage[s]["sweep"]], + [p["n"] * p["steps"] / p["median_s"] / 1e6 + for p in by_stage[s]["sweep"]]) for s in names} + sizes = sorted({n for xs, _ in rate.values() for n in xs}) + print(f'{"N":>11}' + "".join(f"{names[s]:>14}" for s in names) + " Mcells/s") + for n in sizes: + cells = [f'{ys[xs.index(n)]:>14.0f}' if n in xs else f'{"-":>14}' + for xs, ys in (rate[names[s]] for s in names)] + print(f"{n:>11,}" + "".join(cells)) + return sizes, rate + + +def save_sweep(stage, fn, warmup=1, repeats=3): + """Attach a benchmark sweep to an existing timings.json row. + + Run from the notebook that owns the stage, after its save_timing call: + each tool is then measured in its own process, free of the others.""" + records = load_timings() + row = next(r for r in records if r.get("stage") == stage) + row["sweep"] = timed_sweep(fn, warmup=warmup, repeats=repeats) + Path(TIMINGS_PATH).write_text(json.dumps(records, indent=2)) + + +def save_breakdown(stage, breakdown, n, steps): + """Attach a `nsys_breakdown` result to an existing timings.json row. + + Like `save_sweep`, run from the notebook that owns the stage.""" + records = load_timings() + row = next(r for r in records if r.get("stage") == stage) + row["breakdown"] = {"n": n, "steps": steps, **breakdown} + Path(TIMINGS_PATH).write_text(json.dumps(records, indent=2)) + + +def nsys_breakdown(report, wall_s): + """Split `wall_s` into GPU kernels, memory copies and host time [s]. + + Kernel and copy times come from `nsys stats` on `report`, so the report must + cover exactly the call that `wall_s` measured. Host time is the remainder: + the interpreter, the launches and any wait the device did not fill.""" + import subprocess + out = subprocess.run( + ["nsys", "stats", "--force-export=true", + "--report", "cuda_gpu_kern_sum", + "--report", "cuda_gpu_mem_time_sum", str(report)], + capture_output=True, text=True).stdout + totals, section = {}, None + for line in out.splitlines(): + if "(cuda_gpu_kern_sum)" in line: + section = "kernel_s" + elif "(cuda_gpu_mem_time_sum)" in line: + section = "memcpy_s" + elif section: + f = line.split() + if len(f) > 2 and f[0].replace(".", "", 1).isdigit(): + totals[section] = totals.get(section, 0) + int(f[1].replace(",", "")) + kernel = totals.get("kernel_s", 0) / 1e9 + memcpy = totals.get("memcpy_s", 0) / 1e9 + return {"total_s": wall_s, "kernel_s": kernel, "memcpy_s": memcpy, + "host_s": max(wall_s - kernel - memcpy, 0.0)} + + +def load_timings(): + """Return all timing records as a list of dicts (empty if missing).""" + path = Path(TIMINGS_PATH) + return json.loads(path.read_text()) if path.exists() else [] + + +# --- Experimental setup reports ------------------------------------------ + +MACHINE_PATH = str(HERE / "machine.json") + + +def physical_cores(): + """Physical core count, falling back to the logical count.""" + import psutil + return psutil.cpu_count(logical=False) or psutil.cpu_count() + + +def machine_report(): + """Document the measurement environment and experimental setup: software versions, + CPU/GPU identity, clock/governor state.""" + import platform, subprocess + info = {"python": platform.python_version(), "numpy": np.__version__} + # x86 kernels name the CPU in /proc/cpuinfo, aarch64 ones do not. + try: + info["cpu"] = next( + (ln.split(":", 1)[1].strip() for ln in open("/proc/cpuinfo") + if ln.split(":", 1)[0].strip() in ("model name", "Model name", "Model")), + platform.machine()) + except OSError: + info["cpu"] = platform.machine() + info["cores_physical_logical"] = f"{physical_cores()}/{os.cpu_count()}" + try: + info["cpu_governor"] = open( + "/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").read().strip() + except OSError: + pass + for mod in ("jax", "numba", "cupy"): + try: + info[mod] = __import__(mod).__version__ + except ImportError: + pass + try: + q = subprocess.run( + ["nvidia-smi", "--query-gpu=name,driver_version,memory.total," + "clocks.sm,clocks.max.sm,temperature.gpu", + "--format=csv,noheader"], capture_output=True, text=True, timeout=10) + if q.returncode == 0: + (info["gpu"], info["driver"], info["gpu_mem"], info["gpu_sm_clock"], + info["gpu_sm_clock_max"], info["gpu_temp_c"]) = \ + [s.strip() for s in q.stdout.splitlines()[0].split(",")] + except (OSError, subprocess.TimeoutExpired): + pass + w = max(len(k) for k in info) + for k, v in info.items(): + print(f" {k:<{w}} {v}") + return info + + +def save_sizing(extra=None): + """Fix this machine's problem sizes and record them in machine.json. + + NB 08 settles them once, so every rung sweeps the same sizes.""" + record = dict(load_machine()) + record.update(extra or {}) + record["canonical"] = list(_derive_canonical()) + record["sweep"] = [list(p) for p in _derive_sweep()] + Path(MACHINE_PATH).write_text(json.dumps(record, indent=2)) + n, steps = record["canonical"] + print(f" canonical N = {n:,} cells, {steps} steps") + print(" sweep " + + ", ".join(f"{m:,}" for m, _ in record["sweep"])) + return record + + +def to_mib(nbytes): + """Bytes -> binary MiB (2**20 bytes).""" + return nbytes / 2**20 + + +def working_set_bytes(n_cells, n_arrays=6, itemsize=8): + """Bytes one step of the canonical two-pass solver streams. + + h, hu double-buffered (4 state arrays) plus the two face-flux arrays. + Fused single-pass kernels (e.g. PyOMP's) compute fluxes in registers + and carry only the state: pass n_arrays=4.""" + return n_arrays * n_cells * itemsize + + +def llc_mib(): + """Largest cache visible to core 0, in MiB; None if sysfs reports none.""" + import glob + scale = {"K": 2**10, "M": 2**20, "G": 2**30} + sizes = [] + for p in glob.glob("/sys/devices/system/cpu/cpu0/cache/index*/size"): + s = open(p).read().strip() + sizes.append(float(s.rstrip("KMG")) * scale.get(s[-1], 1) / 2**20) + return max(sizes) if sizes else None + + +def load_machine(): + """Return machine.json's contents ({} if absent).""" + path = Path(MACHINE_PATH) + return json.loads(path.read_text()) if path.exists() else {} + + +# --- Build helper ----------------------------------------------------------- + +def run_cmd(cmd, cwd=None): + """Run a command, echo its last stdout line; raise with stderr on failure.""" + import subprocess + r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if r.returncode != 0: + raise RuntimeError(f"command failed: {' '.join(map(str, cmd))}\n{r.stderr}") + if r.stdout.strip(): + print(r.stdout.strip().splitlines()[-1]) + + +# --- Presentation helpers --------------------------------------------------- +# +# Cosmetic utility functions that receive measurements or derived quantities. + +_MARKERS = ("o-", "s-", "d-", "v-", "^-", "D-", "p-", "x-", "+-") + + +def plot_rate_sweep(x, series, xlabel, title, ylabel="throughput [Mcells/s]", + boundaries=(), logy=True, from_zero=False): + """Plot one line per named series against x on a log-x axis. + + boundaries is a sequence of (x, label) verticals, drawn where a caller- + computed regime changes (e.g. cache to DRAM). A series may be a plain list + of y values against the shared x, or its own (xs, ys) pair when a tool + could not afford every size.""" + import matplotlib.pyplot as plt + fig, ax = plt.subplots(figsize=(7.5, 4)) + series = {k: (v if isinstance(v, tuple) else (x[:len(v)], v)) + for k, v in series.items()} + for (name, (xs, ys)), marker in zip(series.items(), _MARKERS): + ax.plot(xs, ys, marker, label=name) + ax.set_xscale("log") + if logy: + ax.set_yscale("log") + elif from_zero: + ax.set_ylim(0, max(max(ys) for _, ys in series.values()) * 1.15) + for xb, label in boundaries: + ax.axvline(xb, ls=":", color="#666", lw=1) + ax.text(xb, ax.get_ylim()[0], f" {label}", rotation=90, va="bottom", + ha="left", fontsize=8, color="#444") + ax.set(xlabel=xlabel, ylabel=ylabel, title=title) + ax.legend(ncol=2, fontsize=8) + ax.grid(alpha=0.3, which="both") + plt.tight_layout() + plt.show() + + +def plot_cold_warm(labels, cold_s, warm_s, title): + """Grouped cold-vs-warm bars on a log time axis.""" + import matplotlib.pyplot as plt + x, w = np.arange(len(labels)), 0.38 + fig, ax = plt.subplots(figsize=(7, 4)) + ax.bar(x - w / 2, [t * 1e3 for t in cold_s], w, + label="cold (first call)", color="#c33") + ax.bar(x + w / 2, [t * 1e3 for t in warm_s], w, + label="warm (median)", color="#5a8") + ax.set_xticks(x) + ax.set_xticklabels(labels) + ax.set_yscale("log") + ax.set(ylabel="time [ms, log]", title=title) + ax.legend() + ax.grid(alpha=0.3, axis="y") + plt.tight_layout() + plt.show() + + +def plot_compile_share(labels, cold_s, warm_s, title): + """Stacked bars splitting each first call into execution and one-time compile.""" + import matplotlib.pyplot as plt + warm_ms = [t * 1e3 for t in warm_s] + comp_ms = [max(c - w, 0.0) * 1e3 for c, w in zip(cold_s, warm_s)] + total = [w + c for w, c in zip(warm_ms, comp_ms)] + ex_frac = [w / t for w, t in zip(warm_ms, total)] + cm_frac = [c / t for c, t in zip(comp_ms, total)] + x = np.arange(len(labels)) + fig, ax = plt.subplots(figsize=(7, 4)) + ax.bar(x, ex_frac, width=0.55, color="#27a", label="execution (time per run)") + ax.bar(x, cm_frac, width=0.55, bottom=ex_frac, color="#c33", + label="XLA compile (one-time)") + for xi, (cf, warm) in enumerate(zip(cm_frac, warm_ms)): + ax.text(xi, 1.02, f"{cf * 100:.0f}% compile\n{warm:,.0f} ms run", + ha="center", va="bottom", fontsize=8) + ax.set_xticks(x) + ax.set_xticklabels(labels) + ax.set_ylim(0, 1.3) + ax.set_yticks([0, .25, .5, .75, 1.]) + ax.set_yticklabels(["0%", "25%", "50%", "75%", "100%"]) + ax.set(ylabel="share of the first-call time", title=title) + ax.legend(loc="lower right", fontsize=8) + plt.tight_layout() + plt.show() + + +def animate_pulse(n_cells=256, length=20.0, n_steps=2500, skip=40): + """Animate the bump-pulse height over n_steps, as embedded HTML.""" + import matplotlib.pyplot as plt + from matplotlib.animation import FuncAnimation + from IPython.display import HTML + dx = length / n_cells + h, hu = bump_ic(n_cells, L=length) + dt = fixed_dt(1.1, dx) + frames = [(0, h[1:-1].copy())] + for step in range(1, n_steps + 1): + apply_bc_reflective(h, hu) + h, hu = step_numpy(h, hu, dx, dt) + if step % skip == 0: + frames.append((step, h[1:-1].copy())) + xs = (np.arange(n_cells) + 0.5) * dx + fig, ax = plt.subplots(figsize=(8, 3.6)) + line, = ax.plot(xs, frames[0][1], color="#27a", linewidth=2) + ax.set_xlim(0, length) + ax.set_ylim(0.93, 1.13) + ax.set(xlabel="x [m]", ylabel="h [m]", + title="1D bump pulse - water height over time") + ax.grid(alpha=0.3) + stamp = ax.text(0.02, 0.95, "", transform=ax.transAxes, fontsize=10, + verticalalignment="top") + + def _update(idx): + step, frame = frames[idx] + line.set_ydata(frame) + stamp.set_text(f"step {step:4d} t = {step * dt:.3f} s") + return line, stamp + + anim = FuncAnimation(fig, _update, frames=len(frames), interval=80, blit=True) + plt.close(fig) + return HTML(anim.to_jshtml()) + + +def require_stages(by_stage, stages, key="sweep"): + """Raise unless every stage carries `key`, naming the notebooks to re-run.""" + missing = [s for s in stages if key not in by_stage.get(s, {})] + if missing: + raise SystemExit(f"no {key} recorded for: {', '.join(missing)}." + " Re-run those notebooks, then this one.") + + +# --- Self-test -------------------------------------------------------------- + +def smoke(N=256, n_steps=100): + """Run the bump-pulse IC for n_steps and assert the solution is sane.""" + L = 10.0 + dx = L / N + h, hu = bump_ic(N, L=L) # defaults h0=1.0, amplitude=0.1 + dt = fixed_dt(1.1, dx) # h_max = h0 + amplitude + for _ in range(n_steps): + apply_bc_reflective(h, hu) + h, hu = step_numpy(h, hu, dx, dt) + assert np.isfinite(h).all(), "h is not finite" + assert h.min() >= 0.0, f"h.min() = {h.min()} < 0" + centre = h[1 + N // 2] + assert centre < 1.1 - 0.005, "bump did not propagate out of the centre" + print(f"swe_core OK (N={N}, n_steps={n_steps}, dt={dt:.4e}, centre={centre:.4f})") + + +if __name__ == "__main__": + smoke() diff --git a/tutorials/pyhpc/notebooks/swe_cub_solver.cpp b/tutorials/pyhpc/notebooks/swe_cub_solver.cpp new file mode 100644 index 00000000..419bdd80 --- /dev/null +++ b/tutorials/pyhpc/notebooks/swe_cub_solver.cpp @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +// 1D shallow-water Rusanov step, solved entirely on the GPU with CUB. +// +// Fields live in device memory for the lifetime of the solver: gpu_swe_init +// allocates and uploads, gpu_swe_steps integrates, gpu_swe_fetch copies the +// result back. Each step runs two cub::DeviceFor::Bulk passes: the first +// computes every face flux once, the second updates the cells from the +// stored fluxes. + +#include +#include +#include +#include +#include + +#define CUDA_CHECK(call) \ + do { \ + const cudaError_t err_ = (call); \ + if (err_ != cudaSuccess) { \ + std::fprintf(stderr, "CUDA error '%s' at %s:%d\n", \ + cudaGetErrorString(err_), __FILE__, __LINE__); \ + std::abort(); \ + } \ + } while (0) + +__device__ inline void rusanov_face(double hL, double hR, double huL, double huR, + double g, double& Fh, double& Fhu) { + const double DRY = 1e-6; + const double hL_s = hL > DRY ? hL : DRY; + const double hR_s = hR > DRY ? hR : DRY; + const double uL = huL / hL_s, uR = huR / hR_s; + const double cL = sqrt(g * hL_s), cR = sqrt(g * hR_s); + const double a = fmax(fabs(uL) + cL, fabs(uR) + cR); + Fh = 0.5 * (huL + huR) - 0.5 * a * (hR - hL); + Fhu = 0.5 * (huL * uL + 0.5 * g * hL * hL + huR * uR + 0.5 * g * hR * hR) + - 0.5 * a * (huR - huL); +} + +// Device-resident state for one grid size. +struct SweCubState { + double* h0 = nullptr; // pristine initial condition + double* hu0 = nullptr; + double* h = nullptr; // working state + double* hu = nullptr; + double* hn = nullptr; // second buffer + double* hun = nullptr; + double* Fh = nullptr; // face fluxes + double* Fhu = nullptr; + long Np2 = 0; +}; + +static SweCubState g_cub; + +static void gpu_swe_release() { + if (g_cub.h0) CUDA_CHECK(cudaFree(g_cub.h0)); + if (g_cub.hu0) CUDA_CHECK(cudaFree(g_cub.hu0)); + if (g_cub.h) CUDA_CHECK(cudaFree(g_cub.h)); + if (g_cub.hu) CUDA_CHECK(cudaFree(g_cub.hu)); + if (g_cub.hn) CUDA_CHECK(cudaFree(g_cub.hn)); + if (g_cub.hun) CUDA_CHECK(cudaFree(g_cub.hun)); + if (g_cub.Fh) CUDA_CHECK(cudaFree(g_cub.Fh)); + if (g_cub.Fhu) CUDA_CHECK(cudaFree(g_cub.Fhu)); + g_cub = SweCubState{}; +} + +// Allocate for this grid size and upload the initial condition. A repeat call +// at the resident size does nothing. +void gpu_swe_init(const double* h0, const double* hu0, long Np2) { + if (Np2 < 2) { + std::fprintf(stderr, "Np2 < 2\n"); + std::abort(); + } + // CUB sets the index type, ensure valid for 32-bit + if (Np2 - 1 > INT_MAX) { + std::fprintf(stderr, "N too large for 32-bit indexing\n"); + std::abort(); + } + if (g_cub.Np2 == Np2) return; + gpu_swe_release(); + + const int N = static_cast(Np2 - 2); + const size_t bytes = Np2 * sizeof(double); + const size_t fbytes = (N + 1) * sizeof(double); + CUDA_CHECK(cudaMalloc(&g_cub.h0, bytes)); + CUDA_CHECK(cudaMalloc(&g_cub.hu0, bytes)); + CUDA_CHECK(cudaMalloc(&g_cub.h, bytes)); + CUDA_CHECK(cudaMalloc(&g_cub.hu, bytes)); + CUDA_CHECK(cudaMalloc(&g_cub.hn, bytes)); + CUDA_CHECK(cudaMalloc(&g_cub.hun, bytes)); + CUDA_CHECK(cudaMalloc(&g_cub.Fh, fbytes)); + CUDA_CHECK(cudaMalloc(&g_cub.Fhu, fbytes)); + CUDA_CHECK(cudaMemcpy(g_cub.h0, h0, bytes, cudaMemcpyHostToDevice)); + CUDA_CHECK(cudaMemcpy(g_cub.hu0, hu0, bytes, cudaMemcpyHostToDevice)); + g_cub.Np2 = Np2; +} + +// Copy the result of the last solve back to the host. +void gpu_swe_fetch(double* h_out, double* hu_out) { + if (g_cub.Np2 == 0) { + std::fprintf(stderr, "gpu_swe_fetch before gpu_swe_init\n"); + std::abort(); + } + const size_t bytes = g_cub.Np2 * sizeof(double); + CUDA_CHECK(cudaMemcpy(h_out, g_cub.h, bytes, cudaMemcpyDeviceToHost)); + CUDA_CHECK(cudaMemcpy(hu_out, g_cub.hu, bytes, cudaMemcpyDeviceToHost)); +} + +// Integrate n_steps from the initial condition. Everything stays on the device. +void gpu_swe_steps(double dx, double dt, double g, long n_steps) { + if (g_cub.Np2 == 0) { + std::fprintf(stderr, "gpu_swe_steps before gpu_swe_init\n"); + std::abort(); + } + const int N = static_cast(g_cub.Np2 - 2); + const long Np2 = g_cub.Np2; + const double inv = dt / dx; + const size_t bytes = Np2 * sizeof(double); + + // Restart from the initial condition so repeated timings do equal work. + CUDA_CHECK(cudaMemcpy(g_cub.h, g_cub.h0, bytes, cudaMemcpyDeviceToDevice)); + CUDA_CHECK(cudaMemcpy(g_cub.hu, g_cub.hu0, bytes, cudaMemcpyDeviceToDevice)); + + double *h = g_cub.h, *hu = g_cub.hu, *hn = g_cub.hn, *hun = g_cub.hun; + double *Fh = g_cub.Fh, *Fhu = g_cub.Fhu; + + for (long s = 0; s < n_steps; ++s) { + double *H = h, *HU = hu, *HN = hn, *HUN = hun, *FH = Fh, *FHU = Fhu; + + // Pass 1: one flux per face f = 0..N (between cells f and f+1) + CUDA_CHECK(cub::DeviceFor::Bulk(N + 1, [=] __device__ (int f) { + const double hL = (f == 0) ? H[1] : H[f]; + const double huL = (f == 0) ? -HU[1] : HU[f]; + const double hR = (f == N) ? H[N] : H[f + 1]; + const double huR = (f == N) ? -HU[N] : HU[f + 1]; + rusanov_face(hL, hR, huL, huR, g, FH[f], FHU[f]); + })); + + // Pass 2: update the interior from the stored fluxes + CUDA_CHECK(cub::DeviceFor::Bulk(N, [=] __device__ (int j) { + const int i = j + 1; + HN[i] = H[i] - inv * (FH[i] - FH[i - 1]); + HUN[i] = HU[i] - inv * (FHU[i] - FHU[i - 1]); + if (i == 1) { HN[0] = H[1]; HUN[0] = -HU[1]; } + if (i == N) { HN[Np2 - 1] = H[N]; HUN[Np2 - 1] = -HU[N]; } + })); + + double *t = h; h = hn; hn = t; t = hu; hu = hun; hun = t; + } + + // Store the buffers in whichever order the swaps left them. + g_cub.h = h; g_cub.hu = hu; g_cub.hn = hn; g_cub.hun = hun; + CUDA_CHECK(cudaDeviceSynchronize()); + CUDA_CHECK(cudaGetLastError()); +} diff --git a/tutorials/pyhpc/notebooks/swe_raw_cuda_solver.cpp b/tutorials/pyhpc/notebooks/swe_raw_cuda_solver.cpp new file mode 100644 index 00000000..57790836 --- /dev/null +++ b/tutorials/pyhpc/notebooks/swe_raw_cuda_solver.cpp @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +// 1D Shallow Water Equation solver with CUDA kernels: identical two-pass +// algorithm to swe_cub_solver.cpp without CUB abstractions. +// Use __global__ kernels with <<>>() launch syntax. +// The trade-off is that you lose the occupancy-driven launch sizing, +// the algorithm library, and friendly abstractions. + +#include +#include +#include + +#define CUDA_CHECK_RAW(call) \ + do { \ + const cudaError_t err_ = (call); \ + if (err_ != cudaSuccess) { \ + std::fprintf(stderr, "CUDA error '%s' at %s:%d\n", \ + cudaGetErrorString(err_), __FILE__, __LINE__); \ + std::abort(); \ + } \ + } while (0) + +__device__ inline void rusanov_face_raw(double hL, double hR, double huL, double huR, + double g, double& Fh, double& Fhu) { + const double DRY = 1e-6; + const double hL_s = hL > DRY ? hL : DRY; + const double hR_s = hR > DRY ? hR : DRY; + const double uL = huL / hL_s, uR = huR / hR_s; + const double cL = sqrt(g * hL_s), cR = sqrt(g * hR_s); + const double a = fmax(fabs(uL) + cL, fabs(uR) + cR); + Fh = 0.5 * (huL + huR) - 0.5 * a * (hR - hL); + Fhu = 0.5 * (huL * uL + 0.5 * g * hL * hL + huR * uR + 0.5 * g * hR * hR) + - 0.5 * a * (huR - huL); +} + +// Pass 1: one flux per face f = 0..N, reflective ghost values derived on read. +__global__ void swe_faces_kernel(const double* __restrict__ H, const double* __restrict__ HU, + double* __restrict__ FH, double* __restrict__ FHU, + long N, double g) { + const long f = (long)blockIdx.x * blockDim.x + threadIdx.x; + if (f > N) return; + const double hL = (f == 0) ? H[1] : H[f]; + const double huL = (f == 0) ? -HU[1] : HU[f]; + const double hR = (f == N) ? H[N] : H[f + 1]; + const double huR = (f == N) ? -HU[N] : HU[f + 1]; + rusanov_face_raw(hL, hR, huL, huR, g, FH[f], FHU[f]); +} + +// Pass 2: update the interior from the stored fluxes; carry ghosts. +__global__ void swe_update_kernel(const double* __restrict__ H, const double* __restrict__ HU, + const double* __restrict__ FH, const double* __restrict__ FHU, + double* __restrict__ HN, double* __restrict__ HUN, + long N, long Np2, double inv) { + const long i = 1 + (long)blockIdx.x * blockDim.x + threadIdx.x; + if (i > N) return; + HN[i] = H[i] - inv * (FH[i] - FH[i - 1]); + HUN[i] = HU[i] - inv * (FHU[i] - FHU[i - 1]); + if (i == 1) { HN[0] = H[1]; HUN[0] = -HU[1]; } + if (i == N) { HN[Np2 - 1] = H[N]; HUN[Np2 - 1] = -HU[N]; } +} + +// Device-resident state for one grid size, as in swe_cub_solver.cpp. +struct SweRawState { + double* h0 = nullptr; // pristine initial condition + double* hu0 = nullptr; + double* h = nullptr; // working state + double* hu = nullptr; + double* hn = nullptr; // second buffer + double* hun = nullptr; + double* Fh = nullptr; // face fluxes + double* Fhu = nullptr; + long Np2 = 0; +}; + +static SweRawState g_raw; + +static void gpu_swe_release_raw() { + if (g_raw.h0) CUDA_CHECK_RAW(cudaFree(g_raw.h0)); + if (g_raw.hu0) CUDA_CHECK_RAW(cudaFree(g_raw.hu0)); + if (g_raw.h) CUDA_CHECK_RAW(cudaFree(g_raw.h)); + if (g_raw.hu) CUDA_CHECK_RAW(cudaFree(g_raw.hu)); + if (g_raw.hn) CUDA_CHECK_RAW(cudaFree(g_raw.hn)); + if (g_raw.hun) CUDA_CHECK_RAW(cudaFree(g_raw.hun)); + if (g_raw.Fh) CUDA_CHECK_RAW(cudaFree(g_raw.Fh)); + if (g_raw.Fhu) CUDA_CHECK_RAW(cudaFree(g_raw.Fhu)); + g_raw = SweRawState{}; +} + +// Allocate for this grid size and upload the initial condition. A repeat call +// at the resident size does nothing. +void gpu_swe_init_raw(const double* h0, const double* hu0, long Np2) { + if (Np2 < 2) { + std::fprintf(stderr, "Np2 < 2\n"); + std::abort(); + } + if (g_raw.Np2 == Np2) return; + gpu_swe_release_raw(); + + const long N = Np2 - 2; + const size_t bytes = Np2 * sizeof(double); + const size_t fbytes = (N + 1) * sizeof(double); + CUDA_CHECK_RAW(cudaMalloc(&g_raw.h0, bytes)); + CUDA_CHECK_RAW(cudaMalloc(&g_raw.hu0, bytes)); + CUDA_CHECK_RAW(cudaMalloc(&g_raw.h, bytes)); + CUDA_CHECK_RAW(cudaMalloc(&g_raw.hu, bytes)); + CUDA_CHECK_RAW(cudaMalloc(&g_raw.hn, bytes)); + CUDA_CHECK_RAW(cudaMalloc(&g_raw.hun, bytes)); + CUDA_CHECK_RAW(cudaMalloc(&g_raw.Fh, fbytes)); + CUDA_CHECK_RAW(cudaMalloc(&g_raw.Fhu, fbytes)); + CUDA_CHECK_RAW(cudaMemcpy(g_raw.h0, h0, bytes, cudaMemcpyHostToDevice)); + CUDA_CHECK_RAW(cudaMemcpy(g_raw.hu0, hu0, bytes, cudaMemcpyHostToDevice)); + g_raw.Np2 = Np2; +} + +// Copy the result of the last solve back to the host. +void gpu_swe_fetch_raw(double* h_out, double* hu_out) { + if (g_raw.Np2 == 0) { + std::fprintf(stderr, "gpu_swe_fetch_raw before gpu_swe_init_raw\n"); + std::abort(); + } + const size_t bytes = g_raw.Np2 * sizeof(double); + CUDA_CHECK_RAW(cudaMemcpy(h_out, g_raw.h, bytes, cudaMemcpyDeviceToHost)); + CUDA_CHECK_RAW(cudaMemcpy(hu_out, g_raw.hu, bytes, cudaMemcpyDeviceToHost)); +} + +// Integrate n_steps from the initial condition. Everything stays on the device. +void gpu_swe_steps_raw(double dx, double dt, double g, long n_steps) { + if (g_raw.Np2 == 0) { + std::fprintf(stderr, "gpu_swe_steps_raw before gpu_swe_init_raw\n"); + std::abort(); + } + const long Np2 = g_raw.Np2; + const long N = Np2 - 2; + const double inv = dt / dx; + const size_t bytes = Np2 * sizeof(double); + + // Restart from the initial condition so repeated timings do equal work. + CUDA_CHECK_RAW(cudaMemcpy(g_raw.h, g_raw.h0, bytes, cudaMemcpyDeviceToDevice)); + CUDA_CHECK_RAW(cudaMemcpy(g_raw.hu, g_raw.hu0, bytes, cudaMemcpyDeviceToDevice)); + + double *h = g_raw.h, *hu = g_raw.hu, *hn = g_raw.hn, *hun = g_raw.hun; + double *Fh = g_raw.Fh, *Fhu = g_raw.Fhu; + + const int block = 256; + const int grid_c = (int)((N + block - 1) / block); + const int grid_f = (int)((N + block) / block); + for (long s = 0; s < n_steps; ++s) { + swe_faces_kernel<<>>(h, hu, Fh, Fhu, N, g); + CUDA_CHECK_RAW(cudaGetLastError()); + swe_update_kernel<<>>(h, hu, Fh, Fhu, hn, hun, N, Np2, inv); + CUDA_CHECK_RAW(cudaGetLastError()); + double *t = h; h = hn; hn = t; t = hu; hu = hun; hun = t; + } + + // Store the buffers in whichever order the swaps left them. + g_raw.h = h; g_raw.hu = hu; g_raw.hn = hn; g_raw.hun = hun; + CUDA_CHECK_RAW(cudaDeviceSynchronize()); + CUDA_CHECK_RAW(cudaGetLastError()); +} diff --git a/tutorials/pyhpc/notebooks/swe_step.cpp b/tutorials/pyhpc/notebooks/swe_step.cpp new file mode 100644 index 00000000..38433bd4 --- /dev/null +++ b/tutorials/pyhpc/notebooks/swe_step.cpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Nanobind module for a 1D Shallow Water Equation solver in OpenMP + +#include +#include +#include +#include +#include + +namespace nb = nanobind; + +inline void rusanov_face(double hL, double hR, + double huL, double huR, + double g, + double& Fh, double& Fhu) { + constexpr double DRY = 1e-6; + const double hL_s = hL > DRY ? hL : DRY; + const double hR_s = hR > DRY ? hR : DRY; + const double uL = huL / hL_s; + const double uR = huR / hR_s; + const double cL = std::sqrt(g * hL_s); + const double cR = std::sqrt(g * hR_s); + const double a = std::max(std::abs(uL) + cL, std::abs(uR) + cR); + Fh = 0.5 * (huL + huR) - 0.5 * a * (hR - hL); + Fhu = 0.5 * (huL * uL + 0.5 * g * hL * hL + + huR * uR + 0.5 * g * hR * hR) - 0.5 * a * (huR - huL); +} + +// One forward-Euler Rusanov step on 1D arrays of shape (N+2,): +// [ghost, h[1], h[2], ..., h[N], ghost] +// Each interface flux is computed exactly once, as in swe_core.step_numpy: +// pass 1 writes the flux into caller-provided face buffers (length >= N+1), +// pass 2 differences the stored fluxes. The caller pre-allocates all +// buffers and re-applies BCs between steps; ghost cells are carried +// through unchanged. +void cpp_step( + nb::ndarray, nb::c_contig> h_in, + nb::ndarray, nb::c_contig> hu_in, + nb::ndarray, nb::c_contig> h_out, + nb::ndarray, nb::c_contig> hu_out, + nb::ndarray, nb::c_contig> Fh_buf, + nb::ndarray, nb::c_contig> Fhu_buf, + double dx, double dt, double g) +{ + const double* h = h_in.data(); + const double* hu = hu_in.data(); + double* h_new = h_out.data(); + double* hu_new = hu_out.data(); + double* Fh = Fh_buf.data(); + double* Fhu = Fhu_buf.data(); + const size_t Np2 = h_in.shape(0); + const size_t N = Np2 - 2; + const double inv = dt / dx; + + if (Np2 < 2) + throw std::invalid_argument("state arrays need at least 2 cells"); + if (hu_in.shape(0) != Np2 || h_out.shape(0) != Np2 || hu_out.shape(0) != Np2) + throw std::invalid_argument("state arrays must share one length"); + if (Fh_buf.shape(0) < N + 1 || Fhu_buf.shape(0) < N + 1) + throw std::invalid_argument("face buffers need at least N+1 elements"); + + // Pass 1: one flux per interface i+1/2, i = 0..N. + #pragma omp parallel for + for (size_t f = 0; f <= N; ++f) + rusanov_face(h[f], h[f + 1], hu[f], hu[f + 1], g, Fh[f], Fhu[f]); + + // Pass 2: difference the stored fluxes over the interior. + h_new[0] = h[0]; hu_new[0] = hu[0]; + h_new[Np2-1] = h[Np2-1]; hu_new[Np2-1] = hu[Np2-1]; + #pragma omp parallel for + for (size_t i = 1; i <= N; ++i) { + h_new[i] = h[i] - inv * (Fh[i] - Fh[i - 1]); + hu_new[i] = hu[i] - inv * (Fhu[i] - Fhu[i - 1]); + } +} + +NB_MODULE(swe_step, m) { + m.doc() = "1D SWE Rusanov step (nanobind)."; + m.def("cpp_step", &cpp_step, + nb::arg("h"), nb::arg("hu"), nb::arg("h_new"), nb::arg("hu_new"), + nb::arg("Fh"), nb::arg("Fhu"), + nb::arg("dx"), nb::arg("dt"), nb::arg("g") = 9.81); +} diff --git a/tutorials/pyhpc/notebooks/syllabi/pyhpc__cupy_kernels_mpi_jax_omp_interop__2_days.ipynb b/tutorials/pyhpc/notebooks/syllabi/pyhpc__cupy_kernels_mpi_jax_omp_interop__2_days.ipynb new file mode 100644 index 00000000..8663adb1 --- /dev/null +++ b/tutorials/pyhpc/notebooks/syllabi/pyhpc__cupy_kernels_mpi_jax_omp_interop__2_days.ipynb @@ -0,0 +1,79 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c9fb56fd", + "metadata": {}, + "source": [ + "## PyHPC Tutorial - CuPy, Kernels, MPI, JAX, OMP, Interop - 2 Days\n", + "\n", + "### Notebooks\n", + "\n", + "| # | Topic | Solution | Technologies |\n", + "|---|-------|----------|--------------|\n", + "| 00 | [NumPy](../00__numpy.ipynb) | [Click](../solutions/00__numpy__SOLUTION.ipynb) | NumPy |\n", + "| 01 | [CuPy](../01__cupy.ipynb) | [Click](../solutions/01__cupy__SOLUTION.ipynb) | CuPy |\n", + "| 02 | [Power Iteration - CuPy - Memory Spaces](../02__power_iteration__cupy__memory_spaces.ipynb) | [Click](../solutions/02__power_iteration__cupy__memory_spaces__SOLUTION.ipynb) | CuPy |\n", + "| 03 | [Power Iteration - CuPy - Asynchrony](../03__power_iteration__cupy__asynchrony.ipynb) | [Click](../solutions/03__power_iteration__cupy__asynchrony__SOLUTION.ipynb) | CuPy, Nsight Systems |\n", + "| 04 | [Copy - Kernel Authoring](../04__copy__kernel_authoring.ipynb) | [Click](../solutions/04__copy__kernel_authoring__SOLUTION.ipynb) | Numba CUDA, Nsight Compute |\n", + "| 05 | [Book Histogram - Kernel Authoring](../05__book_histogram__kernel_authoring.ipynb) | [Click](../solutions/05__book_histogram__kernel_authoring__SOLUTION.ipynb) | Numba CUDA, Nsight Compute |\n", + "| 06 | [mpi4py](../06__mpi4py.ipynb) | [Click](../solutions/06__mpi4py__SOLUTION.ipynb) | mpi4py |\n", + "| 08 | [SWE - Intro](../08__swe__intro.ipynb) | - | NumPy |\n", + "| 09 | [SWE - JAX](../09__swe__jax.ipynb) | [Click](../solutions/09__swe__jax__SOLUTION.ipynb) | JAX |\n", + "| 10 | [SWE - PyOMP](../10__swe__pyomp.ipynb) | [Click](../solutions/10__swe__pyomp__SOLUTION.ipynb) | PyOMP |\n", + "| 11 | [SWE - nanobind](../11__swe__nanobind.ipynb) | [Click](../solutions/11__swe__nanobind__SOLUTION.ipynb) | nanobind |\n", + "| 12 | [SWE - CppJIT - CUB](../12__swe__cppjit__cub.ipynb) | [Click](../solutions/12__swe__cppjit__cub__SOLUTION.ipynb) | CppJIT, CUDA |\n", + "| 14 | [SWE - Synthesis](../14__swe__synthesis.ipynb) | [Click](../solutions/14__swe__synthesis__SOLUTION.ipynb) | - |\n", + "| 07 | [C++ Interop](../07__cpp_interop.ipynb) | [Click](../solutions/07__cpp_interop__SOLUTION.ipynb) | ctypes, cffi, nanobind, CppJIT |\n", + "\n", + "### Materials\n", + "\n", + "| Docker Compose | Brev Instance | Brev Provider |\n", + "|----------------|---------------|---------------|\n", + "| [Link](https://github.com/NVIDIA/accelerated-computing-hub/blob/generated/main/tutorials/pyhpc/notebooks/syllabi/pyhpc__cupy_kernels_mpi_jax_omp_interop__2_days__docker_compose.yml) | 4xL4, 2xL4, 2xL40S, or 1x L40S | Crusoe or any other with Flexible Ports |\n", + "\n", + "### Introduction\n", + "\n", + "This tutorial tours the high-performance Python landscape, from array programming to custom GPU kernels and multi-process parallelism. We start with the NumPy and CuPy array model, move data deliberately between host and device, profile asynchronous execution, and author custom CUDA kernels.\n", + "\n", + "We then distribute work across processes with mpi4py and solve one problem -- the 1D Shallow Water Equations -- five different ways, trading a NumPy baseline for JAX, PyOMP, nanobind, and CppJIT, and measure each against the others.\n", + "\n", + "In this tutorial we will cover:\n", + "- NumPy fundamentals and the `ndarray` memory model.\n", + "- Accelerating array workflows on the GPU with CuPy, including memory spaces and asynchrony.\n", + "- Authoring and profiling custom CUDA kernels with Numba CUDA, Nsight Systems, and Nsight Compute.\n", + "- Distributed computing with mpi4py.\n", + "- Programming models and Python/C++ interoperability: JAX, PyOMP, nanobind, and CppJIT.\n", + "\n", + "Attendees are expected to have general knowledge of HPC and Python programming. Some familiarity with NumPy and C/C++ is helpful but not required." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/test/pytest.ini b/tutorials/pyhpc/test/pytest.ini new file mode 100644 index 00000000..1dd0dacc --- /dev/null +++ b/tutorials/pyhpc/test/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = -v -s --durations=0 --durations-min=0.0 diff --git a/tutorials/pyhpc/test/test_notebooks.py b/tutorials/pyhpc/test/test_notebooks.py new file mode 100644 index 00000000..c2902a74 --- /dev/null +++ b/tutorials/pyhpc/test/test_notebooks.py @@ -0,0 +1,113 @@ +""" +Test that the tutorial notebooks execute without errors. + +The notebooks run as an ordered ladder (00 to 13). For each rung we prefer +the filled-in solution notebook when one exists, and otherwise fall back to +the exercise notebook (the intro/reference notebook 08 and the mpi4py +walkthrough 06 have no separate solution and are complete as written). + +Ordering matters for the SWE sub-ladder: notebooks 08 to 13 each write +their rows to timings.json and 14 reads them, so they must run before 14. pytest +executes the parametrized cases in list order, so listing the rungs 00..13 +in order is sufficient. +""" + +import subprocess +import sys +import time +from pathlib import Path + +import nbformat +import pytest +from nbclient import NotebookClient +from nbclient.exceptions import CellExecutionError + +NOTEBOOKS_DIR = Path(__file__).resolve().parent.parent / "notebooks" + + +def _runnable_notebook(stem): + """Pick the solution variant if it exists, else the exercise notebook.""" + sol = NOTEBOOKS_DIR / "solutions" / f"{stem}__SOLUTION.ipynb" + if sol.exists(): + return sol + matches = sorted(NOTEBOOKS_DIR.glob(f"{stem}*.ipynb")) + return matches[0] if matches else None + + +LADDER_STEMS = [ + "00__numpy", + "01__cupy", + "02__power_iteration__cupy__memory_spaces", + "03__power_iteration__cupy__asynchrony", + "04__copy__kernel_authoring", + "05__book_histogram__kernel_authoring", + "06__mpi4py", + "07__cpp_interop", + "08__swe__intro", + "09__swe__jax", + "10__swe__pyomp", + "11__swe__nanobind", + "12__swe__cppjit__cub", + "13__swe__mpi4py", + "14__swe__synthesis", +] +ladder = [(stem, _runnable_notebook(stem)) for stem in LADDER_STEMS] +ladder = [(stem, nb) for stem, nb in ladder if nb is not None] +ladder_ids = [stem for stem, _ in ladder] + + +def _gpu_state(): + """One-line GPU snapshot for debugging slow/failed execution.""" + try: + out = subprocess.run( + ["nvidia-smi", + "--query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu", + "--format=csv,noheader"], + capture_output=True, text=True, timeout=5, + ) + if out.returncode == 0: + print(f" GPU: {out.stdout.strip()}") + except Exception as e: # noqa: BLE001 - debug aid only + print(f" GPU state check failed: {e}") + + +def _execute(notebook_path): + """Execute a notebook cell-by-cell, printing per-cell timing.""" + with open(notebook_path, encoding="utf-8") as f: + nb = nbformat.read(f, as_version=4) + stem = notebook_path.name.removesuffix("__SOLUTION.ipynb").removesuffix(".ipynb") + if stem == "03__power_iteration__cupy__asynchrony": + kernel_name = "nsightful-nsys" + elif stem in ("04__copy__kernel_authoring", "05__book_histogram__kernel_authoring"): + kernel_name = "nsightful-ncu" + else: + kernel_name = nb.metadata.get("kernelspec", {}).get("name", "python3") + client = NotebookClient( + nb, + timeout=900, # seconds per cell + kernel_name=kernel_name, + resources={"metadata": {"path": str(notebook_path.parent)}}, + ) + with client.setup_kernel(): + for i, cell in enumerate(nb.cells): + if cell.cell_type != "code": + continue + preview = cell.source[:60].replace("\n", " ") + print(f" cell {i}: {preview}...", end="", flush=True) + cell_start = time.time() + client.execute_cell(cell, i) + print(f" [{time.time() - cell_start:.1f}s]") + sys.stdout.flush() + + +@pytest.mark.parametrize("stem,notebook_path", ladder, ids=ladder_ids) +def test_notebook_executes(stem, notebook_path): + """Execute one ladder notebook and fail if any cell raises.""" + print(f"\n=== {notebook_path.relative_to(NOTEBOOKS_DIR)} ===") + _gpu_state() + start = time.time() + try: + _execute(notebook_path) + except CellExecutionError as e: + pytest.fail(f"{notebook_path.name} failed after {time.time() - start:.1f}s:\n{e}") + print(f"{notebook_path.name} ran in {time.time() - start:.1f}s") diff --git a/tutorials/pyhpc/test/test_packages.py b/tutorials/pyhpc/test/test_packages.py new file mode 100644 index 00000000..e4310560 --- /dev/null +++ b/tutorials/pyhpc/test/test_packages.py @@ -0,0 +1,198 @@ +""" +Smoke tests for the pyhpc package stack. + +Each test imports a library the tutorial relies on and exercises it just +enough to prove it is installed and functional on this machine (GPU, MPI, +and the C++/CUDA JIT toolchain included). These run fast and fail loudly, +so a broken image is caught before the much slower notebook suite. +""" + +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import warnings + +import numpy as np + + +def test_cupy(): + """CuPy element-wise ops, reduction, and matmul on the GPU.""" + import cupy as cp + + a = cp.arange(10, dtype=cp.float64) + assert float(cp.sum(a + a)) == 90.0 + m = cp.ones((4, 4)) + assert float(cp.matmul(m, m)[0, 0]) == 4.0 + + +def test_numba_cuda(): + """numba.cuda JIT-compiles and runs an element-wise kernel on the GPU.""" + from numba import cuda + + @cuda.jit + def add_one(x): + i = cuda.grid(1) + if i < x.size: + x[i] += 1.0 + + import cupy as cp + + x = cp.zeros(256, dtype=cp.float64) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + add_one[1, 256](x) + cuda.synchronize() + assert float(cp.sum(x)) == 256.0 + + +def test_cuda_cooperative(): + """cuda.cooperative builds a block-load algorithm (used by notebook 05).""" + import cuda.coop as coop + + block_load = coop.block.load(np.uint8, 128, 4, "striped") + assert block_load.files # linkable device source was generated + + +def test_jax_gpu(): + """JAX runs a jit-compiled function on a CUDA device.""" + import jax + import jax.numpy as jnp + + assert any(d.platform == "gpu" for d in jax.devices()) + + @jax.jit + def f(x): + return jnp.sum(x * x) + + assert float(f(jnp.arange(5.0))) == 30.0 + + +def test_pyomp(): + """PyOMP runs an OpenMP parallel-for region from an @njit function.""" + from numba.openmp import njit + from numba.openmp import openmp_context as openmp + + @njit + def parallel_sum(out, n): + with openmp("parallel for"): + for i in range(n): + out[i] = i * 2 + + out = np.zeros(1000, dtype=np.int64) + parallel_sum(out, 1000) + assert out[10] == 20 + assert int(out.sum()) == sum(i * 2 for i in range(1000)) + + +def test_nanobind(): + """nanobind is importable and exposes its CMake support files.""" + import nanobind + + assert nanobind.cmake_dir() + + +def test_cppjit(): + """CppJIT compiles C++ in-process and calls it (CUDA enabled).""" + import cppjit + + assert cppjit.CUDA_ENABLED, "CppJIT was built without CUDA support" + cppjit.cppdef("int cppjit_smoke_add(int a, int b) { return a + b; }") + assert cppjit.gbl.cppjit_smoke_add(2, 3) == 5 + + +def test_cffi(): + """cffi declares and calls a C function from the system C library.""" + from cffi import FFI + + ffi = FFI() + ffi.cdef("int abs(int);") + libc = ffi.dlopen(None) + assert libc.abs(-7) == 7 + + +def test_memory_profiler(): + """memory_profiler samples the memory use of a callable.""" + from memory_profiler import memory_usage + + mem = memory_usage((sum, ([0] * 100_000,))) + assert mem and max(mem) > 0 + + +def test_nsightful(): + """Nsightful imports and both profiler-backed kernels are installed.""" + import nsightful + + assert nsightful.notebook.is_interactive_notebook() is False + assert importlib.util.find_spec("jupyterlab_nvidia_nsight") is None + + result = subprocess.run( + [sys.executable, "-m", "jupyter", "kernelspec", "list", "--json"], + capture_output=True, text=True, check=True, + ) + kernels = json.loads(result.stdout)["kernelspecs"] + assert kernels["nsightful-ncu"]["spec"]["metadata"]["nsightful_profiler"] == "ncu" + assert kernels["nsightful-nsys"]["spec"]["metadata"]["nsightful_profiler"] == "nsys" + + +def test_matplotlib_follows_jupyter_theme(): + """Matplotlib is imported lazily and follows the configured Jupyter theme.""" + startup = "/usr/local/etc/ipython/startup/10-matplotlib-theme.py" + imports = [ + "import matplotlib", + "import matplotlib.pyplot as plt", + "from matplotlib.pyplot import plot", + ] + assert os.path.isfile(startup) + + with tempfile.TemporaryDirectory() as home: + settings_dir = os.path.join( + home, + ".jupyter/lab/user-settings/@jupyterlab/apputils-extension", + ) + os.makedirs(settings_dir) + with open( + os.path.join(settings_dir, "themes.jupyterlab-settings"), + "w", + encoding="utf-8", + ) as settings: + json.dump({"theme": "JupyterLab Dark"}, settings) + + env = os.environ.copy() + env["HOME"] = home + for statement in imports: + program = ( + "import sys\n" + "assert 'matplotlib' not in sys.modules\n" + f"{statement}\n" + "import matplotlib\n" + "assert matplotlib.rcParams['text.color'] == 'white'\n" + ) + result = subprocess.run( + [sys.executable, "-m", "IPython", "--quick", "-c", program], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0, result.stderr + + +def test_mpi4py(): + """mpi4py runs with multiple local ranks and reduces correctly.""" + program = ( + "from mpi4py import MPI\n" + "comm = MPI.COMM_WORLD\n" + "total = comm.allreduce(comm.Get_rank(), op=MPI.SUM)\n" + "assert total == sum(range(comm.Get_size())), total\n" + "if comm.Get_rank() == 0:\n" + " print('mpi4py ranks:', comm.Get_size())\n" + ) + result = subprocess.run( + ["mpirun.mpich", "-launcher", "fork", "-n", "4", sys.executable, "-c", program], + capture_output=True, text=True, timeout=120, + ) + assert result.returncode == 0, f"mpirun failed:\n{result.stdout}\n{result.stderr}" + assert "mpi4py ranks: 4" in result.stdout diff --git a/tutorials/stdpar/brev/requirements.txt b/tutorials/stdpar/brev/requirements.txt index 65e05bf5..b6d93ece 100644 --- a/tutorials/stdpar/brev/requirements.txt +++ b/tutorials/stdpar/brev/requirements.txt @@ -18,7 +18,6 @@ conan # Jupyter jupyter jupyter-server-proxy -jupyterlab-nvidia-nsight jupyterlab-execute-time # NVIDIA devtools diff --git a/tutorials/stdpar/brev/test.bash b/tutorials/stdpar/brev/test.bash index ce55a200..b733d7aa 100644 --- a/tutorials/stdpar/brev/test.bash +++ b/tutorials/stdpar/brev/test.bash @@ -9,7 +9,18 @@ TUTORIAL_ROOT=/accelerated-computing-hub/tutorials/stdpar -nvidia-smi +if command -v nvidia-smi >/dev/null 2>&1; then + nvidia-smi || exit 1 +else + NVIDIA_GPU_DEVICE=$(find /dev -maxdepth 1 -type c \ + -name 'nvidia[0-9]*' -print -quit 2>/dev/null) + if [ -n "${NVIDIA_GPU_DEVICE}" ]; then + echo "NVIDIA GPU device ${NVIDIA_GPU_DEVICE} is available; nvidia-smi is not installed" + else + echo "Error: no NVIDIA GPU is available" >&2 + exit 1 + fi +fi if [ $# -gt 0 ]; then if [[ "$1" == -* ]] || [[ "$1" == */* ]] || [[ "$1" == *.py ]]; then