Add local Transformers inference for CUDA / Apple Silicon / CPU (fixes silent empty output on MPS) - #56
Add local Transformers inference for CUDA / Apple Silicon / CPU (fixes silent empty output on MPS)#56lucastsui wants to merge 2 commits into
Conversation
The released modeling_unlimitedocr.py hardcodes CUDA (14 .cuda() calls and
3 autocast("cuda") blocks), so the model cannot run on Apple Silicon or CPU.
Naively replacing .cuda() with .to(device) is not enough: on the MPS backend
masked_scatter_ silently mis-scatters non-contiguous sources, scrambling the
injected image embeddings so the model emits an immediate end-of-sequence
token and returns empty output with no error.
This adds an additive local-inference path:
- patch_model_for_local.py: patches a local snapshot; replaces the injection
with explicit positional assignment (identical math, correct on CUDA, MPS,
and CPU) and makes every hardcoded CUDA call follow the model's device.
Asserts on the exact released code so it fails loudly rather than
mis-patching, and is idempotent.
- infer_transformers.py: single-process inference via transformers on
cuda / mps / cpu, mirroring infer.py's --image_dir / --pdf interface and
gundam/base modes.
- tests/test_local_patch.py: verifies the positional-assignment replacement
is exactly equivalent to masked_scatter_ semantics for contiguous and
non-contiguous sources, and correct on MPS when available.
- README: a short Local inference section.
Validated on an M4 Max (48 GB): the patched pristine snapshot transcribes a
page of the Unlimited-OCR paper correctly on MPS (645 tokens, ~35 tok/s),
where the unpatched port returns empty output; pytest 3/3.
|
Diagnosis matches exactly what we found in #18 and documented in our HF Hub fix (Discussion #5): the inputs_embeds[idx].masked_scatter_(images_seq_mask[idx].unsqueeze(-1).cuda(), images_in_this_batch)is the actual point of failure on MPS, not just the Comparing the two fix mechanismsOur HF Hub PR keeps
Given your quantitative validation is stronger (isolated divergence test + full 14-page document run), I'd lean toward this PR's approach being the safer merge candidate if the maintainers pick one. One thing worth cross-checkingPR #57 (also just opened, Mac support) independently found that Overall: solid, well-tested contribution. The idempotent, fail-loud patch script design is the right call for a script that mutates a downloaded model snapshot. |
|
To answer the autocast question directly, the MPS path runs autocast-free by construction. infer_transformers.py itself never calls torch.autocast, and patch_model_for_local.py rewrites the three hardcoded torch.autocast("cuda", ...) sites in modeling_unlimitedocr.py to torch.autocast(self.device.type, dtype=torch.bfloat16, enabled=(self.device.type == "cuda")), so on MPS the context manager is constructed but disabled. Weights and inputs are already bf16, so autocast only ever mattered for CUDA mixed-precision behavior, and gating it rather than deleting it keeps the CUDA path identical to upstream. That means the bbox-loop failure mode from #57 cannot trigger here, which matches the observed behavior of the 14-page run staying loop-free end to end. Since you flagged #57, I also checked its other cross-platform claim, that transformers 4.57 silently ignores logits_processor entries that do not subclass LogitsProcessor. This matters for this PR because upstream's SlidingWindowNoRepeatNgramProcessor is a plain class and infer_transformers.py passes it on every run with no_repeat_ngram_size=35 and ngram_window=128. On transformers 4.57.1 the claim does not reproduce. GenerationMixin._merge_criteria_processor_list appends custom processors unconditionally, with no isinstance filter on the custom list, and LogitsProcessorList.call duck-types, so a two-argument call(input_ids, scores) takes the plain call branch. import torch
from transformers.generation.logits_process import LogitsProcessorList
from transformers.generation.utils import GenerationMixin
class Plain: # deliberately NOT a LogitsProcessor subclass
def __init__(self): self.hits = 0
def __call__(self, input_ids, scores):
self.hits += 1
return scores
p = Plain()
LogitsProcessorList([p])(torch.zeros(1, 5, dtype=torch.long), torch.zeros(1, 10))
assert p.hits == 1 # applied, not ignored
merged = GenerationMixin._merge_criteria_processor_list(None, LogitsProcessorList(), [p])
assert p in merged # survives generate()'s mergeBoth assertions pass on 4.57.1. So the ngram guard is active in this PR's runs as-is, which is likely one more reason the long run stays clean. Subclassing LogitsProcessor is harmless hygiene, but before treating the silent-ignore as a repo bug it would be good to know which transformers version #57 observed it on. One more observation from reading #57 with your mechanism comparison in mind. It does not identify the injection-site bug at all. The only masked_scatter_ occurrence in its 6.2k-line diff is the unmodified upstream line, which its blanket rewrite of .cuda() calls to .to('mps') touches only incidentally. That may well land the same mask-device fix as your HF #5, which would explain their correct output, but it seems worth confirming before the two PRs are treated as equivalent on correctness. On the two fix mechanisms, agreed with your read, and I would weight one factor more heavily than diff size. Positional assignment also survives any second latent bug in the same op family, and the MPS track record here with masked_scatter_ and autocast suggests that is a live risk. |
Changes: - Remove `class SlidingWindowNoRepeatNgramProcessor(LogitsProcessor):` → plain class. Confirmed no-op: transformers 4.57's LogitsProcessorList uses duck typing, no isinstance gate. Removing the inheritance costs nothing and removes a non-essential change for upstream maintainers to review. - Remove unused `from transformers import LogitsProcessor` import. - Add comment to the autocast-removal site explaining the ablation: 6 variants tested (autocast in infer-only / both / bf16 / fp16 / no autocast). The "retarget device string" approach (PR baidu#56 / HF Hub baidu#5) does not work on Mac when autocast is also present in the forward() image processing path — it produces bbox coordinate degeneration (100100100...). Removing autocast entirely in the generation paths is what works. Net effect: PR diff becomes slightly smaller (one fewer change to review). Same end-user behavior on MPS.
|
Your Short version: Your gate sidesteps this cleanly: with Also, nice catch on #57's diff not actually touching the |
|
Your masked_scatter_ question caught a real error in this PR's description, so taking that first. The description attributed the failure at the injection site to the source tensor's layout. For this call site that attribution was wrong. The 155.0 divergence is a real trigger, but it is not the one firing in Unlimited-OCR. On the upstream line the source is contiguous and the mask is broadcast.
An exact mimic of the real call (cat-built source, That answers your question directly. Grid repro (runs on any Mac with MPS)import torch
T, H, n = 16, 32, 6 # band rows 3:9
def source(kind, dtype):
s = (torch.arange(n * H, dtype=torch.float32).reshape(n, H) + 1).to(dtype)
if kind == "contig": return s
s = s.t().contiguous().t()
assert not s.is_contiguous()
return s
def mask(kind, dev):
band = torch.zeros(T, dtype=torch.bool, device=dev); band[3:3+n] = True
return {"materialized": band.unsqueeze(-1).expand(T, H).contiguous(),
"unsqueeze[T,1]": band.unsqueeze(-1), # upstream exact
"expand-stride0": band.unsqueeze(-1).expand(T, H),
"mat-noncontig": band.unsqueeze(0).expand(H, T).contiguous().t()}[kind]
def cell(mk, sk, dtype):
out = {}
for dev in ("cpu", "mps"):
dst = torch.zeros(T, H, dtype=dtype, device=dev)
dst.masked_scatter_(mask(mk, dev), source(sk, dtype).to(dev))
out[dev] = dst.cpu().float()
return (out["cpu"] - out["mps"]).abs().max().item()
for dtype in (torch.float32, torch.bfloat16):
for mk in ("materialized", "unsqueeze[T,1]", "expand-stride0", "mat-noncontig"):
print(dtype, f"{mk:15s}", [cell(mk, sk, dtype) for sk in ("contig", "noncontig")])
# exact real-call layout: cat-built (contiguous) source, [T,1] mask, batch-view dst
ref = {}
for dev in ("cpu", "mps"):
emb = torch.zeros(2, T, H, dtype=torch.bfloat16, device=dev)
sm = torch.zeros(2, T, dtype=torch.bool, device=dev); sm[0, 3:3+n] = True
src = torch.cat([torch.arange(4*H, dtype=torch.float32).reshape(4, H) + 1,
torch.arange(2*H, dtype=torch.float32).reshape(2, H) + 200]
).to(torch.bfloat16).to(dev)
assert src.is_contiguous()
emb[0].masked_scatter_(sm[0].unsqueeze(-1), src)
ref[dev] = emb.cpu().float()
print("real-call divergence:", (ref["cpu"] - ref["mps"]).abs().max().item()) # 264.0On the autocast trace, the proposed mechanism cannot fire under a retargeted MPS context, because the branch's guard is the same legacy CUDA-scoped accessor as its payload. Measured on torch 2.10, inside an enabled Two further blocks make it moot for this checkpoint regardless. The fallback lives only in |
The masked_scatter_ trigger at the injection call is the stride-0 broadcast mask from .unsqueeze(-1), not source contiguity (the source is torch.cat output and always contiguous). Test the positional- assignment replacement against the exact real layout ([B,T,H] batch view destination, [T,1] broadcast mask, cat-built sources) on CPU and MPS, fp32 and bf16, and correct the trigger attribution in patch_model_for_local.py's comments.
|
@lucastsui that grid is thorough and it caught something real — thanks for running it rather than trusting the PR description (mine included). You asked whether HF Hub #5 materializes the mask or leaves it as a stride-0 broadcast. Checked the actual patch — it's the latter: # HF Hub Discussion #5 (kushdab), the fix as posted:
_mask = images_seq_mask[idx].unsqueeze(-1).expand_as(inputs_embeds[idx]).to(inputs_embeds.device)
inputs_embeds[idx].masked_scatter_(_mask, images_in_this_batch)
The actual fix per your grid is On the autocast side: agreed with your read, and separately confirmed with Konsn666 on #57 that the |
Problem
The released
modeling_unlimitedocr.pyhardcodes CUDA (14.cuda()calls, 3torch.autocast("cuda", ...)blocks), so the model cannot run on Apple Silicon (MPS) or CPU at all.Replacing
.cuda()with.to(device)is not sufficient. On the MPS backend,torch.Tensor.masked_scatter_is silently wrong under two independent conditions, and the image-embedding injection athttps://github.com/baidu/Unlimited-OCR (HF snapshot,
modeling_unlimitedocr.py, theinputs_embeds[idx].masked_scatter_(...)line)hits one of them. The mask at that call is
images_seq_mask[idx].unsqueeze(-1), a[T, 1]tensor the op broadcasts across the hidden dim with stride 0, and a stride-0 (broadcast) mask mis-scatters even with a contiguous source. The source at that call is contiguous, sinceimages_in_this_batchcomes fromtorch.cat, which always allocates a fresh contiguous tensor (including for a single-element list). A non-contiguous source is a second, independent trigger of the same op bug. Measured divergence of the op against the CPU reference on MPS (isolated reproduction; float32 / float16 / bfloat16 all identical):[T,H], contiguous[T,1]viaunsqueeze(-1)(the injection call's exact layout)[T,H]stride-0expand[T,H], non-contiguous stridesAn exact mimic of the injection call (cat-built source,
[T,1]mask, destination a batch view) diverges 264.0. Either trigger scrambles the visual tokens with no error raised, and the model emits an immediate end-of-sequence token: empty output, silently, for every image.(An earlier revision of this description attributed the failure at this call site to source non-contiguity. That is a real trigger, but the one firing in this model is the broadcast mask; see the grid discussion in the comments, prompted by kushdab's question. The fix is unaffected either way: positional assignment avoids the op, and with it both triggers.)
What this PR adds (additive only, nothing existing is touched)
patch_model_for_local.py— patches a local snapshot for device-agnostic use: replaces the injection with explicit positional assignment (mathematically identical; correct on CUDA, MPS, and CPU) and makes every hardcoded CUDA call follow the model's device. It asserts on the exact released code (1 injection fix + 19 counted device edits) so it fails loudly instead of mis-patching if the file changes, and it is idempotent.infer_transformers.py— single-process local inference via 🤗 Transformers oncuda/mps/cpu, mirroringinfer.py's--image_dir/--pdfinterface and thegundam/baseimage modes.tests/test_local_patch.py— unit tests that the positional-assignment replacement is exactly equivalent tomasked_scatter_semantics for contiguous and non-contiguous sources (CPU reference), and correct on MPS when available. The tests assert our replacement is right rather than that the upstream op is wrong, so they stay green if PyTorch fixes the underlying issue. The matrix includes the injection call's exact layout: [B,T,H] batch-view destination, [T,1] broadcast mask, cat-built source.Validation
pytest tests/test_local_patch.py: 5/5 pass (including the MPS cases, fp32 and bf16).Notes
modeling_unlimitedocr.py(it is correct on CUDA too); happy to open that as a Hub PR instead or in addition, whichever you prefer.Open to maintainer direction on scope and placement.