Skip to main content
The previous tutorial covered the basic syntax of writing a GEMV in CSL. This tutorial creates a complete program which you can compile and run on the fabric simulator or a real Cerebras system.
This documentation refers to the simulator or real system as the “device,” and the CPU from which programs are launched as the “host.” Functions that run on the device are often called “device kernels.”

Learning Objectives

After completing this tutorial, you should know how to:
  • Write and compile a full CSL program with CSL’s memcpy infrastructure
  • Write a host program in Python using the SdkRuntime host runtime
  • Launch a device kernel using SdkRuntime’s RPC launch mechanism
  • Copy data from device to host using SdkRuntime’s memcpy_d2h function

Example Overview

Your program will run on a single processing element (PE). This tutorial demonstrates the program with a simulated fabric consisting of an 8 x 3 block of PEs.
The coordinates of PEs are always specified (column, row). The dimensions of a grid of PEs are specified (width, height), or, equivalently, (number of columns, number of rows).
Diagram of an 8 by 3 simulated fabric with the program's single PE placed at column 4, row 1

Problem Steps

Visually, this program consists of the following steps: 1. Host launches function on PE.
Diagram of the host launching a function on the PE
2. Function initializes A, x, b, and computes y.
Diagram of the PE initializing A, x, and b, then computing y
3. Host copies result y from device.
Diagram of the host copying the result y from the device

Write the CSL

The previous tutorial declared arrays and wrote functions initialize and gemv to initialize and compute y = Ax + b. What else does the device code need to form a complete program?
  1. A top-level “layout” file, which defines the program rectangle on which your kernel will run, and assigns a code file to the single PE in the rectangle.
  2. Initialization of the memcpy library infrastructure, which allows the host to launch kernels and copy data to and from the device.
This section first walks through layout.csl, which defines the program layout, included below.

Initialize Memcpy Infrastructure

At the very top of this file is an @import_module call, which imports the top-level memcpy infrastructure. This module import requires width and height parameters which correspond to the dimensions of the program rectangle. This program only uses a single PE, so width and height are both 1. Module imports in CSL act like unique struct types. Thus, the code in the CSL standard library file memcpy/get_params can be used like a struct named memcpy.

Define the Layout

The layout block is evaluated at compile time. It defines the number of PEs used in the program and assigns code to each of those PEs. @set_rectangle defines the shape of the program. Because the program runs on a single PE, it’s compiled for a 1x1 rectangle of PEs. The single PE has coordinate (0,0) and is assigned the code file pe_program.csl, explored later. The program also passes some memcpy-related parameters to it. The memcpy struct contains a function named get_params, which returns some parameters for the memcpy infrastructure that each PE’s code file must include. This function takes as an argument the column number of the PE; thus, for this program, the appropriate parameters are returned by memcpy.get_params(0).

Export Symbols

The host program will directly launch a device kernel, and copy back the result y. The two @export_name calls make the symbols visible to the host program. The first @export_name call makes the symbol named y visible to the host, as a pointer to an array of type f32. Its mutability is set to false, meaning that the host can only read from and not write to the symbol. The second @export_name call makes the symbol init_and_compute visible to the host; this is the function you’ll launch from the host to compute the GEMV. This function takes no arguments, so its type is fn()void.

Add Memcpy to the PE Program

Now, take a look at pe_program.csl, which defines the code assigned to the single PE. This program is largely the same as the preceding tutorial’s code.csl file, but with some additional infrastructure related to memcpy.
At the top, a parameter named memcpy_params is declared: this parameter’s value is set at compile time by @set_tile_code in layout.csl. Next is another memcpy-related @import_module, this time importing the PE-specific <memcpy/memcpy> standard library file as a struct named sys_mod. The functions initialize and gemv are identical to the previous tutorial. However, note one addition to init_and_compute. After gemv finishes, the memcpy infrastructure must be notified that additional commands from the host can proceed. This is why the function calls sys_mod.unblock_cmd_stream() at its end. The control flow of every host-callable function in a CSL program must end with a call to unblock_cmd_stream(). Everything inside the comptime block is evaluated at compile time. This comptime block exports symbols so they can be advertised to the host. In particular, y_ptr, which is a pointer to the array y, is exported with the name y. The init_and_compute function is also exported.

Compile CSL Code

Compile this code for the CS-2 simulator using:
This command produces multiple ELF files, in a directory named out. The following sections walk through several aspects of this command. First, specify the top-level file to be compiled, in this case layout.csl. pe_program.csl does not have to be specified in the compilation command, because it is included by layout.csl. You must also specify the fabric dimensions of the target device, and the fabric offset at which the program is placed. As specified above, this tutorial uses an 8 x 3 simulated fabric, with the program’s lone PE placed at column 4, row 1 of the fabric.
Every program using memcpy must use a fabric offset of 4,1, and if compiling for a simulated fabric, must use a fabric dimension of at least width+7,height+1, where width and height are the dimensions of the program. These additional PEs are used by memcpy to route data on and off the wafer.
Last, note the flags specifying memcpy and channels. Every program using memcpy must include the --memcpy flag. When running on a real system, the channels flag determines the max throughput for transferring data on and off the wafer. Its value can be no larger than the height of the program rectangle (the number of rows), and maxes out at 16. Typically, performance improvements are minimal past 8 channels. This program is also compatible with the CS-3 architecture. Specify the --arch flag to determine which architecture to compile for. The default value is --arch=wse2, where WSE-2 is the processor architecture used in the CS-2. Specify the value --arch=wse3 to compile for WSE-3, the processor architecture used in the CS-3.

Write the Host Code

What does the host code need to do?
  1. Import needed libraries
  2. Specify paths to compiled code and instantiate runner object
  3. Run device kernel init_and_compute
  4. Copy back y and check result
The following sections explain some features of the run.py file containing the host code, shown below.

Imports

SdkRuntime is the library containing the functionality necessary for loading and running the device code, as well as copying data on and off the wafer. Along with SdkRuntime, the code imports MemcpyDataType and MemcpyOrder, which are enums containing types for use with memcpy calls, explained in more detail below.

Instantiate the Runner

This script contains two arguments: name and cmaddr. Use name to specify the directory containing the compilation output. cmaddr is discussed later; for now, leave it unspecified. Instantiate a runner object using SdkRuntime’s constructor:
Before loading, grab a handle for later copying y off the device, with the call to runner.get_id('y'). Then load the program onto the device and begin running with runner.load() and runner.run().

Run the Device Kernel

Next, launch the device kernel init_and_compute:
The nonblock=False flag simply specifies that this call waits to return control to the host program until after the kernel has been launched. Otherwise, this call returns control to the host immediately.

Copy Back the Result

A call to memcpy_d2h copies the result y back from the device. First, allocate space on the host to hold the result:
Then, copy y from the device into this array:
This call has quite a few arguments, so the following sections walk through them. The first argument is the array on the host to hold the result, allocated on the previous line. The next argument, y_symbol, is the symbol on device that points to the y array. The next four arguments specify the location of the rectangle of PEs from which to copy, referred to as the “region of interest” or ROI. The first two, 0, 0, specify that the northwest corner of the ROI begins at PE (0, 0) within the program rectangle. Thus, it begins at the northwesternmost corner of the program rectangle. The next two specify the width and height of the ROI. This tutorial only copies the result back from a single PE, so the width and height of the ROI is simply 1, 1.
Note that the ROI is specified based on its position in the program rectangle, NOT its position in the device fabric.
The next argument specifies how many elements to copy back from each PE in the ROI. In this case, the result y has M elements. The next four arguments are all keyword arguments specifying certain attributes of this copy operation. Discussion of the streaming keyword is deferred to a future tutorial. Note, however, that any copy between host-to-device which copies to or from a device symbol uses streaming=False. The order keyword specifies the layout of the data copied back to y_result. memcpy_d2h always copies into a 1D array on the host. ROW_MAJOR specifies that the data is ordered by (ROI height, ROI width, elements per PE). Thus, the data copied back from each PE is contiguous in the result array. COL_MAJOR, on the other hand, specifies that the data is ordered by (elements per PE, ROI width, ROI height). Thus, the result array will contain the 0th element from each PE, followed by the 1st element from each PE, and so on. For this tutorial, because the copy is from a single PE, ROW_MAJOR and COL_MAJOR are identical. In general, for copies over larger fabrics, COL_MAJOR is more performant than ROW_MAJOR. The data_type keyword specifies the width of the data copied back. This tutorial copies back single-precision floating point numbers, so the data width is 32 bit. nonblock=False specifies that this call will not return control to the host until the copy into y_result has finished.
How does the program ensure that this copy does not happen until init_and_compute has finished? The memcpy infrastructure in the CSL program can only execute one command at a time. After a device kernel is launched, unblock_cmd_stream must be called before a memcpy_d2h can proceed. The call to unblock_cmd_stream at the end of the init_and_compute function in pe_program.csl guarantees that init_and_compute finishes before the memcpy_d2h occurs.

Finish the Program and Check the Result

The call to runner.stop() stops the execution of the program on device. The code then checks that the y_result copied back from the device matches the y_expected pre-computed on the host. If they match, it prints a SUCCESS message.

Run the Program

Run the program using cs_python, which wraps the Cerebras-provided Python instance for executing host code.
You should see a SUCCESS! message at the end of execution. You have successfully run your first program!

Move from Simulator to System

So far, this program has been compiled and run using the fabric simulator, but with a few modest changes, you can also compile and run it on a real Cerebras system. First, modify the compile command to replace the fabric-dims with the actual dimensions of the target fabric. Most CS-3s have a fabric dimension of 762 x 1172, so the compile command becomes:
This program is also compatible with the CS-2, which has a fabric dimension of 757 x 996. Compiling for the CS-2 requires specifying the WSE-2 architecture:
The Cerebras system is a network attached accelerator. When targeting a real system for running a program, you must know its IP address. This is the purpose of the SdkRuntime constructor’s cmaddr keyword argument. If the IP address is stored in an environment variable named $CS_IP_ADDR, then you can run on the system with:
This example uses port 9000 to connect to the system and launch the program.
The compile and run commands above are used when running the SDK directly from a host node connected to the CS system. If using a Wafer-Scale Cluster in appliance mode, see Running SDK on a Wafer-Scale Cluster.

Exercises

This tutorial’s host code initializes A, x, and b to the same values they’re initialized to on the device, manually. Instead of initializing them like this, you could also use memcpy_d2h calls to copy them from the device just as with y. Create exported symbols for A, x, and b, and use them to copy these arrays back to the host and compute an expected result for y. Note that A, x, and b are not initialized until the init_and_compute device kernel executes. You can also break up init_and_compute into two device kernel calls. Create separate device kernel calls for initialize and gemv which are launched separately on the host, and copy back A, x, and b after you launch initialize but before you launch gemv.

Next

In the next tutorial, you’ll expand this program to use data structure descriptors (DSDs), a core language feature of CSL.