Skip to content

Commit 9cfd28c

Browse files
authored
Nvidia target via NVRTC and Nim ↦ CUDA DSL (#487)
* commit the initial Nim ⇒ CUDA DSL, NVRTC & CUDA execution helpers * [tests/examples] add example for a BigInt modular addition * remove `nimcuda` dependency, wrap everything we need manually * merge `execCuda` logic for LLVM & NVRTC IMPORTANT NOTE: For LLVM we generate `array_t` types for the finite field elements. By doing this we make it impossible to just copy over a Constantine finite field element or elliptic curve point (which are also an `array_t` type). Therefore, we have a `CUfunctionLLVM` type, which is used to differentiate between different `execCuda` calls, based on their "origin" (i.e. LLVM or NVRTC backends). Based on that backend we either allow passing simple structs by their host pointer or force a copy. * add `quitOnFailure` for the `check` calls to avoid regression This was added for a reason after all in 5d66b52 * [tests] turn big int `modadd` example into real test * copy `libpaths.nim` over from nimcuda * [cuda] add partial support for `const` in CUDA generator * [cuda] fix minor issue in `if` statements in CUDA generator * [cuda] add support for named blocks * [cuda] add support for `bool` * [cuda] remove unnecessary semicolon * [cuda] add support for `{.volatile.}` variables * [nvrtc] add `modadd`, `modsub`, `mtymul` implementations using inline PTX * [cuda] support basic type conversions By mapping them to a regular cast. * [cuda] support `var` parameters in procs * [cuda] make sure proc body is a block * [cuda] support boolean / bitwise AND/OR and XOR, NOT * [cuda] support int32 literals * [cuda] handle prefix `not` * [cuda] make sure to pass `array` types by pointer instead of copy * [nvrtc] add more helpers, add TODO to investigate `slct` calls * [nvrtc] add many more field arithmetic / bigint operations ``` proc setZero(a: var BigInt) {.device.} = proc setOne(a: var BigInt) {.device.} = proc add(r: var BigInt, a, b: BigInt) {.device.} = proc sub(r: var BigInt, a, b: BigInt) {.device.} = proc mul(r: var BigInt, a, b: BigInt) {.device.} = proc ccopy(a: var BigInt, b: BigInt, condition: bool) {.device.} = proc csetZero(r: var BigInt, condition: bool) {.device.} = proc csetOne(r: var BigInt, condition: bool) {.device.} = proc cadd(r: var BigInt, a: BigInt, condition: bool) {.device.} = proc csub(r: var BigInt, a: BigInt, condition: bool) {.device.} = proc doubleElement(r: var BigInt, a: BigInt) {.device.} = proc nsqr(r: var BigInt, a: BigInt, count: int) {.device.} = proc isZero(r: var bool, a: BigInt) {.device.} = proc isOdd(r: var bool, a: BigInt) {.device.} = proc neg(r: var BigInt, a: BigInt) {.device.} = proc cneg(r: var BigInt, a: BigInt, condition: bool) {.device.} = proc shiftRight(r: var BigInt, k: uint32) {.device.} = proc div2(r: var BigInt) {.device.} = ``` [back] * [tests] add test to pass by pointer and `var` * [tests] add test case for modadd/sub/mtymul * [tests] add basic test cases for all new NVRTC operations * [tests] update modadd/sub, mtymul test for new `getFieldModulus` * add BabyBear field * [nvrtc] handle `mtymul` for fields with 1 limb * [cuda] support nested array types, unpack generic instantiatons * [cuda] correctly generate ptr to array & ptr to array return types * [cuda] automatically generate `memcpy` for static array types Static array types cannot be assigned in C/C++/CUDA. We generate a `memcpy` call for them. Obviously this does not work for arrays with structs that have pointer fields or similar (at least not if you expect to copy the values) * [nvrtc] use `const` for field modulus and other CT constants * [cuda] extend error message for non copyable inputs * [cuda] disable passStructByPointer also for CUDA Once one wants to launch multiple blocks / threads per block this approach breaks for slightly larger objects * [cuda] add `{.nimonly.}` pragma one can use in `cuda` block This pragma makes us not emit any code for that function. Useful to call it in the definition of a `const` * [cuda] better logic for detection of type names * [cuda] support `const` by mapping it to a `__constant__` * [cuda] allow type determination from array literal * [nvrtc] get rid of complexity with custom uint32 constants * [staticFor] add stepped variant of `staticFor` * [cuda] support func, discard and command nnkCommand * [cuda] better handle required semicolons To fix: ```nim const code = cuda: proc sum() {.device.} = let inputIdx = 0 let rateIdx = 0 if inputIdx > 0 and rateIdx > 0: discard return echo code ``` which previously produced: ``` extern "C" __device__ void sum(){ long long inputIdx = 0; long long rateIdx = 0; if (((0 < inputIdx); && (0 < rateIdx);)) { ; }; return ; }; ``` and now: ``` extern "C" __device__ void sum(){ long long inputIdx = 0; long long rateIdx = 0; if (((0 < inputIdx) && (0 < rateIdx))) { ; }; return ; }; ``` * [cuda] extract type from `getType` for execution helper * [cuda] special case `CUdeviceptr` as a type that *must not* be copied This allows the user to e.g. allocate and copy memory to a CUDA device before calling `execute`. * [cuda] allow passing in shared memory size for a kernel * [nvidia ABI] wrap cuModuleGetGlobal, cudaMemcpyKind and a couple more * [cuda] support while loops * [cuda] support void pointers and `nil` literals * [cuda] refactor out module loading from execution so that one can e.g. copy to a global symbol before execution * [cuda] store PTX before echoeing it * [cuda] add `copyToSymbol` helper to copy to constant symbol in CUDA code * [cuda] generalize `volatile` annotation to support other pragmas i.e. to write ```cuda extern shared int foo[]; ``` we will now support ```nim var foo {.cuExtern, shared.}: array[0, int] ``` (the 0 size is the current placeholder on how to designate a `[]` array from Nim) * [cuda] `cudaName` pragma for custom name for a proc, eg __syncthreads Allows to map a proc to a custom name. Useful for names that we can't write due to Nim limitations (i.e. starting with an underscore or names that match Nim keywords) * [cuda] support float literals * [cuda] map arrays of explicit length 0 to `[]` arrays in CUDA * [cuda] explicitly support constants, mapped to `__constant__` One can either define a Nim `const` for a variable that is already a const at the Nim compile time or use ```nim var foo {.constant.}: theType ``` if one wishes to copy to the symbol before kernel execution. This is useful for global constants that are not filled in at CUDA compile time, but before execution. For example: ```nim # Filled with `copyToSymbol` at runtime from host! var rc16 {.constant.}: array[30, array[BABYBEAR_WIDTH, BigInt]] var matInternalDiagM1 {.constant.}: array[BABYBEAR_WIDTH, BigInt] var montyInverse {.constant.}: BigInt ``` And in the host code: ```nim var nvrtc = initNvrtc(CudaCode) nvrtc.compile() nvrtc.getPtx() nvrtc.load() var p2bb = Poseidon2BabyBear.init() # copy Poseidon2 constants to CUDA kernel nvrtc.copyToSymbol("rc16", p2bb.rc16) nvrtc.copyToSymbol("matInternalDiagM1", p2bb.matInternalDiagM1) nvrtc.copyToSymbol("montyInverse", p2bb.montyInverse) ``` * [cuda] minor cleanup * [cuda] add `gridDim`, `cuExtern` and `share` + device malloc/free * force compilation with `-d:CTT_32` for the moment Need to finalize the logic of mapping 64 bit limbs to 32 bit limb constants
1 parent 8265585 commit 9cfd28c

15 files changed

Lines changed: 4883 additions & 297 deletions

constantine/math_compiler/codegen_nvidia.nim

Lines changed: 26 additions & 289 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,6 @@ export
7171
# Cuda Driver API
7272
# ------------------------------------------------------------
7373

74-
template check*(status: CUresult, quitOnFailure = true) =
75-
## Check the status code of a CUDA operation
76-
## Exit program with error if failure
77-
78-
let code = status # ensure that the input expression is evaluated once only
79-
80-
if code != CUDA_SUCCESS:
81-
writeStackTrace()
82-
stderr.write(astToStr(status) & " " & $instantiationInfo() & " exited with error: " & $code & '\n')
83-
if quitOnFailure:
84-
quit 1 # NOTE: this hides exceptions if they are thrown!
85-
8674
func cuModuleLoadData*(module: var CUmodule, sourceCode: openArray[char]): CUresult {.inline.}=
8775
cuModuleLoadData(module, sourceCode[0].unsafeAddr)
8876
func cuModuleGetFunction*(kernel: var CUfunction, module: CUmodule, fnName: openArray[char]): CUresult {.inline.}=
@@ -221,277 +209,6 @@ proc exec*[T](jitFn: CUfunction, r: var T, a, b: T) =
221209
check cuMemFree(aGPU)
222210
check cuMemFree(bGPU)
223211

224-
proc getTypes(n: NimNode): seq[NimNode] =
225-
case n.kind
226-
of nnkIdent, nnkSym: result.add getTypeInst(n)
227-
of nnkLiterals: result.add getTypeInst(n)
228-
of nnkBracket, nnkTupleConstr, nnkPar:
229-
for el in n:
230-
result.add getTypes(el)
231-
else:
232-
case n.typeKind
233-
of ntyPtr: result.add getTypeInst(n)
234-
else:
235-
error("Arguments to `execCuda` must be given as a bracket, tuple or typed expression. Instead: " & $n.treerepr)
236-
237-
proc requiresCopy(n: NimNode): bool =
238-
## Returns `true` if the given type is not a trivial data type, which implies
239-
## it will require copying its value manually.
240-
case n.typeKind
241-
of ntyBool, ntyChar, ntyInt .. ntyUint64: # range includes all floats
242-
result = false
243-
else:
244-
result = true
245-
246-
proc allowsCopy(n: NimNode): bool =
247-
## Returns `true` if the given type is allowed to be copied. That means it is
248-
## either `requiresCopy` or a `var` symbol.
249-
result = n.requiresCopy or n.symKind == nskVar
250-
251-
proc getIdent(n: NimNode): NimNode =
252-
## Generate a `GPU` suffixed ident
253-
# Note: We want a deterministic name, because we call `getIdent` for the same symbol
254-
# in multiple places atm.
255-
case n.kind
256-
of nnkIdent, nnkSym: result = ident(n.strVal & "GPU")
257-
else: result = ident("`" & n.repr & "`GPU")
258-
259-
proc determineDevicePtrs(r, i: NimNode, iTypes: seq[NimNode]): seq[(NimNode, NimNode)] =
260-
## Returns the device pointer ident and its associated original symbol.
261-
for el in r:
262-
if not el.allowsCopy:
263-
error("The argument for `res`: " & $el.repr & " of type: " & $el.getTypeImpl().treerepr &
264-
" does not allow copying. Copying to the address of all result variables is required.")
265-
result.add (getIdent(el), el)
266-
for idx in 0 ..< i.len:
267-
let input = i[idx]
268-
let t = iTypes[idx]
269-
if t.requiresCopy():
270-
result.add (getIdent(input), input)
271-
272-
proc assembleParams(r, i: NimNode, iTypes: seq[NimNode]): seq[NimNode] =
273-
## Returns all parameters. Depending on whether they require copies or
274-
## are `res` parameters, either the input parameter or the `GPU` parameter.
275-
for el in r: # for `res` we always copy!
276-
result.add getIdent(el)
277-
for idx in 0 ..< i.len:
278-
let input = i[idx]
279-
let t = iTypes[idx]
280-
if t.requiresCopy():
281-
result.add getIdent(input)
282-
else:
283-
result.add input
284-
285-
proc sizeArg(n: NimNode): NimNode =
286-
## The argument to `sizeof` must be the size of the data we copy. If the
287-
## input type is already given as a `ptr T` type, we need the size of
288-
## `T` and not `ptr`.
289-
case n.typeKind
290-
of ntyPtr: result = n.getTypeInst()[0]
291-
else: result = n
292-
293-
# little helper macro constructors
294-
template check(arg): untyped = nnkCall.newTree(ident"check", arg)
295-
template size(arg): untyped = nnkCall.newTree(ident"sizeof", sizeArg arg)
296-
template address(arg): untyped = nnkCall.newTree(ident"addr", arg)
297-
template csize_t(arg): untyped = nnkCall.newTree(ident"csize_t", arg)
298-
template pointer(arg): untyped = nnkCall.newTree(ident"pointer", arg)
299-
300-
proc maybeAddress(n: NimNode): NimNode =
301-
## Returns the address of the given node, *IFF* the type is not a
302-
## pointer type already
303-
case n.typeKind
304-
of ntyPtr: result = n
305-
else: result = address(n)
306-
307-
proc genParams(pId, r, i: NimNode, iTypes: seq[NimNode]): NimNode =
308-
## Generates the parameter `params` variable
309-
let ps = assembleParams(r, i, iTypes)
310-
result = nnkBracket.newTree()
311-
for p in ps:
312-
result.add pointer(maybeAddress p)
313-
result = nnkLetSection.newTree(
314-
nnkIdentDefs.newTree(pId, newEmptyNode(), result)
315-
)
316-
317-
proc genVar(n: NimNode): (NimNode, NimNode) =
318-
## Generates a let `tmp` variable and returns its identifier and
319-
## the let section.
320-
result[0] = genSym(nskLet, "tmp")
321-
result[1] = nnkLetSection.newTree(
322-
nnkIdentDefs.newTree(
323-
result[0],
324-
getTypeInst(n),
325-
n
326-
)
327-
)
328-
329-
proc genLocalVars(inputs: NimNode): (NimNode, NimNode) =
330-
result[0] = newStmtList() # defines local vars
331-
result[1] = nnkBracket.newTree() # returns new bracket of vars for parameters
332-
for el in inputs:
333-
case el.kind
334-
of nnkLiterals, nnkConstDef: # define a local with the value of it
335-
let (s, v) = genVar(el)
336-
result[0].add v
337-
result[1].add s
338-
of nnkSym:
339-
if el.strVal in ["true", "false"]:
340-
let (s, v) = genVar(el)
341-
result[0].add v
342-
result[1].add s
343-
else:
344-
result[1].add el # keep symbol
345-
else:
346-
result[1].add el # keep symbol
347-
348-
proc maybeWrap(n: NimNode): NimNode =
349-
if n.kind notin {nnkBracket, nnkTupleConstr}:
350-
result = nnkBracket.newTree(n)
351-
else:
352-
result = n
353-
354-
proc endianCheck(): NimNode =
355-
result = quote do:
356-
static: doAssert cpuEndian == littleEndian, block:
357-
# From https://developer.nvidia.com/cuda-downloads?target_os=Linux
358-
# Supported architectures for Cuda are:
359-
# x86-64, PowerPC 64 little-endian, ARM64 (aarch64)
360-
# which are all little-endian at word-level.
361-
#
362-
# Due to limbs being also stored in little-endian, on little-endian host
363-
# the CPU and GPU will have the same binary representation
364-
# whether we use 32-bit or 64-bit words, so naive memcpy can be used for parameter passing.
365-
366-
"Most CPUs (x86-64, ARM) are little-endian, as are Nvidia GPUs, which allows naive copying of parameters.\n" &
367-
"Your architecture '" & $hostCPU & "' is big-endian and GPU offloading is unsupported on it."
368-
369-
proc execCudaImpl(jitFn, res, inputs: NimNode): NimNode =
370-
# Maybe wrap individually given arguments in a `[]` bracket, e.g.
371-
# `execCuda(res = foo, inputs = bar)`
372-
let res = maybeWrap res
373-
let inputs = maybeWrap inputs
374-
375-
result = newStmtList()
376-
result.add endianCheck()
377-
378-
# get the types of the inputs
379-
let rTypes = getTypes(res)
380-
let iTypes = getTypes(inputs)
381-
382-
# determine all required `CUdeviceptr`
383-
let devPtrs = determineDevicePtrs(res, inputs, iTypes)
384-
385-
# generate device pointers, allocate memory and copy data
386-
for x in devPtrs:
387-
# `var rGPU: CUdeviceptr`
388-
result.add nnkVarSection.newTree(
389-
nnkIdentDefs.newTree(
390-
x[0],
391-
ident"CUdeviceptr",
392-
newEmptyNode()
393-
)
394-
)
395-
396-
# `check cuMemAlloc(rGPU, csize_t sizeof(r))`
397-
result.add(
398-
check nnkCall.newTree(
399-
ident"cuMemAlloc",
400-
x[0],
401-
csize_t size(x[1])
402-
)
403-
)
404-
# `check cuMemcpyHtoD(aGPU, a.addr, csize_t sizeof(a))`
405-
result.add(
406-
check nnkCall.newTree(
407-
ident"cuMemcpyHtoD",
408-
x[0],
409-
maybeAddress x[1],
410-
csize_t size(x[1])
411-
)
412-
)
413-
414-
# Generate local variables
415-
let (decl, vars) = genLocalVars(inputs)
416-
result.add decl
417-
418-
# assemble the parameters
419-
let pId = ident"params"
420-
let params = genParams(pId, res, vars, iTypes)
421-
result.add params
422-
423-
# launch the kernel
424-
result.add quote do:
425-
check cuLaunchKernel(
426-
`jitFn`,
427-
1, 1, 1, # grid(x, y, z)
428-
1, 1, 1, # block(x, y, z)
429-
sharedMemBytes = 0,
430-
CUstream(nil),
431-
`pId`[0].unsafeAddr, nil)
432-
433-
# copy back results
434-
let devPtrsRes = determineDevicePtrs(res, nnkBracket.newTree(), @[])
435-
for x in devPtrsRes:
436-
result.add(
437-
check nnkCall.newTree(
438-
ident"cuMemcpyDtoH",
439-
maybeAddress x[1],
440-
x[0],
441-
csize_t size(x[1])
442-
)
443-
)
444-
445-
# free memory
446-
for x in devPtrs:
447-
result.add(
448-
check nnkCall.newTree(
449-
ident"cuMemFree",
450-
x[0]
451-
)
452-
)
453-
result = quote do:
454-
block:
455-
`result`
456-
457-
macro execCuda*(jitFn: CUfunction,
458-
res: typed,
459-
inputs: typed): untyped =
460-
## Given a CUDA function, execute the kernel. Copies all non trivial data types to
461-
## to the GPU via `cuMemcpyHtoD`. Any argument given as `res` will be copied back
462-
## from the GPU after kernel execution finishes.
463-
##
464-
## IMPORTANT:
465-
## The arguments passed to the CUDA kernel will be in the order in which they are
466-
## given to the macro. This especially means `res` arguments will be passed first.
467-
##
468-
## Example:
469-
## ```nim
470-
## execCuda(fn, res = [r, s], inputs = [a, b, c]) # if all arguments have the same type
471-
## # or
472-
## execCuda(fn, res = (r, s), inputs = (a, b, c)) # if different types
473-
## ```
474-
## will pass the parameters as `[r, s, a, b, c]`.
475-
##
476-
## For more examples see the test case `tests/gpu/t_exec_literals_consts.nim`.
477-
##
478-
## We do not perform any checks on whether the given types are valid as arguments to
479-
## the CUDA target! Also, all arguments given as `res` are expected to be copied.
480-
## To return a value for a simple data type, use a `ptr X` type. However, it is allowed
481-
## to simply pass a `var` symbol as a `res` argument. We automatically copy to the
482-
## the memory location.
483-
##
484-
## We also copy all `res` data to the GPU, so that a return value can also be used
485-
## as an input.
486-
##
487-
## NOTE: This function is mainly intended for convenient execution of a single kernel
488-
result = execCudaImpl(jitFn, res, inputs)
489-
490-
macro execCuda*(jitFn: CUfunction,
491-
res: typed): untyped =
492-
## Overload of the above for empty `inputs`
493-
result = execCudaImpl(jitFn, res, nnkBracket.newTree())
494-
495212
# ############################################################
496213
#
497214
# Compilation helper
@@ -516,6 +233,12 @@ type
516233

517234
NvidiaAssembler* = ref NvidiaAssemblerObj
518235

236+
## We define a distinct version of the `CUfunction` type to differentiate
237+
## producing a kernel via the LLVM backend from the more direct approach
238+
## using NVRTC. This is because the data passing for field elements
239+
## is more complicated on the LLVM side (requires a manual copy).
240+
CUfunctionLLVM* = distinct CUfunction
241+
519242
proc `=destroy`*(nv: NvidiaAssemblerObj) =
520243
## XXX: Need to also call the finalizer for `asy` in the future!
521244
# NOTE: In the destructor we don't want to quit on a `check` failure.
@@ -592,7 +315,7 @@ proc initNvAsm*[Name: static Algebra](field: type EC_ShortW_Jac[Fp[Name], G1], w
592315
result.fd = result.cd.fd
593316
result.asy.definePrimitives(result.cd)
594317

595-
proc compile*(nv: NvidiaAssembler, kernName: string): CUfunction =
318+
proc compile*(nv: NvidiaAssembler, kernName: string): CUfunctionLLVM =
596319
## Overload of `compile` below.
597320
## Call this version if you have manually used the Assembler_LLVM object
598321
## to build instructions and have a kernel name you wish to compile.
@@ -617,18 +340,32 @@ proc compile*(nv: NvidiaAssembler, kernName: string): CUfunction =
617340
check cuModuleLoadData(nv.cuMod, ptx)
618341
# will be cleaned up when `NvidiaAssembler` goes out of scope
619342

620-
result = nv.cuMod.getCudaKernel(kernName)
343+
result = CUfunctionLLVM(nv.cuMod.getCudaKernel(kernName))
621344

622-
proc compile*(nv: NvidiaAssembler, fn: FieldFnGenerator): CUfunction =
345+
proc compile*(nv: NvidiaAssembler, fn: FieldFnGenerator): CUfunctionLLVM =
623346
## Given a function that generates code for a finite field operation, compile
624347
## that function on the given Nvidia target and return a CUDA function.
625348
# execute the `fn`
626349
let kernName = nv.asy.fn(nv.fd)
627-
result = nv.compile(kernName)
350+
result = CUfunctionLLVM(nv.compile(kernName))
628351

629-
proc compile*(nv: NvidiaAssembler, fn: CurveFnGenerator): CUfunction =
352+
proc compile*(nv: NvidiaAssembler, fn: CurveFnGenerator): CUfunctionLLVM =
630353
## Given a function that generates code for an elliptic curve operation, compile
631354
## that function on the given Nvidia target and return a CUDA function.
632355
# execute the `fn`
633356
let kernName = nv.asy.fn(nv.cd)
634-
result = nv.compile(kernName)
357+
result = CUfunctionLLVM(nv.compile(kernName))
358+
359+
import ./experimental/cuda_execute_dsl
360+
macro execCuda*(jitFn: CUfunctionLLVM,
361+
res: typed,
362+
inputs: typed): untyped =
363+
## See `execCuda` in `constantine/math_compiler/experimental/cuda_execute_dsl.nim`
364+
## for an explanation.
365+
##
366+
## This LLVM overload makes sure we disallow passing simple structs
367+
## via their pointer and instead always copy them (required due to our
368+
## type definitions for finite field elements and elliptic curve points
369+
## on the LLVM target).
370+
execCudaImpl(jitFn, newLit 1, newLit 1, res, inputs,
371+
passStructByPointer = false)

0 commit comments

Comments
 (0)