Running GPU AI workloads with a Ruby on Rails monolith | DocuSeal
All posts
Running GPU AI workloads with a Ruby on Rails monolith
August 10, 2026<br>• Written by<br>Pete Matsyburka
DocuSeal is an open-source e-signature platform, and to get started, users need to upload their documents and map the fields to be filled or signed. Manually adding dozens or even hundreds of fields to complex forms can be tedious, so we implemented an AI field detection feature based on a computer vision model trained to detect fields on a wide range of publicly available PDF forms. We run our AI workloads on an NVIDIA GPU instance within the Ruby process of our Rails monolith, with no Python and no microservices.
Ruby on an NVIDIA GPU
We like the convenience of building our Rails monolith app with Ruby, and to make it easy to develop and maintain AI features, we also wanted to have AI field detection within the same Ruby on Rails monolith app. To achieve this, we’ve built an AI field detection inference pipeline with Ruby and the Sidekiq async jobs processor running on an NVIDIA T4 GPU instance. The screenshot below displays nvtop NVIDIA GPU utilization by the Ruby Sidekiq process on a production GPU worker during fields detection.
TensorRT with Ruby
To run computer vision models efficiently on GPUs, NVIDIA provides the TensorRT inference runtime. TensorRT exposes a C++ API, and no Ruby binding for it existed, so we built a very small single-file Rice C++ binding that links only the methods a forward pass needs: loading an engine, inspecting its tensors, binding device memory, executing, and synchronizing the CUDA stream. We made these TensorRT Ruby bindings open source under the Apache 2.0 license, available on GitHub.
Copy
Copied
gem install tensorrt
Building and installing the gem requires TensorRT and NVIDIA CUDA on the system. The entire TensorRT binding is a single TensorRT::Engine class with 11 methods:
Copy
Copied
require 'tensorrt'
engine = TensorRT::Engine.new(model_path, verbose: false)
engine.num_io_tensors # Number of input/output tensors<br>engine.get_tensor_name(index) # Tensor name by index<br>engine.is_input?(name) # Check if tensor is input<br>engine.get_tensor_shape(name) # Shape as array [1, 3, 640, 640]<br>engine.get_tensor_bytes(name) # Size in bytes<br>engine.get_tensor_dtype(name) # Data type, e.g. float32
engine.set_tensor_address(name, device_ptr) # Bind GPU memory
engine.execute # Synchronous execution<br>engine.enqueue # Asynchronous execution
engine.get_stream # CUDA stream handle<br>engine.stream_synchronize # Wait for stream completion
The inference pipeline
The pipeline consists of three stages:
Preprocessing. Render the PDF page and prepare the input tensor.
Forward pass. Run the model on the GPU with TensorRT.
Postprocessing. Convert output tensors into field coordinates on the page.
1. Preprocessing: from PDF page to input tensor
First, PDF pages are rendered with PDFium. The rendered image is scaled to the model input resolution with aspect ratio preserved, padded to a square, normalized with the standard ImageNet mean and standard deviation, and transposed from HWC to CHW layout. Image operations are performed with ruby-vips, the libvips binding, and for tensor operations we use Numo, a Ruby alternative to NumPy:
Copy
Copied
MEAN = [0.485, 0.456, 0.406].freeze<br>STD = [0.229, 0.224, 0.225].freeze
scale = [resolution.to_f / image.width, resolution.to_f / image.height].min
resized = image.resize(scale, vscale: scale, kernel: :lanczos3)
pad_x = ((resolution - (image.width * scale).round) / 2.0).round<br>pad_y = ((resolution - (image.height * scale).round) / 2.0).round
image = resized.embed(pad_x, pad_y, resolution, resolution, background: [255, 255, 255])
# ImageNet normalization<br>image /= 255.0<br>image = (image - MEAN) / STD
img_array = Numo::SFloat.from_binary(image.write_to_memory, [resolution, resolution, 3])
input_tensor = img_array.transpose(2, 0, 1).reshape(1, 3, resolution, resolution)
2. Forward pass: running the model on the GPU
To run the forward pass, the input tensor is cast to the data type the engine declares, serialized to a binary string, written into a host buffer, and copied to GPU VRAM. Execution is started with enqueue, which submits the work and returns immediately. retrieve is then used to wait for the forward pass to complete and read the output:
Copy
Copied
def enqueue(input_tensor)<br>host_ptr = FFI::MemoryPointer.new(:uint8, @input_size)<br>host_ptr.write_bytes(Numo::SFloat.cast(input_tensor).to_binary)
TensorRT::CUDA.memcpy_htod_async(@input_ptr, host_ptr, @input_size, @cuda_stream)
@engine.enqueue # non-blocking<br>end
def retrieve<br>@engine.stream_synchronize
{ dets: read_output(@dets_ptr, @dets_size),<br>labels: read_output(@labels_ptr, @labels_size) }<br>end
def read_output(device_ptr, size)<br>host_ptr = FFI::MemoryPointer.new(:uint8, size)
TensorRT::CUDA.memcpy_dtoh(host_ptr, device_ptr, size)
Numo::SFloat.from_binary(host_ptr.read_bytes(size))<br>end
In production...