Repository: Xilinx/finn-examples (language: python)

Issue:
Re-enable experimental partitioning feature for ResNet-50 build
This PR re-enables the partitioning feature of finn-experimental for ResNet-50. The finn commit is updated in get-finn.sh which will pull changes on the finn repo and also update the used finn-experimental version.

Before merging, Xilinx/finn#920 on the finn repo will need to be tested and merged. Then there is a final commit hash update needed in get-finn.sh before this PR can be merged.


Discussion / hints:

Fix patch:
diff --git a/build/build-all.sh b/build/build-all.sh
index 4fc2652..1b91e8c 100755
--- a/build/build-all.sh
+++ b/build/build-all.sh
@@ -32,41 +32,53 @@
 SCRIPT=$(readlink -f "$0")
 # absolute path this script is in, thus /home/user/bin
 SCRIPTPATH=$(dirname "$SCRIPT")
-# subdirs for all finn-examples build folders
-BUILD_FOLDERS="bnn-pynq kws mobilenet-v1 resnet50 vgg10-radioml cybersecurity-mlp"
-# all HW platforms we build for
-PLATFORMS="Pynq-Z1 Ultra96 ZCU104 U250"
 
-# fetch correct compiler version
-cd $SCRIPTPATH
-bash get-finn.sh
+# clean old build files if present
+RELEASE_TARGET=$SCRIPTPATH/release
+rm -rf $RELEASE_TARGET || true
+
+# collect all local folders, each are considered build folders
+LOCAL_BUILD_FOLDERS=$(find . -maxdepth 1 -type d -printf "%P ")
 
+# remove trailing spaces and store the directory names in an array
+IFS=' ' read -r -a BUILD_FOLDERS <<< "$LOCAL_BUILD_FOLDERS"
 
-# fetch all models, continue on error
-for BUILD_FOLDER in $BUILD_FOLDERS; do
-    cd $SCRIPTPATH/$BUILD_FOLDER/models
-    rm -rf *.zip *.onnx *.npz
-    ./download-model.sh || true
+# fetch all models (if there are models to fetch), continue on error
+for BUILD_FOLDER in ${BUILD_FOLDERS[@]}; do
+    if [ -d "$SCRIPTPATH/$BUILD_FOLDER/models" ]; then
+        cd $SCRIPTPATH/$BUILD_FOLDER/models
+        rm -rf *.zip *.onnx *.npz
+        ./download-model.sh || true
+    fi
 done
 
 # run all build scripts, continue on error
 cd $SCRIPTPATH/finn
-for BUILD_FOLDER in $BUILD_FOLDERS; do
+for BUILD_FOLDER in ${BUILD_FOLDERS[@]}; do
     ./run-docker.sh build_custom $SCRIPTPATH/$BUILD_FOLDER || true
 done
 
 # gather all release folders, continue on error
-RELEASE_TARGET=$SCRIPTPATH/release
 mkdir -p $RELEASE_TARGET
-for BUILD_FOLDER in $BUILD_FOLDERS; do
+for BUILD_FOLDER in ${BUILD_FOLDERS[@]}; do
     cp -r $SCRIPTPATH/$BUILD_FOLDER/release/* $RELEASE_TARGET || true
 done
 
-# create zipfiles for finn-examples upload
-cd $SCRIPTPATH/$BUILD_FOLDER/release
-rm -rf *.zip
-for PLATFORM in $PLATFORMS; do
-    zip -r $PLATFORM.zip $PLATFORM/ || true
-    MD5SUM=$(md5sum $PLATFORM.zip)
-    echo "$PLATFORM.zip : $MD5SUM" >> md5sum.log
+# create zip files for finn-examples upload
+cd $RELEASE_TARGET
+for dir in */; do
+    # remove trailing slash to get the directory name
+    dir_name="${dir%/}"
+
+    # check if it is a directory we are zipping
+    if [ -d "$dir_name" ]; then
+        zip -r "${dir_name}.zip" "$dir_name" || true
+    fi
+done
+
+# calculate the MD5sum for each of the zip files
+zip_files=(*.zip)
+for zip_file in "${zip_files[@]}"; do
+    md5sum_value=$(md5sum "$zip_file" | awk '{print $1}')
+    echo "$zip_file : $md5sum_value" >> md5sum.log
 done
diff --git a/build/get-finn.sh b/build/get-finn.sh
index ec47388..6dbe581 100755
--- a/build/get-finn.sh
+++ b/build/get-finn.sh
@@ -1,5 +1,6 @@
 #!/bin/bash
-# Copyright (c) 2020, Xilinx
+# Copyright (C) 2020-2022, Xilinx
+# Copyright (C) 2023, Advanced Micro Devices, Inc.
 # All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or without
@@ -30,7 +31,7 @@
 # URL for git repo to be cloned
 REPO_URL=https://github.com/Xilinx/finn
 # commit hash for repo
-REPO_COMMIT=0484acba8e28dab95380bd78466c3d4230bf9960
+REPO_COMMIT=02ce6954c93963c8407cd5c20761fccf92e1c70d
 # directory (under the same folder as this script) to clone to
 REPO_DIR=finn
 
diff --git a/build/mobilenet-v1/build.py b/build/mobilenet-v1/build.py
index 837b85b..7ec022d 100644
--- a/build/mobilenet-v1/build.py
+++ b/build/mobilenet-v1/build.py
@@ -78,6 +78,7 @@ def select_build_steps(platform):
             step_mobilenet_convert_to_hls_layers_separate_th,
             "step_create_dataflow_partition",
             "step_apply_folding_config",
+            "step_minimize_bit_width",
             "step_generate_estimate_reports",
             "step_hls_codegen",
             "step_hls_ipgen",
@@ -94,6 +95,7 @@ def select_build_steps(platform):
             step_mobilenet_convert_to_hls_layers,
             "step_create_dataflow_partition",
             "step_apply_folding_config",
+            "step_minimize_bit_width",
             "step_generate_estimate_reports",
             "step_hls_codegen",
             "step_hls_ipgen",
diff --git a/build/resnet50/build.py b/build/resnet50/build.py
index 6c17e7a..5ed6b2c 100644
--- a/build/resnet50/build.py
+++ b/build/resnet50/build.py
@@ -55,6 +55,7 @@
     step_resnet50_convert_to_hls,
     "step_create_dataflow_partition",
     "step_apply_folding_config",
+    "step_minimize_bit_width",
     "step_generate_estimate_reports",
     "step_hls_codegen",
     "step_hls_ipgen",
diff --git a/build/vgg10-radioml/build.py b/build/vgg10-radioml/build.py
index 3535859..4641020 100755
--- a/build/vgg10-radioml/build.py
+++ b/build/vgg10-radioml/build.py
@@ -69,6 +69,7 @@ def select_build_steps(platform):
         "step_create_dataflow_partition",
         "step_target_fps_parallelization",
         "step_apply_folding_config",
+        "step_minimize_bit_width",
         "step_generate_estimate_reports",
         "step_hls_codegen",
         "step_hls_ipgen",
diff --git a/ci/Jenkinsfile b/ci/Jenkinsfile
new file mode 100644
index 0000000..60ac21d
--- /dev/null
+++ b/ci/Jenkinsfile
@@ -0,0 +1,486 @@
+pipeline {
+  parameters {
+    booleanParam(name: 'isRelease', defaultValue: false, description: 'Set this value to true if doing a release test cycle')
+  }
+  agent none
+  stages {
+    stage('Build') {
+      when {
+        // beforeAgent set to 'true' to prevent an offline agent hanging the stage
+        beforeAgent true
+        expression { params['isRelease'] }
+      }
+      agent {
+        label 'finn-build'
+      }
+      steps {
+        cleanLocalCloneDir()
+        dir('build') {
+          script {
+            // Build all examples, we list the builds to allow easy inclusion/exclusion
+            def buildList = ["bnn-pynq",
+                             "cybersecurity-mlp",
+                             "kws",
+                             "mobilenet-v1",
+                             "resnet50",
+                             "vgg10-radioml"]
+            createParallelBuilds(buildList)
+            createReleaseArea(buildList)
+          }
+        }
+      }
+    }
+    stage('Prepare Bitstreams') {
+      when {
+        // beforeAgent set to 'true' to prevent an offline agent hanging the stage
+        beforeAgent true
+        expression { params['isRelease'] }
+      }
+      agent {
+        label 'finn-build'
+      }
+      steps {
+        dir('build/release') {
+          stash name: "bitfile_stash", includes: "*.zip"
+        }
+      }
+    }
+    stage('Check Boards Are Online') {
+      agent none
+      steps {
+        script {
+          env.PYNQ_ONLINE = isNodeOnline('finn-pynq')
+          env.ULTRA96_ONLINE = isNodeOnline('finn-ultra96')
+          env.ALVEO_HOST_ONLINE = isNodeOnline('finn-u250')
+          env.ZCU104_ONLINE = isNodeOnline('finn-zcu104')
+        }
+      }
+    }
+    stage('Run Notebooks on Hardware') {
+      parallel {
+        stage('Test Pynq-Z1 notebooks') {
+          when {
+            beforeAgent true
+            expression {
+              return env.PYNQ_ONLINE == 'true'
+            }
+          }
+          agent {
+            label 'finn-pynq'
+          }
+          environment {
+            BOARD = 'Pynq-Z1'
+            USER_CREDENTIALS = credentials('pynq-z1-credentials')
+          }
+          steps {
+            catchError(stageResult: 'FAILURE') {
+              script {
+                cleanLocalCloneDir()
+                getBoardFiles(env.BOARD)
+
+                // Create test script
+                createTestScript('pynq_notebooks', "finn-examples_${env.BOARD}")
+
+                // Execute the script as the root user - needed for zynq platforms
+                // Use an env variable to help collect test results later in pipeline
+                env.TEST_PYNQ = "SUCCESS"
+                sh 'echo $USER_CREDENTIALS_PSW | sudo -S ./run-tests.sh'
+              }
+            }
+          }
+          post {
+            always {
+              // Collect the results file on the slave node by stashing
+              stash name: "finn_examples_test_PynqZ1", includes: "finn-examples_${env.BOARD}.xml,finn-examples_${env.BOARD}.html"
+              postCleanup()
+            }
+            failure {
+              postFailure(env.BOARD)
+            }
+            success {
+              postSuccess(env.BOARD)
+            }
+          }
+        }
+        stage('Test Ultra96 notebooks') {
+          when {
+            beforeAgent true
+            expression {
+              return env.ULTRA96_ONLINE == 'true'
+            }
+          }
+          agent {
+            label 'finn-ultra96'
+          }
+          environment {
+            BOARD = 'Ultra96'
+            USER_CREDENTIALS = credentials('pynq-z1-credentials')
+          }
+          steps {
+            catchError(stageResult: 'FAILURE') {
+              script {
+                cleanLocalCloneDir()
+                getBoardFiles(env.BOARD)
+
+                // Create test script
+                createTestScript('ultra96_notebooks', "finn-examples_${env.BOARD}")
+
+                // Execute the script as the root user - needed for zynq platforms
+                // Use an env variable to help collect test results later in pipeline
+                env.TEST_ULTRA96 = "SUCCESS"
+                sh 'echo $USER_CREDENTIALS_PSW | sudo -S ./run-tests.sh'
+              }
+            }
+          }
+          post {
+            always {
+              // Collect the results file on the slave node by stashing
+              stash name: "finn_examples_test_${env.BOARD}", includes: "finn-examples_${env.BOARD}.xml,finn-examples_${env.BOARD}.html"
+              postCleanup()
+            }
+            failure {
+              postFailure(env.BOARD)
+            }
+            success {
+              postSuccess(env.BOARD)
+            }
+          }
+        }
+        stage('Test Alveo U250 notebooks') {
+          when {
+            beforeAgent true
+            expression {
+              return env.ALVEO_HOST_ONLINE == 'true'
+            }
+          }
+          agent {
+            label 'finn-u250'
+          }
+          environment {
+            BOARD = 'xilinx_u250_gen3x16_xdma_2_1_202010_1'
+          }
+          steps {
+            catchError(stageResult: 'FAILURE') {
+              script {
+                cleanLocalCloneDir()
+                getBoardFiles(env.BOARD)
+
+                // Create test script
+                createTestScript('alveo_notebooks', "finn-examples_${env.BOARD}")
+
+                // Execute the script as non-root user - root permissions not needed
+                // Use an env variable to help collect test results later in pipeline
+                env.TEST_U250 = "SUCCESS"
+                sh './run-tests.sh'
+              }
+            }
+          }
+          post {
+            always {
+              // Collect the results file on the slave node by stashing
+              stash name: "finn_examples_test_${env.BOARD}", includes: "finn-examples_${env.BOARD}.xml,finn-examples_${env.BOARD}.html"
+              postCleanup()
+            }
+            failure {
+              postFailure(env.BOARD)
+            }
+            success {
+              postSuccess(env.BOARD)
+            }
+          }
+        }
+        stage('Test ZCU104 notebooks') {
+          when {
+            beforeAgent true
+            expression {
+              return env.ZCU104_ONLINE == 'true'
+            }
+          }
+          agent {
+            label 'finn-zcu104'
+          }
+          environment {
+            BOARD = 'ZCU104'
+            USER_CREDENTIALS = credentials('pynq-z1-credentials')
+          }
+          steps {
+            catchError(stageResult: 'FAILURE') {
+              script {
+                cleanLocalCloneDir()
+                getBoardFiles(env.BOARD)
+
+                // Create test script
+                createTestScript('zcu_notebooks', "finn-examples_${env.BOARD}")
+
+                // Execute the script as the root user - needed for zynq platforms
+                // Use an env variable to help collect test results later in pipeline
+                env.TEST_ZCU104 = "SUCCESS"
+                // RADIOML_PATH needs to be passed to the root bash shell
+                // This is for the radio_with_cnns notebook
+                sh 'echo $USER_CREDENTIALS_PSW | sudo -S --preserve-env=RADIOML_PATH ./run-tests.sh'
+              }
+            }
+          }
+          post {
+            always {
+              // Collect the results file on the slave node by stashing
+              stash name: "finn_examples_test_${env.BOARD}", includes: "finn-examples_${env.BOARD}.xml,finn-examples_${env.BOARD}.html"
+              postCleanup()
+            }
+            failure {
+              postFailure(env.BOARD)
+            }
+            success {
+              postSuccess(env.BOARD)
+            }
+          }
+        }
+      }
+    }
+    stage('Check Stage Results') {
+      agent {
+        label 'finn-build'
+      }
+      steps {
+        catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') {
+          script {
+            // Check if any agent's tests (stages) were skipped due to being offline
+            def overallResult = checkAllBoards()
+            if (!overallResult) {
+              error('One or more conditions failed')
+            }
+          }
+        }
+      }
+      post {
+        always {
+          script {
+            // Delete previous build's XML test results
+            sh 'mkdir -p reports'
+            cleanPreviousBuildFiles('reports')
+
+            dir('reports') {
+                // Only unstash for stages that ran
+                unstashSuccessfulStage(env.TEST_PYNQ, "finn_examples_test_PynqZ1")
+                unstashSuccessfulStage(env.TEST_ULTRA96, "finn_examples_test_Ultra96")
+                unstashSuccessfulStage(env.TEST_U250, "finn_examples_test_U250")
+                unstashSuccessfulStage(env.TEST_ZCU104, "finn_examples_test_ZCU104")
+            }
+
+            // Combine individual HTML files to one single report
+            sh './run-docker.sh pytest_html_merger -i reports/ -o reports/test_report_final.html'
+
+            // Archive the XML & HTML test results
+            archiveArtifacts artifacts: "reports/*.xml"
+            archiveArtifacts artifacts: "reports/*.html"
+
+            // Plot what XML files were created during the test run
+            junit 'reports/*.xml'
+          }
+        }
+      }
+    }
+  }
+}
+
+void createParallelBuilds(buildList) {
+  parallel buildList.collectEntries {
+    ["${it}", generateStage(it)]
+  }
+}
+
+def generateStage(job) {
+  return {
+    withEnv(["FINN_HOST_BUILD_DIR=${env.FINN_HOST_BUILD_DIR}/${job}"]) {
+      stage(job) {
+        catchError(stageResult: 'Failure') {
+          script {
+            cleanPreviousBuildFiles("${env.FINN_HOST_BUILD_DIR}")
+
+            // Make sure we have the latest FINN dev
+            getFinn("finn-${job}")
+
+            // Download model if available
+            dir(job) {
+              if (fileExists('models/download-model.sh')) {
+                dir('models') {
+                  sh "rm -rf *.zip *.onnx *.npz"
+                  sh "./download-model.sh"
+                }
+              }
+            }
+
+            // Run build script
+            dir("finn-${job}") {
+              sh "./run-docker.sh build_custom ../${job}"
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+void createReleaseArea(buildList) {
+  sh "rm -rf release"
+  sh "mkdir -p release"
+
+  // Gather all release folders
+  buildList.each {
+    if(fileExists("${it}/release")) {
+      sh "cp -r ${it}/release/* release"
+    }
+  }
+
+  // Create zip files + MD5sum for finn-examples upload
+  dir('release') {
+    def releaseDirs = findFiles()
+    releaseDirs.each {
+      sh "zip -r ${it.name}.zip ${it.name}"
+      def md5sum_val = sh(script: "md5sum ${it.name}.zip | awk \'{print \$1}\'", returnStdout: true)
+      sh "echo '${it.name}.zip : ${md5sum_val} ' >> md5sum.log"
+    }
+  }
+}
+
+void postCleanup() {
+  // Delete the created test script
+  sh 'rm run-tests.sh'
+}
+
+void postFailure(String board) {
+  echo "Failed to run ${board} tests"
+}
+
+void postSuccess(String board) {
+  echo "${board} tests passed"
+}
+
+void unstashSuccessfulStage(String stageEnvVariableSet, String stashName) {
+  if (stageEnvVariableSet) {
+    unstash stashName
+  }
+}
+
+void cleanPreviousBuildFiles(String buildDir) {
+  // Delete any build files from a previous build
+  // Previous build folders affect findCopyZip() and can cause the stage to fail
+  if (!buildDir.empty) {
+    sh "rm -rf ${buildDir}"
+  }
+}
+
+void cleanLocalCloneDir() {
+  // This removes previous run's test reports, bitstreams and test files
+  sh 'rm -f *.xml'
+  sh 'rm -rf finn_examples/bitfiles/bitfiles.zip.d'
+  sh 'python ci/download-test-files.py -r'
+}
+
+void downloadTestData() {
+  sh 'python ci/download-test-files.py -d'
+}
+
+void downloadTestDataAndBitstreams(String board) {
+  sh """python ci/download-test-files.py -d -b ${board}"""
+}
+
+void getBoardFiles(String board) {
+  // For release job: unstash previously generated bitstreams for testing
+  // For non-release job: download existing bitstreams for testing
+  if (params['isRelease']) {
+    downloadTestData()
+    sh 'mkdir -p finn_examples/bitfiles/bitfiles.zip.d'
+    dir('finn_examples/bitfiles/bitfiles.zip.d') {
+      unstash name: "bitfile_stash"
+      sh """unzip ${board}.zip"""
+    }
+  } else {
+    downloadTestDataAndBitstreams(board)
+  }
+}
+
+void createTestScript(String boardNotebooks, String testResultsFilename) {
+  // Create the script - stating what set of notebooks to use
+  if(boardNotebooks == "alveo_notebooks")
+    sh """echo "#!/bin/bash
+. /opt/xilinx/xrt/setup.sh
+. ${VENV_ACTIVATE}
+python -m pytest -m ${boardNotebooks} --junitxml=${testResultsFilename}.xml --html=${testResultsFilename}.html --self-contained-html" >> run-tests.sh
+    """
+  else
+    sh """echo "#!/bin/bash
+. /etc/profile.d/pynq_venv.sh
+. /etc/profile.d/xrt_setup.sh
+python -m pytest -m ${boardNotebooks} --junitxml=${testResultsFilename}.xml --html=${testResultsFilename}.html --self-contained-html" >> run-tests.sh
+    """
+
+  // Give permissions to script
+  sh 'chmod 777 run-tests.sh'
+}
+
+void isNodeOnline(String labelName) {
+  Label label = Jenkins.instance.getLabel(labelName)
+  def agentOnline = false
+
+  if (label) {
+    List<Node> nodes = Jenkins.instance.getNodes()
+
+    nodes.each { node ->
+      if (node.getAssignedLabels().contains(label)) {
+        def computer = node.toComputer()
+        if (computer && computer.isOnline()) {
+          agentOnline = true
+        } else {
+          echo """Agent ${node.displayName} is offline"""
+        }
+      }
+    }
+  } else {
+    echo """Node with label ${labelName} not found"""
+  }
+
+  return agentOnline
+}
+
+def checkAllBoards() {
+  def overallResult = true
+
+  if (env.PYNQ_ONLINE == 'false') {
+    overallResult = false
+  }
+
+  if (env.ALVEO_HOST_ONLINE == 'false') {
+    overallResult = false
+  }
+
+  if (env.ULTRA96_ONLINE == 'false') {
+    overallResult = false
+  }
+
+  if (env.ZCU104_ONLINE == 'false') {
+    overallResult = false
+  }
+
+  return overallResult
+}
+
+void getFinn(String repoName) {
+    // Using shell commands due to git plugin not behaving when cloning a new repo into
+    // an existing one. Likely a way to do this, but shell commands are sufficient
+
+    // Cleanup existing checkout
+    sh "rm -rf ${repoName}"
+
+    // Clone FINN repo into finn directory and checkout correct branch
+    sh "git clone https://github.com/Xilinx/finn ${repoName}"
+    sh "git -C ${repoName} checkout ${env.FINN_TARGET_BRANCH}"
+
+    // WORKAROUND - Jenkins is not an interactive shell, deselect '-it',
+    // in order to build bitstreams/run build script with Jenkins.
+    // Furthermore, remove pdb from the command to avoid hanging in the debugger on fail
+    dir(repoName) {
+        sh "sed -i '/DOCKER_INTERACTIVE=\"-it\"/d' run-docker.sh"
+        sh "sed -i -e 's/-mpdb -cc -cq//g' run-docker.sh"
+    }
+}
diff --git a/finn_examples/driver.py b/finn_examples/driver.py
index f701122..373f8ba 100644
--- a/finn_examples/driver.py
+++ b/finn_examples/driver.py
@@ -193,7 +193,16 @@ def load_runtime_weights(self, flush_accel=True, verify=True):
                 layer_w = rt_weight_dict[(sdp_ind, layer_ind)]
                 layer_mmio.write_mm(0, layer_w.tobytes())
                 if verify:
-                    new_w = np.copy(layer_mmio.array[: layer_w.shape[0]])
+                    if self.platform == "alveo":
+                        # Pynq for Alveo uses tinynumpy under the hood. There is a bug when going
+                        # from a tinynumpy.ndarray to numpy.ndarray. To work around this, we first
+                        # convert the tinynumpy.ndarray to a list and then copy the list to a
+                        # numpy.ndarray.
+                        new_w = np.copy(
+                            list(layer_mmio.array[: layer_w.shape[0]]), dtype=layer_w.dtype
+                        )
+                    else:
+                        new_w = np.copy(layer_mmio.array[: layer_w.shape[0]])
                     assert (layer_w == new_w).all()
         if flush_accel:
             # run accelerator to flush any stale weights from weight streamer FIFOs
diff --git a/finn_examples/models.py b/finn_examples/models.py
index 8a10f77..8076124 100644
--- a/finn_examples/models.py
+++ b/finn_examples/models.py
@@ -224,12 +224,35 @@ def get_driver_mode():
     return driver_modes[get_edge_or_pcie()]
 
 
+# The Alveo 'deployment' platform name doesn't match the device name reported by Pynq
+# To workaround, check against a list of supported pynq platforms. If the reported
+# device name is not in this list, check against a map of alveo platforms and
+# translate the name to 'deployment' if found. Otherwise, it is an unsupported (in
+# FINN-Examples) platform.
+def check_platform_is_valid(platform):
+    pynq_platforms = ["Pynq-Z1", "ZCU104", "Ultra96"]
+
+    alveo_platforms = {
+        "xilinx_u250_gen3x16_xdma_shell_2_1": "xilinx_u250_gen3x16_xdma_2_1_202010_1",
+        "xilinx_u250_gen3x16_xdma_shell_4_1": "xilinx_u250_gen3x16_xdma_4_1_202210_1",
+        "xilinx_u55c_gen3x16_xdma_base_3": "xilinx_u55c_gen3x16_xdma_3_202210_1",
+    }
+
+    if platform in pynq_platforms:
+        return platform
+    elif platform in alveo_platforms:
+        return alveo_platforms[platform]
+    else:
+        raise Exception("Platform not currently supported by FINN-Examples")
+
+
 def resolve_target_platform(target_platform):
     if target_platform is None:
-        return pynq.Device.active_device.name
+        platform = pynq.Device.active_device.name
     else:
         assert target_platform in [x.name for x in pynq.Device.devices]
-        return target_platform
+        platform = target_platform
+    return check_platform_is_valid(platform)
 
 
 def kws_mlp(target_platform=None):
@@ -333,7 +356,7 @@ def resnet50_w1a2_imagenet(target_platform=None):
 def vgg10_w4a4_radioml(target_platform=None):
     target_platform = resolve_target_platform(target_platform)
     driver_mode = get_driver_mode()
-    model_name = "vgg10-radioml-w4a4"
+    model_name = "radioml_w4a4_small_tidy"
     filename = find_bitfile(model_name, target_platform)
     fclk_mhz = 250.0
     return FINNExampleOverlay(
diff --git a/finn_examples/notebooks/2_imagenet_with_cnns.ipynb b/finn_examples/notebooks/2_imagenet_with_cnns.ipynb
index 5d76397..a30607e 100755
--- a/finn_examples/notebooks/2_imagenet_with_cnns.ipynb
+++ b/finn_examples/notebooks/2_imagenet_with_cnns.ipynb
@@ -63,9 +63,9 @@
    ],
    "source": [
     "#mobilenetv1_w4a4 is available on U250 and ZCU104\n",
-    "#accel = models.mobilenetv1_w4a4_imagenet()\n",
+    "accel = models.mobilenetv1_w4a4_imagenet()\n",
     "#resnet50_w1a2 is available on U250 only\n",
-    "accel = models.resnet50_w1a2_imagenet()\n",
+    "#accel = models.resnet50_w1a2_imagenet()\n",
     "#some systems with custom builds might require a manual platform setting:\n",
     "#accel = models.mobilenetv1_w4a4_imagenet(\"ZCU102\")"
    ]
diff --git a/finn_examples/notebooks/5_radioml_with_cnns.ipynb b/finn_examples/notebooks/5_radioml_with_cnns.ipynb
index cced25b..bc983f1 100755
--- a/finn_examples/notebooks/5_radioml_with_cnns.ipynb
+++ b/finn_examples/notebooks/5_radioml_with_cnns.ipynb
@@ -82,7 +82,7 @@
     "import os\n",
     "import h5py\n",
     "\n",
-    "dataset_dir = \"/home/xilinx/datasets/radioml_2018\"\n",
+    "dataset_dir = os.environ[\"RADIOML_PATH\"]\n",
     "print(dataset_dir)"
    ]
   },
diff --git a/pyproject.toml b/pyproject.toml
index bd1b24b..7c60c34 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,3 +3,11 @@ requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2", "wheel", "pynq>=2.5.1
 
 [tool.setuptools_scm]
 write_to = "finn_examples/_version.py"
+
+[tool.pytest.ini_options]
+markers = [
+    "pynq_notebooks",
+    "zcu_notebooks",
+    "ultra96_notebooks",
+    "alveo_notebooks",
+]


Test patch:
diff --git a/ci/download-test-files.py b/ci/download-test-files.py
new file mode 100644
index 0000000..87ddff8
--- /dev/null
+++ b/ci/download-test-files.py
@@ -0,0 +1,82 @@
+import argparse
+import glob
+import json
+import os
+import subprocess
+import sys
+
+parser = argparse.ArgumentParser()
+parser.add_argument("-d", "--data", action="store_true", help="download data files")
+parser.add_argument("-b", "--board", help="select which board's bitfiles to download")
+parser.add_argument("-r", "--remove", action="store_true", help="delete data and bitfiles")
+
+data_links_dir = "/../finn_examples/data"
+bit_links_dir = "/../finn_examples/bitfiles"
+bitfile = "bitfiles.zip.link"
+bitfile_zip_dir = "bitfiles.zip.d"
+
+
+def runcmd(cmd, verbose=False, *args, **kwargs):
+    process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
+    std_out, std_err = process.communicate()
+    if verbose:
+        print(std_out.strip(), std_err)
+    pass
+
+
+def load_data_links(file):
+    with open(file, "r") as f:
+        data = json.load(f)
+    url = data["url"]
+    print("found url: {}".format(url))
+    return url
+
+
+def load_bitfile_links(file, board):
+    with open(file, "r") as f:
+        data = json.load(f)
+    url = data[board]["url"]
+    print("found bitfile url: {}".format(url))
+    return url
+
+
+if __name__ == "__main__":
+    args = parser.parse_args()
+    root_dir = os.path.dirname(os.path.abspath(__file__))
+
+    if args.remove:
+        os.chdir(root_dir + data_links_dir)
+        runcmd("rm -rf audio_samples")
+        runcmd("rm -rf *.npy")
+        runcmd("rm -rf *.zip")
+        os.chdir(root_dir + bit_links_dir)
+        runcmd("rm -rf {}".format(bitfile_zip_dir))
+        print("Files deleted")
+        sys.exit()
+
+    # Download the data files needed for certain notebooks
+    if args.data:
+        os.chdir(root_dir + data_links_dir)
+
+        for link_file in glob.glob("*.link"):
+            url = load_data_links(link_file)
+            runcmd("wget {}".format(url))
+            zip_file = os.path.basename(url)
+            runcmd("unzip {}".format(zip_file))
+
+    # Download bitstreams for the requested board
+    if args.board:
+        os.chdir(root_dir + bit_links_dir)
+
+        bitfile = "bitfiles.zip.link"
+        bitfile_zip_dir = "bitfiles.zip.d"
+        bitfile_url = load_bitfile_links(bitfile, args.board)
+
+        # make bitstream dir and
+        runcmd("mkdir {}".format(bitfile_zip_dir))
+        os.chdir(bitfile_zip_dir)
+        runcmd("wget {}".format(bitfile_url))
+        zip_file = os.path.basename(bitfile_url)
+        runcmd("unzip {}".format(zip_file))
+
+print("Done")
diff --git a/tests/test_jupyter_notebooks.py b/tests/test_jupyter_notebooks.py
new file mode 100644
index 0000000..e0e86c0
--- /dev/null
+++ b/tests/test_jupyter_notebooks.py
@@ -0,0 +1,98 @@
+import pytest
+
+import nbformat
+from nbconvert.preprocessors import ExecutePreprocessor
+
+notebook_timeout_seconds = 3600
+notebook_dir = "./finn_examples/notebooks/"
+
+pynq_notebooks = [
+    pytest.param(
+        notebook_dir + "0_mnist_with_fc_networks.ipynb",
+        marks=pytest.mark.pynq_notebooks,
+    ),
+    pytest.param(
+        notebook_dir + "1_cifar10_with_cnv_networks.ipynb",
+        marks=pytest.mark.pynq_notebooks,
+    ),
+    pytest.param(
+        notebook_dir + "3_binarycop_mask_detection.ipynb",
+        marks=[pytest.mark.pynq_notebooks, pytest.mark.xfail],
+    ),
+    pytest.param(
+        notebook_dir + "4_keyword_spotting.ipynb",
+        marks=pytest.mark.pynq_notebooks,
+    ),
+    pytest.param(
+        notebook_dir + "6_cybersecurity_with_mlp.ipynb",
+        marks=pytest.mark.pynq_notebooks,
+    ),
+]
+
+zcu_notebooks = [
+    pytest.param(
+        notebook_dir + "0_mnist_with_fc_networks.ipynb",
+        marks=pytest.mark.zcu_notebooks,
+    ),
+    pytest.param(
+        notebook_dir + "1_cifar10_with_cnv_networks.ipynb",
+        marks=pytest.mark.zcu_notebooks,
+    ),
+    pytest.param(
+        notebook_dir + "5_radioml_with_cnns.ipynb",
+        marks=pytest.mark.zcu_notebooks,
+    ),
+    pytest.param(
+        notebook_dir + "6_cybersecurity_with_mlp.ipynb",
+        marks=pytest.mark.zcu_notebooks,
+    ),
+]
+
+ultra96_notebooks = [
+    pytest.param(
+        notebook_dir + "0_mnist_with_fc_networks.ipynb",
+        marks=pytest.mark.ultra96_notebooks,
+    ),
+    pytest.param(
+        notebook_dir + "1_cifar10_with_cnv_networks.ipynb",
+        marks=pytest.mark.ultra96_notebooks,
+    ),
+    pytest.param(
+        notebook_dir + "6_cybersecurity_with_mlp.ipynb",
+        marks=pytest.mark.ultra96_notebooks,
+    ),
+]
+
+alveo_notebooks = [
+    pytest.param(
+        notebook_dir + "0_mnist_with_fc_networks.ipynb",
+        marks=pytest.mark.alveo_notebooks,
+    ),
+    pytest.param(
+        notebook_dir + "1_cifar10_with_cnv_networks.ipynb",
+        marks=pytest.mark.alveo_notebooks,
+    ),
+    pytest.param(
+        notebook_dir + "2_imagenet_with_cnns.ipynb",
+        marks=pytest.mark.alveo_notebooks,
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "notebook", pynq_notebooks + zcu_notebooks + ultra96_notebooks + alveo_notebooks
+)
+def test_notebook_exec(notebook):
+    with open(notebook) as f:
+        nb = nbformat.read(f, as_version=4)
+        ep = ExecutePreprocessor(timeout=notebook_timeout_seconds, kernel_name="python3")
+
+        # debug only for now...
+        notebook_dump = notebook.replace(".ipynb", ".dump")
+        with open(notebook_dump, "w") as f:
+            f.write(str(ep.preprocess(nb)))
+
+        try:
+            assert ep.preprocess(nb) is not None, f"Got empty notebook for {notebook}"
+        except Exception:
+            assert False, f"Failed executing {notebook}"