Skip to content

Unlimited-OCR-2 on CUDA: the family's steps on NVIDIA GPUs, the router's long-context speedup (stacked on #2074) - #2077

Open
BobClawblaw wants to merge 39 commits into
ashhart:mainfrom
BobClawblaw:unlimited-ocr-cuda-pr
Open

BobClawblaw wants to merge 39 commits into
ashhart:mainfrom
BobClawblaw:unlimited-ocr-cuda-pr

Conversation

@BobClawblaw

@BobClawblaw BobClawblaw commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Unlimited-OCR-2 (#2074's unlimited-ocr family) gains a CUDA backend: tensorfold-native serve --vision on an NVIDIA GPU serves page images with the release's BF16 weights as published, one stream, greedy under the publisher's page guard, as the Metal engine does. Before this, the family served on Metal only and CUDA refused unlimited-ocr checkpoints.

How it is built:

  • zig/kernels/cuda/uocr.cu (with uocr_common.cuh, uocr_vision.cuh and uocr_decoder.cuh) holds the Metal family's 28 own steps, each rounded at the same points as its Metal kernel:

    • the image encoder: SAM's windows, decomposed relative positions and attention, LayerNorm2d, the GELUs, CLIP's embeddings;
    • the decoder: page2d rotary, the DASH router (block keys, page-aware candidates, top 16, entmax-1.5), the block-sparse attention, the MoE gate and combine, one token's matvecs and experts picked on the GPU, and the greedy pick under the page guard.
  • The projections run on cuBLASLt through cuda/vit.zig's Gemms (the bias in its epilogue, or fp32 sums and one rounding), as MOSS-Image's CUDA path does. They sum in cuBLASLt's order, not Metal's, so the replies are not bit-equal to the Metal engine's: they part at near-ties (Exactness, below).

  • families/uocr/cuda_*.zig and native/uocr_cuda.zig mirror the Metal host: the weights in one device buffer, the encoder, the decoder and the served model, opened from native/cuda.zig beside the registry families.

  • The family's host files are shared rather than copied. A file may belong to only one module, so two things move:

    • preprocess.With(picture) takes the picture decoder: core/picture through its relative path on Metal, the core module's on CUDA;
    • the page guard moves to guard.zig (generate.zig re-exports it), so the CUDA module never reaches the Metal decoder.

    The CUDA module is rooted at zig/src/uocr_cuda.zig for the same reason.

  • The router change: a decoded token's router scored every 32-key block of the context with one warp, then took the top 16 in 16 rounds, so decode slowed as the context grew. It is now a 256-thread block. Each block's score is one thread's sum, in the same order as before. Each warp takes its own top 16 by shuffles, and warp 0 merges the eight lists in the same order: value first, then the lower block. The reads, their biases and the replies are bit for bit what they were.

Checks on this head

  • Head commit: e0b153ec1: the branch rebased onto Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text #2074's 6f2582554, which is Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text #2074 rebased onto main ac51580b2 (62 commits past b331731e6) plus the THIRD_PARTY_NOTICES entry for the release's Python files. Only CHANGELOG.md needed resolving: both lines kept.
  • Lean checker, python3 tools/zig/lean_check.py on Linux: 425 problems on main (ac51580b2), 425 on Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text #2074's head and 425 on this head (and 425 each on the earlier base b331731e6, on Linux and macOS). New findings: 0; zig/build/cuda.zig and registry.zig stay at 600 lines. (On Windows the checker reads files as cp1252 and counts 15 more on every tree.)
  • Tests run on this head:
    • Linux, RTX 5090 (sm_120), on e0b153ec1: zig fmt --check build.zig zig clean; zig build native install -Dsm=120 builds; zig build test-cuda-host -Dsm=120 -Dnvcc=...: Build Summary: 309/309 steps succeeded; 4791/4812 tests passed (21 skipped), no leak reports; the built server refuses serve --vision --parallel 2; tf-cuda-test uocr-contract: SUMMARY uocr: 35 kernels, 35 pass gates 1-4, 0 fail; 33 controls: 33 red as planted, 0 missed.
    • Rebase A/B, RTX 5090, the harness's ocr-match back to back: e0b153ec1 against the pre-rebase e1e345a25 (the same served code as afd186eac). All seven replies are byte-identical (the sha256 in the table below), with the same token counts; decode 275.5-306.4 tok/s against 275.2-306.5, first token within 0.03 s except report-1, the run's first request (0.63 s against 0.36 s).
    • The bullets below are from before the rebase.
    • Linux, GB10 (sm_121), nvcc 13.0: zig build test-cuda-host -Dsm=121 -Dnvcc=...: Build Summary: 307/307 steps succeeded; 4748/4771 tests passed (23 skipped), the records' new test among them; zig fmt --check clean; tf-cuda-test uocr-contract: SUMMARY uocr: 35 kernels, 35 pass gates 1-4, 0 fail; 33 controls: 33 red as planted, 0 missed.
    • The RTX 5090 and M5 Ultra runs below are on e1e345a25, which differs from this head only in the launch records, their host test and the CHANGELOG.
    • Linux, RTX 5090 (sm_120): zig build test-cuda-host -Dsm=120 -Dnvcc=...: Build Summary: 307/307 steps succeeded; 1183/1184 tests passed (1 skipped): zig re-ran the test binaries this head changes and took the rest from its cache of 029369d16's full run (4748/4769, 21 skipped). The new refusal test fails with the check disabled. The built server refuses serve --vision --parallel 2 with that message. In the full run three passing steps print to stderr and so show a failed command: line (Dory RoPE's word table, a gfx1030 N/A line, the libtfgrammar warning); none is this change. No leak reports.
    • Linux: zig build native install -Dsm=120 -Dnvcc=... builds.
    • Linux, RTX 5090 (sm_120): tf-cuda-test uocr-contract on 029369d16 (the same kernels and contract files as this head): SUMMARY uocr: 35 kernels, 35 pass gates 1-4, 0 fail; 33 controls: 33 red as planted, 0 missed.
    • macOS, M5 Ultra: zig fmt --check build.zig zig, zig build test, test-golden, test-kernel-contract, test-server-security test-pull-security (24 passed, 0 failed), check-release-paths, fuzz-cortheon, dev-tools and zig build native install: all pass on this head and on Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text #2074's head. No leak reports.
    • Earlier base (06322491f on Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text #2074's 10fe5c459), Windows 11, RTX 4080 SUPER: test-cuda-host -Dsm=89 285/285 steps; tf-cuda-test uocr-contract gave the same SUMMARY on sm_89.
  • CI: pending on the draft.

Receipt

  • Environment:
    • TensorFold: e1e345a25 (this head less the 13.0 records and the CHANGELOG line, which change no served code), except where marked as the RTX 4080 SUPER's earlier base.
    • Machines: one GB10 (128 GB unified, driver 580.178, Linux, nvcc 13.0); one RTX 5090 (32 GB, driver 615.71, Linux, nvcc 13.3); one M5 Ultra (256 GB, macOS) for the Metal side; earlier, one RTX 4080 SUPER (16 GB, driver 610.88, Windows 11).
    • Toolchain: Zig 0.17.0.
    • Checkpoint: baidu/Unlimited-OCR-2 at revision 0480966938291445591f69f08a13b041c073a96a.
  • Paths and platforms:
    • Ran: CUDA sm_121 and sm_120 (Linux) on this head and sm_89 (Windows) on the earlier base: prompt (image encoder, chunked prefill, MoE on gathered rows) and decode (matvecs, on-GPU experts, on-GPU pick), one stream at contexts up to 98,304 on sm_89 and 83,728 on sm_120.
    • Could not run: sm_80, sm_86.
  • Exactness, measured with the recipe harness's ocr-match plan on the same page PNGs: the recipe's five pages alone, the five as one document, and the release's 15-page paper three times over as one 45-page request.
    • Against the Metal engine (M5 Ultra, Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text #2074's code on this head): equal on the two report pages, different on the other five cases. Each engine repeats its own bytes exactly.

      case completion tokens (CUDA / Metal) sha256 CUDA sha256 Metal equal
      report-1 198 / 198 58c9ad432da973e4 58c9ad432da973e4 yes
      report-2 141 / 141 366f37903b4eec02 366f37903b4eec02 yes
      paper-1 543 / 543 9211a96c01c0dd84 9960608a870adef4 no
      paper-3 911 / 899 44da612e99c36206 357d7be06ce47b96 no
      paper-10 1959 / 1959 8d377476d6ea03c6 66767fb282460f00 no
      five pages as one document 3766 / 3764 f7fc6f672d7c67d1 a5acde0bcdabd6d1 no
      the paper's 15 pages x3 (45 pages) 43035 / 43043 3904790bb7f0ef28 6e4269decbf659ac no
    • Against the release's own infer (modeling_unlimitedocr.py) on the CPU in torch bf16 (x86, torch 2.13.0+cpu, transformers 4.52.1), greedy, gundam mode: the text without layout boxes is identical in 5 of 6 cases; box coordinates moved in 13 of 284, by at most 2 on the 0-999 grid (the RTX 4080 SUPER on the earlier base: 13 of 284, by at most 5). report-2 and paper-10 are identical.

    • Every place the CUDA reply leaves the reference is a near-tie in the reference. Driving the reference along the CUDA reply and recording its logits after the page guard wherever its own pick (argmax, the lowest id on a tie) differs: 9 such steps over the four cases, and at each the CUDA token is the reference's second choice, either an exact tie (4) or one bf16 step below (0.125 or 0.25, 5). The sixth case's text difference (paper-3, $ [5, 6, …] $ against [5, 6, …]) is one of them: 24.50 against 24.375.

    • GB10 (sm_121), the same code. Its recipe renders report-1 and report-2 in Liberation Sans (the other runs used macOS's Arial) and its paper-9 differs too, so only paper-1, paper-3 and paper-10 compare with the RTX 5090 and Metal on the same images: paper-10 equals the 5090, paper-1 and paper-3 do not, and none equals Metal. GB10 sha256: report-1 597333ed92039bdf, report-2 2b39eebc29d23b82, paper-1 16439a803c144e9f, paper-3 09b699e1a4d6f621, paper-10 8d377476d6ea03c6, the document 2071ded6b6e9af5b (3,764 tokens), 45 pages 3678e6a64800e404 (42,994). So the bytes depend on the GPU model as well as the backend. Against the x86 reference, on the GB10's own pages, report-1 and paper-10 are identical. Driving the reference along the GB10's replies, as above, finds 11 steps where its own pick differs, and at each the GB10's token is its second choice: an exact tie (6) or one bf16 step below (0.25 or 0.125, 5).

      case step reference top logit GB10's token logit gap
      report-2 35 173 45.00 172 44.75 0.25
      paper-1 25 251 38.50 247 38.50 0 (tie)
      paper-1 66 135 37.75 134 37.75 0 (tie)
      paper-1 464 506 41.00 501 40.75 0.25
      paper-3 13 275 57.25 274 57.25 0 (tie)
      paper-3 145 [ 24.50 24.375 0.125
      document 397 797 57.25 796 57.25 0 (tie)
      document 707 M 20.875 MP 20.875 0 (tie)
      document 857 100 33.25 101 33.00 0.25
      document 1802 119 39.00 120 38.75 0.25
      document 1811 409 56.75 407 56.75 0 (tie)

      The same infer on the GB10's own CPU (aarch64, the recipe's reference) writes paper-3's $ [5, 6, …] $ as the GB10 does: the reference's own pick at that near-tie depends on the CPU.

    • A repeated request gives the same bytes, and two requests at once give their solo replies (one stream: they queue). There are no drafts, so there is no "draft": false comparison.

  • Decode speed (the harness's streamed decode_tps; tools/bench_openai.py not run):
    • GB10: one page 116-117 tok/s; the five-page document 116.4; the 45-page document's whole 42,994-token reply 110.4.
    • RTX 5090, this head: one page 304-306 tok/s; the five-page document 304.4; the 45-page document's whole 43,035-token reply 275.3.
    • RTX 4080 SUPER, earlier base: one page 241-244 tok/s; the five-page document 240.9; 1,500 tokens after a 40,685-token prompt 223.8 tok/s with the router change, 140.1 before it; after 13,565 prompt tokens 234.7 against 192.4; the 45-page document's whole reply 222.2 against 117.7, byte-identical.
    • main has no CUDA path for this family to compare with.
  • Prompt speed, all cold: GB10, first token 0.62-1.05 s for one page, 2.99 s for five pages and 26.6 s for 45; RTX 5090, first token 0.21-0.36 s for one page (909 prompt tokens, gundam tiles), 1.05 s for five pages (4,525) and 9.39 s for 45 pages (40,685); RTX 4080 SUPER, 0.36-0.44 s, 1.73 s and 15.3 s. tools/bench_cache.py was not run: the family has no prompt cache.
  • Licence of new weights: n/a.
Kernel contract, only when a GPU kernel is new or changed
  • Kernels registered: all 28 tf_uocr_* kernels in uocr.cu. None replaces a CUDA kernel. The command is tf-cuda-test uocr-contract, with five groups that mirror Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text #2074's Metal groups (zig/tests/cuda/contract_uocr_*.zig):
    • rows (9 kernels, 10 registrations): add, layer_norm (SAM and CLIP eps), layer_norm2d, gelu, quick_gelu, rms_norm, rope, router_norm (stated NaN rule: nan_to_num), silu_mul;
    • moves (9): im2col (all four geometries), window, unwindow_add, rel_pos, concat, clip_embed, gather, scatter, embed;
    • route (5, 6 registrations): block_keys, route (picks bit-exact, biases in their ranges, up to 4,096 blocks), moe_gate (stated rule, ties to the lower expert), moe_combine, argmax (stated rule, plain and with EOS barred);
    • attention (2, 4 registrations): tf_uocr_attention as SAM 14x14 windows, SAM global and CLIP, and tf_uocr_attend;
    • matvec (3): matvec at every one-token shape up to the 129,280 x 1,280 lm_head, expert_gu and expert_down through a 64-entry table whose unpicked entries point at NaN rows.
  • Served shapes are the Metal groups'. The bounds follow the CUDA arithmetic under the glue flags:
    • division and sqrtf correctly rounded;
    • expf within 2 ulps and logf within 1;
    • the summation depths of each kernel's own order (strided per-thread chains, warp butterflies, warp partials).
  • Planted controls: 33, in zig/kernels/cuda/contract_uocr_*.cuh (each a copy of the production kernel with one break), all red as planted:
    • a dropped bf16 point in each rounding kernel;
    • swapped or shifted indices in the moves;
    • ties to the higher block, expert or id, and a short local gap or a lost last page in the router;
    • a shifted relative position, a dropped key, a key past the row, a dropped slot or a doubly counted block in attention;
    • half-precision sums in rel_pos and matvec;
    • wrong experts picked in the routed matvecs.
  • SUMMARY uocr: 35 kernels, 35 pass gates 1-4, 0 fail; 33 controls: 33 red as planted, 0 missed. There are no known findings. The tightest passes:
    • kernels checked against a pointwise interval at 1.000 of it (an output on its interval's edge);
    • the bf16-output kernels at 0.988 to 0.994 of the float64 bound.
  • Gate 5: the 28 launches are in uocr_cuda_*_records.zig, among cuda_pending. Their nvcc 13.0 usage comes from tf-cuda-test kernel-contract-limits on nvcc 13.0's -Dsm=80,86,89,120,121 fatbins: every launch PASS on every SM. 13.3's is in measurements/uocr_133.zig, from the same tool on 13.3's fatbins, also PASS everywhere; 19 of the 28 compile the same under both, the other nine differ by a few registers. 13.2 stays pending (later_pending). The registry's host test checks 13.0's and 13.3's usage against all five SMs.
  • The 13.0 limits run also reports 226 stale records in other families' launches (qwen4, qwen35, flashnext, nvfp4_experts_tile, act_rows), none of them this change's.
  • The 13.3 kernel-contract-limits run stopped at dsv41_x3ld's sm_121a-only kernel (SymbolNotInImage): this build has no sm_121a image. That is not this change.
  • Gates 1-4 ran on sm_121 (GB10, this head), sm_120 (RTX 5090) and sm_89 (RTX 4080 SUPER, the earlier base). Chips they have not run on: sm_80, sm_86.

Checklist

  • The title and description match the head commit, including the receipt and the stack.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency. (The contract's 33 planted controls each fail; the production kernels pass.)
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths in the diff or its commits. No AI attribution lines.
  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off.
  • Drafted output equals "draft": false (no drafts), a resumed prompt equals a fresh one (no prompt cache), and concurrent equals solo.
  • No precision traded for speed. The router change keeps every bit. The CUDA port sums in fp32 as the Metal family does; its projections sum in cuBLASLt's order, not Metal's, so its replies are not bit-equal to Metal's (below).
  • Decode and prompt processing are no slower than main at one stream: main does not serve this family on CUDA.

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @BobClawblaw. Porting all 28 steps with a five-group contract, and planting 33 controls that all go red, is thorough. The router change also keeps its picks bit for bit. This waits on #2074, so we'll review it fully once #2074 lands. Before then:

  1. The port claims the Metal rounding op for op, so the bar is CUDA token ids equal to the Metal engine on the recipe's five pages and the 45-page document. We can run the Metal side on an Apple M3/M5 Ultra and post its hashes.
  2. Against the torch reference, 13 of 284 boxes moved, and one page differs. For each first differing id, please show the reference's top-two logit margin, so we can see they're ties.
  3. Your lean count is 439 on Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text #2074's head, while main reads 424. Could you check whether Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text #2074 adds findings, or whether the Windows run counts differently?
  4. Once it's rebased, we can run the uocr-contract and the served pages on a GB10.

@BobClawblaw

Copy link
Copy Markdown
Contributor Author

Thanks. On 3: #2074 adds no findings. On Linux, its merge base (023def309), #2074's head and this head all read 424, with identical finding lists (main is 425 today). The Windows 439 comes from lean_check.py reading files with read_text() and no encoding: on Windows that's cp1252, so each multi-byte UTF-8 character counts as two or three columns and 15 lines go past 120. Forcing cp1252 on Linux gives 439 on all three trees. A one-line encoding="utf-8" fix would make the count match on every platform; happy to open it separately.

On 1 and 2: we'll rebase #2074 and this onto main, then post the CUDA hashes for the five pages and the 45-page document next to your Metal ones, plus the reference's top-two margins at each first differing id.

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @BobClawblaw, that's a real find. lean_check.py reads with read_text() and no encoding, so on Windows each multi-byte UTF-8 character counts as several columns. Yes please, open the one-line encoding="utf-8" fix as its own PR and we'll land it quickly. The rebase plan for #2074 and this one sounds right, and we'll post the Metal hashes for the five pages and the 45-page document when you post the CUDA ones.

@BobClawblaw

Copy link
Copy Markdown
Contributor Author

Rebased both onto main (8db1ee1a3): #2074's head is 4b95ba97f, this one's fa3d50cce. Not pushed yet. The new commit on this branch fixes a leak: when the CUDA open refused after creating its host (no driver, or a checkpoint it can't load), it returned null past its errdefers, and the host leaked (42,592 bytes, plus the context and driver on the load path). Those refusals now return error.Refused like native/gemma4.zig's. The leak report is gone on a checkpoint that fails to load.

1. CUDA against Metal. I ran both sides on the rebased head with the same page PNGs: Metal on an M5 Ultra, CUDA on an RTX 5090 (sm_120, Linux). Each engine repeats its own bytes exactly, but CUDA does not equal Metal:

case completion tokens (CUDA / Metal) sha256 CUDA sha256 Metal equal
report-1 198 / 198 58c9ad432da973e4 58c9ad432da973e4 yes
report-2 141 / 141 366f37903b4eec02 366f37903b4eec02 yes
paper-1 543 / 543 9211a96c01c0dd84 9960608a870adef4 no
paper-3 911 / 899 44da612e99c36206 357d7be06ce47b96 no
paper-10 1959 / 1959 8d377476d6ea03c6 66767fb282460f00 no
five pages as one document 3766 / 3764 f7fc6f672d7c67d1 a5acde0bcdabd6d1 no
the paper's 15 pages x3 (45 pages) 43035 / 43043 3904790bb7f0ef28 6e4269decbf659ac no

The differences start at box coordinates one or a few grid points apart, and paper-3 is the $ [5, 6, …] $ near-tie the other way round. So the description's "rounded at the same points, op for op" holds for the family's own kernels but not end to end: the projections sum in cuBLASLt's order, not Metal's, and that is enough to flip near-ties. I'll correct the description. Bit-equality with Metal would need the projections in Metal's summation order on CUDA. Is that the bar you want for this PR, or is agreement with the torch reference within near-ties acceptable for a second backend?

2. Margins against the torch reference. This is the release's own infer (x86 CPU, torch 2.13.0, bf16, greedy, gundam), driven along the CUDA reply. At every step where the reference's own pick (argmax, the lowest id on a tie) differs from CUDA's token, it records the reference's logits after the page guard. There are 9 such steps across the four cases that differ from it. In every one, CUDA's token is the reference's second choice, and the gap is an exact tie or one bf16 step of the logit:

case step reference top logit CUDA's token logit gap
report-1 10 63 45.75 64 45.50 0.25
paper-1 66 135 38.25 134 38.25 0 (tie)
paper-1 72 155 50.25 154 50.25 0 (tie)
paper-1 464 501 40.75 506 40.75 0 (tie)
paper-3 145 [ 24.50 24.375 0.125
document 36 175 42.50 176 42.50 0 (tie)
document 451 553 47.25 552 47.00 0.25
document 757 482 34.50 481 34.25 0.25
document 767 506 31.625 508 31.50 0.125

paper-10 and report-2 equal the reference exactly. The x86 summary on this build is the same as the 4080's: text without boxes identical in 5 of 6, box coordinates moved 13 of 284, by at most 2.

3. As above: no new findings. On Linux the rebased head reads 425, the same as main.

Checks on the rebased head: zig build test-cuda-host -Dsm=120: 303/303 steps, 4675/4695 tests passed (20 skipped). The Metal side was built and served for the hashes above, but zig build test on macOS has not been run on this head yet.

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @BobClawblaw, and for reporting the Metal comparison straight. Two of seven equal, and every other difference is a near-tie in the reference, all nine of them an exact tie or one bf16 step: that's the clearest way to show it. Whether a second backend must match Metal bit for bit was our call, and the answer is no here: each engine exact against itself, and agreement with the publisher's reference within near-ties, is the bar for this family. Forcing Metal's summation order on CUDA would give up cuBLASLt for differences that are exact ties or one bf16 step. Two small things. The CHANGELOG has only #2074's Metal line; please add one for CUDA, saying it may part from Metal at near-ties. And gate 5's usage should be at nvcc 13.0, which the release fatbins use; if you can't run 13.0, say so and we'll generate those records on a GB10. Once #2074 lands and this is rebased, we'll run uocr-contract and the served pages on a GB10 (sm_121) and post the hashes beside yours.

BobClawblaw added a commit to BobClawblaw/TensorFold that referenced this pull request Nov 4, 2026
@BobClawblaw

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. Both are in. The head is afd186eac, still on #2074's 86857ca4b.

  1. CHANGELOG: a CUDA line follows Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text #2074's Metal line. It says CUDA may part from Metal at near-ties, and also from one GPU model to another (see the GB10 run below).
  2. Gate 5 at nvcc 13.0: I measured the 28 launches' usage from nvcc 13.0's own fatbins: zig build fatbins kernel-contract-fatbins -Dnvcc=<13.0> -Dsm=80,86,89,120,121, then tf-cuda-test kernel-contract-limits.
    • Every uocr launch passes on all five SMs.
    • 19 of the 28 compile to the same usage as under 13.3. The other nine differ by a few registers; the highest is tf_uocr_attention at 128.
    • The records keep later_pending, so 13.2 stays pending and 13.3 uses its own set. The registry's host test now checks 13.0's usage, 13.2 pending and 13.3's usage on every launch.
    • The same limits run reports 226 stale records outside this family (qwen4, qwen35, flashnext, nvfp4_experts_tile, act_rows). This PR doesn't touch any of them.

On this head, Linux: test-cuda-host passes (Build Summary: 307/307 steps succeeded; 4748/4771 tests passed (23 skipped)), zig fmt --check is clean, and lean reads 425, the same as #2074's head.

GB10 (sm_121, Linux, nvcc 13.0), this code. We had one free, so we ran it ahead of yours:

  • tf-cuda-test uocr-contract: SUMMARY uocr: 35 kernels, 35 pass gates 1-4, 0 fail; 33 controls: 33 red as planted, 0 missed.
  • The served pages, through the recipe harness's ocr-match plan:
    • Each case repeats its own bytes.
    • Against the torch bf16 reference, the text without boxes is identical in 6 of 6 cases; box coordinates moved in 10 of 288, by at most 5. report-1 and paper-10 are byte-identical to the reference.
    • Decode runs at 110-117 tok/s. First token, cold: 0.62 s for a page, 2.99 s for five, 26.6 s for 45.
  • Against the RTX 5090 on the same commit: only paper-10 is equal. The other six differ, each first at a box coordinate. For example, report-1's title box is [85, 65, 414, 92] on the GB10 and [89, 64, 411, 91] on the 5090; the GB10's matches the reference.
case completion tokens (GB10 / 5090 / Metal) sha256 GB10 equal to 5090 equal to Metal
report-1 198 / 198 / 198 597333ed92039bdf no no
report-2 141 / 141 / 141 2b39eebc29d23b82 no no
paper-1 543 / 543 / 543 16439a803c144e9f no no
paper-3 911 / 911 / 899 09b699e1a4d6f621 no no
paper-10 1959 / 1959 / 1959 8d377476d6ea03c6 yes no
five pages as one document 3764 / 3766 / 3764 2071ded6b6e9af5b no no
the paper's 15 pages x3 (45 pages) 42994 / 43035 / 43043 3678e6a64800e404 no no

So each GPU is exact against itself, but the bytes depend on the GPU model as well as on the backend. Not done: I have not driven the reference along the GB10's replies to show its departures are near-ties, as I did for the 5090.

This stays a draft until #2074 lands; then I'll rebase it.

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @BobClawblaw, both are in: the CUDA CHANGELOG line says what users need to know, and measuring gate 5
from 13.0's own fatbins is the right record. Your GB10 run answers what we'd planned to check. It also shows the bytes
depend on the GPU model, not only the backend, which is fine under the same bar, as long as the departures are
near-ties. Two small things before it leaves draft. First, the table you made for the 5090, driving the reference
along the GB10's replies, since that's the GPU it will serve on. Second, THIRD_PARTY_NOTICES: if any of the
family's code, here or in #2074, follows the release's modeling_unlimitedocr.py rather than the paper and the
weights, please add an entry naming that file and its licence. If it doesn't, say so and we're done.

BobClawblaw added a commit to BobClawblaw/TensorFold that referenced this pull request Nov 5, 2026
@BobClawblaw
BobClawblaw force-pushed the unlimited-ocr-cuda-pr branch from afd186e to dfb2246 Compare November 5, 2026 00:05
@BobClawblaw

Copy link
Copy Markdown
Contributor Author

1. The reference driven along the GB10's replies. Same tool and reference as the 5090 table (the release's own infer, x86 CPU, torch 2.13.0, bf16, greedy, gundam), on the pages the GB10 run read. 11 steps over five cases leave the reference's own pick. At every one, the GB10's token is the reference's second choice: an exact tie (6) or one bf16 step below (5).

case step reference top logit GB10's token logit gap
report-1 – – – – – none (equal)
report-2 35 173 45.00 172 44.75 0.25
paper-1 25 251 38.50 247 38.50 0 (tie)
paper-1 66 135 37.75 134 37.75 0 (tie)
paper-1 464 506 41.00 501 40.75 0.25
paper-3 13 275 57.25 274 57.25 0 (tie)
paper-3 145 [ 24.50 24.375 0.125
document 397 797 57.25 796 57.25 0 (tie)
document 707 M 20.875 MP 20.875 0 (tie)
document 857 100 33.25 101 33.00 0.25
document 1802 119 39.00 120 38.75 0.25
document 1811 409 56.75 407 56.75 0 (tie)

paper-10 equals the reference. paper-3's step 145 is the same $ [5, 6, …] $ near-tie the 5090 took. The same infer on the GB10's own CPU (aarch64) picks the GB10's side of it, so the reference's own pick there depends on the CPU.

One correction to the description. The GB10 run's report-1 and report-2 PNGs are not the ones the 5090 and Metal runs read (the GB10 recipe renders them in Liberation Sans, the others in macOS's Arial), and its paper-9 differs too. So the GB10-against-5090 comparison holds only for paper-1, paper-3 and paper-10. paper-10 is equal there; paper-1 and paper-3 differ. The report-1 title box I quoted, and the hashes for both documents, compare different inputs.

2. THIRD_PARTY_NOTICES. Yes, the family follows the release's code, not only the paper and the weights. That code is modeling_unlimitedocr.py (the page guard, gundam's tile choice, the views' token layout and the prompt), and with it deepencoder.py (SAM and CLIP), dash_attention.py with modeling_deepseekv2.py (the router), and _sparse_base_impl.py (the decoder; its header is Apache-2.0, DeepSeek-AI and HuggingFace). The release is MIT, Copyright (c) 2026 Baidu. #2074's f6848b8c2 adds a THIRD_PARTY_NOTICES entry naming each file, what follows it and its licence, and this PR is rebased onto it (dfb224642, no code changed). The description now has the GB10 table and the corrected comparison.

@BobClawblaw
BobClawblaw marked this pull request as ready for review November 5, 2026 00:05
SAM ViT-B, its neck and CLIP-L to the projector's rows, for the 1024
global view and 640 tiles (position tables resized as torch does,
bit-identical). tf-uocr-vision gates each stage against float32 on a
reference dump's pixels.
…oE) and greedy generation

tf-uocr-decode runs a page end to end against a reference dump: the
prompt's ids, the spliced embeddings, each layer's prefill rows against
float32, the first logits, and the greedy reply.
…matvecs for one token

49 -> 142 tok/s on a one-page reply; the two-page reply now equals torch
bf16's over all 337 tokens.
Prompt rows always take the GEMM (an expert's lone row in a chunk took the
matvec before), so a row's bits do not depend on the chunking;
tf-uocr-decode --chunk ROWS checks the first logits and the reply.
… template in the family folder)

One request at a time on the lane host, greedy under the page guard. A
page takes gundam tiles when they fit its share of the image tokens (the
publisher's server default), else the base view.
…ld-native

uocr.cu ports uocr/vision.metal's and uocr/decoder.metal's steps op for op, rounded at the same points; cuBLASLt
(cuda/vit.zig's Gemms) runs the projections. The CUDA host (cuda_weights, cuda_vision, cuda_decoder, cuda_serve)
mirrors the Metal one: one device buffer for the bf16 weights, matvecs and on-GPU expert picks for a decoded token,
the greedy pick under the page guard on the GPU. native/uocr_cuda.zig opens it beside the registry families.

The host files stay shared: preprocess.With takes the picture decoder (core's on CUDA, a file belongs to one
module), and the page guard moves to guard.zig so the CUDA module does not reach the Metal decoder.

On an RTX 4080 SUPER (Windows 11): the six OCR cases' text matches the publisher's torch reference (one near-tie
on a page alone, `$ [5] $` vs `[5]`, which the Mac's run takes the other way), 236-242 tok/s on a page, first
token 0.35-0.46 s, and a 45-page document (40,685 prompt tokens) runs to its last page in a 98,304-token window.
… then merged

A decoded token's router read every block's summary key with one warp, then took the top 16 in 16 rounds: its time
grew with the context (140 tok/s at 41k tokens against 238 at 1k on an RTX 4080 SUPER). Each block's score is now a
thread's (the same sequential sum), each warp keeps its own top 16 by shuffles, and warp 0 merges the eight lists in
the one order (value, then the lower block), so the reads and their biases are bit for bit what they were.

RTX 4080 SUPER, 1,500 decoded tokens: 224 tok/s after a 40,685-token prompt (was 140), 235 after 13,565 (was 192),
242 after 909; the 45-page document's 43,027-token reply 222 tok/s (was 118), byte-identical to before.
…indow_add, rel_pos, concat, clip_embed, gather, scatter, embed)
…on as SAM windows, SAM global and CLIP; tf_uocr_attend), 5 controls
…eir 13.3 usage checked; the lean rule's 600 lines kept
…d, so its errdefers release the host, context and driver
…e-SM fatbins (13.2 still pending)

tf-cuda-test kernel-contract-limits on nvcc 13.0's fatbins for sm_80, 86, 89, 120 and 121: every uocr launch PASS on every SM. 19 of the 28 compile as under 13.3, the other nine differ by a few registers. The records keep later_pending, so 13.2 stays pending and 13.3 keeps its own set.
@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thanks @BobClawblaw, that's the table we hoped for: every departure on the GB10 is a tie or one bf16 step, so it
meets the bar, and the font correction is the kind of detail that keeps the comparison honest. With the notices in
#2074, this is ready on our side once #2074 lands and you rebase.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants