Tutorial: BigInt Modular Multiplication
This tutorial walks you through building a 256-bit big-integer application using cuPQC-BigInt. You'll learn how to add two 256-bit values and compute (a * b) mod m inside your own CUDA kernel, with one value per thread.
cuPQC-BigInt requires cuPQC SDK 0.6.0 or newer, the release that introduced the library. cuPQC 0.6 targets compute capabilities 8.0, 8.6, 8.7, 8.9, and 9.0. Unlike the other cuPQC libraries, cuPQC-BigInt is exposed entirely as __device__ functions, so every operation is called from inside your own kernel rather than launched from the host.
Step 1: Project Setup
Clone the cuPQC repository:
This will download all examples including the addition and modular multiplication example. The Makefile in this directory will compile all examples, including example_bigint_addmulmod.
Step 2: Include Required Headers
Start by including the necessary cuPQC SDK headers:
#include <array>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <vector>
#include <bigint.hpp>
#include <cucheck.hpp>
using namespace cupqc;
The bigint.hpp header provides the fixed-width, unsigned multi-precision integer types and their device-side operations. The cucheck.hpp header provides the CUDA_CHECK macro used to wrap CUDA API calls on the host.
Step 3: Select the Target Architecture
SM<>() selects the architecture the descriptor generates code for. It is optional in a bigint descriptor and defaults to SM<800>, which is what the host compilation pass and any unlisted architecture use. Because the Makefile compiles with -arch=native, the example derives the operator from __CUDA_ARCH__ so the descriptor stays in step with whatever architecture is actually being compiled:
#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ == 900)
#define BIGINT_EXAMPLE_SM() + SM<900>()
#elif defined(__CUDA_ARCH__) && (__CUDA_ARCH__ == 890)
#define BIGINT_EXAMPLE_SM() + SM<890>()
#elif defined(__CUDA_ARCH__) && (__CUDA_ARCH__ == 870)
#define BIGINT_EXAMPLE_SM() + SM<870>()
#elif defined(__CUDA_ARCH__) && (__CUDA_ARCH__ == 860)
#define BIGINT_EXAMPLE_SM() + SM<860>()
#elif defined(__CUDA_ARCH__) && (__CUDA_ARCH__ == 800)
#define BIGINT_EXAMPLE_SM() + SM<800>()
#else
#define BIGINT_EXAMPLE_SM()
#endif
The macro stops at 9.0 because that is the highest SM<> specialization the SDK provides, so a newer architecture falls through to the default rather than naming an SM<> that does not exist.
Step 4: Define the Big-Integer Type
Compose the big-integer type from operators, in the same style as the other cuPQC libraries:
// 256-bit integers, one thread per value.
using BI256 = decltype(BitWidth<256>() + Thread() BIGINT_EXAMPLE_SM());
BitWidth<256>() sets the width to 256 bits. BW must be a multiple of 32, and the limb count is BW / 32, so BI256::num_limbs is 8.
Thread() gives a single thread ownership of the whole value. Exactly one of Thread() or Warp() is required; Warp() instead spreads one value across TPI<T> consecutive warp lanes, which keeps register pressure and carry-propagation cost down for very wide integers.
SM<...>() selects the target architecture, supplied here by BIGINT_EXAMPLE_SM().
Only BitWidth is required to form a complete descriptor.
The descriptor exposes two value types:
using bigint = typename BI256::bigint; // 256-bit value
using bigint_wide = typename BI256::bigint_wide; // 512-bit double-width value
Multiplication and squaring produce a double-width result, which is why bigint_wide exists: for a 256-bit type it carries 512 bits. Both operands of a binary operation must be the same type. Nothing is implicitly promoted or truncated, so mixing widths is a compile error rather than a silent conversion.
Step 5: Create the Kernel
Create a CUDA kernel that processes one value per thread:
__global__ void add_mulmod_kernel(uint32_t* sums, uint32_t* products,
const uint32_t* global_buf_a,
const uint32_t* global_buf_b,
const uint32_t* global_buf_m,
unsigned int count)
{
const unsigned int index = blockIdx.x * blockDim.x + threadIdx.x;
if (index >= count) {
return;
}
const BI256::bigint a(global_buf_a, index);
const BI256::bigint b(global_buf_b, index);
const BI256::bigint m(global_buf_m, index);
// 256-bit integer addition (wraps on overflow).
const auto sum = a + b;
// (a * b) mod m.
const auto product = a.mul_mod(b, m);
sum.store(sums, index);
product.store(products, index);
}
Values are arrays of little-endian 32-bit limbs, so limb 0 is least significant. Constructors and store optionally take an instance index, which makes a packed array of independent big integers loadable and storable directly by batch index:
BI256::bigint a(global_buf_a, index): load instance index from a packed limb buffer
a + b: fixed-width 256-bit addition; the result wraps modulo 2^256
a.mul_mod(b, m): forms the full double-width product of a and b, then reduces it modulo m
sum.store(sums, index): write the value back to instance index of the output buffer
Both results are stored, so the host can verify the sum and the modular product independently.
Step 6: Implement the Host Code
The host allocates packed limb buffers, copies the inputs to the device, launches the kernel, and copies the results back. Small values keep host-side verification easy; the same layout supports arbitrary 256-bit input values:
int main()
{
// ... banner and configuration printouts ...
const unsigned int count = 2;
using host_limb_array = std::array<uint32_t, BI256::num_limbs>;
static_assert(sizeof(host_limb_array) == BI256::num_limbs * sizeof(uint32_t));
std::vector<host_limb_array> a(count, host_limb_array{});
std::vector<host_limb_array> b(count, host_limb_array{});
std::vector<host_limb_array> m(count, host_limb_array{});
std::vector<host_limb_array> sums(count);
std::vector<host_limb_array> products(count);
a[0][0] = 7u;
b[0][0] = 5u;
m[0][0] = 11u; // (7 + 5) = 12, (7 * 5) mod 11 = 2
a[1][0] = 40u;
b[1][0] = 9u;
m[1][0] = 13u; // (40 + 9) = 49, (40 * 9) mod 13 = 3
// ... cudaMalloc and cudaMemcpy of d_a, d_b, d_m, d_sums, d_products ...
constexpr unsigned int kThreads = 128;
add_mulmod_kernel<<<(count + kThreads - 1) / kThreads, kThreads>>>(
d_sums, d_products, d_a, d_b, d_m, count);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaMemcpy(sums.data(), d_sums, bytes, cudaMemcpyDeviceToHost));
CUDA_CHECK(cudaMemcpy(products.data(), d_products, bytes, cudaMemcpyDeviceToHost));
// ... cudaFree of all device buffers ...
}
Because each input value fits in limb 0, verification only has to inspect the least significant limb of each result:
std::printf(" 7 + 5 = %u (expected 12)\n", sums[0][0]);
std::printf(" (7 * 5) mod 11 = %u (expected 2)\n", products[0][0]);
std::printf(" 40 + 9 = %u (expected 49)\n", sums[1][0]);
std::printf(" (40 * 9) mod 13 = %u (expected 3)\n\n", products[1][0]);
if (sums[0][0] != 12u || products[0][0] != 2u ||
sums[1][0] != 49u || products[1][0] != 3u) {
std::fprintf(stderr, "FAIL: unexpected sums or products.\n");
return EXIT_FAILURE;
}
std::printf("Example completed successfully.\n");
return EXIT_SUCCESS;
Step 7: Build and Run
The Makefile will build all examples in the folder. It expects the cuPQC SDK at /usr/local/cupqc-sdk or a user-specified path, which you can set with the CUPQC_SDK_DIR environment variable or by modifying the Makefile. Run the addition and modular multiplication example:
cuPQC-BigInt is a static library built for a specific set of widths per TPI, and it is linked with LTO (-dlto -lcupqc-bigint). A configuration the type system accepts but the shipped library does not provide — an unsupported width, or a Thread()-only operation used on a warp descriptor — compiles cleanly and then fails to link.
Step 8: Understanding the Output
Expected output:
================================================================
BigInt Addition and Modular Multiplication Example
================================================================
This example demonstrates two of the most common cuPQC-BigInt
operations: fixed-width addition, which wraps modulo 2^256, and
mul_mod, which computes the full double-width product and then
reduces it modulo m.
Configuration: 256-bit width, thread execution (one value per thread)
Limb layout: 8 little-endian 32-bit limbs per value
========================================
Results
========================================
7 + 5 = 12 (expected 12)
(7 * 5) mod 11 = 2 (expected 2)
40 + 9 = 49 (expected 49)
(40 * 9) mod 13 = 3 (expected 3)
Example completed successfully.
Customization Tips
Choose the Right Modular Reduction Path
mul_mod performs a full wide multiply followed by a division-based remainder and requires no precomputation, which makes it the right choice for a one-off operation like the one in this example. When you reuse a single modulus across a loop, a precomputed path amortizes its setup cost instead. Barrett precomputes a reciprocal with setup_barrett, then reduce_barrett reduces a double-width value; setup runs once per modulus, and approx and den_clz are opaque state you pass through unchanged:
BI256::bigint approx;
int den_clz;
BI256::bigint::setup_barrett(m, approx, den_clz); // once per modulus
BI256::bigint_wide num = a.mul_wide(b);
BI256::bigint rem;
BI256::bigint::reduce_barrett(rem, num, m, approx, den_clz);
The wide numerator must satisfy num.hi < den, which holds for a product when both factors are already reduced.
Stay in the Montgomery Domain
For a chain of repeated multiplications against a fixed modulus, convert once with to_montgomery, multiply with mul_montgomery, and convert back at the end with from_montgomery. Remaining in Montgomery form across the operation chain avoids paying a general reduction on every step.
Make Error Handling Strict During Development
Kernels cannot throw exceptions, and only div_rem and inv_mod return a bigint_error enumerator. Operators and functions like /, %, mod, add_mod, mul_mod, and pow_mod have no return channel for error codes, so cuPQC-BigInt embeds the error-handling policy in the compile-time type descriptor. Because the policy is a template parameter, you can debug aggressively during development and then ship with zero runtime cost:
OnErrorNone(default): takes no action; the result is unspecified and the kernel continues.OnErrorTrap: calls__trap()on the offending thread, halting the kernel without printing a message.OnErrorPrintTrap: prints the failing block and thread indices along with the error, then traps. Best kept to debugging, sinceprintffrom a kernel is expensive.
Both trapping policies require thread execution (Thread()); warp instances use OnErrorNone only. The BigInt vector addition tutorial covers the remaining execution-mode and bit-width rules, including which operations are thread-only and how to widen a value.
Learn More
- Example Source Code - Complete addition and modular multiplication example
- cuPQC-BigInt User Guide - Usage guide with examples
- cuPQC-BigInt API Reference - Complete API documentation
- cuPQC-BigInt Features - Supported widths, operations, and reduction paths