Chapter 3: Parallel Pattern Matching on GPUs
3.5 Mapping Approach
We describe our workflow in this section. More specifically, we describe the libraries and interfaces we used to parallelize the Java code of interest on the GPU, and how the linking between the languages was performed. A diagram of our workflow can be found in Figure 3-2.
Figure 3-2: Workflow diagram
There are some scenarios where writing Java code alone will not meet the needs of an application. Developers use the JNI to implement Java native methods that take care of these situations. According to [49], some examples include: the Java standard library does not support required platform-dependent features, a library written in a different language is already
available and needs to be used, and a small segment of performance critical code needs to be implemented in a lower-level language. The last case is exactly what we are dealing with. We want to implement the part of our code that does pattern matching in CUDA, a low level language. Therefore, in order to run CUDA kernels from Java, we need to use the Java Native Interface library. JNI is a standard programming framework that allows Java code running in a
JVM to call and be called by native applications (written in a different language such as C, C++, and assembly) that are both operating system and hardware specific.
Our workflow diagram starts off by detecting the call to perform pattern matching in the user code using the ASM library. Once the function is detected, bytecode injection is used to replace it with our own implementation. Our file which is still written in Java contains the JNI calls that implement the native functions that in turn call the CUDA kernel. We can divide the work into three parts: modifications to the Java code, creation of the native code which we have written in C, and creation of the CUDA code.
3.5.1 Modifications to the Java code
In order to call a native method from Java, that method has to be declared as a native
method. This is done by preceding the method signature by the native keyword. Now, we are able to call this method like any other function. Another thing we’ll have to add is the shared library that contains both the C and the CUDA objects. We do this by calling the
System.loadLibrary method and passing the name of the library (the .dll if in Windows or the .so if in Unix). Loading the library maps the implementation of the native method to its definition. Now we are ready to compile the Java class.
Once we have the class compiled, we need to create the header file that will be used in the shared library. To do so, we will make use of the ‘javah’ tool. It generates a header file that provides the signature for the native methods defined in that class. The name of a native
language function consists of the prefix Java, the package name, the class name, and the name of the Java native method, all separated by an underscore. The method parameters include the following:
- a JNI environment pointer through which the native code accesses parameters and objects passed to it from Java
- a JNI class object which is similar to this in Java
- the method parameters and return types. However, these types will no longer be Java defined types. For example, an int type will now be mapped to a jint type, and a float [] will be of type jfloatArray. This native method signature will be used to write the C implementation.
3.5.2 Writing the C code
This is the part where we implement our native methods. We have chosen to code in C rather than in C++ since we found that it will be easier to integrate with CUDA, but the concept is very similar for either. In addition to that, it proved easier in the linking phase. The first thing we must do is include the header file we had previously generated. Then, we implement the methods in the header file and make sure to preserve the signature descriptions (including the JNI environment pointer and the JNI class object). Inside this method we will need to perform appropriate conversions and assign pointers to variables depending on the application. We will also include a call to a function in the CUDA file that implements a kernel that does pattern matching.
With respect to pattern matching, the Java function takes two parameters: the input text file to search within, and the regular expression. These two variables are of type String, which in JNI are translated to a jstring type. As mentioned previously, we have used the Cuda-grep library. If we ignore the input flags required to run this library, the entry point is a C++ file that has as parameters the filename and the pattern, both defined as char * types. In order to map
the types correctly, we had to first convert the jstring type using the
GetStringUTFChars method, which returns a const char* type. After that, we cast the const char* to char*. In addition to that, we changed parts of the Cuda-grep C++ code to be able to integrate it with our system, and to be able to return the total number of occurrences of the pattern, as opposed to just the lines in which the pattern occurs.
3.5.3 Writing the CUDA code
This is the .cu file that contains the allocation of inputs in device memory, the allocation of the output in both the host and the device memories, transferring the input from host to device, performing the computation, copying back the result from device to host, and freeing the
memories. The computation step is done by invoking a kernel. This is done by calling the kernel method, passing the parameters, and specifying the number of threads and the number of thread blocks to perform the computations. The kernel definition is identified by the __global__ prefix. The kernel contains the code that does the pattern matching.
We have used the .cu files provided by the Cuda-grep library, slightly adjusting them for our needs.
3.5.4 Putting It All Together
Once we have the C code and the CUDA code, we will need to create the shared library that we load in the Java program. To do so, we will first compile the C code into an object file using the ‘gcc’compiler, and the CUDA code into an object file using the ‘nvcc’ compiler. After that, we will link both files using ‘gcc’ into a shared library, making sure to include the necessary flags and directories. The last step is specifying the location of this shared library in
the Java application. We can now run the Java program and the native method will execute on the GPU and return the control to the Java application. Moreover, when we run the Java code with the –javaagent option, we will be able to automatically detect the pattern matching function, and replace it with its GPU counterpart.