Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions oar-ocr-vl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ download-binaries = ["oar-ocr-core/download-binaries"]
# When enabled, turns on Candle's CUDA backend for GPU acceleration.
cuda = [
"candle-core/cuda",
"dep:candle-flash-attn",
"candle-nn/cuda",
"candle-transformers/cuda",
"oar-ocr-core/cuda",
Expand All @@ -39,9 +40,11 @@ metal = [

[dependencies]
candle-core = "0.11.0"
candle-flash-attn = { version = "0.11.0", optional = true }
candle-nn = "0.11.0"
candle-transformers = "0.11.0"
html-escape = "0.2"
half = "2"
image.workspace = true
oar-ocr-core.workspace = true
once_cell = "1.19"
Expand Down
11 changes: 9 additions & 2 deletions oar-ocr-vl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This crate provides native Rust inference for document VLMs using [Candle](https
| [PaddleOCR-VL](https://huggingface.co/PaddlePaddle/PaddleOCR-VL) | 0.9B | SOTA document parsing VLM supporting 109 languages, text, tables, formulas, and 11 chart types |
| [PaddleOCR-VL-1.5](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.5) | 0.9B | Next-gen PaddleOCR-VL with 94.5% on OmniDocBench v1.5, adds text spotting and seal recognition |
| [PaddleOCR-VL-1.6](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.6) | 1.0B | Region-aware refinement on top of PaddleOCR-VL-1.5; 96.33% on OmniDocBench v1.6 (SOTA), drop-in compatible with the 1.5 loader |
| [HunyuanOCR 1.5](https://huggingface.co/tencent/HunyuanOCR) | Lightweight | End-to-end OCR VLM for multilingual document parsing, text spotting, and information extraction (archived 1.0 weights also supported) |
| [HunyuanOCR 1.5](https://huggingface.co/tencent/HunyuanOCR) | 1.0B | End-to-end OCR VLM for multilingual document parsing, text spotting, and information extraction (archived 1.0 weights also supported) |
| [GLM-OCR](https://huggingface.co/zai-org/GLM-OCR) | 0.9B | #1 on OmniDocBench v1.5 (94.62), optimized for real-world scenarios with MTP loss and RL training |
| [MinerU2.5](https://huggingface.co/opendatalab/MinerU2.5-2509-1.2B) | 1.2B | Decoupled document parsing VLM with strong text, formula, and table recognition |

Expand Down Expand Up @@ -44,6 +44,12 @@ To enable GPU acceleration (CUDA), add the feature flag:
cargo add oar-ocr-vl --features cuda
```

HunyuanOCR's custom CUDA kernels compile PTX for the oldest GPU detected by
`nvidia-smi` at build time. For headless, container, or cross-machine builds,
set the target explicitly, for example
`CUDA_COMPUTE_CAP=89 cargo build --features cuda`. The DFlash kernels require
compute capability 8.0 or newer.

## Usage

### PaddleOCR-VL
Expand Down Expand Up @@ -191,12 +197,13 @@ cargo run --release --features cuda --example paddleocr_vl -- \
```bash
cargo run --release --features cuda --example hunyuanocr -- \
--model-dir models/HunyuanOCR \
--dflash-dir models/HunyuanOCR/dflash \
--device cuda \
--prompt "Detect and recognize text in the image, and output the text coordinates in a formatted manner." \
document.jpg
```

The model repository root contains HunyuanOCR 1.5. The loader detects it automatically; use `--model-dir models/HunyuanOCR/v1.0` for the archived 1.0 checkpoint.
The model repository root contains HunyuanOCR 1.5. The loader detects it automatically; use `--model-dir models/HunyuanOCR/v1.0` for the archived 1.0 checkpoint. `--dflash-dir` enables the official 15-token parallel draft path for 1.5; omit it for ordinary autoregressive decoding. Library callers can use `HunyuanOcr::from_dirs(target_dir, dflash_dir, device)` or `HunyuanOcr::from_dir_with_dflash(model_dir, device)` when the draft is stored in the official `dflash/` subdirectory.

### GLM-OCR (Direct Inference)

Expand Down
105 changes: 105 additions & 0 deletions oar-ocr-vl/build.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,113 @@
use std::process::Command;

const MIN_DFLASH_COMPUTE_CAP: u32 = 80;

fn parse_compute_cap(value: &str) -> Option<(String, u32)> {
let value = value.trim().to_ascii_lowercase();
let value = value
.strip_prefix("compute_")
.or_else(|| value.strip_prefix("sm_"))
.unwrap_or(&value)
.replace('.', "");
let digit_count = value.bytes().take_while(u8::is_ascii_digit).count();
if digit_count == 0 {
return None;
}
let (digits, suffix) = value.split_at(digit_count);
if !matches!(suffix, "" | "a" | "f") {
return None;
}
let mut base = digits.parse::<u32>().ok()?;
if base < 20 {
base *= 10;
}
Some((format!("{base}{suffix}"), base))
}

fn detect_local_compute_cap() -> Option<u32> {
let output = Command::new("nvidia-smi")
.args(["--query-gpu=compute_cap", "--format=csv,noheader"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| parse_compute_cap(line).map(|(_, base)| base))
// PTX compiled for the oldest GPU reported by nvidia-smi remains
// loadable on newer GPUs in a heterogeneous machine.
.min()
}

fn cuda_compute_arch() -> String {
if let Ok(value) = std::env::var("CUDA_COMPUTE_CAP") {
let (arch, base) = parse_compute_cap(&value).unwrap_or_else(|| {
panic!(
"invalid CUDA_COMPUTE_CAP={value:?}; expected values such as 89, 8.9, sm_89, or compute_89"
)
});
assert!(
base >= MIN_DFLASH_COMPUTE_CAP,
"HunyuanOCR DFlash CUDA kernels require compute capability 8.0 or newer; got CUDA_COMPUTE_CAP={value:?}"
);
Comment on lines +50 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow non-DFlash CUDA builds below Ampere

When CUDA_COMPUTE_CAP is set for a headless/cross-machine CUDA build targeting a pre-Ampere GPU (for example 75 for Turing), this assertion aborts the entire oar-ocr-vl CUDA build even though the crate still has supported non-DFlash paths and select_dtype already falls back away from BF16 on older devices. The autodetect branch below merely warns and emits compute_80 PTX, so explicit older targets should not be fatal unless the user actually tries to load the DFlash-only kernels; otherwise users building PaddleOCR/GLM/Hunyuan AR for older CUDA GPUs are blocked at build time.

Useful? React with 👍 / 👎.

return format!("compute_{arch}");
}

match detect_local_compute_cap() {
Some(base) if base >= MIN_DFLASH_COMPUTE_CAP => format!("compute_{base}"),
Some(base) => {
println!(
"cargo:warning=detected GPU compute capability {base} is below the HunyuanOCR DFlash minimum; compiling forward-compatible compute_{MIN_DFLASH_COMPUTE_CAP} PTX"
);
format!("compute_{MIN_DFLASH_COMPUTE_CAP}")
}
None => {
println!(
"cargo:warning=could not detect a CUDA GPU; compiling compute_{MIN_DFLASH_COMPUTE_CAP} PTX (set CUDA_COMPUTE_CAP to override for cross/headless builds)"
);
format!("compute_{MIN_DFLASH_COMPUTE_CAP}")
}
}
}

fn main() {
println!("cargo:rerun-if-changed=src/hunyuanocr/dynamic_kv.cu");
println!("cargo:rerun-if-env-changed=CUDA_COMPUTE_CAP");
println!("cargo:rerun-if-env-changed=NVCC");
let metal_enabled = std::env::var_os("CARGO_FEATURE_METAL").is_some();
let cuda_enabled = std::env::var_os("CARGO_FEATURE_CUDA").is_some();
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();

if metal_enabled && target_os != "macos" {
panic!("oar-ocr-vl feature `metal` is only supported on macOS targets");
}

if cuda_enabled {
let cuda_arch = cuda_compute_arch();
let nvcc = std::env::var_os("NVCC").unwrap_or_else(|| "nvcc".into());
let out_dir = std::path::PathBuf::from(
std::env::var_os("OUT_DIR").expect("Cargo always sets OUT_DIR"),
);
let output = Command::new(&nvcc)
.args(["--ptx", "--std=c++17", "-O3"])
.arg(format!("--gpu-architecture={cuda_arch}"))
.arg("-o")
.arg(out_dir.join("hunyuan_dynamic_kv.ptx"))
.arg("src/hunyuanocr/dynamic_kv.cu")
.output()
.unwrap_or_else(|error| {
panic!(
"failed to invoke {:?} for HunyuanOCR dynamic KV kernel; install the CUDA toolkit or set NVCC to the compiler path: {error}",
nvcc
)
});
if !output.status.success() {
panic!(
"{:?} failed for HunyuanOCR dynamic KV kernel ({cuda_arch}):\n{}",
nvcc,
String::from_utf8_lossy(&output.stderr)
);
}
}
}
73 changes: 65 additions & 8 deletions oar-ocr-vl/examples/hunyuanocr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ mod utils;

use clap::Parser;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use tracing::{error, info};

Expand All @@ -37,6 +38,10 @@ struct Args {
#[arg(short, long)]
model_dir: PathBuf,

/// Optional DFlash draft directory (official checkpoint: <model-dir>/dflash)
#[arg(long)]
dflash_dir: Option<PathBuf>,

/// Paths to input images to process
#[arg(required = true)]
images: Vec<PathBuf>,
Expand All @@ -49,12 +54,26 @@ struct Args {
#[arg(long, default_value = "4096")]
max_tokens: usize,

/// Override repetition penalty (1.0 matches the official speed benchmark)
#[arg(long)]
repetition_penalty: Option<f64>,

/// Instruction prompt (default: text spotting)
#[arg(
long,
default_value = "Detect and recognize text in the image, and output the text coordinates in a formatted manner."
)]
prompt: String,

/// Suppress generated text and print aggregate timing/token statistics
#[arg(long)]
benchmark: bool,
}

fn token_fingerprint(tokens: &[u32]) -> u64 {
tokens.iter().fold(0xcbf29ce484222325_u64, |hash, token| {
(hash ^ u64::from(*token)).wrapping_mul(0x100000001b3)
})
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
Expand Down Expand Up @@ -90,14 +109,33 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
args.model_dir.display()
);
let load_start = Instant::now();
let model = HunyuanOcr::from_dir(&args.model_dir, device)?;
let mut model = match &args.dflash_dir {
Some(dflash_dir) => {
if !dflash_dir.exists() {
return Err(format!("DFlash directory not found: {}", dflash_dir.display()).into());
}
HunyuanOcr::from_dirs(&args.model_dir, dflash_dir, device)?
}
None => HunyuanOcr::from_dir(&args.model_dir, device)?,
};
if let Some(penalty) = args.repetition_penalty {
model.set_repetition_penalty(penalty)?;
}
info!(
"HunyuanOCR {} loaded in {:.2}ms",
"HunyuanOCR {} loaded in {:.2}ms{}, repetition penalty {:.3}",
model.version(),
load_start.elapsed().as_secs_f64() * 1000.0
load_start.elapsed().as_secs_f64() * 1000.0,
model
.dflash_num_speculative_tokens()
.map(|n| format!(", DFlash enabled ({n} speculative tokens)"))
.unwrap_or_default(),
model.repetition_penalty(),
);

info!("\n=== Processing {} images ===", existing_images.len());
let mut total_inference = Duration::ZERO;
let mut total_tokens = 0usize;
let mut succeeded = 0usize;
for image_path in &existing_images {
info!("\nProcessing: {}", image_path.display());
let rgb_img = match load_image(image_path) {
Expand All @@ -110,20 +148,39 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {

let infer_start = Instant::now();
match model
.generate(&[rgb_img], &[args.prompt.as_str()], args.max_tokens)
.generate_tokens(&[rgb_img], &[args.prompt.as_str()], args.max_tokens)
.pop()
{
Some(Ok(result)) => {
Some(Ok(tokens)) => {
let elapsed = infer_start.elapsed();
total_inference += elapsed;
total_tokens += tokens.len();
succeeded += 1;
info!(
" Inference time: {:.2}ms",
infer_start.elapsed().as_secs_f64() * 1000.0
" Inference time: {:.2}ms, tokens: {}, fingerprint: {:016x}",
elapsed.as_secs_f64() * 1000.0,
tokens.len(),
token_fingerprint(&tokens)
);
println!("{}", result);
if !args.benchmark {
println!("{}", model.decode_tokens(&tokens)?);
}
}
Some(Err(e)) => error!(" Inference failed: {}", e),
None => error!(" No result returned from model"),
}
}

if succeeded > 0 {
info!(
"Benchmark summary: pages={}, total={:.2}ms, avg={:.2}ms/page, tokens={}, throughput={:.2} tokens/s",
succeeded,
total_inference.as_secs_f64() * 1000.0,
total_inference.as_secs_f64() * 1000.0 / succeeded as f64,
total_tokens,
total_tokens as f64 / total_inference.as_secs_f64()
);
}

Ok(())
}
Loading
Loading