Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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