mcap/modules/Labels/module.jai

443 lines
20 KiB
Plaintext

// Labels: images and text in one space, from an image-text encoder (SigLIP 2), so an image can be
// scored against any phrase: a list of labels, or words typed into a search.
//
// engine, ok, error := labels_create(models_folder, "siglip2-base-patch16-384");
// images := labels_embed_images(engine, pictures); // one unit vector per picture
// phrases := labels_embed_texts(engine, .["code editor", "schematic"]);
// score := labels_score(engine, image_vector, phrase_vector); // 0 to 1
//
// The models are listed in models_folder/models.config (fetch_models.jai downloads them) and run
// with onnxruntime (modules/onnxruntime): the vision model on an NVIDIA GPU through CUDA when it
// can, else on the CPU; the text model, small inputs and only a few at a time, on the CPU. On macOS
// the vision model runs on the GPU through Core ML (modules/Core_ML) when its vision_coreml package
// is there (tools/export_siglip_coreml.py makes it): about ten times the CPU's speed.
Labels_Provider :: enum { CPU; CUDA; COREML; }
// Tightly packed or not: stride is the bytes from one row to the next.
Labels_Image :: struct {
pixels: *u8; // RGBA
width, height, stride: s32;
}
Labels_Model_Info :: struct {
name: string;
title: string;
vision, text, tokenizer: string; // file names in the models folder
vision_coreml: string; // the vision model as a Core ML package, for macOS; "" for none
image_size: s32 = 384;
max_tokens: s32 = 64;
logit_scale: float = 0;
logit_bias: float = 0;
image_batch: s32 = 8;
}
Labels_Engine :: struct {
model: Labels_Model_Info;
provider: Labels_Provider; // the vision model's
gpu_explanation: string; // why the GPU is not in use; "" when it is
env: *OrtEnv;
memory_info: *OrtMemoryInfo;
vision, text: Ort_Session;
#if OS == .MACOS coreml: *Coreml_Model; // the vision model, when it runs through Core ML
tokenizer: *Gemma_Tokenizer;
dimensions: s32; // of every vector
}
LABELS_MODELS_CONFIG :: "models.config";
// The models models.config lists, in its order; from the context allocator.
labels_list_models :: (models_folder: string) -> [] Labels_Model_Info, error: string {
models: [..] Labels_Model_Info;
path := tprint("%/%", models_folder, LABELS_MODELS_CONFIG);
text, ok := read_entire_file(path, log_errors = false,, temp);
if !ok return models, tprint("Cannot read %.", path);
model: *Labels_Model_Info;
version_seen := false;
line_number := 0;
for raw: split(text, "\n",, temp) {
line_number += 1;
line := trim(labels_strip_comment(raw));
if !line.count continue;
if begins_with(line, "@version") { version_seen = true; continue; }
if !version_seen return models, tprint("% does not start with @version 1.", path);
if begins_with(line, "[model ") && ends_with(line, "]") {
model = array_add(*models);
model.* = .{};
model.name = copy_string(trim(slice(line, 7, line.count - 8)));
continue;
}
colon := find_index_from_left(line, #char ":");
if colon < 0 || !model { log_error("[Labels] %:%: expected `key: value` under a [model NAME]. Ignored.", path, line_number); continue; }
key := trim(slice(line, 0, colon));
value := trim(advance(line, colon + 1));
if value.count >= 2 && value[0] == #char "\"" && value[value.count - 1] == #char "\"" value = slice(value, 1, value.count - 2);
number, number_ok := string_to_float(value);
if key == {
case "title"; model.title = copy_string(value);
case "vision"; model.vision = copy_string(value);
case "text"; model.text = copy_string(value);
case "tokenizer"; model.tokenizer = copy_string(value);
case "vision_coreml"; model.vision_coreml = copy_string(value);
case "image_size"; if number_ok model.image_size = cast(s32) number;
case "max_tokens"; if number_ok model.max_tokens = cast(s32) number;
case "logit_scale"; if number_ok model.logit_scale = number;
case "logit_bias"; if number_ok model.logit_bias = number;
case "image_batch"; if number_ok model.image_batch = cast(s32) number;
case; log_error("[Labels] %:%: unknown key %. Ignored.", path, line_number, key);
}
}
return models, "";
}
// An engine for the model named in models_folder/models.config; its vision model on CUDA with
// prefer_gpu when every library that needs is present, else on the CPU.
labels_create :: (models_folder: string, model_name: string, prefer_gpu := true) -> *Labels_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 := labels_list_models(models_folder);
if models_error return null, false, models_error;
model: *Labels_Model_Info;
for * models if it.name == model_name { model = it; break; }
if !model return null, false, tprint("% has no [model %].", LABELS_MODELS_CONFIG, model_name);
engine := New(Labels_Engine);
engine.model = model.*;
api := ort.api;
ok, error := ort_check(api.CreateEnv(ort.log_level, "labels", *engine.env));
if !ok { labels_destroy(engine); return null, false, error; }
ort_check(api.CreateCpuMemoryInfo(.OrtArenaAllocator, .OrtMemTypeDefault, *engine.memory_info));
engine.tokenizer, ok, error = tokenizer_load(tprint("%/%", models_folder, model.tokenizer));
if !ok { labels_destroy(engine); return null, false, error; }
HINT :: "run `jai fetch_models.jai` in modules/Labels and rebuild";
cores := cast(s32) max(get_number_of_processors(.ALL_LOGICAL), 1);
vision_path := tprint("%/%", models_folder, model.vision);
use_gpu := false;
#if OS == .MACOS if prefer_gpu && model.vision_coreml && !file_exists(tprint("%/%", models_folder, model.vision_coreml)) {
engine.gpu_explanation = sprint("Labelling runs on the CPU: % is not there; tools/export_siglip_coreml.py makes it.", model.vision_coreml);
} else if prefer_gpu && model.vision_coreml {
// Core ML on the GPU; on any failure, onnxruntime on the CPU below, with the reason.
coreml_path := tprint("%/%", models_folder, model.vision_coreml);
engine.coreml, ok, error = coreml_load(coreml_path, "pixel_values", "pooler_output");
if ok engine.provider = .COREML; // its first run is below, once the vectors' size is known
else engine.gpu_explanation = sprint("Labelling runs on the CPU: Core ML could not load % on the GPU:\n%", model.vision_coreml, error);
}
#if OS == .MACOS if prefer_gpu && !model.vision_coreml {
engine.gpu_explanation = sprint("Labelling runs on the CPU: there is no Core ML model for it; tools/export_siglip_coreml.py makes one.");
}
if prefer_gpu && OS != .MACOS {
available, explanation := ort_cuda_check("Labelling");
use_gpu = available;
if !available engine.gpu_explanation = copy_string(explanation);
} else if !prefer_gpu {
engine.gpu_explanation = copy_string("Labelling runs on the CPU, as it was asked to.");
}
if use_gpu {
ort_gpu_lock(); // one CUDA engine made or released at a time (modules/onnxruntime/session.jai)
engine.vision, ok, error = ort_create_session(engine.env, vision_path, true, 2, output = "pooler_output", missing_hint = HINT);
if ok ok, error = labels_warm_up(engine);
if !ok ort_release_session(*engine.vision);
ort_gpu_unlock();
if ok {
engine.provider = .CUDA;
} else {
engine.gpu_explanation = sprint("Labelling runs on the CPU: the CUDA libraries were found, but onnxruntime could not start on the GPU:\n%", error);
use_gpu = false;
}
}
if !use_gpu && engine.provider != .COREML {
engine.vision, ok, error = ort_create_session(engine.env, vision_path, false, cores, output = "pooler_output", missing_hint = HINT);
if !ok { labels_destroy(engine); return null, false, error; }
engine.provider = .CPU;
}
engine.text, ok, error = ort_create_session(engine.env, tprint("%/%", models_folder, model.text), false, max(cores / 2, 1), output = "pooler_output", missing_hint = HINT);
if !ok { labels_destroy(engine); return null, false, error; }
// The vectors' size, from a run of the text model on an empty text.
probe := labels_embed_texts(engine, .[""],, temp);
if !engine.dimensions { labels_destroy(engine); return null, false, "The text model gave no vector."; }
#if OS == .MACOS if engine.provider == .COREML {
// Core ML's first run; when it fails, onnxruntime on the CPU after all.
ok, error = labels_warm_up(engine);
if !ok {
coreml_free(engine.coreml);
engine.coreml = null;
engine.provider = .CPU;
engine.gpu_explanation = sprint("Labelling runs on the CPU: Core ML could not run % on the GPU:\n%", model.vision_coreml, error);
engine.vision, ok, error = ort_create_session(engine.env, vision_path, false, cores, output = "pooler_output", missing_hint = HINT);
if !ok { labels_destroy(engine); return null, false, error; }
}
}
return engine, true, "";
}
labels_destroy :: (engine: *Labels_Engine) {
if !engine return;
if engine.provider == .CUDA ort_gpu_lock();
ort_release_session(*engine.vision);
if engine.provider == .CUDA ort_gpu_unlock();
#if OS == .MACOS coreml_free(engine.coreml);
ort_release_session(*engine.text);
tokenizer_free(engine.tokenizer,, context.default_allocator);
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);
}
labels_gpu_status :: (engine: *Labels_Engine) -> in_use: bool, explanation: string {
return engine.provider == .CUDA || engine.provider == .COREML, engine.gpu_explanation;
}
// What runs the vision model, for people and the files it labels: "CUDA", "Core ML" or "CPU".
labels_provider_name :: (engine: *Labels_Engine) -> string {
if engine.provider == {
case .CUDA; return "CUDA";
case .COREML; return "Core ML";
}
return "CPU";
}
// One unit vector per image, engine.dimensions long, one after another; from allocator. An image
// that fails gives a zero vector, and error says why (the last error).
labels_embed_images :: (engine: *Labels_Engine, images: [] Labels_Image, allocator := context.allocator) -> [] float, error: string {
size := engine.model.image_size;
dims := engine.dimensions;
result := NewArray(images.count * dims, float, initialized = true,, allocator);
error: string;
plane := cast(s64) size * size;
batch := cast(s64) max(engine.model.image_batch, 1);
input := NewArray(plane * 3 * batch, float,, context.default_allocator);
defer array_free(input,, context.default_allocator);
#if OS == .MACOS if engine.coreml {
// One image a run: the Core ML model takes [1, 3, size, size].
out := NewArray(dims, float,, temp);
for images {
labels_prepare_image(it, size, input.data);
ok, run_error := coreml_run(engine.coreml, array_view(input, 0, plane * 3), s64.[1, 3, size, size], out);
if !ok { error = tprint("%", run_error); continue; }
labels_normalize_into(out.data, dims, result.data + it_index * dims);
}
return result, error;
}
first := 0;
while first < images.count {
count := min(batch, images.count - first);
for 0..count - 1 labels_prepare_image(images[first + it], size, input.data + plane * 3 * it);
shape := s64.[count, 3, size, size];
out, ok, run_error := ort_run_float(*engine.vision, engine.memory_info, array_view(input, 0, plane * 3 * count), shape);
if !ok {
error = tprint("%", run_error);
} else {
if out.dim_count == 2 && out.dims[0] == count && out.dims[1] == dims {
for n: 0..count - 1 labels_normalize_into(out.data + n * dims, dims, result.data + (first + n) * dims);
} else {
error = tprint("The vision model gave an output of shape %, not [%, %].", array_view(out.dims, 0, out.dim_count), count, dims);
}
ort_release_output(*out);
}
first += count;
}
return result, error;
}
// One unit vector per text, as labels_embed_images gives them; from allocator.
labels_embed_texts :: (engine: *Labels_Engine, texts: [] string, allocator := context.allocator) -> [] float, error: string {
if !texts.count return .[], "";
tokens := engine.model.max_tokens;
ids := NewArray(texts.count * tokens, s64,, temp);
for text, n: texts {
encoded := tokenizer_encode(engine.tokenizer, text, tokens);
memcpy(ids.data + n * tokens, encoded.data, tokens * size_of(s64));
}
shape := s64.[texts.count, tokens];
out, ok, run_error := ort_run(*engine.text, engine.memory_info, ids.data, ids.count * size_of(s64), .ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64, shape);
if !ok return .[], tprint("%", run_error);
defer ort_release_output(*out);
if out.dim_count != 2 || out.dims[0] != texts.count return .[], tprint("The text model gave an output of shape %.", array_view(out.dims, 0, out.dim_count));
dims := cast(s32) out.dims[1];
if !engine.dimensions engine.dimensions = dims;
result := NewArray(texts.count * dims, float,, allocator);
for n: 0..texts.count - 1 labels_normalize_into(out.data + n * dims, dims, result.data + n * dims);
return result, "";
}
// How well a phrase fits an image, 0 to 1, from their unit vectors: the model's own sigmoid.
labels_score :: (engine: *Labels_Engine, image: *float, text: *float) -> float {
return labels_score(engine.model, image, text, engine.dimensions);
}
labels_score :: (model: Labels_Model_Info, image: *float, text: *float, dimensions: s64) -> float {
cosine: float;
for 0..dimensions - 1 cosine += image[it] * text[it];
logit := exp(model.logit_scale) * cosine + model.logit_bias;
return 1.0 / (1.0 + exp(-logit));
}
// A vector as half floats, for storing; and back. Temp / allocator.
labels_to_half :: (vector: [] float) -> [] u16 {
result := NewArray(vector.count, u16,, temp);
for vector result[it_index] = labels_float_to_half(it);
return result;
}
labels_from_half :: (half: [] u16, allocator := context.allocator) -> [] float {
result := NewArray(half.count, float,, allocator);
for half result[it_index] = float16_to_float32(cast(Float16) it);
return result;
}
// A float as 16-bit float bits: the Float16 module's float_to_half_fast3 (Fabian Giesen's, public
// domain), copied with #no_aoc. It relies on unsigned wraparound, which a build with arithmetic
// overflow checks (the app's release build) stops at; the module's own is not marked.
labels_float_to_half :: (value: float32) -> u16 #no_aoc {
f32_infinity :: cast(u32) 255 << 23;
f16_infinity :: cast(u32) 31 << 23;
magic : float32 : 0h0780_0000;
sign_mask : u32 : 0x8000_0000;
round_mask : u32 : 0xffff_f000;
using bits: union { f: float32; u: u32; }
f = value;
sign := u & sign_mask;
u ^= sign;
half: u16;
if u >= f32_infinity {
half = ifx u > f32_infinity then cast(u16) 0x7e00 else 0x7c00; // NaN to a quiet NaN, infinity to infinity
} else { // normal, denormal or zero
u &= round_mask;
f *= magic;
u -= round_mask;
if u > f16_infinity u = f16_infinity; // too large: infinity
half = cast(u16) (u >> 13);
}
return half | cast(u16) (sign >> 16);
}
#scope_module
// Runs the vision model once, on a blank image, when it is made. As OCR's engines are (see
// ocr_warm_up): with cuBLASLt 13.8, the process's first CUDA session destroyed without ever having
// run left the next one to crash on its first run.
labels_warm_up :: (engine: *Labels_Engine) -> ok: bool, error: string {
size := engine.model.image_size;
input := NewArray(3 * size * size, float, initialized = true,, context.default_allocator);
defer array_free(input,, context.default_allocator);
#if OS == .MACOS if engine.coreml {
out := NewArray(engine.dimensions, float,, context.default_allocator);
defer array_free(out,, context.default_allocator);
ok, error := coreml_run(engine.coreml, input, s64.[1, 3, size, size], out);
if !ok return false, tprint("The first run on the GPU failed: %", error);
return true, "";
}
out, ok, error := ort_run_float(*engine.vision, engine.memory_info, input, s64.[1, 3, size, size]);
if !ok return false, tprint("The first run on the GPU failed: %", error);
ort_release_output(*out);
return true, "";
}
// The image resized to size x size (not keeping its shape, as SigLIP's processor does) with PIL's
// bilinear filter, which averages over each output pixel's footprint when shrinking; then as planar
// RGB, scaled to -1..1, at dest.
labels_prepare_image :: (image: Labels_Image, size: s32, dest: *float) {
// Horizontal pass into rows of size floats per channel, then vertical.
across := labels_resample_weights(image.width, size);
down := labels_resample_weights(image.height, size);
rows := NewArray(cast(s64) image.height * size * 3, float,, temp);
for y: 0..image.height - 1 {
row := image.pixels + cast(s64) y * image.stride;
for x: 0..size - 1 {
w := *across[x];
r, g, b: float;
for k: 0..w.weights.count - 1 {
p := row + (w.first + k) * 4;
r += p[0] * w.weights[k]; g += p[1] * w.weights[k]; b += p[2] * w.weights[k];
}
at := (cast(s64) y * size + x) * 3;
rows[at] = r; rows[at + 1] = g; rows[at + 2] = b;
}
}
plane := cast(s64) size * size;
for y: 0..size - 1 {
w := *down[y];
for x: 0..size - 1 {
r, g, b: float;
for k: 0..w.weights.count - 1 {
at := (cast(s64) (w.first + k) * size + x) * 3;
r += rows[at] * w.weights[k]; g += rows[at + 1] * w.weights[k]; b += rows[at + 2] * w.weights[k];
}
i := cast(s64) y * size + x;
dest[i] = clamp(r, 0, 255) / 127.5 - 1; // (v / 255 - 0.5) / 0.5
dest[i + plane] = clamp(g, 0, 255) / 127.5 - 1;
dest[i + plane * 2] = clamp(b, 0, 255) / 127.5 - 1;
}
}
}
Resample_Weights :: struct { first: s32; weights: [] float; }
// For each of out positions, the input positions it takes and their weights: a triangle filter
// widened by the scale when shrinking, as PIL's bilinear resize. Temp.
labels_resample_weights :: (in: s32, out: s32) -> [] Resample_Weights {
result := NewArray(out, Resample_Weights,, temp);
scale := cast(float) in / out;
filter_scale := max(scale, 1.0);
support := filter_scale; // the triangle reaches 1 unit, scaled
for i: 0..out - 1 {
centre := (i + 0.5) * scale;
first := max(cast(s32) (centre - support + 0.5), 0);
last := min(cast(s32) (centre + support + 0.5), in);
weights := NewArray(max(last - first, 1), float,, temp);
total: float;
for k: 0..last - first - 1 {
x := (first + k - centre + 0.5) / filter_scale;
w := max(0.0, 1 - abs(x));
weights[k] = w;
total += w;
}
if total > 0 for * weights it.* /= total;
else { first = clamp(cast(s32) centre, 0, in - 1); weights[0] = 1; }
result[i] = .{first, weights};
}
return result;
}
labels_normalize_into :: (source: *float, dims: s64, dest: *float) {
length: float;
for 0..dims - 1 length += source[it] * source[it];
length = sqrt(length);
scale := ifx length > 0 then 1 / length else 0;
for 0..dims - 1 dest[it] = source[it] * scale;
}
labels_strip_comment :: (line: string) -> string {
in_quotes := false;
for i: 0..line.count - 1 {
c := line[i];
if c == #char "\"" in_quotes = !in_quotes;
else if c == #char "#" && !in_quotes && (i == 0 || line[i - 1] == #char " " || line[i - 1] == #char "\t") return slice(line, 0, i);
}
return line;
}
#load "tokenizer.jai";
#import "Basic";
#import "String";
#import "File";
#import "File_Utilities";
#import "Math";
#import "System";
#import "Hash_Table";
#import "Float16";
#import "onnxruntime";
#if OS == .MACOS #import "Core_ML";