emelryan commited on
Commit
0fc3ebf
·
1 Parent(s): bcba463

Reduce recognizer VRAM by discarding logits per chunk

Browse files

Computes argmax + softmax probability of the winning token inside
the recognizer chunk loop and discards the full [N, T, num_tokens]
logits tensor immediately, reducing peak VRAM.

Also converts region vertices to numpy during graph_build to avoid
per-region .cpu() calls in post-processing.

nemotron-ocr/src/nemotron_ocr/inference/pipeline_v2.py CHANGED
@@ -409,23 +409,28 @@ class NemotronOCRV2(NemotronOCR):
409
  """Run recognizer in fixed-size region chunks.
410
 
411
  Every chunk is padded to exactly ``recognizer_chunk_size`` regions
412
- so CUDA always sees the same tensor shape — eliminates kernel
413
- recompilation and cuBLAS workspace re-allocation.
 
 
 
414
 
415
  Returns:
416
- rec_output: (N_regions, T, C)
417
- rec_features: (N_regions, T, D)
 
418
  """
419
  n = rec_quads.shape[0]
420
  if n == 0:
421
  device = rec_quads.device
422
  return (
423
- torch.empty(0, self.max_width, self.num_tokens, dtype=torch.float16, device=device),
 
424
  torch.empty(0, self.max_width, self.recognizer.feature_depth, dtype=torch.float16, device=device),
425
  )
426
 
427
  cs = self.recognizer_chunk_size
428
- out_parts, feat_parts = [], []
429
  with amp.autocast("cuda", enabled=True), torch.inference_mode():
430
  for start in range(0, n, cs):
431
  end = min(start + cs, n)
@@ -434,13 +439,21 @@ class NemotronOCRV2(NemotronOCR):
434
  if real_n < cs:
435
  pad = torch.zeros(cs - real_n, *chunk.shape[1:], dtype=chunk.dtype, device=chunk.device)
436
  chunk = torch.cat([chunk, pad], dim=0)
437
- o, f = self.recognizer(chunk)
438
- out_parts.append(o[:real_n])
439
- feat_parts.append(f[:real_n])
 
 
 
 
 
 
 
440
 
441
- if len(out_parts) == 1:
442
- return out_parts[0], feat_parts[0]
443
- return torch.cat(out_parts, dim=0), torch.cat(feat_parts, dim=0)
 
444
 
445
  # ------------------------------------------------------------------
446
  # Phase 6 — Relational model + build output dict
@@ -453,12 +466,17 @@ class NemotronOCRV2(NemotronOCR):
453
  region_counts,
454
  region_counts_cpu,
455
  rec_features,
456
- rec_output,
 
457
  e2e_det_conf,
458
  confidence,
459
  padded_lengths,
460
  ):
461
- """Run relational model and assemble the output dict used by post-processing."""
 
 
 
 
462
  with amp.autocast("cuda", enabled=True), torch.inference_mode():
463
  rel_output = self.relational(
464
  rel_quads,
@@ -481,12 +499,9 @@ class NemotronOCRV2(NemotronOCR):
481
  scale_per_region = torch.repeat_interleave(scale_factors, counts_long, dim=0)
482
  quads_scaled = quads_cuda * scale_per_region.view(-1, 1, 1)
483
 
484
- rec_logits = rec_output.float()
485
- log_norm = rec_logits.logsumexp(dim=2, keepdim=True)
486
- seq_ids = rec_logits.argmax(dim=2)
487
- seq_probs = (rec_logits.gather(2, seq_ids.unsqueeze(2)) - log_norm).squeeze(2).exp()
488
 
489
- # Pre-compute text confidence on GPU (geometric mean of per-token probs)
490
  _before_eos = (seq_ids == 1).cumsum(dim=1) == 0
491
  _real = (seq_ids != 0) & _before_eos
492
  _counts = _real.sum(dim=1).clamp(min=1).float()
@@ -510,48 +525,19 @@ class NemotronOCRV2(NemotronOCR):
510
  "e2e_recog_features": rec_features,
511
  }
512
 
513
- return output, rec_output
514
 
515
  # ------------------------------------------------------------------
516
  # Phase 7 — Post-process
517
  # ------------------------------------------------------------------
518
 
519
- def _decode_with_fallback(self, output, rec_output):
520
- """Call recog_encoder, falling back to full softmax if needed.
521
-
522
- The local encoder accepts pre-computed argmax indices (dim=2) for
523
- efficiency. The HuggingFace encoder only accepts full softmax
524
- probabilities (dim=3). We try the fast path first and cache the
525
- result so subsequent calls skip the try/except.
526
- """
527
- if getattr(self, "_use_full_softmax", False):
528
- output = dict(output)
529
- output["sequences"] = F.softmax(rec_output.float(), dim=2)
530
- output.pop("sequence_probs", None)
531
- return self.recog_encoder.convert_targets_to_labels(
532
- output, image_size=None, is_gt=False,
533
- )
534
-
535
- try:
536
- batch = self.recog_encoder.convert_targets_to_labels(
537
- output, image_size=None, is_gt=False,
538
- )
539
- self._use_full_softmax = False
540
- return batch
541
- except (ValueError, RuntimeError):
542
- logger.info(
543
- "Encoder does not support argmax sequences; "
544
- "falling back to full softmax (one-time detection)."
545
- )
546
- self._use_full_softmax = True
547
- output = dict(output)
548
- output["sequences"] = F.softmax(rec_output.float(), dim=2)
549
- output.pop("sequence_probs", None)
550
- return self.recog_encoder.convert_targets_to_labels(
551
- output, image_size=None, is_gt=False,
552
- )
553
 
554
- def _postprocess_batch(self, output, rec_output, original_shapes, merge_level, include_invalid, timings=None):
555
  """Decode sequences, build relation graphs, format per-image results."""
556
  import gc
557
  gc_was_enabled = gc.isenabled()
@@ -559,17 +545,17 @@ class NemotronOCRV2(NemotronOCR):
559
 
560
  try:
561
  return self._postprocess_batch_inner(
562
- output, rec_output, original_shapes, merge_level, include_invalid, timings,
563
  )
564
  finally:
565
  if gc_was_enabled:
566
  gc.enable()
567
 
568
- def _postprocess_batch_inner(self, output, rec_output, original_shapes, merge_level, include_invalid, timings=None):
569
  _t = time.perf_counter
570
 
571
  t0 = _t()
572
- batch = self._decode_with_fallback(output, rec_output)
573
  recog_decode_ms = (_t() - t0) * 1000
574
 
575
  t0 = _t()
@@ -595,7 +581,8 @@ class NemotronOCRV2(NemotronOCR):
595
 
596
  for example in batch:
597
  for text_region in example:
598
- text_region.region = text_region.region.vertices
 
599
  graph_build_ms = (_t() - t0) * 1000
600
 
601
  t0 = _t()
@@ -679,7 +666,12 @@ class NemotronOCRV2(NemotronOCR):
679
 
680
  # Phase 2.5: centerness + peak prefilter
681
  if self._use_prefilter:
 
682
  det_conf = self._prefilter_detections(det_conf, det_rboxes)
 
 
 
 
683
 
684
  # Phase 3: NMS
685
  t0 = time.perf_counter()
@@ -709,22 +701,23 @@ class NemotronOCRV2(NemotronOCR):
709
  torch.cuda.synchronize()
710
  T["rectify"] = (time.perf_counter() - t0) * 1000
711
 
712
- # Phase 5: recognizer
713
  t0 = time.perf_counter()
714
- rec_output, rec_features = self._run_recognizer_chunked(rec_quads)
715
  del rec_quads
716
  torch.cuda.synchronize()
717
  T["recognizer"] = (time.perf_counter() - t0) * 1000
718
 
719
  # Phase 6: relational model
720
  t0 = time.perf_counter()
721
- output, rec_output_raw = self._run_relational_and_build_output(
722
  rel_quads,
723
  quads_cuda,
724
  region_counts,
725
  region_counts_cpu,
726
  rec_features,
727
- rec_output,
 
728
  e2e_det_conf,
729
  confidence,
730
  padded_lengths,
@@ -736,7 +729,7 @@ class NemotronOCRV2(NemotronOCR):
736
  # Phase 7: postprocess (broken down into sub-phases)
737
  t0 = time.perf_counter()
738
  results = self._postprocess_batch(
739
- output, rec_output_raw, original_shapes, merge_level, include_invalid, timings=T,
740
  )
741
  T["post_total"] = (time.perf_counter() - t0) * 1000
742
 
@@ -747,13 +740,13 @@ class NemotronOCRV2(NemotronOCR):
747
 
748
  logger.info(
749
  "batch=%d regions=%d(%s) "
750
- "img_load=%.1f gpu_pre=%.1f det=%.1f nms=%.1f "
751
  "rectify=%.1f recog=%.1f rel=%.1f "
752
  "post[decode=%.1f rel=%.1f graph=%.1f fmt=%.1f]=%.1f "
753
  "total=%.1f ms",
754
  num_images, total_regions, counts_str,
755
  T.get("img_load", 0), T.get("gpu_preproc", 0),
756
- T["detector"], T["nms"],
757
  T["rectify"], T["recognizer"], T["relational"],
758
  T.get("post_recog_decode", 0), T.get("post_rel_decode", 0),
759
  T.get("post_graph_build", 0), T.get("post_format", 0),
 
409
  """Run recognizer in fixed-size region chunks.
410
 
411
  Every chunk is padded to exactly ``recognizer_chunk_size`` regions
412
+ so CUDA always sees the same tensor shape.
413
+
414
+ Computes argmax + softmax probability of the winning token inside
415
+ each chunk loop and discards the full logits immediately, reducing
416
+ peak VRAM from ``[N, T, num_tokens]`` to ``[chunk_size, T, num_tokens]``.
417
 
418
  Returns:
419
+ rec_ids: (N_regions, T) int64 — argmax token indices
420
+ rec_probs: (N_regions, T) float32 — softmax probability of winning token
421
+ rec_features: (N_regions, T, D) — transformer features for relational model
422
  """
423
  n = rec_quads.shape[0]
424
  if n == 0:
425
  device = rec_quads.device
426
  return (
427
+ torch.empty(0, self.max_width, dtype=torch.int64, device=device),
428
+ torch.empty(0, self.max_width, dtype=torch.float32, device=device),
429
  torch.empty(0, self.max_width, self.recognizer.feature_depth, dtype=torch.float16, device=device),
430
  )
431
 
432
  cs = self.recognizer_chunk_size
433
+ ids_parts, probs_parts, feat_parts = [], [], []
434
  with amp.autocast("cuda", enabled=True), torch.inference_mode():
435
  for start in range(0, n, cs):
436
  end = min(start + cs, n)
 
439
  if real_n < cs:
440
  pad = torch.zeros(cs - real_n, *chunk.shape[1:], dtype=chunk.dtype, device=chunk.device)
441
  chunk = torch.cat([chunk, pad], dim=0)
442
+ logits, feats = self.recognizer(chunk)
443
+
444
+ ids = logits.argmax(dim=2)
445
+ probs = torch.softmax(logits, dim=2).gather(
446
+ 2, ids.unsqueeze(2)
447
+ ).squeeze(2).float()
448
+
449
+ ids_parts.append(ids[:real_n])
450
+ probs_parts.append(probs[:real_n])
451
+ feat_parts.append(feats[:real_n])
452
 
453
+ def _cat_or_single(parts):
454
+ return parts[0] if len(parts) == 1 else torch.cat(parts, dim=0)
455
+
456
+ return _cat_or_single(ids_parts), _cat_or_single(probs_parts), _cat_or_single(feat_parts)
457
 
458
  # ------------------------------------------------------------------
459
  # Phase 6 — Relational model + build output dict
 
466
  region_counts,
467
  region_counts_cpu,
468
  rec_features,
469
+ rec_ids,
470
+ rec_probs,
471
  e2e_det_conf,
472
  confidence,
473
  padded_lengths,
474
  ):
475
+ """Run relational model and assemble the output dict.
476
+
477
+ Accepts pre-computed argmax ids and per-token probabilities from the
478
+ recognizer (avoids materializing the full logits tensor).
479
+ """
480
  with amp.autocast("cuda", enabled=True), torch.inference_mode():
481
  rel_output = self.relational(
482
  rel_quads,
 
499
  scale_per_region = torch.repeat_interleave(scale_factors, counts_long, dim=0)
500
  quads_scaled = quads_cuda * scale_per_region.view(-1, 1, 1)
501
 
502
+ seq_ids = rec_ids
503
+ seq_probs = rec_probs
 
 
504
 
 
505
  _before_eos = (seq_ids == 1).cumsum(dim=1) == 0
506
  _real = (seq_ids != 0) & _before_eos
507
  _counts = _real.sum(dim=1).clamp(min=1).float()
 
525
  "e2e_recog_features": rec_features,
526
  }
527
 
528
+ return output
529
 
530
  # ------------------------------------------------------------------
531
  # Phase 7 — Post-process
532
  # ------------------------------------------------------------------
533
 
534
+ def _decode_with_fallback(self, output):
535
+ """Decode recognized sequences using pre-computed argmax indices."""
536
+ return self.recog_encoder.convert_targets_to_labels(
537
+ output, image_size=None, is_gt=False,
538
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
 
540
+ def _postprocess_batch(self, output, original_shapes, merge_level, include_invalid, timings=None):
541
  """Decode sequences, build relation graphs, format per-image results."""
542
  import gc
543
  gc_was_enabled = gc.isenabled()
 
545
 
546
  try:
547
  return self._postprocess_batch_inner(
548
+ output, original_shapes, merge_level, include_invalid, timings,
549
  )
550
  finally:
551
  if gc_was_enabled:
552
  gc.enable()
553
 
554
+ def _postprocess_batch_inner(self, output, original_shapes, merge_level, include_invalid, timings=None):
555
  _t = time.perf_counter
556
 
557
  t0 = _t()
558
+ batch = self._decode_with_fallback(output)
559
  recog_decode_ms = (_t() - t0) * 1000
560
 
561
  t0 = _t()
 
581
 
582
  for example in batch:
583
  for text_region in example:
584
+ v = text_region.region.vertices
585
+ text_region.region = v.cpu().numpy() if hasattr(v, 'cpu') else np.asarray(v)
586
  graph_build_ms = (_t() - t0) * 1000
587
 
588
  t0 = _t()
 
666
 
667
  # Phase 2.5: centerness + peak prefilter
668
  if self._use_prefilter:
669
+ t0 = time.perf_counter()
670
  det_conf = self._prefilter_detections(det_conf, det_rboxes)
671
+ torch.cuda.synchronize()
672
+ T["prefilter"] = (time.perf_counter() - t0) * 1000
673
+ else:
674
+ T["prefilter"] = 0.0
675
 
676
  # Phase 3: NMS
677
  t0 = time.perf_counter()
 
701
  torch.cuda.synchronize()
702
  T["rectify"] = (time.perf_counter() - t0) * 1000
703
 
704
+ # Phase 5: recognizer (argmax + softmax prob per chunk, discards full logits)
705
  t0 = time.perf_counter()
706
+ rec_ids, rec_probs, rec_features = self._run_recognizer_chunked(rec_quads)
707
  del rec_quads
708
  torch.cuda.synchronize()
709
  T["recognizer"] = (time.perf_counter() - t0) * 1000
710
 
711
  # Phase 6: relational model
712
  t0 = time.perf_counter()
713
+ output = self._run_relational_and_build_output(
714
  rel_quads,
715
  quads_cuda,
716
  region_counts,
717
  region_counts_cpu,
718
  rec_features,
719
+ rec_ids,
720
+ rec_probs,
721
  e2e_det_conf,
722
  confidence,
723
  padded_lengths,
 
729
  # Phase 7: postprocess (broken down into sub-phases)
730
  t0 = time.perf_counter()
731
  results = self._postprocess_batch(
732
+ output, original_shapes, merge_level, include_invalid, timings=T,
733
  )
734
  T["post_total"] = (time.perf_counter() - t0) * 1000
735
 
 
740
 
741
  logger.info(
742
  "batch=%d regions=%d(%s) "
743
+ "img_load=%.1f gpu_pre=%.1f det=%.1f prefilt=%.1f nms=%.1f "
744
  "rectify=%.1f recog=%.1f rel=%.1f "
745
  "post[decode=%.1f rel=%.1f graph=%.1f fmt=%.1f]=%.1f "
746
  "total=%.1f ms",
747
  num_images, total_regions, counts_str,
748
  T.get("img_load", 0), T.get("gpu_preproc", 0),
749
+ T["detector"], T["prefilter"], T["nms"],
750
  T["rectify"], T["recognizer"], T["relational"],
751
  T.get("post_recog_decode", 0), T.get("post_rel_decode", 0),
752
  T.get("post_graph_build", 0), T.get("post_format", 0),