Due 11:59 PM, Tuesday, April 30th
Due 11:59 PM, Wednesday, May 1st
1. Handy References
-
Manual pages for
pthread
functions.
2. Lab 9 Goals:
-
Parallelize a shared-memory application using the pthreads library.
-
Protect threaded code with synchronization primitives.
-
Revisit 2D arrays and file I/O in C.
-
Gain more exposure to pointers and dynamic memory allocation.
-
Practice debugging threaded programs.
3. Lab Description
This lab is designed to give you some practice writing and debugging multi-threaded pthreads programs, using synchronization primitives, and experience designing and running scalability experiments.
You’ll implement a parallel version of Conway’s Game of Life using your sequential solution as a starting point for this lab. See the lab 6 assignment to remind yourself about the sequential version of the program and the rules of the game. You will evaluate the scalability of your implementation as you increase the problem size and the number of threads.
Your lab 9 solution will use the same method as lab 6 for initializing a dynamically allocated board from an input file given as a command line argument, and will have the same output mode options. You should probably begin lab 9 by first fixing any errors in your lab 6 sequential solution.
4. Parallel Requirements
Your parallel version of the game of life should be structured as follows:
-
The main thread should begin by initializing the game board by reading the input file in the same way as the sequential version. The input files format has not changed. Your timing code should not include this step but should include all other parts of the execution.
-
After initializing the game board, the main thread spawns off worker threads that will simulate multiple rounds of the game, with each thread computing just its portion of cells of the new board each round. Grid cells are allocated to threads using either a row-wise or column-wise partitioning specified by a command line argument. You may assume that you will not be asked to create more threads than there are rows/columns (whichever you’ve been asked to partition by). In other words, you don’t need to worry about a case in which there are threads that have no rows/columns assigned to them.
-
Your program should use a global variable that will be shared among all threads to count the number of live cells that are processed during each round. That is, each thread must keep a count of the number of live cells it encountered during a round and use that value to update the global count when it has completed its execution of the round. You should NOT count the live cells in the print function like your serial version did! The rationale for this requirement is that it creates a critical section when the threads attempt to write the global values, so you’ll need to use synchronization to protect those writes.
-
If printing after each round is enabled, you should designate one thread to be the printing thread, and only that thread should do the printing after each round (otherwise the output can be interleaved in crazy ways). It should print both the board and a count of the number of live cells. The output should be identical to that of the serial version.
-
At the end of the simulation’s execution, the main thread should the elapsed time. You will not be graded on absolute performance, but your code should run faster when run in parallel. As a sanity check, try running a large board instance (e.g., 1500x1500) for many rounds (e.g., 100+). You should see a substantial improvement between running with one thread and a handful of threads (2-4). It’s that sort of relative improvement that we’ll be looking for. Note: for accurate timing, a run with no printing arguments enabled should not include any calls to
usleep
in the run. -
Your simulation should have no
valgrind
errors other than those suppressed by runningvalgrind-gol
. You only need to run valgrind for output modes 0 and 1, but NOT with the graphics enabled. -
Your solution should support using the graphics library. Some details are below and there are comments in the starter code. This is a very small portion of the credit for the lab and isn’t the main focus, so it probably shouldn’t be the first priority.
4.1. Synchronization
You will need to think about where and how to synchronize thread actions to remove race-conditions. For example, you need to ensure that no thread starts the next round of play until all threads are done with the current round, otherwise threads will not read consistent values for the given round. If printing is enabled, you need to ensure that the printing thread prints a consistent version of the game board. You will also need to ensure that threads do not race to update the global live cell counter.
Since synchronization variables are intended to be shared among threads, you may declare them as globals if you find that to be helpful.
4.2. Command line arguments
Your program will take the same two command line arguments from lab 6, plus three new command line arguments:
-
An integer to specify the number of threads (the degree of parallelism).
-
A 0/1 flag to specify how to parallelize the GOL program (0: row-wise grid cell allocation, 1: column-wise grid cell allocation). More details below.
-
A 0/1 flag to specify if the per-thread board allocation should be printed out or not at start up (0:don’t print configuration information, 1: print allocation information).
$ ./gol
usage: ./gol infile.txt output_mode[0,1,2] num_threads partition[0,1] print_partition[0,1]
Here are some example command lines:
# run with config values read from file1.txt, do not print the board after each round
# create 8 threads, use row-wise partitioning, print per-thread partitioning details:
./gol file1.txt 0 8 0 1
# run with config file file2.txt, print the board after each round, create
# 15 threads, use column-wise partitioning, don't print per-thread partitioning:
./gol file2.txt 1 15 1 0
The print per-thread board allocation command line option will help you debug the grid cell allocation scheme to ensure that you are correctly allocating rows or columns across the worker threads.
Your program should handle badly formed command lines and check for bad values entered (e.g., a negative number of threads). It should print out an error message and exit for badly formed command lines and handle most bad input values similarly.
4.3. Partitioning
You will implement two different ways of partitioning the computation: one partitions the board’s rows across threads, the other partitions the board’s columns across threads. As an example, suppose that the board is 8x8, and that there are 4 threads. Here is how the threads (with logical tids 0-3) would be assigned to the grid using row and column partitioning, respectively:
row partitioning column partitioning ---------------- ---------------- 0 0 0 0 0 0 0 0 0 0 1 1 2 2 3 3 0 0 0 0 0 0 0 0 0 0 1 1 2 2 3 3 1 1 1 1 1 1 1 1 0 0 1 1 2 2 3 3 1 1 1 1 1 1 1 1 0 0 1 1 2 2 3 3 2 2 2 2 2 2 2 2 0 0 1 1 2 2 3 3 2 2 2 2 2 2 2 2 0 0 1 1 2 2 3 3 3 3 3 3 3 3 3 3 0 0 1 1 2 2 3 3 3 3 3 3 3 3 3 3 0 0 1 1 2 2 3 3
When the number of threads does not evenly divide the dimension by which you are partitioning, divide the rows (or columns) up so that there is at most a difference of 1 assigned row (or column) between any two threads. For example, for an 8x7 board and 3 threads, you would partition rows and columns like this among the 3 threads (with tids 0-2):
row partitioning column partitioning ---------------- ---------------- 0 0 0 0 0 0 0 0 0 0 1 1 2 2 0 0 0 0 0 0 0 0 0 0 1 1 2 2 0 0 0 0 0 0 0 0 0 0 1 1 2 2 1 1 1 1 1 1 1 0 0 0 1 1 2 2 1 1 1 1 1 1 1 0 0 0 1 1 2 2 1 1 1 1 1 1 1 0 0 0 1 1 2 2 2 2 2 2 2 2 2 0 0 0 1 1 2 2 2 2 2 2 2 2 2 0 0 0 1 1 2 2
In this partitioning scheme, a single row (or column) is assigned to exactly one thread; a single row (or column) is never split between two or more threads. Thus, in the above example threads 0 and 1 have one more row each of work to do than thread 2 in the row-wise partitioning, and thread 0 has one more column of work to do than threads 1 and 2 in the column-wise partitioning.
4.4. Printing Partitioning Information
When the 5th command line option is non-zero, your program should print thread partitioning information. Each thread should print:
-
Its thread id (the logical id number that you assign, not the pthread_self value).
-
Its start and end row index values.
-
The total number of rows it is allocated.
-
Its start and end column index values.
-
The total number of columns it is allocated.
The threads will run in different orders, so you may see each thread’s output in any order, which is fine. If you’d like, you can try to avoid some interleaving of individual threads output by calling fflush
after printf
:
printf("tid %d my values ...\n", mytid, ...);
fflush(stdout); // force the printf output to be printed to the terminal
Here are some example runs with different numbers of threads partitioning a 100x100 grid. The total number of rows and columns per thread are shown in parentheses (your output does not need to be identical to this, but it must include all the required parts):
# 9 threads, column-wise partitioning
./gol big 0 9 1 1
tid 0: rows: 0:99 (100) cols: 0:11 (12)
tid 1: rows: 0:99 (100) cols: 12:22 (11)
tid 2: rows: 0:99 (100) cols: 23:33 (11)
tid 4: rows: 0:99 (100) cols: 45:55 (11)
tid 3: rows: 0:99 (100) cols: 34:44 (11)
tid 5: rows: 0:99 (100) cols: 56:66 (11)
tid 6: rows: 0:99 (100) cols: 67:77 (11)
tid 7: rows: 0:99 (100) cols: 78:88 (11)
tid 8: rows: 0:99 (100) cols: 89:99 (11)
# 6 threads, row-wise partitioning
./gol big 0 6 0 1
tid 0: rows: 0:16 (17) cols: 0:99 (100)
tid 1: rows: 17:33 (17) cols: 0:99 (100)
tid 3: rows: 51:67 (17) cols: 0:99 (100)
tid 2: rows: 34:50 (17) cols: 0:99 (100)
tid 4: rows: 68:83 (16) cols: 0:99 (100)
tid 5: rows: 84:99 (16) cols: 0:99 (100)
# 17 threads, row-wise partitioning
./gol big 0 17 0 1
tid 0: rows: 0: 5 (6) cols: 0:99 (100)
tid 1: rows: 6:11 (6) cols: 0:99 (100)
tid 3: rows: 18:23 (6) cols: 0:99 (100)
tid 2: rows: 12:17 (6) cols: 0:99 (100)
tid 4: rows: 24:29 (6) cols: 0:99 (100)
tid 5: rows: 30:35 (6) cols: 0:99 (100)
tid 6: rows: 36:41 (6) cols: 0:99 (100)
tid 7: rows: 42:47 (6) cols: 0:99 (100)
tid 10: rows: 60:65 (6) cols: 0:99 (100)
tid 11: rows: 66:71 (6) cols: 0:99 (100)
tid 12: rows: 72:77 (6) cols: 0:99 (100)
tid 13: rows: 78:83 (6) cols: 0:99 (100)
tid 8: rows: 48:53 (6) cols: 0:99 (100)
tid 15: rows: 90:94 (5) cols: 0:99 (100)
tid 14: rows: 84:89 (6) cols: 0:99 (100)
tid 9: rows: 54:59 (6) cols: 0:99 (100)
tid 16: rows: 95:99 (5) cols: 0:99 (100)
Note that for the run with 17 threads, there are enough threads to see "out of order" execution due to the exact scheduling of threads on the CPUs. That’s fine, we’re at the mercy of the OS scheduler!
4.5. Correctness
In addition to printing out per-thread board partitioning information to verify correct allocation, you should also ensure that your parallel version simulates GOL correctly. Run your parallel version on some of the same input files as your sequential one with printing enabled. The results should be identical.
If you see odd output, either your parallelization of GOL is incorrect, or you are missing synchronization, or both. You can remove the call to system("clear") to see the history of each round to help you debug incorrect GOL computation (of course run for a small number of iterations).
5. Visualization Library
Like lab 6, we’ll be using a visualization library to display the board. This time, the visualization should also be helpful for determining which thread is responsible for each region of the board by coloring them differently. I tried to indicate exactly what you need to do to make the graphics library work in the starter code’s TODO comments. Please let me know if I can clarify what needs to happen to support graphics. This component of the lab is new!
In a nutshell, when the output mode is OUTPUT_VISI, the graphics library needs:
-
In main, prior to creating any threads, call
init_pthread_animation
andget_animation_buffer
. The former returns a handle (way of referring to the graphics library) and the latter returns the graphics buffer. Both of these values need to be accessible to all the threads (e.g., as a global variable or passed in the thread’s parameter). The buffer you get back fromget_animation_buffer
is thebuff
value you’ll pass intogol_step
when the output mode is 2 (OUTPUT_VISI). -
In main, after creating the threads, main should call run_animation prior to attempting to join all the threads.
-
Each thread, when updating the board, should update the graphics buffer with a color for what to draw on the board. This is the same as what you did in the serial version of the lab when you were setting
buff[buff_index]
. To make things a bit easier for you now, I’ve defined some common colors incolors.h
, so you can refer to colors likec3_black
,c3_green
, etc. Thecolors.h
file also defines an array of eight colors, so you can assign each thread a color by indexing into thecolors
array using the thread’s id. For example, my code for this portion looks like:if (buff != NULL) { int buff_index = (data->rows - (i + 1)) * data->cols + j; if (next_board[i * data->cols + j]) { /* Live cells get the color using this thread's ID as the index */ buff[buff_index] = colors[data->id]; } else { /* Dead cells are blank. */ buff[buff_index] = c3_black; } }
To get the pretty rainbow background, you can reverse the assignments above.
-
Somewhere in the code for each worker thread, after you’ve done step 3 to set the colors, every thread needs to call
draw_ready(handle)
to apply its changes to the board.
6. Tips
-
The man pages for the various pthread functions are very helpful. Additionally, Tia has some links to other pthreads documentations and tutorials
-
Use the
perror()
function to print out error messages from failed system and pthread library calls.perror
prints out detailed, human-readable information about the error that occurred. You call it passing in a string with program-specific error message. For example:big_buff = malloc(...); if (big_buff == NULL) { perror("mallocing big buffer failed"); }
See the man page for perror
for more information and for the include files you need to add to your program to use it.
-
You will need to use some synchronization in your solution. You may use any of the pthread synchronization primitives: mutexes, condition variables, and/or barriers. Remember that before using any of these, you need to initialize them and that all threads need to have access to the shared synchronization variables (either pass a reference to them at thread creation or declare them as static global variables). Here are some code snippets for using the barrier synchronization primitive:
// declare a global pthread_barrier_t static pthread_barrier_t barrier; // initialize it in a function (e.g., main) before using it: if(pthread_barrier_init(&barrier, NULL, num_threads)){ perror("pthread_barrier_init"); exit(1); } // threads than can call pthread_barrier_wait to synchronize: ret = pthread_barrier_wait(&barrier); if(ret != 0 && ret != PTHREAD_BARRIER_SERIAL_THREAD) { perror("pthread_barrier_wait"); exit(1); }
-
With the exception of pthread synchronization primitives and cell counters, you should not use any other global variables in your program. Any values that your threads need should be passed to them via the (
void *arg
) parameter. Look at the parallel max program we did for an example of how to do this.
7. Submitting
Please remove any debugging output prior to submitting.
To submit your code, simply commit your changes locally using git add
and git commit
. Then run git push
while in your lab directory. Only one partner needs to run the final push, but make sure both partners have pulled and merged each others changes. See the section on Using a shared repo on the git help page.