mcap/modules/OCR/module.jai
Musa Mahmood dd7cf83ca6 [0.1.25] OCR on the GPU runs as many detector and recognizer runs at once as fit in its memory
The workers ran gpu_workers (4) runs at once whatever the images: the server
model, four large images at a time, took 15 GB of a 16 GB GPU and spilled into
shared system memory, and a smaller GPU would fare far worse.

modules/OCR/budget.jai: each run reserves what it is expected to need, and
waits while the runs in flight or the GPU's free memory leave too little; one
run always goes. The need is measured when the engine is made, from the arena's
peak (AllocatorGetStats MaxInUse) over runs on two input sizes, so it holds for
any model and GPU: the mobile detector 6 MB + 137 MB a megapixel, the server's
4 MB + 1015, against 511 and 522 MB measured for a 2560 x 1440 tile. The limit
is the GPU's free memory less 10% (at least 512 MB), read at the start of each
ocr_process call (cudaMemGetInfo, ort_cuda_memory); the free memory is also read
as each run starts, since finished runs' blocks stay in the arena until the
stage ends.

gpu-bench, RTX 5080 laptop: the server model 335 ms a capture (854 before),
peak 13.1 GB (15.4), passes within 4%; the mobile model unchanged (140 ms,
5.7 GB), as its runs fit.
2026-10-17 21:31:17 -04:00

476 lines
21 KiB
Plaintext

// OCR: the text in images, with where each line is. A batch of RGBA images goes in; each comes back
// as its text lines, with a box and a confidence for each.
//
// engine, ok, error := ocr_create(models_folder, "ppocr-v5-mobile");
// results := ocr_process(engine, images);
//
// The models are listed in models_folder/models.config (fetch_models.jai downloads them) and run
// with onnxruntime (modules/onnxruntime), on an NVIDIA GPU through CUDA when it can, else on the
// CPU. ocr_gpu_status says why the GPU is not in use, and what to install so it can be.
//
// It runs PP-OCRv5's pipeline: detection (detect.jai) finds each line as a rectangle at any angle,
// then recognition (recognize.jai) reads it level. When the model lists an orientation classifier,
// lines that look upside down are read both ways; lines read downwards or upwards are read too.
Ocr_Provider :: enum { CPU; CUDA; }
// Tightly packed or not: stride is the bytes from one row to the next.
Ocr_Image :: struct {
pixels: *u8; // RGBA
width, height, stride: s32;
}
Ocr_Line :: struct {
x, y, width, height: s32; // in the image: the bounds of corners
text: string;
confidence: float; // 0 to 1: the mean probability of its characters
corners: [4] Vector2; // in the image's pixels: the text's top left, top right, bottom right, bottom left
}
Ocr_Result :: struct {
lines: [] Ocr_Line; // top to bottom, then left to right
failed: bool;
error: string;
}
Ocr_Engine :: struct {
model: Ocr_Model_Info;
provider: Ocr_Provider;
gpu_explanation: string; // why the GPU is not in use; "" when it is
env: *OrtEnv;
memory_info: *OrtMemoryInfo;
det, rec: Ocr_Session;
orientation: Ocr_Session; // the text line orientation classifier; none when its session is null
orientation_width, orientation_height: s32; // its input size
dictionary: [] string; // class n is dictionary[n - 1]
workers: s32; // threads ocr_process uses
rec_batch: s32; // text lines per recognizer run: the model's rec_batch_gpu or rec_batch_cpu
options: Ocr_Options;
stats: Ocr_Stats; // since creation, or the last ocr_reset_stats
pool: Ocr_Pool; // the worker threads, for the engine's life (run_on_workers)
budget: Ocr_Budget; // on CUDA: how many runs fit in GPU memory at once (budget.jai)
// When set, and true, ocr_process stops starting work: the batch of lines or image each worker is
// on is finished, and then every image fails as "cancelled" (none is part read). Set by the caller.
cancel: *bool;
}
OCR_CANCELLED :: "cancelled";
ocr_cancelled :: (engine: *Ocr_Engine) -> bool {
return engine.cancel && engine.cancel.*;
}
// Time spent in each stage, summed over the worker threads (so it can exceed the elapsed time).
Ocr_Stats :: struct {
images: s64;
lines: s64;
detect_us: s64; // preparing images, the detector, and finding the boxes
recognize_us: s64; // cropping lines, the recognizer, and decoding the text
orientation_us: s64; // cropping lines and the orientation classifier
turned: s64; // lines that read better the other way up, or level as found
budget_waits: s64; // detector and recognizer runs that waited for GPU memory (budget.jai)
}
ocr_stats :: (engine: *Ocr_Engine) -> Ocr_Stats { return engine.stats; }
ocr_reset_stats :: (engine: *Ocr_Engine) { engine.stats = .{}; }
// How an engine runs on the GPU, beyond its model's models.config entry. Nearly every image and
// batch is a different size. onnxruntime's arena keeps each block it takes from CUDA and never joins
// or frees them, so inputs that grow one run after another leave a block of every size behind: the
// runs go largest first (ocr_process) so later ones fit in what the first took.
Ocr_Options :: struct {
gpu_workers: s32 = 4; // threads preparing images and decoding results while the GPU runs the networks
cuda_arena_extend_strategy := "kSameAsRequested"; // how much the arena takes when it runs short: what is asked; "kNextPowerOfTwo" takes twice as much each time, up to twice what the runs need
cudnn_max_workspace := false; // let cuDNN take as much working memory as it finds best (onnxruntime's own default)
rec_width_step: s32 = 64; // a recognizer batch's width is rounded up to a multiple of this, so shapes recur
}
// An engine for the model named in models_folder/models.config. With prefer_gpu it runs on CUDA when
// every library that needs is present; otherwise, or when CUDA fails to start, on the CPU.
ocr_create :: (models_folder: string, model_name: string, prefer_gpu := true, options := Ocr_Options.{}) -> *Ocr_Engine, ok: bool, error: string {
push_allocator(context.default_allocator);
loaded, load_error := ort_load();
if !loaded return null, false, load_error;
models, models_error := ocr_list_models(models_folder);
if models_error return null, false, models_error;
model: *Ocr_Model_Info;
for * models if it.name == model_name { model = it; break; }
if !model return null, false, tprint("% has no [model %].", OCR_MODELS_CONFIG, model_name);
engine := New(Ocr_Engine);
engine.model = model.*;
engine.options = options;
dictionary_text, dictionary_ok := read_entire_file(tprint("%/%", models_folder, model.dictionary), log_errors = false);
if !dictionary_ok { ocr_destroy(engine); return null, false, tprint("Cannot read the dictionary %/%.", models_folder, model.dictionary); }
entries := split(dictionary_text, "\n");
for * entries if it.count && it.*[it.count - 1] == #char "\r" it.count -= 1;
while entries.count && !entries[entries.count - 1].count entries.count -= 1; // the final newline
engine.dictionary = entries;
api := ort.api;
ok, error := ort_check(api.CreateEnv(ort.log_level, "ocr", *engine.env));
if !ok { ocr_destroy(engine); return null, false, error; }
ort_check(api.CreateCpuMemoryInfo(.OrtArenaAllocator, .OrtMemTypeDefault, *engine.memory_info));
cores := cast(s32) max(get_number_of_processors(.ALL_LOGICAL), 1); // efficiency cores included
use_gpu := false;
if prefer_gpu {
available, explanation := ort_cuda_check("OCR"); // modules/onnxruntime/cuda.jai
use_gpu = available;
if !available engine.gpu_explanation = copy_string(explanation);
} else {
engine.gpu_explanation = copy_string("OCR runs on the CPU, as it was asked to.");
}
if use_gpu {
// A few CPU threads prepare images and decode results while the GPU runs the networks.
engine.workers = max(options.gpu_workers, 1);
ort_gpu_lock(); // one CUDA engine made or released at a time (modules/onnxruntime/session.jai)
sessions_ok, sessions_error := ocr_create_sessions(engine, models_folder, true, 2);
if sessions_ok sessions_ok, sessions_error = ocr_warm_up(engine);
if !sessions_ok ocr_release_sessions(engine);
if sessions_ok {
engine.provider = .CUDA; // before calibrating: ocr_shrink_arena only works on CUDA
ocr_budget_calibrate(engine);
}
ort_gpu_unlock();
if sessions_ok {
engine.provider = .CUDA;
engine.rec_batch = max(engine.model.rec_batch_gpu, 1);
} else {
engine.gpu_explanation = sprint("OCR runs on the CPU: the CUDA libraries were found, but onnxruntime could not start on the GPU:\n%", sessions_error);
use_gpu = false;
}
}
if !use_gpu {
// Several images at once, each network run spread over the rest of the cores.
engine.workers = clamp(cores / 4, 1, 8);
threads := max(cores / engine.workers, 1);
sessions_ok, sessions_error := ocr_create_sessions(engine, models_folder, false, threads);
if !sessions_ok { ocr_destroy(engine); return null, false, sessions_error; }
engine.provider = .CPU;
engine.rec_batch = max(engine.model.rec_batch_cpu, 1);
}
ocr_pool_start(engine);
return engine, true, "";
}
ocr_provider :: (engine: *Ocr_Engine) -> Ocr_Provider {
return engine.provider;
}
// Whether the GPU is in use, and when not, why, and what to install so it can be.
ocr_gpu_status :: (engine: *Ocr_Engine) -> in_use: bool, explanation: string {
return engine.provider == .CUDA, engine.gpu_explanation;
}
ocr_destroy :: (engine: *Ocr_Engine) {
if !engine return;
ocr_pool_stop(engine);
ocr_budget_destroy(engine);
if engine.provider == .CUDA ort_gpu_lock();
ocr_release_sessions(engine);
if engine.provider == .CUDA ort_gpu_unlock();
if engine.memory_info ort.api.ReleaseMemoryInfo(engine.memory_info);
if engine.env ort.api.ReleaseEnv(engine.env);
free(engine.gpu_explanation,, context.default_allocator);
free(engine,, context.default_allocator); // the dictionary and model strings are kept: small, and shared with ocr_list_models' caller
}
// The text in each image. Results (lines and their text) come from allocator. An image that fails
// is marked failed with the reason; the others are unaffected.
ocr_process :: (engine: *Ocr_Engine, images: [] Ocr_Image, allocator := context.allocator) -> [] Ocr_Result {
results := NewArray(images.count, Ocr_Result,, allocator);
if !images.count return results;
ocr_budget_update(engine); // budget.jai: the GPU memory this call's runs may share
// Detection: images shared out among the workers.
per_image := NewArray(images.count, Image_Work,, context.default_allocator);
defer {
for * per_image array_free(it.boxes);
array_free(per_image,, context.default_allocator);
}
for * per_image it.image = *images[it_index];
// The largest first: the detector's arena then holds a block that fits every later image
// (Ocr_Options). Smallest first, it took a new one for each larger image and kept them all.
detect_order := NewArray(per_image.count, *Image_Work,, context.default_allocator);
defer array_free(detect_order,, context.default_allocator);
for * per_image detect_order[it_index] = it;
quick_sort(detect_order, (a: *Image_Work, b: *Image_Work) -> s64 {
return cast(s64) b.image.width * b.image.height - cast(s64) a.image.width * a.image.height;
});
run_on_workers(engine, detect_order.count, (engine: *Ocr_Engine, index: s64, data: *void) {
work := (cast(**Image_Work) data)[index];
start := current_time_monotonic();
work.boxes, work.ok, work.error = ocr_detect(engine, work.image.*);
atomic_add(*engine.stats.detect_us, to_microseconds(current_time_monotonic() - start));
if work.error work.error = copy_string(work.error,, context.default_allocator);
}, detect_order.data);
ocr_shrink_arena(engine, *engine.det); // before recognition, so the two stages' blocks are not held at once
if ocr_cancelled(engine) return ocr_all_cancelled(results, allocator);
// Recognition: every line of every image, batched by width so little of each batch is padding.
jobs: [..] Line_Job;
jobs.allocator = context.default_allocator;
defer {
for jobs free(it.text,, context.default_allocator);
array_free(jobs);
}
rec_height := engine.model.rec_height;
turned: [..] bool; // per first job: whether detection turned its box a quarter
turned.allocator = context.default_allocator;
defer array_free(turned);
for work: per_image {
if !work.ok continue;
for work.boxes {
array_add(*jobs, .{image = work.image, corners = it.corners, width = ocr_line_width(it.corners, rec_height)});
array_add(*turned, it.turned);
}
}
// Other ways to read a line, recognized beside it, the more confident reading kept: upside down,
// where the orientation classifier finds it more likely than not; and level as it was found, for a
// box detection turned a quarter, since a narrow word such as "1" is as tall as a line read downwards.
first_count := jobs.count;
upside_down := NewArray(first_count, bool,, context.default_allocator);
defer array_free(upside_down,, context.default_allocator);
Orientation_Context :: struct { jobs: [] Line_Job; upside_down: [] bool; size: s64; failed: bool; error: string; }
classify := Orientation_Context.{jobs = jobs, upside_down = upside_down, size = max(engine.rec_batch, 1)};
if engine.orientation.session && first_count {
run_on_workers(engine, (first_count + classify.size - 1) / classify.size, (engine: *Ocr_Engine, index: s64, data: *void) {
c := cast(*Orientation_Context) data;
first := index * c.size;
count := min(c.size, c.jobs.count - first);
batch := NewArray(count, *Line_Job,, temp);
for * batch it.* = *c.jobs[first + it_index];
start := current_time_monotonic();
ok, error := ocr_classify_batch(engine, batch, array_view(c.upside_down, first, count));
atomic_add(*engine.stats.orientation_us, to_microseconds(current_time_monotonic() - start));
if !ok { c.failed = true; c.error = copy_string(error,, context.default_allocator); } // the last error wins
}, *classify);
}
defer free(classify.error,, context.default_allocator);
for n: 0..first_count - 1 {
job := jobs[n];
if upside_down[n] array_add(*jobs, .{image = job.image, corners = ocr_corners_turned(job.corners, 2), width = job.width, primary = n});
if turned[n] {
level := ocr_corners_turned(job.corners, 3);
array_add(*jobs, .{image = job.image, corners = level, width = ocr_line_width(level, rec_height), primary = n});
}
}
order := NewArray(jobs.count, *Line_Job,, context.default_allocator);
defer array_free(order,, context.default_allocator);
for * jobs order[it_index] = it;
// Widest first, so each batch fits in the arena block the first one took (Ocr_Options). Narrowest
// first, every batch was wider than the last and took a new block: 5.7 GB of them for the mobile
// model on gpu-bench's 30 images, where the widest batch needs 0.9 GB.
quick_sort(order, (a: *Line_Job, b: *Line_Job) -> s64 { return b.width - a.width; });
batch_size := max(engine.rec_batch, 1);
batch_count := (order.count + batch_size - 1) / batch_size;
Batch_Context :: struct { order: [] *Line_Job; size: s64; failed: bool; error: string; }
batches := Batch_Context.{order = order, size = batch_size};
run_on_workers(engine, batch_count, (engine: *Ocr_Engine, index: s64, data: *void) {
b := cast(*Batch_Context) data;
first := index * b.size;
count := min(b.size, b.order.count - first);
start := current_time_monotonic();
ok, error := ocr_recognize_batch(engine, array_view(b.order, first, count));
atomic_add(*engine.stats.recognize_us, to_microseconds(current_time_monotonic() - start));
if !ok { b.failed = true; b.error = copy_string(error,, context.default_allocator); } // the last error wins; any is enough to say why
}, *batches);
if ocr_cancelled(engine) {
free(batches.error,, context.default_allocator);
return ocr_all_cancelled(results, allocator);
}
if classify.failed && !batches.failed {
batches.failed = true;
batches.error = copy_string(classify.error,, context.default_allocator);
}
// The more confident of each line's readings takes the first job's place.
for * other: array_view(jobs, first_count, jobs.count - first_count) {
first := *jobs[other.primary];
if other.confidence > first.confidence {
swap(*first.text, *other.text);
first.corners, first.confidence = other.corners, other.confidence;
engine.stats.turned += 1;
}
}
ocr_shrink_arena(engine, *engine.rec); // models.jai: what this call's runs took, given back
// Each image's lines, in reading order.
for work, image_index: per_image {
result := *results[image_index];
if !work.ok {
result.failed = true;
result.error = copy_string(work.error,, allocator);
free(work.error,, context.default_allocator);
continue;
}
if batches.failed {
result.failed = true;
result.error = copy_string(batches.error,, allocator);
continue;
}
lines: [..] Ocr_Line;
lines.allocator = allocator;
for job: array_view(jobs, 0, first_count) {
if job.image != work.image || !job.text.count continue;
x0, y0, x1, y1 := ocr_corner_bounds(job.corners, job.image.width, job.image.height);
array_add(*lines, .{x = x0, y = y0, width = x1 - x0, height = y1 - y0, text = copy_string(job.text,, allocator),
confidence = job.confidence, corners = job.corners});
}
// Rows by the middle of each line, then left to right within a row.
quick_sort(lines, (a: Ocr_Line, b: Ocr_Line) -> s64 {
ay, by := a.y + a.height / 2, b.y + b.height / 2;
if abs(ay - by) > min(a.height, b.height) / 2 return ifx ay < by then -1 else 1;
return a.x - b.x;
});
result.lines = lines;
engine.stats.lines += lines.count;
}
engine.stats.images += images.count;
free(batches.error,, context.default_allocator);
return results;
}
#scope_module
ocr_all_cancelled :: (results: [] Ocr_Result, allocator: Allocator) -> [] Ocr_Result {
for * results {
it.failed = true;
it.error = copy_string(OCR_CANCELLED,, allocator);
}
return results;
}
Image_Work :: struct {
image: *Ocr_Image;
boxes: [..] Detected_Box;
ok: bool;
error: string;
}
Work_Proc :: #type (engine: *Ocr_Engine, index: s64, data: *void);
// Calls proc for each index below count, on engine.workers threads at once.
// The engine's worker threads: started with it and kept until ocr_destroy, never one set per call.
// onnxruntime (1.26 to 1.30 at least) sets up a CUDA stream with cuBLAS and cuDNN handles for every
// OS thread that runs a session, about 10 MB each, kept after the thread ends (microsoft/onnxruntime
// #33037). Workers made afresh for every stage of every call, as they were, gave the OCR sessions
// thousands of threads in a scan, and starting them cost time: 6% on gpu-bench's mobile OCR.
Ocr_Pool :: struct {
threads: [] Thread;
started: s64;
mutex: Mutex; // one run_on_workers at a time
wake: Semaphore; // signalled once per worker for each job, and for quitting
done: Semaphore; // each worker signals it when it has finished its part of a job
quit: bool;
// The job: items 0..count-1, taken in turn.
count, next: s64;
proc: Work_Proc;
data: *void;
}
ocr_pool_start :: (engine: *Ocr_Engine) {
pool := *engine.pool;
init(*pool.mutex);
init(*pool.wake);
init(*pool.done);
// thread_init allocates the thread's temporary storage from the context allocator.
push_allocator(context.default_allocator);
pool.threads = NewArray(max(engine.workers, 1), Thread);
for * pool.threads {
if !thread_init(it, ocr_pool_worker, temporary_storage_size = 1024 * 1024) break;
thread_start(it, engine); // sets thread.data
pool.started += 1;
}
}
ocr_pool_stop :: (engine: *Ocr_Engine) {
pool := *engine.pool;
if !pool.threads.count return;
pool.quit = true;
for 1..pool.started signal(*pool.wake);
for 0..pool.started - 1 {
thread_is_done(*pool.threads[it], -1);
thread_deinit(*pool.threads[it]);
}
array_free(pool.threads,, context.default_allocator);
destroy(*pool.wake);
destroy(*pool.done);
destroy(*pool.mutex);
pool.* = .{};
}
ocr_pool_worker :: (thread: *Thread) -> s64 {
// Workers allocate from the heap, and use their own temporary storage.
context.allocator = context.default_allocator;
engine := cast(*Ocr_Engine) thread.data;
pool := *engine.pool;
while true {
Default_Allocator.thread_idle(); // parked until the next job: give back what this thread's heap holds free
wait_for(*pool.wake);
if pool.quit break;
while true {
if ocr_cancelled(engine) break; // what is left fails as cancelled (ocr_process)
index := atomic_add(*pool.next, 1);
if index >= pool.count break;
pool.proc(engine, index, pool.data);
reset_temporary_storage();
}
signal(*pool.done);
}
return 0;
}
run_on_workers :: (engine: *Ocr_Engine, count: s64, proc: Work_Proc, data: *void) {
if count <= 0 return;
pool := *engine.pool;
if !pool.started { // no threads: do it here
for 0..count - 1 {
if ocr_cancelled(engine) break;
proc(engine, it, data);
}
return;
}
lock(*pool.mutex);
defer unlock(*pool.mutex);
pool.count, pool.next, pool.proc, pool.data = count, 0, proc, data;
// Every worker is woken and reports back, even with fewer items than workers: a worker that finds
// nothing left reports at once, so no wake is left over for the next job.
for 1..pool.started signal(*pool.wake);
for 1..pool.started wait_for(*pool.done);
}
#load "models.jai";
#load "detect.jai";
#load "recognize.jai";
#load "budget.jai";
#import "Basic";
#import "String";
#import "File";
#import "File_Utilities";
#import "Math";
#import "Sort";
#import "System";
#import "Thread";
#import "Atomics";
#import "onnxruntime";
Default_Allocator :: #import "Default_Allocator"; // rpmalloc (modules/Default_Allocator), for thread_idle where the workers wait
#if OS == .WINDOWS {
#import "Windows";
#import "Windows_Utf8";
} else {
#import "POSIX";
}