openai_gemm.py (505 lines of code) (raw):

import re import time import ctypes import appdirs import os.path import subprocess import numpy as np import pycuda.driver as drv from operator import mul from struct import unpack_from from pycuda.tools import context_dependent_memoize from scikits.cuda import cublas def matmul(A, B, C, alpha=1.0, beta=0.0, stream=None, bench=False): """ C = alpha * A . B + beta * C C = alpha * A.T . B + beta * C C = alpha * A . B.T + beta * C C = alpha * A.T . B.T + beta * C bench: return benchmark data for all available tiles + cublas """ # this could be relaxed, kernels are capable of mixed precision (with minor tweaks) # the s/h prefix would then go away and each type would be specified with kernel build option assert A.dtype.type == B.dtype.type == C.dtype.type if C.dtype.type is np.float32: prefix = "s" elif C.dtype.type is np.float16: prefix = "h" else: raise TypeError("Only floating point dot currently supported.") # (m,n) = (m,k) . (k,n) m = A.shape[0] n = B.shape[1] k = A.shape[1] assert m == C.shape[0] assert n == C.shape[1] assert k == B.shape[0] # Extract the operations and contiguous dimension sizes (cda, cdb, cdc). # Note that these can be the same as from the shape unless the non-contiguous dimension is sliced. # One dimension must be contiguous (DRAM efficiency demands this). # Note that the strides here do not include the datatype size as they would in numpy. # A transpose op (.T) on a GPUTensor reverses the shape and strides then flags the tensor as transposed (is_trans=True) - # The underlying data is unchanged. if A.is_trans: opA = 'T' cda = A.strides[1] assert A.strides[0] == 1 else: opA = 'N' cda = A.strides[0] assert A.strides[1] == 1 if B.is_trans: opB = 'T' cdb = B.strides[1] assert B.strides[0] == 1 else: opB = 'N' cdb = B.strides[0] assert B.strides[1] == 1 cdc = C.strides[0] assert C.strides[1] == 1 op = opA + opB # get and autotune the kernel selection kernel, params, dynamic_shared = _get_gemm_kernel(prefix, op, cda, cdb, cdc, m, n, k) # bind dynamic params params[2:8] = (stream, C.gpudata, A.gpudata, B.gpudata, alpha, beta) # call the kernel kernel.prepared_async_call(*params, shared_size=dynamic_shared) # unbind dynamic params params[2:8] = (None,) * 6 # return benchmark data if requested if bench: return _get_bench_data()[(prefix, op, cda, cdb, cdc, m, n, k)] return C #################################################################################################### # scikits.cuda doesn't expose cublasSgemmEx or cublasHgemm cublas._libcublas.cublasSgemmEx.restype = int cublas._libcublas.cublasSgemmEx.argtypes = [ cublas._types.handle, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_int ] def cublasSgemmEx(handle, transa, transb, m, n, k, alpha, A, lda, B, ldb, beta, C, ldc): status = cublas._libcublas.cublasSgemmEx(handle, cublas._CUBLAS_OP[transa], cublas._CUBLAS_OP[transb], m, n, k, ctypes.byref(ctypes.c_float(alpha)), int(A), 2, lda, int(B), 2, ldb, ctypes.byref(ctypes.c_float(beta)), int(C), 2, ldc) cublas.cublasCheckStatus(status) cublas._libcublas.cublasHgemm.restype = int cublas._libcublas.cublasHgemm.argtypes = [ cublas._types.handle, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int ] h_dtype = np.dtype(np.float16) def cublasHgemm(handle, transa, transb, m, n, k, alpha, A, lda, B, ldb, beta, C, ldc): alpha = unpack_from('H', h_dtype.type(alpha))[0] beta = unpack_from('H', h_dtype.type(beta))[0] status = cublas._libcublas.cublasHgemm(handle, cublas._CUBLAS_OP[transa], cublas._CUBLAS_OP[transb], m, n, k, ctypes.byref(ctypes.c_uint16(alpha)), int(A), lda, int(B), ldb, ctypes.byref(ctypes.c_uint16(beta)), int(C), ldc) cublas.cublasCheckStatus(status) cublasXgemm = { "s" : cublas.cublasSgemm, "h" : cublasSgemmEx, "h2" : cublasHgemm, } @context_dependent_memoize def _get_sm_count(): attributes = drv.Context.get_device().get_attributes() return attributes[drv.device_attribute.MULTIPROCESSOR_COUNT] @context_dependent_memoize def _get_compute_capability(): attributes = drv.Context.get_device().get_attributes() major = attributes[drv.device_attribute.COMPUTE_CAPABILITY_MAJOR] minor = attributes[drv.device_attribute.COMPUTE_CAPABILITY_MINOR] return major, minor @context_dependent_memoize def _get_events(): return (drv.Event(), drv.Event()) @context_dependent_memoize def _get_cublas(): return cublas.cublasCreate() @context_dependent_memoize def _get_bench_data(): return dict() def _ceil_div(x, y): return -(-x // y) def _closest_divisor(val, div): divisors = sorted([(abs(i - div), i) for i in range(2, 8) if val % i == 0]) if len(divisors): return (divisors[0][1], val // divisors[0][1]) else: return (1, val) # Tile sizes: m, n, k, vA,vB,vC div, op (dynamic shared options) k128x128x8 = (128, 128, 8, 4, 4, 1, 2, 0, (0,)) k32x32x32 = ( 32, 32, 32, 4, 4, 1, 4, 0, (0, 2**14)) k32x64x32_NN = ( 32, 64, 32, 8, 4, 4, 4, 1, (0, 2**13)) k32x32x64_NT = ( 32, 32, 64, 8, 8, 4, 4, 1, (0,)) k16x64x64_NN = ( 16, 64, 64, 8, 4, 4, 4, 1, (0,)) k16x64x64_NT = ( 16, 64, 64, 8, 8, 4, 4, 1, (0,)) selections = { "s" : { "TN" : (k128x128x8, k32x32x32), "NN" : (k128x128x8, k32x32x32), "NT" : (k128x128x8, k32x32x32), "TT" : (k128x128x8, k32x32x32), }, "h" : { "TN" : (k128x128x8, k32x32x32), "NN" : (k128x128x8, k32x32x32, k32x64x32_NN, k16x64x64_NN), "NT" : (k128x128x8, k32x32x32, k32x32x64_NT, k16x64x64_NT), "TT" : (k128x128x8, k32x32x32), }, } # Autotune kernel selection @context_dependent_memoize def _get_gemm_kernel(prefix, op, cda, cdb, cdc, m, n, k): if op[0] == 'T': vec4A = (cda & 3) == 0 and (m & 3) == 0 vec8A = (cda & 7) == 0 and (m & 7) == 0 dimA = (k,cda) else: vec4A = (cda & 3) == 0 and (k & 3) == 0 vec8A = (cda & 7) == 0 and (k & 7) == 0 dimA = (m,cda) if op[1] == 'T': vec4B = (cdb & 3) == 0 and (k & 3) == 0 vec8B = (cdb & 7) == 0 and (k & 7) == 0 dimB = (n,cdb) else: vec4B = (cdb & 3) == 0 and (n & 3) == 0 vec8B = (cdb & 7) == 0 and (n & 7) == 0 dimB = (k,cdb) vec4C = (cdc & 3) == 0 and (n & 3) == 0 dimC = (m,cdc) dtype = np.dtype(np.float32 if prefix == 's' else np.float16) A = drv.mem_alloc(mul(*dimA) * dtype.itemsize) B = drv.mem_alloc(mul(*dimB) * dtype.itemsize) C = drv.mem_alloc(mul(*dimC) * dtype.itemsize) # TODO: use curand dataA = np.random.uniform(-1.0, 1.0, dimA).astype(dtype) dataB = np.random.uniform(-1.0, 1.0, dimB).astype(dtype) drv.memcpy_htod(int(A), dataA) drv.memcpy_htod(int(B), dataB) # Using random data gets you more accurate autotune results # drv.memset_d8(int(A), 0, mul(*dimA) * dtype.itemsize) # drv.memset_d8(int(B), 0, mul(*dimB) * dtype.itemsize) timings = [] cache = [] # scale the repeat count to amount of work repeat = min(max(int(5e11 * 28 / (m*n*k * 2.0 * _get_sm_count()) ), 10), 5000) warmup = repeat #print repeat start, end = _get_events() flops = m * n * k * 2.0 for tileM, tileN, tileK, vecA, vecB, vecC, div, base_op, dyn_shared in selections[prefix][op]: vecA = (vecA == 4 and vec4A) or (vecA == 8 and vec8A) vecB = (vecB == 4 and vec4B) or (vecB == 8 and vec8B) vecC = vecC == 1 or vec4C vec = vecA and vecB and vecC if base_op: # The op is part of the base kernel name base = "%sgemm_%dx%dx%d_%s" % (prefix, tileM, tileN, tileK, op) opts = ( "vec", ) if vec else () else: # The op is an option passed to a more generic kernel base = "%sgemm_%dx%dx%d" % (prefix, tileM, tileN, tileK) opts = ( op, "vec" ) if vec else (op,) kernel = get_kernel(base, opts) blk_A = _ceil_div(m, tileM) blk_B = _ceil_div(n, tileN) # TODO: perhaps autotune all possible small divisors blk_a, blk_A = _closest_divisor(blk_A, div) blk_b, blk_B = _closest_divisor(blk_B, div) if blk_a == 1: blk_a, blk_A = (blk_A, 1) for dynamic_shared in dyn_shared: params = [ (blk_a * blk_b, blk_B, blk_A), (kernel.threads, 1, 1), None, C, A, B, 1.0, 0.0, cda, cdb, cdc, m, n, k, blk_a, blk_b ] #print kernel.name, params, dynamic_shared # Warmup (once per config) for r in range(warmup): kernel.prepared_async_call(*params) warmup = 0 # Benchmark start.record() for r in range(repeat): kernel.prepared_async_call(*params, shared_size=dynamic_shared) end.record() end.synchronize() msecs = end.time_since(start) / float(repeat) gflops = flops / (msecs * 1000000.0) params[3:8] = (None,) * 5 timings.append((msecs, gflops, kernel, params, dynamic_shared)) cache.append((msecs, gflops, kernel.name, dynamic_shared)) major, minor = _get_compute_capability() if prefix == "h" and major == 6 and minor == 0: cublas_gemm = cublasXgemm["h2"] else: cublas_gemm = cublasXgemm[prefix] # record a cublas time for reference cublas_handle = _get_cublas() start.record() for r in range(repeat): # convert row order to col order cublas_gemm(cublas_handle, op[1], op[0], n, m, k, 1.0, B, cdb, A, cda, 0.0, C, cdc) end.record() end.synchronize() msecs = end.time_since(start) / float(repeat) gflops = flops / (msecs * 1000000.0) cache.append( (msecs, gflops, "cuBLAS", 0) ) # cache complete timing data for benchmark comparisons # this data could be cached to disk for quicker autotuning on future runs _get_bench_data()[(prefix, op, cda, cdb, cdc, m, n, k)] = cache # return the fastest kernel return tuple(sorted(timings)[0][2:5]) # Utility function to test all tiles for the given dimensions and dtype def matmul_test(ng, dtype, op, m, n, k, ones=False, out=False): prefix = "s" if dtype is np.float32 else "h" if op[0] == 'T': vec4A = (m & 3) == 0 vec8A = (m & 7) == 0 dimA = (k,m) cda = m else: vec4A = (k & 3) == 0 vec8A = (k & 7) == 0 dimA = (m,k) cda = k if op[1] == 'T': vec4B = (k & 3) == 0 vec8B = (k & 7) == 0 dimB = (n,k) cdb = k else: vec4B = (n & 3) == 0 vec8B = (n & 7) == 0 dimB = (k,n) cdb = n vec4C = (n & 3) == 0 dimC = (m,n) cdc = n A1 = ng.empty(dimA, dtype=dtype) B1 = ng.empty(dimB, dtype=dtype) C1 = ng.empty(dimC, dtype=dtype) C2 = ng.empty(dimC, dtype=dtype) if ones: A1[:] = 1.0 B1[:] = 1.0 else: # fill with uniform randoms from -1 to 1 A1[:] = 2 * (.5 - ng.rand()) B1[:] = 2 * (.5 - ng.rand()) # for reducing outputs partial1 = ng.empty((C1.shape[0],1), dtype=np.float32) partial2 = partial1[0:1,0:1] cublas_handle = _get_cublas() for tileM, tileN, tileK, vecA, vecB, vecC, div, base_op, dyn_shared in selections[prefix][op]: vecA = (vecA == 4 and vec4A) or (vecA == 8 and vec8A) vecB = (vecB == 4 and vec4B) or (vecB == 8 and vec8B) vecC = vecC == 1 or vec4C vec = vecA and vecB and vecC if base_op: # The op is part of the base kernel name base = "%sgemm_%dx%dx%d_%s" % (prefix, tileM, tileN, tileK, op) opts = ( "vec", ) if vec else () else: # The op is an option passed to a more generic kernel base = "%sgemm_%dx%dx%d" % (prefix, tileM, tileN, tileK) opts = ( op, "vec" ) if vec else (op,) kernel = get_kernel(base, opts) blk_A = _ceil_div(m, tileM) blk_B = _ceil_div(n, tileN) blk_a, blk_A = _closest_divisor(blk_A, div) blk_b, blk_B = _closest_divisor(blk_B, div) if blk_a == 1: blk_a, blk_A = (blk_A, 1) for alpha, beta in ( (1.0,0.0), (0.5,0.5) ): try: if ones: C1[:] = 1.0 else: C1[:] = 2 * (.5 - ng.rand()) C2[:] = C1 params = [ (blk_a * blk_b, blk_B, blk_A), (kernel.threads, 1, 1), None, C1.gpudata, A1.gpudata, B1.gpudata, alpha, beta, cda, cdb, cdc, m, n, k, blk_a, blk_b ] kernel.prepared_async_call(*params) # convert row order to col order cublasXgemm[prefix](cublas_handle, op[1], op[0], n, m, k, alpha, B1.gpudata, cdb, A1.gpudata, cda, beta, C2.gpudata, cdc) # Check for NaNs partial1[:] = ng.min(ng.finite(C1), axis=1) partial2[:] = ng.min(partial1, axis=0) if partial2.get()[0,0] == 0.0: print "Error: NaN kernel: %s mnk: (%d,%d,%d) ab: (%f,%f)" % (kernel.name, m,n,k, alpha,beta) exit() # Get Max Diff partial1[:] = ng.max(abs(C2 - C1), axis=1) partial2[:] = ng.max(partial1, axis=0) diff = partial2.get()[0,0] # Get Mean partial1[:] = ng.sum(abs(C2), axis=1) partial2[:] = ng.sum(partial1, axis=0) mean = partial2.get()[0,0] / C2.size # Scale diff by the mean pctErr = 100 * diff / mean #print "Error: %.3f %s" % (pctErr, kernel.name) maxerr = .005 if dtype is np.float32 else 0.7 if pctErr > maxerr: print "Error: %.3f%% diff: %.5f mean %.5f kernel: %s mnk: (%d,%d,%d) ab: (%f,%f)" % (pctErr, diff, mean, kernel.name, m,n,k, alpha,beta) print params if out: C1 = C1.get() C2 = C2.get() D = abs(C2 - C1) np.savetxt("out_diff.txt", D, fmt='%3.1f') np.savetxt("out_correct.txt", C2, fmt='%5.1f') np.savetxt("out_error", C1, fmt='%5.1f') exit() except drv.Error as e: print "kernel: %s mnk: (%d,%d,%d) ab: (%f,%f)" % (kernel.name, m,n,k, alpha,beta) print e exit() ### below code adapted from Nervana Neon: kernel_specs.py def _get_cache_dir(subdir=None): cache_dir = appdirs.user_cache_dir("openai-gemm") if subdir: subdir = subdir if isinstance(subdir, list) else [subdir] cache_dir = os.path.join(cache_dir, *subdir) if not os.path.exists(cache_dir): os.makedirs(cache_dir) return cache_dir # helpful for kernel development debug = 0 base_dir = os.path.dirname(__file__) maxas_dir = os.path.join(base_dir, "maxas") sass_dir = os.path.join(base_dir, "sass") kernels = { # Generic gemm tiles "sgemm_128x128x8": {"threads": 256, "sass": "xgemm_128x128x8", "params": "xgemm", "share": "(128*8 + 32)*4 + 4", "args": {"type": "s"} }, "hgemm_128x128x8": {"threads": 256, "sass": "xgemm_128x128x8", "params": "xgemm", "share": "(128*8 + 32)*4 + 4", "args": {"type": "h"} }, "sgemm_32x32x32": {"threads": 128, "sass": "xgemm_32x32x32", "params": "xgemm", "share": "(32*33)*4 + 4", "args": {"type": "s"} }, "hgemm_32x32x32": {"threads": 128, "sass": "xgemm_32x32x32", "params": "xgemm", "share": "(32*33)*4 + 4", "args": {"type": "h"} }, # Custom hgemm tiles designed for small minibatch RNNs "hgemm_32x64x32_NN": {"threads": 128, "sass": "hgemm_32x64x32_NN", "params": "xgemm", "share": "32*33*2 + 64*32*2 + 4" }, "hgemm_32x32x64_NT": {"threads": 128, "sass": "hgemm_32x32x64_NT", "params": "xgemm", "share": "32*65*4 + 4" }, "hgemm_16x64x64_NN": {"threads": 128, "sass": "hgemm_16x64x64_NN", "params": "xgemm", "share": "(16*64 + 32)*2 + 64*64*2 + 4" }, "hgemm_16x64x64_NT": {"threads": 128, "sass": "hgemm_16x64x64_NT", "params": "xgemm", "share": "(16*64 + 32)*2 + (64*64 + 32)*2 + 4" }, } _params = { "xgemm": [ "float* param_C", "float* param_A", "float* param_B", "float param_alpha", "float param_beta", "unsigned param_cda", "unsigned param_cdb", "unsigned param_cdc", "unsigned param_m", "unsigned param_n", "unsigned param_k", "unsigned param_blk_a", "unsigned param_blk_b", ], } _space_re = re.compile(r"\s+") _share_template = r""" .shared .align 4 .b32 share[{0}]; """ _kernel_template = r""" .version {6} .target {0} .address_size 64 // args: {5} .visible .entry {1}( {2} ) .reqntid {3} {{ {4} ret; }} """ def get_ptx_file(kernel_spec, kernel_name, arch, ptx_ver): ptx_dir = _get_cache_dir([arch, 'ptx']) thread_spec = kernel_spec["threads"] args_spec = str(kernel_spec.get("args","")) param_spec = _params[kernel_spec["params"]] kernel_params = [] for p in param_spec: ptype, pname = _space_re.split(p) if ptype[-1] == '*': ptype = '.u64' elif ptype == 'float': ptype = '.f32' else: ptype = '.u32' kernel_params.append(" .param %s %s" % (ptype, pname)) kernel_params = ",\n".join(kernel_params) if "share" in kernel_spec: share = _share_template.format(eval(kernel_spec["share"])) else: share = "" kernel_text = _kernel_template.format(arch, kernel_name, kernel_params, thread_spec, share, args_spec, ptx_ver) kernel_ptx = os.path.join(ptx_dir, kernel_name + ".ptx") current_text = "" if os.path.exists(kernel_ptx): f = open(kernel_ptx, "r") current_text = f.read() f.close() # only write out the kernel if text has changed. if kernel_text != current_text: f = open(kernel_ptx, "w") f.write(kernel_text) f.close() return kernel_ptx include_re = re.compile(r'^<INCLUDE\s+file="([^"]+)"\s*/>') def extract_includes(name, includes=None): if not includes: includes = list() sass_file = os.path.join(sass_dir, name) includes.append((sass_file, os.path.getmtime(sass_file))) for line in open(sass_file, "r"): match = include_re.search(line) if match: extract_includes(match.group(1), includes) return includes def run_command(cmdlist): cmd = " ".join(cmdlist) proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = proc.communicate() if proc.returncode: raise RuntimeError("Error(%d):\n%s\n%s" % (proc.returncode, cmd, err)) if debug: print cmd if out: print out if err: print err @context_dependent_memoize def get_kernel(base_name, options=None): major, minor = _get_compute_capability() if major < 5: raise RuntimeError("sass kernels require Maxwell or greater class hardware") arch = "sm_%d%d" % (major, minor) libprefix = "PERL5LIB=%s" % maxas_dir maxas_i = [libprefix, os.path.join(maxas_dir, "maxas.pl") + " -i -w"] maxas_p = [libprefix, os.path.join(maxas_dir, "maxas.pl") + " -p"] kernel_spec = kernels[base_name] kernel_name = base_name # static options if "args" in kernel_spec: for pair in kernel_spec["args"].items(): maxas_i.append("-D%s %s" % pair) maxas_p.append("-D%s %s" % pair) # dynamic options if options is not None: for opt in options: if type(opt) is tuple: maxas_i.append("-D%s %s" % opt) maxas_p.append("-D%s %s" % opt) kernel_name += "_%s%s" % opt else: maxas_i.append("-D%s 1" % opt) maxas_p.append("-D%s 1" % opt) kernel_name += "_%s" % opt maxas_i.insert(2, "-k " + kernel_name) sass_name = kernel_spec["sass"] + ".sass" cubin_name = kernel_name + ".cubin" cubin_dir = _get_cache_dir([arch, 'cubin']) ptx_version = "4.2" if major < 6 else "5.0" ptx_file = get_ptx_file(kernel_spec, kernel_name, arch, ptx_version) sass_file = os.path.join(sass_dir, sass_name) cubin_file = os.path.join(cubin_dir, cubin_name) if not os.path.exists(sass_file): raise RuntimeError("Missing: %s for kernel: %s" % (sass_name, kernel_name)) ptx_mtime = os.path.getmtime(ptx_file) cubin_mtime = os.path.getmtime(cubin_file) if os.path.exists(cubin_file) else 0 build_cubin = False if ptx_mtime > cubin_mtime: build_cubin = True includes = extract_includes(sass_name) for include, include_mtime in includes: if include_mtime > cubin_mtime: build_cubin = True break if build_cubin: # build the cubin and run maxas in the same command # we don't want the chance of a generated cubin not processed by maxas (in case user hits ^C in between these steps) run_command([ "ptxas -v -arch", arch, "-o", cubin_file, ptx_file, ";" ] + maxas_i + [sass_file, cubin_file]) cubin_mtime = time.time() # output preprocessed and disassembled versions in debug mode if debug: pre_dir = _get_cache_dir([arch, 'pre']) dump_dir = _get_cache_dir([arch, 'dump']) pre_file = os.path.join(pre_dir, kernel_name + "_pre.sass") dump_file = os.path.join(dump_dir, kernel_name + "_dump.sass") pre_mtime = os.path.getmtime(pre_file) if os.path.exists(pre_file) else 0 dump_mtime = os.path.getmtime(dump_file) if os.path.exists(dump_file) else 0 for include, include_mtime in includes: if include_mtime > pre_mtime: run_command(maxas_p + [sass_file, pre_file]) break if cubin_mtime > dump_mtime: run_command(["nvdisasm -c", cubin_file, ">", dump_file]) # generate the function signature for pycuda params = _params[kernel_spec["params"]] sig = "" for p in params: ptype, pname = _space_re.split(p) if ptype[-1] == '*': sig += "Q" elif ptype == 'float': sig += "f" elif ptype == 'unsigned': sig += "I" else: sig += "i" module = drv.module_from_file(cubin_file) func = module.get_function(kernel_name) func.prepare(sig) func.threads = kernel_spec["threads"] func.name = kernel_name func.static_shared = eval(kernel_spec["share"]) return func