Repository navigation
Unlimited-OCR-2 on native Metal: the publisher's BF16 release, page images to structured text - #2074
BobClawblaw wants to merge 2 commits into
Conversation
|
Thanks @BobClawblaw, this is a careful port. A new family that reads the release as published, with the reference
Three small things before it lands:
On your question: yes, we'd build the template in. Use the family's own when the checkpoint ships none, and keep Once these are in, we'll run the recipe's six cases on an M5 Ultra against your reference transcripts. |
10fe5c4 to
3fb5d5b
Compare
|
Thanks. All three are in, rebased on main
The template is built in as you suggested: when the checkpoint ships none and its On |
|
If CUDA is not in this PR's scope, I have a measured CUDA implementation of the same family (DASH tile/step, router, MoE, SAM/CLIP tower) and can stack the backend here once the Metal family lands. Is that a welcome follow-up, and would you prefer it as a separate PR against #2074's branch or against main after merge? |
|
Thanks @BobClawblaw. All three are in: @shuuul, thanks for the CUDA offer, and yes, it's welcome. Please make it its own PR against main once this has landed, with the gate agreed on #2073 first. CUDA is something we can run on our side. |
3fb5d5b to
86857ca
Compare
|
Thanks @ashhart. Rebased onto main On this head: One thing outside this PR: |
|
Thanks @BobClawblaw, and for keeping both lists' main entries in the rebase. This is ready on our side once our runs are in: your six cases on an M5 Ultra against your reference transcripts, and the tensor-units refusal on an M3 Ultra. One small thing we'll handle: the recipe's speed row was measured with another model on the GPU, so we'll put our M5 Ultra numbers there instead. Thanks also for tracking down the |
f6848b8 to
6f25825
Compare
|
Thanks @BobClawblaw. Main moved again: the branch now conflicts in |
|
Thanks @ashhart.
I also rebased onto main |
e843271 to
7a4d267
Compare
|
Rebased onto main |
|
Thanks, @BobClawblaw. The teacher-forced lists are the right evidence, but we can't take the gate as written. CONTRIBUTING.md:78-79 allows a one-step tie policy only for the same two contenders with both margins within it. Paper-3's steps 145 (equal) and 557 (one step), paper-10's 67, and the document's 16 and 757 (equal) can fit that; please name both contenders and both margins at each. A reply that goes its own way after such a tie is expected, and isn't a failure. The document's steps 300 (0.5, two bf16 steps) and 431 (0.75, three), from our own image rows, fall outside it, and the CHANGELOG line (:11) would record them as accepted. So the gate is the teacher-forced check from our own image rows, with every departure inside that policy; the layer limits can stay reported. Could you find where row 1934 grows, then post the document's list again? |
|
Thanks @ashhart. 1. Both contenders and both margins. Torch's side is a second torch bf16 run (same CPU, same release) fed its own reply, recording the two highest logits at every step after the page guard; its pick agrees with the first run's at all 3,763 + 898 + 1,958 steps. Ours is
Steps where a side's own top two are within one bf16 step of each other: torch 9 / 5 / 21 (paper-3 / paper-10 / document), ours 10 / 6 / 19. 2. Where row 1934 grows. Row 1934 is page 3 (paper-1), tile 1 (top right), patch (5, 9), the tile's right edge. Its own embedding is not the cause, and neither is any encoder stage:
Among our 4,515 image rows this one ranks 2,722nd by error; over all of them ours are 0.0122 from float32 at the median against torch's 0.0129, and 0.0144 from each other. The growth is in layer 1: the attention output for this row is twice as far from float32 as torch's, in a direction the layer's gate sees. Float32 gives expert 46 a weight of 0.156 there, torch bf16 0.136, ours 0.221 (the gate recomputed in float64 from each side's residual reproduces each side's weights; Swapping rows between the two encoders confirms it is the row's context, not the row:
So the document's step 431 follows page 3's rows as a whole and step 300 (in page 2's text) follows pages 1 and 2's; each of those rows is inside the ordinary spread between two bf16 encoders, ours no further from float32 than torch's. 3. The gate. As written, the document does not pass it from our own rows: steps 300 and 431 stay at two and three bf16 steps. I found nothing in our code to fix for that: no stage of ours is further from float32 than torch bf16 is, at this row or at the median, and the decoder on torch's own rows is within one step everywhere. To land on torch bf16's side of those two steps we would have to reproduce torch's bf16 rounding rather than float32's values. The release's own float32 run says the same. Fed torch bf16's document reply, torch float32 picks otherwise at six steps, and two of them are 300 and 431, where it takes our side: 16259 over 16208 by 0.43 (1.7 bf16 steps at that magnitude) and 12326 over 13636 by 0.52 (2.1 steps), against our 0.5 and 0.75. Signed margins of torch bf16's token over the other contender, in bf16 steps at that magnitude:
So at 300 and 431 our logits sit with float32, within 0.3 and 0.9 of a step of it, and torch bf16 is the side 1.7 and 3.1 steps away; on paper-10 torch bf16 is itself two steps from float32 at step 1660 (26681 over 27395 by 0.5, float32 the other way by 0.19). I would rather not record the two steps as accepted under the one-step policy. I can reword the CHANGELOG line and the recipe to say that the single pages are within it and the document has two picks outside it, at two and three steps from torch bf16 (both within one step of float32), or hold the document case out of the recipe's claims; which would you prefer? |
|
@BobClawblaw thanks, this is a thorough answer: both contenders and margins at every departure, row 1934 traced to layer 1's attention and the gate it feeds, and the swap runs that pin it to the row's context rather than our encoder. On your question: please hold the document out of the claims. The single pages are within the one-step policy, so they're what the CHANGELOG line and the recipe should claim. The document can be reported beside them with steps 300 and 431, both contenders, both margins, and float32's side as you measured it. That keeps the policy as written. Whether it should allow "within one step of float32" is a separate question for CONTRIBUTING.md, not this PR. To finish:
We'll word the CHANGELOG line at landing to match the scope. |
7a4d267 to
c7cd2ff
Compare
|
Thanks @ashhart. All four are in, on head c7cd2ff (main f03e5aa); the description is rewritten from it. 1. The reply gate. 2. Notices. The page guard is cited from 3. The 4. Rebase and the template helper. 7a4d267 rebased onto f03e5aa (755 commits). Main's Nemotron Parse template and this family's sat at the same spot in Also on this head: the CI set ( |
|
Thanks @BobClawblaw, all four are in on
One small fix: the second loop of that Two optional tidy-ups while you're there:
After that, the next step on our side is the six-case run on our M5 Ultra with your release inputs, and we'll word
|
c7cd2ff to
b68ea38
Compare
|
Thanks @ashhart. All three are in, on head b68ea38 (main f03e5aa, as before; the description is updated from it):
All folded into the family commit. On this head: fmt, |
|
@BobClawblaw thanks, all three are in on
Nothing is left on your side. The release is out, so next we run the six cases on our M5 Ultra with your release inputs, and once those and CI are green it goes on the landing queue, where we'll word the changelog line. Thanks for offering to rebase whichever of this and #1177 lands second.
|
|
@BobClawblaw a quick update: the six-case run on our M5 Ultra is now queued on today's main merged with
|
|
@BobClawblaw, a first result from our M5 Ultra on today's main merged with For the six cases themselves we're missing your inputs: the five page images and the per-stage dump pairs (torch bf16 and float32, with the
|
|
Thanks @ashhart. The inputs are on a release of the fork: https://github.com/BobClawblaw/TensorFold/releases/tag/uocr-b68ea38f2
The dumps are the ones behind the six-case results in the PR text ( |
|
Thanks @BobClawblaw, that's exactly what we needed. The run on our M5 Ultra resumes with these files (checked against your SHA256SUMS), and we'll post the six cases and the block check here.
|
|
Thanks @BobClawblaw. The six cases pass on our M5 Ultra (256 GB), on today's main merged with
On torch's own image rows ( With the earlier host tests (64/64) and the unchanged Qwen reply, that's everything we asked for. It's queued to land after #1177. That PR carries the same
|
b68ea38 to
0376db3
Compare
|
@BobClawblaw main moved under this and it conflicted with main in zig/build/metal_targets.zig , so the landing train left it out. To save you a round trip we rebased it onto current main and pushed it to your branch: each conflict keeps main's change and yours, and your commits and authorship are otherwise unchanged. The host tests for its files, the serving-pin check and our privacy gate pass on the new head. It goes back in the landing queue once its CI is green. No other asks.
|
baidu/Unlimited-OCR-2 at its released bf16 weights on M5-class GPUs: the publisher's preprocessing (bit-exact), the SAM and CLIP image encoder, the DASH MoE decoder with greedy generation, chunked prefill (bit-identical at any cut), serving through tensorfold-native --vision with the family's chat template, and every kernel under the contract. The SAM encoder, the decoder's attention, its page2d rotary and its MoE gate are written from the papers and from the release's modules measured through their inputs and outputs. tf-uocr-blocks gates each encoder step alone against float32, tf-uocr-vision and tf-uocr-decode gate a page's stages, and tools/zig/uocr_reference.py makes their cases from the release's own code. Fed torch bf16's reply, tf-uocr-decode accepts a departure only as a tie: the same two contenders on both sides, each margin within one bf16 step (the reference records every step's two highest logits); --reply-reported reports them instead. The server's built-in templates share one helper (hf_text_parse.zig): Nemotron Parse's replaces the checkpoint's, this family's fills in when the checkpoint ships none, and a --chat-template wins over both.
0376db3 to
dd6530d
Compare
Fixes #2073.
This serves Baidu's BF16
baidu/Unlimited-OCR-2(revision0480966938291445591f69f08a13b041c073a96a) natively on Metal, as published: the engine reads the release's safetensors directly, with no converted pack and no Python or libtorch in the served engine. The image tower (SAM ViT-B, its neck and CLIP-L), the decoder (page2d rotary, DASH block-sparse attention, a 64-expert MoE) and the publisher's page-guarded greedy decoding run in Zig and Metal. Its caches stay in the model's precision: bf16 keys and values, fp32 router keys, as in the publisher's code.The SAM encoder, the decoder's attention, its page2d rotary and its MoE gate are written from the papers, the checkpoint's config and the release's modules measured through their inputs and outputs (forward hooks on its own
infer, intools/zig/uocr_reference.py). No code follows_sparse_base_impl.pyor the librariesdeepencoder.pycites, and none of the earlier code that did is in this branch's history. THIRD_PARTY_NOTICES names only the release's own MIT files the family follows.baidu/Unlimited-OCR-2(BF16) on Metaltensorfold-native serve --visionon M5 (tensor units)infer(torch bf16, CPU) on the five single pagestf-uocr-decodenow gatestf-uocr-blocks)serve --vision MODEL--chat-templatetakes its place. One helper,server/hf_text_parse.zig'sbuiltIn, now holds both built-in templates: Nemotron Parse's replaces the checkpoint's, this family's fills an empty one--parallelabove 1tf-uocr-decode --chunk, odd sizes)uv_attentionfor SAM and CLIP,uv_im2colfor three convolutions,ud_attendfor prompt rows and steps,ud_normalone and with its two additions):SUMMARY 30 kernels: 30 pass gates 1-4, 0 fail, 0 known findings; 30 controls: 30 red as planted, 0 missed; 0 limit problems(tf-kernel-contract --only uocr)tests/contract/metal_main.zig521 lines,server/hf_text.zig600--contextpast the window--context 131073: Unlimited-OCR-2 runs to 131072 tokens), tested innative/uocr.zigResults (M5 Ultra; another model's server loaded on the same GPU, idle; recipe harness
ocrplan, head c7cd2ff)$ [5, 6, ...] $The claim is the five single pages.
tf-uocr-decodefeeds torch bf16's own reply back a token a step and, where our pick differs, now requires a tie: the same two contenders on both sides (the reference'stop2, each step's two highest logits, recorded byuocr_reference.py dump) with both margins within one bf16 step at the logits' size; any other departure fails the tool. On this head every single-page departure is a tie (torch's margin, then ours, in bf16 steps):report-1's and paper-1's two tokens render the same text, so their transcripts are identical; report-2 has no departure. The five-page document is reported beside the claim, outside the one-step policy (
--reply-reported): of its 4 departures in 3,763 steps, 16 (6420 over 6207 by one step; ours 6207 by 0) and 757 (26934 over 25282 by one; ours 25282 by 0) are ties, but at 300 torch holds 16259 and 16208 equal where ours has 16259 by two steps (52.5 against 52.0), and at 431 torch has 13636 over 12326 by one step where ours has 12326 by three (42.75 against 42.0). Float32 is on our side of both (16259 by 1.7 steps, 12326 by 2.1). On torch's own image rows (--their-embeds) only 431 departs, by one step, with every layer and the first logits within their gates; the two wider margins come from our image rows (row 1934, page 3's tile 1, whichtf-uocr-blocksfinds no further from float32 than torch bf16 at any encoder stage). Torch is fragile there itself: its float32 run departs from its bf16 run at token 16 of the document, 13 of paper-3 and 1,660 of paper-10. One different pick sends the rest of a reply its own way, which in the document moves a box and every coordinate after it.The harness pairs boxes in reading order. On page 4 (paper-3) the reference writes one box ours does not, an empty
otherblock[140, 573, 885, 831]spanning the three contribution items after their own boxes, so from there each of our boxes is compared with the reference's next one: that is the 824. Set that box aside and the other 34 pair up, with 74 of their 136 coordinates moved, by at most 3. In the document one row is as sensitive as report-1's layer-8 row: from our own image rows, row 1934 is over the worst-row limit at layers 8 to 10 (3.12 against 2.93 at layer 8), with every layer's cosine and the first logits within their limits; from torch's rows it is 1.45 and every gate passes.Gates
Each limit is twice torch bf16's own distance from float32 (or bf16 rounding's), in cosine error and in the worst row's relative error:
tf-uocr-blocksruns each image-encoder step alone on float32's input rounded to bf16, as moss_image: the transformer gated block by block against float32, on Metal and CUDA #2294 gates MOSS-Image's blocks. All 39 steps pass on report-1's tile view 1 and its global view; a 5% error planted in one row of each kind of step fails it.tf-uocr-visionencodes a page's views from the publisher's pixels (its own preprocessing gives them bit for bit). SAM's output is gated; CLIP's and the projector's are reported against the same limits. On report-1's tile view 1, CLIP row 79 magnifies the bf16 rounding in SAM's output: ours is 0.186 from float32 in relative error against a limit of 0.166, though every step on the way is within its own limits. Torch's own runs land at 0.060 there on the CPU and 0.069 on MPS.tf-uocr-decoderuns a page end to end: the prompt ids and text rows must equal torch's, and on torch's image rows (--their-embeds) every layer and the first logits must be within their limits. On our own image rows they are reported, as CLIP is. Fed torch bf16's reply, every departure must be a tie as defined above (gate.zig'sdeparture,bf16Steps; a departure with another contender or a wider margin fails, tested intest-uocr);--reply-reportedreports them instead, for the document.tools/zig/uocr_reference.pymakes every case from the release's own code:pages,capture(transcripts),dump(each stage of a page, torch bf16 and float32, and now each step's two highest logits) andblocks(each encoder step alone).Commits
Suitein its own file, sotests/contract/metal_main.zigstays at 600 lines or below.zig/kernels/metal/uocr/) under the contract, serving, the gate tools, the reference script, the recipe and the notices entry.Shared code
Outside the family's folders the PR only registers it:
native/metal.zig(theunlimited-ocrdispatch and its name in two refusal lists),cli/hub.zig(title),kernels/metal/sources.zig(two embedded sources),tensorfold.zig(export and test),build/metal_targets.zig(test-uocrand the three tools),build/server.zigandbuild/dsv41_draft.zig(theuocr_chatmodule), the contract wiring (registry_joins.zig'sall_metal,metal_main.zig,metal_limits.zig),server/root.zig(the template test) and the built-in template. That last one is now a shared helper:server/hf_text_parse.zig'sbuiltIn(model_config, checkpoint)returns Nemotron Parse's template for itsmodel_type(replacing the checkpoint's, as main did) and this family's forunlimited-ocrwhen the checkpoint's is empty, else null;hf_text.zigcalls it on one line when there is no--chat-template, and stays at 600 lines. A table test covers both behaviours, an unknown type and a non-object config;uocr_chat_test.zigkeeps the end-to-end cases (built in, overridden, shipped, another type). Served replies on Qwen3.8-27B (MLX 4-bit,TensorFold/Qwen3.8-27B-MLX-4bit) from main's binary (f03e5aa) and this branch's, six greedy requests each (plain, a system prompt, three turns, thinking off, a tool call, a long prompt; 96 tokens): the sametoken_sha, token ids and text on all six, the tool call's name and arguments included (only its randomcall_id differs).Checks run on this head
b68ea38f2, on mainf03e5aac1:c7cd2ff4awith the 10 Nov review's three changes folded into the family commit. The--contexttest's second loop accepts either message the load gives before any checkpoint is read: the missing directory's, or the tensor-unit refusal thatserve.Model.loadmakes first on an M1-M4 Mac (that Mac was not available here; the M5 side is what ran). The recipe's claim paragraph lists report-1's ties (steps 10 and 33 of its 197) and paper-1's (step 88 of 542) with paper-3's and paper-10's, as the table above does.families/uocr/chat.zigkeeps only the template and its model type, sincehf_text_parse.zig's helper does the check. On this head, on the same Mac:zig fmt --check,git diff --check, the lean checker (386 problems, as main has; 0 new),zig build test,test-golden,test-server-security test-pull-security,fuzz-cortheon,check-release-paths,dev-tools,test-nativeandtest-uocrall pass, and the served binary (zig build native -Doptimize=ReleaseFast -Dcpu=native) is equal to c7cd2ff's in every byte of its__TEXTand__DATAsegments; what differs (the Mach-O UUID, one symbol-table entry and some 30 bytes of the string table in__LINKEDIT, and the ad-hoc code signature) differs the same way between two builds of one head, so the kernel contract, the decode runs and the served results below are unchanged.c7cd2ff4a, on mainf03e5aac1:7a4d26786rebased (755 commits; conflicts in the registration lists,THIRD_PARTY_NOTICES.md,CHANGELOG.mdand the two template lines inserver/hf_text.zig, resolved as the helper above), then the review's changes folded into the family commit: the reply's tie gate and the reference'stop2, the--contexttest, the notices entry (the page guard is ingenerate.zig; the MIT licence is cited from the model card, since the release ships no LICENSE file; where the CLIP section and the bicubic come from), the kernel count in the recipe doc, and the recipe's and CHANGELOG's wording (the single pages are the claim; the document is reported beside them). On it, on M5 Ultra, macOS, Zig 0.17.0, these all pass:zig fmt --check,git diff --check, the lean checker (386 problems, as main has; 0 new),zig build test,test-golden,test-kernel-contract(launch map 724 registered, 0 missing),test-server-security test-pull-security,check-release-paths,fuzz-cortheon,dev-tools,test-uocrandtest-native.tf-kernel-contract --only uocrgives the SUMMARY above.tf-uocr-decodeon the six cases from fresh dumps (uocr_reference.py dump, torch 2.15.0 on the CPU): the five single pages pass with the departures in the table,--their-embedspasses on all six, and the document fails the reply gate without--reply-reported(exit 1) and is reported with it. The recipe harness'socrplan on the served build (the table under Results): all six replies byte-identical to the 6 Nov run's on 3ced46888, each case the same again and as two requests at once; decode 282-301 tok/s with GLM-5.3-Flash's server loaded idle beside it (303-308 on 6 Nov beside Ornith).atc overlap --jsonon this head: 59 changed files; 927 open PRs share a file with it (the registration lists,THIRD_PARTY_NOTICES.md,CHANGELOG.md), none in the family's own folders. Five report a conflict in one file each, among the three registration files they share with it (build/metal_targets.zig,native/metal.zig,tensorfold.zig: akol1's qwen27 pair series, #1434-#1437 and #2529); the others apply clean, or were past atc's pull-files limit.Known limits
--vision-max-imagesraises that to 64. Each page takes gundam mode when its tokens fit, else base mode.