GPU AI workloads with a Ruby on Rails monolith

babanooey211 pts1 comments

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...

engine ruby tensorrt image resolution rails

Related Articles