[draft]Optimize Nemotron OCR GPU execution for stream-aware serving

#8
nemotron-ocr/cpp/better_grid_sample/gpu_indirect_grid_sample.cu CHANGED
@@ -79,21 +79,18 @@ void single_ex_grid_sample_bilinear_kernel(const float *pInputImage,
79
  }
80
  }
81
 
82
- template<typename T>
83
  __global__
84
- void indirect_grid_sample_forward_bilinear_kernel(torch::PackedTensorAccessor32<T, 4> inputs,
85
- torch::PackedTensorAccessor32<T, 4> grid,
86
  torch::PackedTensorAccessor32<int64_t, 1> inputIndices,
87
- torch::PackedTensorAccessor32<T, 4> outputs)
88
  {
89
- static_assert(std::is_same<T, float>::value, "Currently only float32 is supported!");
90
- //typedef typename fp_promote<T>::type accum_t;
91
  typedef float accum_t;
92
- constexpr T NEG_ONE = -1;
93
- constexpr T ONE = 1;
94
- constexpr T ZERO = 0;
95
- constexpr T TWO = 2;
96
- constexpr T ZERO_PT_5 = 0.5;
97
  typedef decltype(inputs.stride(0)) index_t;
98
 
99
  const index_t n = blockDim.z * blockIdx.z + threadIdx.z;
@@ -148,12 +145,12 @@ void indirect_grid_sample_forward_bilinear_kernel(torch::PackedTensorAccessor32<
148
  for (index_t row = 0; row < 2; ++row) {
149
  #pragma unroll
150
  for (index_t col = 0; col < 2; ++col) {
151
- T Tpx = my_get_pixel_clamped(localInputs, inXint + col, inYint + row);
152
- opVal += rs[row] * ps[col] * Convert<T, accum_t>::LeftToRight(Tpx);
153
  }
154
  }
155
 
156
- outputs[n][c][outY][outX] = Convert<T, accum_t>::RightToLeft(opVal);
157
  }
158
 
159
  template<typename T>
@@ -230,14 +227,20 @@ void indirect_grid_sample_backward_bilinear_kernel(torch::PackedTensorAccessor64
230
 
231
  torch::Tensor gpu_indirect_grid_sample_forward(torch::Tensor input, torch::Tensor grid, torch::Tensor inputIndices, const std::string &method)
232
  {
233
- auto output = input.new_empty({ inputIndices.size(0), input.size(1), grid.size(1), grid.size(2) });
 
 
234
 
235
 
236
  if (method != "bilinear"s) {
237
  throw runtime_error("Only 'bilinear' sampling is currently supported!");
238
  }
239
 
240
- if (input.size(0) == 1 && input.is_contiguous() && grid.is_contiguous()) {
 
 
 
 
241
  uint32_t gridNumCells = grid.size(1) * grid.size(2);
242
  dim3 blockDim(32, 3, 1);
243
  dim3 gridDim(div_up(gridNumCells, blockDim.x),
@@ -259,11 +262,18 @@ torch::Tensor gpu_indirect_grid_sample_forward(torch::Tensor input, torch::Tenso
259
  dim3 gridDim(div_up(grid.size(1) * grid.size(2), blockDim.x),
260
  div_up(input.size(1), blockDim.y),
261
  div_up(inputIndices.size(0), blockDim.z));
262
- indirect_grid_sample_forward_bilinear_kernel KERNEL_ARG2(gridDim, blockDim) (
263
- input.packed_accessor32<float, 4>(),
264
- grid.packed_accessor32<float, 4>(),
265
- inputIndices.packed_accessor32<int64_t, 1>(),
266
- output.packed_accessor32<float, 4>()
 
 
 
 
 
 
 
267
  );
268
  }
269
 
 
79
  }
80
  }
81
 
82
+ template<typename input_t>
83
  __global__
84
+ void indirect_grid_sample_forward_bilinear_kernel(torch::PackedTensorAccessor32<input_t, 4> inputs,
85
+ torch::PackedTensorAccessor32<float, 4> grid,
86
  torch::PackedTensorAccessor32<int64_t, 1> inputIndices,
87
+ torch::PackedTensorAccessor32<float, 4> outputs)
88
  {
 
 
89
  typedef float accum_t;
90
+ constexpr float NEG_ONE = -1;
91
+ constexpr float ONE = 1;
92
+ constexpr float ZERO = 0;
93
+ constexpr float ZERO_PT_5 = 0.5;
 
94
  typedef decltype(inputs.stride(0)) index_t;
95
 
96
  const index_t n = blockDim.z * blockIdx.z + threadIdx.z;
 
145
  for (index_t row = 0; row < 2; ++row) {
146
  #pragma unroll
147
  for (index_t col = 0; col < 2; ++col) {
148
+ input_t inputPixel = my_get_pixel_clamped(localInputs, inXint + col, inYint + row);
149
+ opVal += rs[row] * ps[col] * Convert<input_t, accum_t>::LeftToRight(inputPixel);
150
  }
151
  }
152
 
153
+ outputs[n][c][outY][outX] = opVal;
154
  }
155
 
156
  template<typename T>
 
227
 
228
  torch::Tensor gpu_indirect_grid_sample_forward(torch::Tensor input, torch::Tensor grid, torch::Tensor inputIndices, const std::string &method)
229
  {
230
+ auto output = input.new_empty(
231
+ { inputIndices.size(0), input.size(1), grid.size(1), grid.size(2) },
232
+ input.options().dtype(torch::kFloat32));
233
 
234
 
235
  if (method != "bilinear"s) {
236
  throw runtime_error("Only 'bilinear' sampling is currently supported!");
237
  }
238
 
239
+ if (grid.scalar_type() != torch::kFloat32) {
240
+ throw std::runtime_error("The CUDA grid must have dtype float32!");
241
+ }
242
+
243
+ if (input.scalar_type() == torch::kFloat32 && input.size(0) == 1 && input.is_contiguous() && grid.is_contiguous()) {
244
  uint32_t gridNumCells = grid.size(1) * grid.size(2);
245
  dim3 blockDim(32, 3, 1);
246
  dim3 gridDim(div_up(gridNumCells, blockDim.x),
 
262
  dim3 gridDim(div_up(grid.size(1) * grid.size(2), blockDim.x),
263
  div_up(input.size(1), blockDim.y),
264
  div_up(inputIndices.size(0), blockDim.z));
265
+ AT_DISPATCH_FLOATING_TYPES_AND_HALF(
266
+ input.scalar_type(),
267
+ "gpu_indirect_grid_sample_forward",
268
+ ([&] {
269
+ typedef typename remap_half<scalar_t>::type input_t;
270
+ indirect_grid_sample_forward_bilinear_kernel KERNEL_ARG2(gridDim, blockDim) (
271
+ input.packed_accessor32<input_t, 4>(),
272
+ grid.packed_accessor32<float, 4>(),
273
+ inputIndices.packed_accessor32<int64_t, 1>(),
274
+ output.packed_accessor32<float, 4>()
275
+ );
276
+ })
277
  );
278
  }
279
 
nemotron-ocr/cpp/cuda_intellisense.cuh CHANGED
@@ -3,6 +3,10 @@
3
 
4
  #pragma once
5
 
 
 
 
 
6
  #if defined(__INTELLISENSE__) || !defined(__NVCC__)
7
  #ifndef KERNEL_ARG2
8
  #define KERNEL_ARG2(grid, block)
@@ -27,8 +31,10 @@ dim3 gridDim;
27
 
28
  #else
29
  #ifndef KERNEL_ARG2
30
- #define KERNEL_ARG2(grid, block) <<< grid, block >>>
31
- #define KERNEL_ARG3(grid, block, sh_mem) <<< grid, block, sh_mem >>>
 
 
32
  #define KERNEL_ARG4(grid, block, sh_mem, stream) <<< grid, block, sh_mem, stream >>>
33
  #endif
34
  #endif
 
3
 
4
  #pragma once
5
 
6
+ #ifdef __NVCC__
7
+ #include <ATen/cuda/CUDAContext.h>
8
+ #endif
9
+
10
  #if defined(__INTELLISENSE__) || !defined(__NVCC__)
11
  #ifndef KERNEL_ARG2
12
  #define KERNEL_ARG2(grid, block)
 
31
 
32
  #else
33
  #ifndef KERNEL_ARG2
34
+ #define KERNEL_ARG2(grid, block) \
35
+ <<< grid, block, 0, at::cuda::getCurrentCUDAStream().stream() >>>
36
+ #define KERNEL_ARG3(grid, block, sh_mem) \
37
+ <<< grid, block, sh_mem, at::cuda::getCurrentCUDAStream().stream() >>>
38
  #define KERNEL_ARG4(grid, block, sh_mem, stream) <<< grid, block, sh_mem, stream >>>
39
  #endif
40
  #endif
nemotron-ocr/cpp/local_ips/quad_all_2_all_dist_v2.cu CHANGED
@@ -143,7 +143,10 @@ torch::Tensor ragged_quad_all_2_all_distance_v2(torch::Tensor embedQuads, torch:
143
 
144
  auto csWorkPerExample = torch::cumsum(workPerExample, 0);
145
 
146
- int64_t totalWork = csWorkPerExample[-1].item<int64_t>();
 
 
 
147
 
148
  dim3 blockSize(16, 2);
149
  dim3 gridSize(div_up(totalWork, blockSize.y), 1);
 
143
 
144
  auto csWorkPerExample = torch::cumsum(workPerExample, 0);
145
 
146
+ // Launch from tensor metadata so we do not synchronize to read a CUDA
147
+ // scalar. The kernel already bounds-checks against the exact cumulative
148
+ // work count.
149
+ int64_t totalWork = embedQuads.size(0) * embedQuads.size(1) * embedQuads.size(1);
150
 
151
  dim3 blockSize(16, 2);
152
  dim3 gridSize(div_up(totalWork, blockSize.y), 1);
nemotron-ocr/cpp/non_maximal_suppression/cuda_non_maximal_suppression.cu CHANGED
@@ -3,6 +3,7 @@
3
 
4
  #include "non_maximal_suppression.h"
5
 
 
6
  #include <cooperative_groups.h>
7
  #include <cooperative_groups/reduce.h>
8
 
@@ -1028,6 +1029,7 @@ struct CollapseRowsResult {
1028
  torch::Tensor ExCounts;
1029
  torch::Tensor StridedMergeQuads;
1030
  int32_t TotalNumQuads;
 
1031
  // NOTE: This will only be available in Debug builds
1032
  torch::Tensor QuadIds;
1033
  int32_t ImageWidth;
@@ -1096,16 +1098,18 @@ CollapseRowsResult collapse_rows(
1096
  }
1097
  #endif
1098
 
1099
- // The final value in `counts` is actually to total number of quads for the entire batch
1100
- int32_t totalQuads = counts[-1].item<int32_t>();
 
 
 
 
1101
 
1102
  counts = counts.slice(/*dim=*/ 0, 0, counts.size(0) - 1);
1103
 
1104
- int64_t maxExCount;
1105
- if (counts.size(0) > 1) {
1106
- maxExCount = counts.max().item<int32_t>();
1107
- } else {
1108
- maxExCount = totalQuads;
1109
  }
1110
 
1111
  static bool s_sortOrder = false;
@@ -1119,7 +1123,15 @@ CollapseRowsResult collapse_rows(
1119
  rowMergeTensor = torch::gather(rowMergeTensor, /*dim=*/ 2, embOrder);
1120
  idsTensor = torch::gather(idsTensor, /*dim=*/ 1, order);
1121
 
1122
- return { counts, rowMergeTensor, totalQuads, idsTensor, imageWidth, imageHeight };
 
 
 
 
 
 
 
 
1123
  }
1124
 
1125
 
@@ -1247,12 +1259,7 @@ AdjacencyResult compute_all_to_all_adjacency(
1247
  {
1248
  torch::Tensor counts = collapseResult.ExCounts;
1249
 
1250
- int64_t maxExCount;
1251
- if (counts.size(0) > 1) {
1252
- maxExCount = counts.max().item<int32_t>();
1253
- } else {
1254
- maxExCount = collapseResult.TotalNumQuads;
1255
- }
1256
 
1257
  auto isStartTensor = torch::ones({ counts.size(0), maxExCount }, counts.options().dtype(torch::kBool));
1258
  auto adjCountsTensor = torch::zeros({ counts.size(0), maxExCount }, counts.options().dtype(torch::kInt32));
@@ -1595,7 +1602,7 @@ nms_result_t cuda_quad_non_maximal_suppression_impl(
1595
  torch::Tensor retQuads, retConf, regionCounts;
1596
 
1597
  {
1598
- CudaStoreTimer tTotal{msTotal, s_timerEnabled};
1599
  {
1600
  CudaStoreTimer t{msRowCollapse, s_timerEnabled && verbose && s_verboseLevel2};
1601
 
 
3
 
4
  #include "non_maximal_suppression.h"
5
 
6
+ #include <algorithm>
7
  #include <cooperative_groups.h>
8
  #include <cooperative_groups/reduce.h>
9
 
 
1029
  torch::Tensor ExCounts;
1030
  torch::Tensor StridedMergeQuads;
1031
  int32_t TotalNumQuads;
1032
+ int32_t MaxExCount;
1033
  // NOTE: This will only be available in Debug builds
1034
  torch::Tensor QuadIds;
1035
  int32_t ImageWidth;
 
1098
  }
1099
  #endif
1100
 
1101
+ // Copy this tiny counter vector once. Reading the total and maximum via
1102
+ // separate CUDA scalar operations would introduce two stream barriers and
1103
+ // launch an unnecessary device reduction.
1104
+ auto countsCpu = counts.cpu();
1105
+ auto countsCpuAcc = countsCpu.accessor<int32_t, 1>();
1106
+ int32_t totalQuads = countsCpuAcc[countsCpuAcc.size(0) - 1];
1107
 
1108
  counts = counts.slice(/*dim=*/ 0, 0, counts.size(0) - 1);
1109
 
1110
+ int32_t maxExCount = 0;
1111
+ for (int64_t i = 0; i < counts.size(0); ++i) {
1112
+ maxExCount = std::max(maxExCount, countsCpuAcc[i]);
 
 
1113
  }
1114
 
1115
  static bool s_sortOrder = false;
 
1123
  rowMergeTensor = torch::gather(rowMergeTensor, /*dim=*/ 2, embOrder);
1124
  idsTensor = torch::gather(idsTensor, /*dim=*/ 1, order);
1125
 
1126
+ return {
1127
+ counts,
1128
+ rowMergeTensor,
1129
+ totalQuads,
1130
+ maxExCount,
1131
+ idsTensor,
1132
+ imageWidth,
1133
+ imageHeight,
1134
+ };
1135
  }
1136
 
1137
 
 
1259
  {
1260
  torch::Tensor counts = collapseResult.ExCounts;
1261
 
1262
+ const int64_t maxExCount = collapseResult.MaxExCount;
 
 
 
 
 
1263
 
1264
  auto isStartTensor = torch::ones({ counts.size(0), maxExCount }, counts.options().dtype(torch::kBool));
1265
  auto adjCountsTensor = torch::zeros({ counts.size(0), maxExCount }, counts.options().dtype(torch::kInt32));
 
1602
  torch::Tensor retQuads, retConf, regionCounts;
1603
 
1604
  {
1605
+ CudaStoreTimer tTotal{msTotal, s_timerEnabled && verbose};
1606
  {
1607
  CudaStoreTimer t{msRowCollapse, s_timerEnabled && verbose && s_verboseLevel2};
1608
 
nemotron-ocr/src/nemotron_ocr/inference/encoders/relational_encoder.py CHANGED
@@ -20,6 +20,39 @@ from nemotron_ocr_cpp import dense_relations_to_graph as cpp_dense_relations_to_
20
  logging.getLogger("shapely.geos").setLevel(logging.FATAL)
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  class RelationalTargetEncoder(TargetEncoderBase):
24
  def __init__(self, input_size, amp_opt=0, is_train=True):
25
  super().__init__(input_size, amp_opt, False)
@@ -38,13 +71,14 @@ class RelationalTargetEncoder(TargetEncoderBase):
38
  if all_word_relations[0].dim() == 1:
39
  all_word_relations = [sparse_to_dense(gt_rel) for gt_rel in all_word_relations]
40
 
41
- all_word_relations_cpu = [r.cpu() if r is not None else r for r in all_word_relations]
42
 
43
  region_counts = region_counts.cpu()
44
  all_quads_cpu = all_quads.cpu()
45
  cs_region_counts = torch.cumsum(region_counts, 0)
46
 
47
- examples = []
 
48
  for i, word_relations_cpu in enumerate(all_word_relations_cpu):
49
  start_offset = cs_region_counts[i - 1] if i > 0 else 0
50
  end_offset = cs_region_counts[i]
@@ -55,14 +89,36 @@ class RelationalTargetEncoder(TargetEncoderBase):
55
  regions = [tr.TextRegion(Quadrangle(q), "") for q in quads]
56
  graph = None
57
  if end_offset > start_offset:
58
- graph = self.dense_relations_to_graph(
59
- word_relations_cpu, line_relations, line_unc, is_gt
 
 
 
 
 
 
 
 
60
  )
61
  else:
62
  graph = tr.RelationGraph()
63
 
64
- ex = tr.Example(regions, relation_graph=graph)
65
- examples.append(ex)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  if limit_idxs is not None:
68
  examples = [examples[idx] for idx in limit_idxs]
@@ -76,8 +132,31 @@ class RelationalTargetEncoder(TargetEncoderBase):
76
  line_log_uncertainty: torch.Tensor = None,
77
  is_gt=False,
78
  ):
79
- lines = [p[0] for p in cpp_dense_relations_to_graph(word_relations)]
80
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  dev = line_logits.device
82
  line_logits = line_logits.float()
83
 
@@ -91,14 +170,14 @@ class RelationalTargetEncoder(TargetEncoderBase):
91
  else:
92
  inv_uncertainty = torch.ones_like(line_logits)
93
 
94
- n_words = word_relations.shape[0]
95
-
96
  line_lengths = torch.tensor([len(line) for line in lines], dtype=torch.int64, device=dev)
97
  word_indices = torch.tensor(
98
  [word for line in lines for word in line], dtype=torch.int64, device=dev
99
  )
100
  line_ids = torch.repeat_interleave(
101
- torch.arange(len(lines), dtype=torch.int64, device=dev), line_lengths
 
 
102
  )
103
  w_idx_to_line_map = torch.empty(n_words, dtype=torch.int64, device=dev)
104
  w_idx_to_line_map[word_indices] = line_ids
@@ -156,7 +235,14 @@ class RelationalTargetEncoder(TargetEncoderBase):
156
  )
157
  line_to_line_probs = line_to_line_probs[:, 1:]
158
 
159
- rel_lines = set(tuple(p[0]) for p in cpp_dense_relations_to_graph(line_to_line_probs.cpu()))
 
 
 
 
 
 
 
160
 
161
  paragraphs = []
162
  for rel_line in rel_lines:
 
20
  logging.getLogger("shapely.geos").setLevel(logging.FATAL)
21
 
22
 
23
+ def _copy_tensors_to_cpu_batched(tensors):
24
+ """Copy CUDA tensors to pinned host memory with one wait per stream.
25
+
26
+ Calling ``Tensor.cpu()`` for each relation matrix serializes every D2H
27
+ transfer with the host. Stage all copies first so CUDA can process them as
28
+ one queue, then wait once before the CPU graph code dereferences them.
29
+ """
30
+ cpu_tensors = []
31
+ pending_streams = {}
32
+
33
+ for tensor in tensors:
34
+ if tensor is None or tensor.device.type == "cpu":
35
+ cpu_tensors.append(tensor)
36
+ continue
37
+
38
+ if tensor.device.type != "cuda":
39
+ cpu_tensors.append(tensor.cpu())
40
+ continue
41
+
42
+ with torch.cuda.device(tensor.device):
43
+ stream = torch.cuda.current_stream(tensor.device)
44
+ cpu_tensor = torch.empty_like(tensor, device="cpu", pin_memory=True)
45
+ cpu_tensor.copy_(tensor, non_blocking=True)
46
+
47
+ cpu_tensors.append(cpu_tensor)
48
+ pending_streams[(tensor.device.index, stream.cuda_stream)] = stream
49
+
50
+ for stream in pending_streams.values():
51
+ stream.synchronize()
52
+
53
+ return cpu_tensors
54
+
55
+
56
  class RelationalTargetEncoder(TargetEncoderBase):
57
  def __init__(self, input_size, amp_opt=0, is_train=True):
58
  super().__init__(input_size, amp_opt, False)
 
71
  if all_word_relations[0].dim() == 1:
72
  all_word_relations = [sparse_to_dense(gt_rel) for gt_rel in all_word_relations]
73
 
74
+ all_word_relations_cpu = _copy_tensors_to_cpu_batched(all_word_relations)
75
 
76
  region_counts = region_counts.cpu()
77
  all_quads_cpu = all_quads.cpu()
78
  cs_region_counts = torch.cumsum(region_counts, 0)
79
 
80
+ prepared_examples = []
81
+ pending_line_graphs = []
82
  for i, word_relations_cpu in enumerate(all_word_relations_cpu):
83
  start_offset = cs_region_counts[i - 1] if i > 0 else 0
84
  end_offset = cs_region_counts[i]
 
89
  regions = [tr.TextRegion(Quadrangle(q), "") for q in quads]
90
  graph = None
91
  if end_offset > start_offset:
92
+ lines = self._word_relations_to_lines(word_relations_cpu)
93
+ line_to_line_probs = self._build_line_relation_probs(
94
+ lines,
95
+ word_relations_cpu.shape[0],
96
+ line_relations,
97
+ line_unc,
98
+ is_gt,
99
+ )
100
+ pending_line_graphs.append(
101
+ (len(prepared_examples), lines, line_to_line_probs)
102
  )
103
  else:
104
  graph = tr.RelationGraph()
105
 
106
+ prepared_examples.append([regions, graph])
107
+
108
+ line_probs_cpu = _copy_tensors_to_cpu_batched(
109
+ [line_probs for _, _, line_probs in pending_line_graphs]
110
+ )
111
+ for (example_idx, lines, _), line_probs in zip(
112
+ pending_line_graphs, line_probs_cpu
113
+ ):
114
+ prepared_examples[example_idx][1] = self._line_probs_to_graph(
115
+ lines, line_probs
116
+ )
117
+
118
+ examples = [
119
+ tr.Example(regions, relation_graph=graph)
120
+ for regions, graph in prepared_examples
121
+ ]
122
 
123
  if limit_idxs is not None:
124
  examples = [examples[idx] for idx in limit_idxs]
 
132
  line_log_uncertainty: torch.Tensor = None,
133
  is_gt=False,
134
  ):
135
+ lines = self._word_relations_to_lines(word_relations)
136
+ line_to_line_probs = self._build_line_relation_probs(
137
+ lines,
138
+ word_relations.shape[0],
139
+ line_logits,
140
+ line_log_uncertainty,
141
+ is_gt,
142
+ )
143
+ line_to_line_probs_cpu = _copy_tensors_to_cpu_batched(
144
+ [line_to_line_probs]
145
+ )[0]
146
+ return self._line_probs_to_graph(lines, line_to_line_probs_cpu)
147
+
148
+ @staticmethod
149
+ def _word_relations_to_lines(word_relations):
150
+ return [p[0] for p in cpp_dense_relations_to_graph(word_relations)]
151
+
152
+ @staticmethod
153
+ def _build_line_relation_probs(
154
+ lines,
155
+ n_words,
156
+ line_logits,
157
+ line_log_uncertainty=None,
158
+ is_gt=False,
159
+ ):
160
  dev = line_logits.device
161
  line_logits = line_logits.float()
162
 
 
170
  else:
171
  inv_uncertainty = torch.ones_like(line_logits)
172
 
 
 
173
  line_lengths = torch.tensor([len(line) for line in lines], dtype=torch.int64, device=dev)
174
  word_indices = torch.tensor(
175
  [word for line in lines for word in line], dtype=torch.int64, device=dev
176
  )
177
  line_ids = torch.repeat_interleave(
178
+ torch.arange(len(lines), dtype=torch.int64, device=dev),
179
+ line_lengths,
180
+ output_size=word_indices.numel(),
181
  )
182
  w_idx_to_line_map = torch.empty(n_words, dtype=torch.int64, device=dev)
183
  w_idx_to_line_map[word_indices] = line_ids
 
235
  )
236
  line_to_line_probs = line_to_line_probs[:, 1:]
237
 
238
+ return line_to_line_probs
239
+
240
+ @staticmethod
241
+ def _line_probs_to_graph(lines, line_to_line_probs_cpu):
242
+ rel_lines = set(
243
+ tuple(p[0])
244
+ for p in cpp_dense_relations_to_graph(line_to_line_probs_cpu)
245
+ )
246
 
247
  paragraphs = []
248
  for rel_line in rel_lines:
nemotron-ocr/src/nemotron_ocr/inference/models/detector/aspp.py CHANGED
@@ -3,8 +3,145 @@
3
 
4
  """Atrous Spatial Pyramid Pooling implementation."""
5
 
 
 
6
  import torch
7
  from torch import nn
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
 
10
  def _grow(rate, power):
@@ -84,6 +221,9 @@ class ASPP(nn.Module):
84
  nn.ReLU(inplace=True),
85
  nn.Dropout(p=dropout),
86
  )
 
 
 
87
 
88
  def forward(self, x):
89
  """The module forward function.
@@ -96,12 +236,24 @@ class ASPP(nn.Module):
96
  """
97
  outs = [kernel(x) for kernel in self.kernels]
98
 
99
- global_pool = self.global_pool(x).expand(-1, -1, *x.shape[2:])
100
- outs.append(global_pool)
101
-
102
- concatenated = torch.cat(outs, dim=1)
 
 
103
 
104
- out = self.final(concatenated)
 
 
 
 
 
 
 
 
 
 
105
 
106
  if x.shape == out.shape:
107
  return x + out
 
3
 
4
  """Atrous Spatial Pyramid Pooling implementation."""
5
 
6
+ import os
7
+
8
  import torch
9
  from torch import nn
10
+ from torch.nn import functional as F
11
+
12
+ try:
13
+ import triton
14
+ import triton.language as tl
15
+ except ImportError:
16
+ triton = None
17
+ tl = None
18
+
19
+
20
+ if triton is not None:
21
+
22
+ @triton.jit
23
+ def _aspp_concat_kernel(
24
+ branch_0,
25
+ branch_1,
26
+ branch_2,
27
+ branch_3,
28
+ branch_4,
29
+ branch_5,
30
+ branch_6,
31
+ pooled,
32
+ output,
33
+ total_elements,
34
+ channels: tl.constexpr,
35
+ spatial_size: tl.constexpr,
36
+ BLOCK_SIZE: tl.constexpr,
37
+ ):
38
+ offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
39
+ valid = offsets < total_elements
40
+ spatial = offsets % spatial_size
41
+ quotient = offsets // spatial_size
42
+ output_channel = quotient % (8 * channels)
43
+ batch = quotient // (8 * channels)
44
+ branch = output_channel // channels
45
+ channel = output_channel % channels
46
+ source_offsets = (batch * channels + channel) * spatial_size + spatial
47
+
48
+ values = tl.load(
49
+ branch_0 + source_offsets,
50
+ mask=valid & (branch == 0),
51
+ other=0.0,
52
+ )
53
+ branch_values = tl.load(
54
+ branch_1 + source_offsets,
55
+ mask=valid & (branch == 1),
56
+ other=0.0,
57
+ )
58
+ values = tl.where(branch == 1, branch_values, values)
59
+ branch_values = tl.load(
60
+ branch_2 + source_offsets,
61
+ mask=valid & (branch == 2),
62
+ other=0.0,
63
+ )
64
+ values = tl.where(branch == 2, branch_values, values)
65
+ branch_values = tl.load(
66
+ branch_3 + source_offsets,
67
+ mask=valid & (branch == 3),
68
+ other=0.0,
69
+ )
70
+ values = tl.where(branch == 3, branch_values, values)
71
+ branch_values = tl.load(
72
+ branch_4 + source_offsets,
73
+ mask=valid & (branch == 4),
74
+ other=0.0,
75
+ )
76
+ values = tl.where(branch == 4, branch_values, values)
77
+ branch_values = tl.load(
78
+ branch_5 + source_offsets,
79
+ mask=valid & (branch == 5),
80
+ other=0.0,
81
+ )
82
+ values = tl.where(branch == 5, branch_values, values)
83
+ branch_values = tl.load(
84
+ branch_6 + source_offsets,
85
+ mask=valid & (branch == 6),
86
+ other=0.0,
87
+ )
88
+ values = tl.where(branch == 6, branch_values, values)
89
+ pooled_offsets = batch * channels + channel
90
+ branch_values = tl.load(
91
+ pooled + pooled_offsets,
92
+ mask=valid & (branch == 7),
93
+ other=0.0,
94
+ )
95
+ values = tl.where(branch == 7, branch_values, values)
96
+ tl.store(output + offsets, values, mask=valid)
97
+
98
+
99
+ def _aspp_concat(branches, pooled):
100
+ """Concatenate fixed ASPP branches without expanding the pooled tensor."""
101
+ if len(branches) != 7:
102
+ raise ValueError("fused ASPP concat expects seven spatial branches")
103
+
104
+ def eager_concat():
105
+ expanded_pooled = pooled.expand(-1, -1, *branches[0].shape[2:])
106
+ return torch.cat([*branches, expanded_pooled], dim=1)
107
+
108
+ if triton is None or not branches[0].is_cuda:
109
+ return eager_concat()
110
+
111
+ batch, channels, height, width = branches[0].shape
112
+ if pooled.shape != (batch, channels, 1, 1):
113
+ raise ValueError("fused ASPP pooled branch has an unexpected shape")
114
+ expected_shape = branches[0].shape
115
+ if (
116
+ any(not branch.is_contiguous() for branch in branches)
117
+ or not pooled.is_contiguous()
118
+ or any(branch.shape != expected_shape for branch in branches)
119
+ or any(branch.dtype != branches[0].dtype for branch in branches)
120
+ or any(branch.device != branches[0].device for branch in branches)
121
+ or pooled.dtype != branches[0].dtype
122
+ or pooled.device != branches[0].device
123
+ ):
124
+ # Channels-last and other non-standard layouts are valid eager inputs,
125
+ # but the Triton kernel indexes a dense NCHW allocation.
126
+ return eager_concat()
127
+ output = torch.empty(
128
+ (batch, 8 * channels, height, width),
129
+ dtype=branches[0].dtype,
130
+ device=branches[0].device,
131
+ )
132
+ total_elements = output.numel()
133
+ block_size = 4096
134
+ _aspp_concat_kernel[(triton.cdiv(total_elements, block_size),)](
135
+ *branches,
136
+ pooled,
137
+ output,
138
+ total_elements,
139
+ channels=channels,
140
+ spatial_size=height * width,
141
+ BLOCK_SIZE=block_size,
142
+ num_warps=8,
143
+ )
144
+ return output
145
 
146
 
147
  def _grow(rate, power):
 
221
  nn.ReLU(inplace=True),
222
  nn.Dropout(p=dropout),
223
  )
224
+ self._fused_concat = (
225
+ os.environ.get("NEMOTRON_OCR_FUSED_ASPP_CONCAT", "0") == "1"
226
+ )
227
 
228
  def forward(self, x):
229
  """The module forward function.
 
236
  """
237
  outs = [kernel(x) for kernel in self.kernels]
238
 
239
+ global_pool = self.global_pool(x)
240
+ if self._fused_concat:
241
+ concatenated = _aspp_concat(outs, global_pool)
242
+ else:
243
+ outs.append(global_pool.expand(-1, -1, *x.shape[2:]))
244
+ concatenated = torch.cat(outs, dim=1)
245
 
246
+ fused_norm_relu_add = getattr(self, "_fused_norm_relu_add", None)
247
+ if fused_norm_relu_add is not None:
248
+ out = self.final[0](concatenated)
249
+ if x.shape == out.shape:
250
+ out = fused_norm_relu_add(out, x)
251
+ return self.final[3](out)
252
+ # This model constructs residual ASPP modules, but retain a safe
253
+ # fallback if a future checkpoint changes the channel count.
254
+ out = self.final[3](F.relu(fused_norm_relu_add.batch_norm(out)))
255
+ else:
256
+ out = self.final(concatenated)
257
 
258
  if x.shape == out.shape:
259
  return x + out
nemotron-ocr/src/nemotron_ocr/inference/models/detector/fast_batch_norm.py ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Optional inference-only fused BatchNorm detector kernels.
5
+
6
+ Fusion is a serve-time transformation: load the original checkpoint first,
7
+ put the detector in eval mode, and only then call the helpers in this module.
8
+ The wrappers intentionally change module structure and therefore do not accept
9
+ the original checkpoint key layout after fusion.
10
+ """
11
+
12
+ import os
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+ import triton
18
+ import triton.language as tl
19
+ from triton.language.extra import libdevice
20
+
21
+
22
+ _VALID_NUM_WARPS = (1, 2, 4, 8)
23
+
24
+
25
+ def _batch_norm_launch_parameters() -> tuple[int, int]:
26
+ """Read and validate the launch configuration shared by every wrapper."""
27
+ try:
28
+ block_size = int(
29
+ os.environ.get("NEMOTRON_OCR_FUSED_BATCH_NORM_BLOCK_SIZE", "2048")
30
+ )
31
+ num_warps = int(os.environ.get("NEMOTRON_OCR_FUSED_BATCH_NORM_NUM_WARPS", "8"))
32
+ except ValueError as exc:
33
+ raise ValueError("fused BatchNorm launch parameters must be integers") from exc
34
+ if block_size <= 0 or block_size & (block_size - 1):
35
+ raise ValueError("fused BatchNorm block size must be a power of two")
36
+ if num_warps not in _VALID_NUM_WARPS:
37
+ raise ValueError(f"fused BatchNorm num warps must be one of {_VALID_NUM_WARPS}")
38
+ return block_size, num_warps
39
+
40
+
41
+ def _is_standard_eval_batch_norm(module: nn.Module) -> bool:
42
+ """Return whether a BatchNorm has every tensor required by the kernels."""
43
+ return (
44
+ isinstance(module, nn.BatchNorm2d)
45
+ and not module.training
46
+ and module.affine
47
+ and module.track_running_stats
48
+ and module.weight is not None
49
+ and module.bias is not None
50
+ and module.running_mean is not None
51
+ and module.running_var is not None
52
+ )
53
+
54
+
55
+ def _require_standard_eval_batch_norm(batch_norm: nn.Module) -> nn.BatchNorm2d:
56
+ if not _is_standard_eval_batch_norm(batch_norm):
57
+ raise ValueError(
58
+ "fused BatchNorm requires an affine, running-stat-tracked "
59
+ "BatchNorm2d already in eval mode"
60
+ )
61
+ return batch_norm
62
+
63
+
64
+ class _FusedBatchNormModule(nn.Module):
65
+ """Refresh nonpersistent derived buffers whenever a wrapper returns to eval."""
66
+
67
+ def _inverse_std_sources(self) -> tuple[tuple[str, nn.BatchNorm2d], ...]:
68
+ return ()
69
+
70
+ def _register_inverse_std(self, name: str, batch_norm: nn.BatchNorm2d) -> None:
71
+ self.register_buffer(
72
+ name,
73
+ torch.rsqrt(batch_norm.running_var + batch_norm.eps),
74
+ persistent=False,
75
+ )
76
+
77
+ @torch.no_grad()
78
+ def _refresh_inverse_std(self) -> None:
79
+ for name, batch_norm in self._inverse_std_sources():
80
+ refreshed = torch.rsqrt(batch_norm.running_var + batch_norm.eps)
81
+ current = getattr(self, name)
82
+ if (
83
+ current.shape == refreshed.shape
84
+ and current.dtype == refreshed.dtype
85
+ and current.device == refreshed.device
86
+ ):
87
+ current.copy_(refreshed)
88
+ else:
89
+ setattr(self, name, refreshed)
90
+
91
+ def train(self, mode: bool = True):
92
+ super().train(mode)
93
+ if not mode:
94
+ self._refresh_inverse_std()
95
+ return self
96
+
97
+
98
+ @triton.jit
99
+ def _batch_norm_relu_kernel(
100
+ input_ptr,
101
+ mean_ptr,
102
+ inverse_std_ptr,
103
+ weight_ptr,
104
+ bias_ptr,
105
+ output_ptr,
106
+ spatial_size: tl.constexpr,
107
+ channels: tl.constexpr,
108
+ BLOCK_SIZE: tl.constexpr,
109
+ ):
110
+ spatial_offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
111
+ batch_channel = tl.program_id(1)
112
+ channel = batch_channel % channels
113
+ offsets = batch_channel * spatial_size + spatial_offsets
114
+ valid = spatial_offsets < spatial_size
115
+ values = tl.load(input_ptr + offsets, mask=valid).to(tl.float32)
116
+ # These are scalar values shared by the whole spatial tile. A block mask
117
+ # is invalid for a scalar pointer in recent Triton releases and would also
118
+ # imply redundant vector loads.
119
+ mean = tl.load(mean_ptr + channel)
120
+ inverse_std = tl.load(inverse_std_ptr + channel)
121
+ weight = tl.load(weight_ptr + channel)
122
+ bias = tl.load(bias_ptr + channel)
123
+ # Match cuDNN's fp32 inference arithmetic exactly: apply the affine weight
124
+ # to the centered value first, then use a round-to-nearest fused
125
+ # multiply-add for inverse standard deviation and bias. Reassociating
126
+ # these products changes rare fp16 rounding boundaries and compounds
127
+ # through the detector.
128
+ normalized = libdevice.fma_rn((values - mean) * weight, inverse_std, bias)
129
+ rounded = normalized.to(tl.float16)
130
+ tl.store(output_ptr + offsets, tl.maximum(rounded, 0.0), mask=valid)
131
+
132
+
133
+ @triton.jit
134
+ def _batch_norm_add_relu_kernel(
135
+ main_ptr,
136
+ residual_ptr,
137
+ mean_ptr,
138
+ inverse_std_ptr,
139
+ weight_ptr,
140
+ bias_ptr,
141
+ output_ptr,
142
+ spatial_size: tl.constexpr,
143
+ channels: tl.constexpr,
144
+ BLOCK_SIZE: tl.constexpr,
145
+ ):
146
+ spatial_offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
147
+ batch_channel = tl.program_id(1)
148
+ channel = batch_channel % channels
149
+ offsets = batch_channel * spatial_size + spatial_offsets
150
+ valid = spatial_offsets < spatial_size
151
+
152
+ main = tl.load(main_ptr + offsets, mask=valid).to(tl.float32)
153
+ residual = tl.load(residual_ptr + offsets, mask=valid).to(tl.float32)
154
+ mean = tl.load(mean_ptr + channel)
155
+ inverse_std = tl.load(inverse_std_ptr + channel)
156
+ weight = tl.load(weight_ptr + channel)
157
+ bias = tl.load(bias_ptr + channel)
158
+
159
+ normalized = libdevice.fma_rn((main - mean) * weight, inverse_std, bias).to(
160
+ tl.float16
161
+ )
162
+ # The eager graph rounds BatchNorm to fp16, then performs an fp16
163
+ # residual add before ReLU. Preserve both rounding boundaries.
164
+ summed = (normalized.to(tl.float32) + residual).to(tl.float16)
165
+ tl.store(output_ptr + offsets, tl.maximum(summed, 0.0), mask=valid)
166
+
167
+
168
+ @triton.jit
169
+ def _batch_norm_relu_add_kernel(
170
+ input_ptr,
171
+ residual_ptr,
172
+ mean_ptr,
173
+ inverse_std_ptr,
174
+ weight_ptr,
175
+ bias_ptr,
176
+ output_ptr,
177
+ spatial_size: tl.constexpr,
178
+ channels: tl.constexpr,
179
+ BLOCK_SIZE: tl.constexpr,
180
+ ):
181
+ spatial_offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
182
+ batch_channel = tl.program_id(1)
183
+ channel = batch_channel % channels
184
+ offsets = batch_channel * spatial_size + spatial_offsets
185
+ valid = spatial_offsets < spatial_size
186
+ values = tl.load(input_ptr + offsets, mask=valid).to(tl.float32)
187
+ residual = tl.load(residual_ptr + offsets, mask=valid).to(tl.float32)
188
+ normalized = libdevice.fma_rn(
189
+ (values - tl.load(mean_ptr + channel)) * tl.load(weight_ptr + channel),
190
+ tl.load(inverse_std_ptr + channel),
191
+ tl.load(bias_ptr + channel),
192
+ ).to(tl.float16)
193
+ activated = tl.maximum(normalized, 0.0)
194
+ # ASPP applies its residual add after the fp16 ReLU.
195
+ summed = (activated.to(tl.float32) + residual).to(tl.float16)
196
+ tl.store(output_ptr + offsets, summed, mask=valid)
197
+
198
+
199
+ @triton.jit
200
+ def _dual_batch_norm_add_relu_kernel(
201
+ main_ptr,
202
+ residual_ptr,
203
+ main_mean_ptr,
204
+ main_inverse_std_ptr,
205
+ main_weight_ptr,
206
+ main_bias_ptr,
207
+ residual_mean_ptr,
208
+ residual_inverse_std_ptr,
209
+ residual_weight_ptr,
210
+ residual_bias_ptr,
211
+ output_ptr,
212
+ spatial_size: tl.constexpr,
213
+ channels: tl.constexpr,
214
+ BLOCK_SIZE: tl.constexpr,
215
+ ):
216
+ spatial_offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
217
+ batch_channel = tl.program_id(1)
218
+ channel = batch_channel % channels
219
+ offsets = batch_channel * spatial_size + spatial_offsets
220
+ valid = spatial_offsets < spatial_size
221
+
222
+ main = tl.load(main_ptr + offsets, mask=valid).to(tl.float32)
223
+ residual = tl.load(residual_ptr + offsets, mask=valid).to(tl.float32)
224
+
225
+ main_normalized = libdevice.fma_rn(
226
+ (main - tl.load(main_mean_ptr + channel)) * tl.load(main_weight_ptr + channel),
227
+ tl.load(main_inverse_std_ptr + channel),
228
+ tl.load(main_bias_ptr + channel),
229
+ ).to(tl.float16)
230
+ residual_normalized = libdevice.fma_rn(
231
+ (residual - tl.load(residual_mean_ptr + channel))
232
+ * tl.load(residual_weight_ptr + channel),
233
+ tl.load(residual_inverse_std_ptr + channel),
234
+ tl.load(residual_bias_ptr + channel),
235
+ ).to(tl.float16)
236
+
237
+ summed = (main_normalized.to(tl.float32) + residual_normalized.to(tl.float32)).to(
238
+ tl.float16
239
+ )
240
+ tl.store(output_ptr + offsets, tl.maximum(summed, 0.0), mask=valid)
241
+
242
+
243
+ class FusedBatchNormReLU(_FusedBatchNormModule):
244
+ """Evaluate an existing BatchNorm2d and ReLU in one Triton pass."""
245
+
246
+ def __init__(self, batch_norm: nn.BatchNorm2d):
247
+ super().__init__()
248
+ self.batch_norm = _require_standard_eval_batch_norm(batch_norm)
249
+ self._register_inverse_std("inverse_std", self.batch_norm)
250
+ self.block_size, self.num_warps = _batch_norm_launch_parameters()
251
+ # Replacement happens after the detector has been put in eval mode.
252
+ # New ``nn.Module`` instances otherwise default to training mode and
253
+ # silently route every inference call through the fallback below.
254
+ self.train(self.batch_norm.training)
255
+
256
+ def _inverse_std_sources(self):
257
+ return (("inverse_std", self.batch_norm),)
258
+
259
+ def forward(self, input_tensor):
260
+ if (
261
+ not input_tensor.is_cuda
262
+ or input_tensor.dtype != torch.float16
263
+ or not input_tensor.is_contiguous()
264
+ or self.batch_norm.training
265
+ ):
266
+ return F.relu(self.batch_norm(input_tensor), inplace=True)
267
+
268
+ _, channels, height, width = input_tensor.shape
269
+ output = torch.empty_like(input_tensor)
270
+ grid = (
271
+ triton.cdiv(height * width, self.block_size),
272
+ input_tensor.numel() // (height * width),
273
+ )
274
+ _batch_norm_relu_kernel[grid](
275
+ input_tensor,
276
+ self.batch_norm.running_mean,
277
+ self.inverse_std,
278
+ self.batch_norm.weight,
279
+ self.batch_norm.bias,
280
+ output,
281
+ spatial_size=height * width,
282
+ channels=channels,
283
+ BLOCK_SIZE=self.block_size,
284
+ num_warps=self.num_warps,
285
+ )
286
+ return output
287
+
288
+
289
+ class FusedBatchNormReLUAdd(_FusedBatchNormModule):
290
+ """Fuse BatchNorm, ReLU, then a residual add in exact eager order."""
291
+
292
+ def __init__(self, batch_norm: nn.BatchNorm2d):
293
+ super().__init__()
294
+ self.batch_norm = _require_standard_eval_batch_norm(batch_norm)
295
+ self._register_inverse_std("inverse_std", self.batch_norm)
296
+ self.block_size, self.num_warps = _batch_norm_launch_parameters()
297
+ self.train(self.batch_norm.training)
298
+
299
+ def _inverse_std_sources(self):
300
+ return (("inverse_std", self.batch_norm),)
301
+
302
+ def forward(self, input_tensor, residual):
303
+ if (
304
+ not input_tensor.is_cuda
305
+ or not residual.is_cuda
306
+ or input_tensor.dtype != torch.float16
307
+ or residual.dtype != torch.float16
308
+ or not input_tensor.is_contiguous()
309
+ or not residual.is_contiguous()
310
+ or input_tensor.shape != residual.shape
311
+ or self.batch_norm.training
312
+ ):
313
+ return residual + F.relu(self.batch_norm(input_tensor), inplace=True)
314
+
315
+ _, channels, height, width = input_tensor.shape
316
+ output = torch.empty_like(input_tensor)
317
+ grid = (
318
+ triton.cdiv(height * width, self.block_size),
319
+ input_tensor.numel() // (height * width),
320
+ )
321
+ _batch_norm_relu_add_kernel[grid](
322
+ input_tensor,
323
+ residual,
324
+ self.batch_norm.running_mean,
325
+ self.inverse_std,
326
+ self.batch_norm.weight,
327
+ self.batch_norm.bias,
328
+ output,
329
+ spatial_size=height * width,
330
+ channels=channels,
331
+ BLOCK_SIZE=self.block_size,
332
+ num_warps=self.num_warps,
333
+ )
334
+ return output
335
+
336
+
337
+ class FusedResidualBlock(_FusedBatchNormModule):
338
+ """Fuse final BatchNorm(s), residual add, and ReLU in a RegNet block."""
339
+
340
+ def __init__(self, block: nn.Module):
341
+ super().__init__()
342
+ if block.training:
343
+ raise ValueError("fused residual blocks require eval mode")
344
+ main_batch_norm = _require_standard_eval_batch_norm(block.f.c[-1])
345
+ projection_batch_norm = None
346
+ if block.proj is not None:
347
+ projection_batch_norm = _require_standard_eval_batch_norm(block.proj[-1])
348
+ block_size, num_warps = _batch_norm_launch_parameters()
349
+
350
+ self.transform = block.f
351
+ self.projection = block.proj
352
+ self.main_batch_norm = main_batch_norm
353
+ self.transform.c[-1] = nn.Identity()
354
+ self._register_inverse_std("main_inverse_std", self.main_batch_norm)
355
+
356
+ self.projection_batch_norm = projection_batch_norm
357
+ if self.projection is not None:
358
+ self.projection[-1] = nn.Identity()
359
+ self._register_inverse_std(
360
+ "projection_inverse_std",
361
+ self.projection_batch_norm,
362
+ )
363
+
364
+ self.block_size, self.num_warps = block_size, num_warps
365
+ self.train(block.training)
366
+
367
+ def _inverse_std_sources(self):
368
+ sources = [("main_inverse_std", self.main_batch_norm)]
369
+ if self.projection_batch_norm is not None:
370
+ sources.append(("projection_inverse_std", self.projection_batch_norm))
371
+ return tuple(sources)
372
+
373
+ def _can_fuse(self, main, residual):
374
+ return (
375
+ main.is_cuda
376
+ and residual.is_cuda
377
+ and main.dtype == torch.float16
378
+ and residual.dtype == torch.float16
379
+ and main.is_contiguous()
380
+ and residual.is_contiguous()
381
+ and main.shape == residual.shape
382
+ and not self.main_batch_norm.training
383
+ and (
384
+ self.projection_batch_norm is None
385
+ or not self.projection_batch_norm.training
386
+ )
387
+ )
388
+
389
+ def forward(self, input_tensor):
390
+ if self.projection is None:
391
+ residual = input_tensor
392
+ main = self.transform(input_tensor)
393
+ else:
394
+ # Preserve the original block's left-to-right evaluation order.
395
+ residual = self.projection(input_tensor)
396
+ main = self.transform(input_tensor)
397
+
398
+ if not self._can_fuse(main, residual):
399
+ main = self.main_batch_norm(main)
400
+ if self.projection_batch_norm is not None:
401
+ residual = self.projection_batch_norm(residual)
402
+ return F.relu(main + residual, inplace=True)
403
+
404
+ _, channels, height, width = main.shape
405
+ output = torch.empty_like(main)
406
+ grid = (
407
+ triton.cdiv(height * width, self.block_size),
408
+ main.numel() // (height * width),
409
+ )
410
+ if self.projection_batch_norm is None:
411
+ _batch_norm_add_relu_kernel[grid](
412
+ main,
413
+ residual,
414
+ self.main_batch_norm.running_mean,
415
+ self.main_inverse_std,
416
+ self.main_batch_norm.weight,
417
+ self.main_batch_norm.bias,
418
+ output,
419
+ spatial_size=height * width,
420
+ channels=channels,
421
+ BLOCK_SIZE=self.block_size,
422
+ num_warps=self.num_warps,
423
+ )
424
+ else:
425
+ _dual_batch_norm_add_relu_kernel[grid](
426
+ main,
427
+ residual,
428
+ self.main_batch_norm.running_mean,
429
+ self.main_inverse_std,
430
+ self.main_batch_norm.weight,
431
+ self.main_batch_norm.bias,
432
+ self.projection_batch_norm.running_mean,
433
+ self.projection_inverse_std,
434
+ self.projection_batch_norm.weight,
435
+ self.projection_batch_norm.bias,
436
+ output,
437
+ spatial_size=height * width,
438
+ channels=channels,
439
+ BLOCK_SIZE=self.block_size,
440
+ num_warps=self.num_warps,
441
+ )
442
+ return output
443
+
444
+
445
+ def fuse_batch_norm_relu(module: nn.Module) -> int:
446
+ """Replace eligible BatchNorm2d + ReLU pairs after checkpoint loading."""
447
+ replacements = 0
448
+ for child in list(module.children()):
449
+ replacements += fuse_batch_norm_relu(child)
450
+ if not isinstance(module, nn.Sequential):
451
+ return replacements
452
+
453
+ names = list(module._modules)
454
+ for batch_norm_name, relu_name in zip(names, names[1:]):
455
+ batch_norm = module._modules[batch_norm_name]
456
+ relu = module._modules[relu_name]
457
+ if _is_standard_eval_batch_norm(batch_norm) and isinstance(relu, nn.ReLU):
458
+ module._modules[batch_norm_name] = FusedBatchNormReLU(batch_norm)
459
+ module._modules[relu_name] = nn.Identity()
460
+ replacements += 1
461
+ return replacements
462
+
463
+
464
+ def fuse_residual_batch_norm_add_relu(module: nn.Module) -> int:
465
+ """Replace eligible eval RegNet blocks after checkpoint loading."""
466
+ from nemotron_ocr.inference.models.detector.regnet import (
467
+ ResBottleneckBlock,
468
+ )
469
+
470
+ replacements = 0
471
+ for name, child in list(module.named_children()):
472
+ if isinstance(child, ResBottleneckBlock) and not child.training:
473
+ main_batch_norm = child.f.c[-1]
474
+ projection_batch_norm = child.proj[-1] if child.proj is not None else None
475
+ eligible = _is_standard_eval_batch_norm(main_batch_norm) and (
476
+ projection_batch_norm is None
477
+ or _is_standard_eval_batch_norm(projection_batch_norm)
478
+ )
479
+ else:
480
+ eligible = False
481
+ if eligible:
482
+ module._modules[name] = FusedResidualBlock(child)
483
+ replacements += 1
484
+ else:
485
+ replacements += fuse_residual_batch_norm_add_relu(child)
486
+ return replacements
487
+
488
+
489
+ def fuse_aspp_batch_norm_relu_add(module: nn.Module) -> int:
490
+ """Fuse safe eval-only residual ASPP paths after checkpoint loading."""
491
+ from nemotron_ocr.inference.models.detector.aspp import ASPP
492
+
493
+ replacements = 0
494
+ for child in module.modules():
495
+ if not isinstance(child, ASPP):
496
+ continue
497
+ batch_norm = child.final[1]
498
+ relu = child.final[2]
499
+ dropout = child.final[3]
500
+ residual_channels_match = (
501
+ child.final[0].out_channels == child.kernels[0][0].in_channels
502
+ )
503
+ if (
504
+ child.training
505
+ or not _is_standard_eval_batch_norm(batch_norm)
506
+ or not isinstance(relu, nn.ReLU)
507
+ or not isinstance(dropout, nn.Dropout)
508
+ or dropout.p != 0
509
+ or not residual_channels_match
510
+ ):
511
+ continue
512
+ child._fused_norm_relu_add = FusedBatchNormReLUAdd(batch_norm)
513
+ child.final[1] = nn.Identity()
514
+ child.final[2] = nn.Identity()
515
+ replacements += 1
516
+ return replacements
nemotron-ocr/src/nemotron_ocr/inference/models/detector/fots_detector.py CHANGED
@@ -2,6 +2,7 @@
2
  # SPDX-License-Identifier: Apache-2.0
3
 
4
  import logging
 
5
  from typing import List, Optional, Tuple
6
 
7
  import torch
@@ -9,6 +10,15 @@ import torch.nn as nn
9
  import torch.nn.functional as F
10
  import math
11
 
 
 
 
 
 
 
 
 
 
12
 
13
  from nemotron_ocr.inference.models.detector.aspp import ASPP
14
  from nemotron_ocr.inference.models.detector import regnet
@@ -16,6 +26,156 @@ from nemotron_ocr.inference.models.detector import regnet
16
  logger = logging.getLogger(__name__)
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  def get_prior_offsets(output_shape, downsample):
20
  """
21
  Returns the locations of the priors in normalized image space.
@@ -131,6 +291,9 @@ class merge(nn.Module):
131
  )
132
 
133
  self.num_features = num_features
 
 
 
134
 
135
  for m in self.modules():
136
  if isinstance(m, nn.Conv2d):
@@ -149,9 +312,12 @@ class merge(nn.Module):
149
  y = x[0]
150
  for i in range(len(x) - 1):
151
  y = self.pre_upsamples[i](y)
152
- y = self.interpolate(y)
153
  side = self.pre_sides[i](x[i + 1])
154
- y = torch.cat((y, side), 1)
 
 
 
 
155
  y = self.post_upsamples[i](y)
156
  feats.append(y)
157
 
@@ -243,7 +409,9 @@ class output(nn.Module):
243
 
244
  def forward(
245
  self, feats: List[torch.Tensor]
246
- ) -> Tuple[torch.Tensor, Optional[torch.Tensor], Optional[torch.Tensor], List[torch.Tensor]]:
 
 
247
  x = feats[-1]
248
 
249
  preds = self.preds(x)
@@ -276,28 +444,41 @@ class output(nn.Module):
276
 
277
  class FOTSDetector(nn.Module):
278
  def __init__(
279
- self, verbose=True, coordinate_mode: str = "RBOX", backbone: str = "regnet_y_8gf",
280
- scope: int = 512, **kwargs
 
 
 
 
281
  ):
282
  super().__init__()
283
 
284
  self.extractor = extractor(backbone, **kwargs)
285
  self.merge = merge(self.extractor.depths)
286
  self.num_features = self.merge.num_features
287
- self.output = output(self.num_features, self.extractor.downsample, coordinate_mode, scope=scope)
 
 
288
  self.verbose = verbose
289
  self.inference_mode = False
290
  self.scope = scope # Store for reference
 
 
 
291
 
292
  self.downsample = self.extractor.downsample
293
 
294
  self.register_buffer(
295
  "input_mean",
296
- torch.tensor([0.485, 0.456, 0.406], dtype=torch.float16).reshape(1, -1, 1, 1),
 
 
297
  )
298
  self.register_buffer(
299
  "input_std",
300
- torch.tensor([0.229, 0.224, 0.225], dtype=torch.float16).reshape(1, -1, 1, 1),
 
 
301
  )
302
 
303
  def set_current_and_total_steps(self, current_step, total_steps):
@@ -312,7 +493,10 @@ class FOTSDetector(nn.Module):
312
  List[torch.Tensor],
313
  List[torch.Tensor],
314
  ]:
315
- x = (x - self.input_mean) / self.input_std
 
 
 
316
  feats = self.extractor(x)
317
 
318
  mg = self.merge(feats)
 
2
  # SPDX-License-Identifier: Apache-2.0
3
 
4
  import logging
5
+ import os
6
  from typing import List, Optional, Tuple
7
 
8
  import torch
 
10
  import torch.nn.functional as F
11
  import math
12
 
13
+ try:
14
+ import triton
15
+ import triton.language as tl
16
+ from triton.language.extra import libdevice
17
+ except ImportError:
18
+ triton = None
19
+ tl = None
20
+ libdevice = None
21
+
22
 
23
  from nemotron_ocr.inference.models.detector.aspp import ASPP
24
  from nemotron_ocr.inference.models.detector import regnet
 
26
  logger = logging.getLogger(__name__)
27
 
28
 
29
+ if triton is not None:
30
+
31
+ @triton.jit
32
+ def _normalize_detector_input_kernel(
33
+ input_ptr,
34
+ mean_ptr,
35
+ std_ptr,
36
+ output_ptr,
37
+ total_elements,
38
+ spatial_size: tl.constexpr,
39
+ channels: tl.constexpr,
40
+ BLOCK_SIZE: tl.constexpr,
41
+ ):
42
+ offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
43
+ valid = offsets < total_elements
44
+ channel = (offsets // spatial_size) % channels
45
+ values = tl.load(input_ptr + offsets, mask=valid).to(tl.float32)
46
+ mean = tl.load(mean_ptr + channel, mask=valid).to(tl.float32)
47
+ std = tl.load(std_ptr + channel, mask=valid).to(tl.float32)
48
+ # Eager autocast performs fp16 subtraction followed by fp16 division.
49
+ # Preserve the intermediate rounding boundary while eliminating one
50
+ # full activation read/write pass.
51
+ centered = (values - mean).to(tl.float16)
52
+ normalized = libdevice.div_rn(centered.to(tl.float32), std).to(tl.float16)
53
+ tl.store(output_ptr + offsets, normalized, mask=valid)
54
+
55
+ @triton.jit
56
+ def _upsample_concat_nearest_kernel(
57
+ input_ptr,
58
+ side_ptr,
59
+ output_ptr,
60
+ total_elements,
61
+ input_channels: tl.constexpr,
62
+ side_channels: tl.constexpr,
63
+ output_height: tl.constexpr,
64
+ output_width: tl.constexpr,
65
+ BLOCK_SIZE: tl.constexpr,
66
+ ):
67
+ offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
68
+ valid = offsets < total_elements
69
+ output_x = offsets % output_width
70
+ quotient = offsets // output_width
71
+ output_y = quotient % output_height
72
+ quotient = quotient // output_height
73
+ output_channel = quotient % (input_channels + side_channels)
74
+ batch = quotient // (input_channels + side_channels)
75
+
76
+ input_offsets = (
77
+ (batch * input_channels + output_channel) * (output_height // 2)
78
+ + output_y // 2
79
+ ) * (output_width // 2) + output_x // 2
80
+ side_channel = output_channel - input_channels
81
+ side_offsets = (
82
+ (batch * side_channels + side_channel) * output_height + output_y
83
+ ) * output_width + output_x
84
+
85
+ from_input = output_channel < input_channels
86
+ values = tl.load(
87
+ input_ptr + input_offsets,
88
+ mask=valid & from_input,
89
+ other=0.0,
90
+ )
91
+ side_values = tl.load(
92
+ side_ptr + side_offsets,
93
+ mask=valid & ~from_input,
94
+ other=0.0,
95
+ )
96
+ tl.store(
97
+ output_ptr + offsets,
98
+ tl.where(from_input, values, side_values),
99
+ mask=valid,
100
+ )
101
+
102
+
103
+ def _upsample_concat_nearest(input_tensor, side_tensor):
104
+ """Fuse exact 2x nearest upsampling with channel concatenation."""
105
+
106
+ def eager_upsample_concat():
107
+ return torch.cat(
108
+ (F.interpolate(input_tensor, scale_factor=2, mode="nearest"), side_tensor),
109
+ dim=1,
110
+ )
111
+
112
+ if triton is None or not input_tensor.is_cuda:
113
+ return eager_upsample_concat()
114
+
115
+ batch, input_channels, input_height, input_width = input_tensor.shape
116
+ side_batch, side_channels, output_height, output_width = side_tensor.shape
117
+ if (
118
+ batch != side_batch
119
+ or output_height != input_height * 2
120
+ or output_width != input_width * 2
121
+ ):
122
+ raise ValueError("fused upsample-concat requires an exact 2x side tensor")
123
+ if (
124
+ not input_tensor.is_contiguous()
125
+ or not side_tensor.is_contiguous()
126
+ or input_tensor.dtype != side_tensor.dtype
127
+ or input_tensor.device != side_tensor.device
128
+ ):
129
+ # Channels-last and other non-standard layouts remain correct through
130
+ # the eager implementation; the Triton kernel assumes dense NCHW.
131
+ return eager_upsample_concat()
132
+
133
+ output = torch.empty(
134
+ (batch, input_channels + side_channels, output_height, output_width),
135
+ dtype=input_tensor.dtype,
136
+ device=input_tensor.device,
137
+ )
138
+ total_elements = output.numel()
139
+ block_size = 4096
140
+ _upsample_concat_nearest_kernel[(triton.cdiv(total_elements, block_size),)](
141
+ input_tensor,
142
+ side_tensor,
143
+ output,
144
+ total_elements,
145
+ input_channels=input_channels,
146
+ side_channels=side_channels,
147
+ output_height=output_height,
148
+ output_width=output_width,
149
+ BLOCK_SIZE=block_size,
150
+ num_warps=8,
151
+ )
152
+ return output
153
+
154
+
155
+ def _normalize_detector_input(input_tensor, mean, std):
156
+ """Fuse the detector's exact fp16 channel normalization."""
157
+ if triton is None or not input_tensor.is_cuda:
158
+ return (input_tensor - mean) / std
159
+ if input_tensor.dtype != torch.float16 or not input_tensor.is_contiguous():
160
+ return (input_tensor - mean) / std
161
+
162
+ output = torch.empty_like(input_tensor)
163
+ spatial_size = input_tensor.shape[-2] * input_tensor.shape[-1]
164
+ block_size = 1024
165
+ _normalize_detector_input_kernel[(triton.cdiv(input_tensor.numel(), block_size),)](
166
+ input_tensor,
167
+ mean,
168
+ std,
169
+ output,
170
+ input_tensor.numel(),
171
+ spatial_size=spatial_size,
172
+ channels=input_tensor.shape[1],
173
+ BLOCK_SIZE=block_size,
174
+ num_warps=4,
175
+ )
176
+ return output
177
+
178
+
179
  def get_prior_offsets(output_shape, downsample):
180
  """
181
  Returns the locations of the priors in normalized image space.
 
291
  )
292
 
293
  self.num_features = num_features
294
+ self._fused_upsample_concat = (
295
+ os.environ.get("NEMOTRON_OCR_FUSED_UPSAMPLE_CONCAT", "0") == "1"
296
+ )
297
 
298
  for m in self.modules():
299
  if isinstance(m, nn.Conv2d):
 
312
  y = x[0]
313
  for i in range(len(x) - 1):
314
  y = self.pre_upsamples[i](y)
 
315
  side = self.pre_sides[i](x[i + 1])
316
+ if self._fused_upsample_concat:
317
+ y = _upsample_concat_nearest(y, side)
318
+ else:
319
+ y = self.interpolate(y)
320
+ y = torch.cat((y, side), 1)
321
  y = self.post_upsamples[i](y)
322
  feats.append(y)
323
 
 
409
 
410
  def forward(
411
  self, feats: List[torch.Tensor]
412
+ ) -> Tuple[
413
+ torch.Tensor, Optional[torch.Tensor], Optional[torch.Tensor], List[torch.Tensor]
414
+ ]:
415
  x = feats[-1]
416
 
417
  preds = self.preds(x)
 
444
 
445
  class FOTSDetector(nn.Module):
446
  def __init__(
447
+ self,
448
+ verbose=True,
449
+ coordinate_mode: str = "RBOX",
450
+ backbone: str = "regnet_y_8gf",
451
+ scope: int = 512,
452
+ **kwargs,
453
  ):
454
  super().__init__()
455
 
456
  self.extractor = extractor(backbone, **kwargs)
457
  self.merge = merge(self.extractor.depths)
458
  self.num_features = self.merge.num_features
459
+ self.output = output(
460
+ self.num_features, self.extractor.downsample, coordinate_mode, scope=scope
461
+ )
462
  self.verbose = verbose
463
  self.inference_mode = False
464
  self.scope = scope # Store for reference
465
+ self._fused_input_normalization = (
466
+ os.environ.get("NEMOTRON_OCR_FUSED_INPUT_NORMALIZATION", "0") == "1"
467
+ )
468
 
469
  self.downsample = self.extractor.downsample
470
 
471
  self.register_buffer(
472
  "input_mean",
473
+ torch.tensor([0.485, 0.456, 0.406], dtype=torch.float16).reshape(
474
+ 1, -1, 1, 1
475
+ ),
476
  )
477
  self.register_buffer(
478
  "input_std",
479
+ torch.tensor([0.229, 0.224, 0.225], dtype=torch.float16).reshape(
480
+ 1, -1, 1, 1
481
+ ),
482
  )
483
 
484
  def set_current_and_total_steps(self, current_step, total_steps):
 
493
  List[torch.Tensor],
494
  List[torch.Tensor],
495
  ]:
496
+ if self._fused_input_normalization:
497
+ x = _normalize_detector_input(x, self.input_mean, self.input_std)
498
+ else:
499
+ x = (x - self.input_mean) / self.input_std
500
  feats = self.extractor(x)
501
 
502
  mg = self.merge(feats)
nemotron-ocr/src/nemotron_ocr/inference/models/relational.py CHANGED
@@ -3,6 +3,7 @@
3
 
4
  import logging
5
  import math
 
6
 
7
  import torch
8
  import torch.nn as nn
@@ -23,17 +24,29 @@ NULL_CONNECTION_WEIGHT = -math.inf
23
  DEFAULT_CHUNK = 128
24
 
25
 
26
- def _pad_flat_to_batched(flat: torch.Tensor, region_counts: torch.Tensor, k_max: int, pad_value: float = 0.0):
 
 
 
 
 
27
  """Reshape flat [N_total, ...] tensors into padded [B, k_max, ...] batches."""
28
  batch_size = int(region_counts.shape[0])
29
  rest = flat.shape[1:]
30
  out = flat.new_full((batch_size, k_max, *rest), pad_value)
31
- offsets = torch.zeros(batch_size + 1, dtype=torch.long, device=flat.device)
32
- offsets[1:] = torch.cumsum(region_counts.to(device=flat.device, dtype=torch.long), dim=0)
33
- for i in range(batch_size):
34
- region_count = int(region_counts[i].item())
35
- if region_count > 0:
36
- out[i, :region_count] = flat[offsets[i] : offsets[i] + region_count]
 
 
 
 
 
 
 
37
  return out
38
 
39
 
@@ -65,6 +78,7 @@ class GlobalRelationalModel(nn.Module):
65
  dropout=0.1,
66
  num_layers=4,
67
  chunk_size=DEFAULT_CHUNK,
 
68
  ):
69
  super().__init__()
70
 
@@ -74,6 +88,9 @@ class GlobalRelationalModel(nn.Module):
74
  self.total_steps = 1
75
  self.k = k
76
  self.chunk_size = chunk_size
 
 
 
77
  self.quad_rectify_grid_size = (2, 3)
78
  self.quad_downscale = 1024.0
79
  self.inference_mode = False
@@ -129,12 +146,27 @@ class GlobalRelationalModel(nn.Module):
129
  xc = x[start:end]
130
  ec = [e[start:end] for e in extra]
131
  if real_n < cs:
132
- xc = torch.cat((xc, xc[:1].expand(cs - real_n, *[-1] * (xc.ndim - 1))), dim=0)
 
 
133
  for i, e in enumerate(ec):
134
  if pad_extra_ones and e.dtype == torch.bool:
135
- ec[i] = torch.cat((e, torch.ones(cs - real_n, *e.shape[1:], dtype=torch.bool, device=e.device)), dim=0)
 
 
 
 
 
 
 
 
 
 
 
136
  else:
137
- ec[i] = torch.cat((e, e[:1].expand(cs - real_n, *[-1] * (e.ndim - 1))), dim=0)
 
 
138
  parts.append(fn(xc, *ec)[:real_n])
139
  return torch.cat(parts, dim=0)
140
 
@@ -168,19 +200,25 @@ class GlobalRelationalModel(nn.Module):
168
  to_rects = torch.gather(
169
  to_rects,
170
  dim=1,
171
- index=closest_other_idxs.unsqueeze(2).expand(-1, -1, curr_rects.shape[1]),
 
 
172
  )
173
  # K,K-1
174
  all_dists = torch.gather(all_dists, dim=1, index=closest_other_idxs)
175
  # K,K-1,2
176
  to_centers = torch.gather(
177
- to_centers, dim=1, index=closest_other_idxs.unsqueeze(2).expand(-1, -1, 2)
 
 
178
  )
179
 
180
  # Add the null column to rects
181
  to_rects = torch.cat(
182
  (
183
- torch.zeros(to_rects.shape[0], 1, to_rects.shape[2], **options(to_rects)),
 
 
184
  to_rects,
185
  ),
186
  dim=1,
@@ -217,7 +255,9 @@ class GlobalRelationalModel(nn.Module):
217
  # Add the null column
218
  closest_other_idxs = torch.cat(
219
  [
220
- torch.zeros(closest_other_idxs.shape[0], 1, **options(closest_other_idxs)),
 
 
221
  closest_other_idxs + 1,
222
  ],
223
  dim=1,
@@ -225,11 +265,15 @@ class GlobalRelationalModel(nn.Module):
225
 
226
  return to_rects, closest_other_idxs
227
 
228
- def prohibit_self_connection(self, dots: torch.Tensor, closest_other_idxs: torch.Tensor = None):
 
 
229
  dots = dots.float()
230
 
231
  if closest_other_idxs is None:
232
- neg_inf = torch.full((dots.shape[-2],), NULL_CONNECTION_WEIGHT, **options(dots)).diag()
 
 
233
 
234
  neg_inf = torch.cat(
235
  (torch.zeros(neg_inf.shape[0], 1, **options(neg_inf)), neg_inf), dim=1
@@ -264,7 +308,9 @@ class GlobalRelationalModel(nn.Module):
264
  recog_features: torch.Tensor,
265
  ):
266
  cs_rg = torch.cumsum(region_counts, 0)
267
- cs_rg = torch.cat([torch.zeros(1, dtype=cs_rg.dtype, device=cs_rg.device), cs_rg])
 
 
268
  ex_offsets = cs_rg
269
 
270
  g_height, g_width = self.quad_rectify_grid_size
@@ -274,7 +320,9 @@ class GlobalRelationalModel(nn.Module):
274
  original_quads, rectified_quads.shape[-2], 1, rectified_quads.shape[-1]
275
  )
276
  else:
277
- quad_widths = torch.full((original_quads.shape[0],), g_width, **options(original_quads))
 
 
278
  num_valid_pos = (quad_widths * g_height).clamp_min(1)
279
 
280
  # Ensure that these values aren't very large
@@ -288,7 +336,10 @@ class GlobalRelationalModel(nn.Module):
288
  return self.combined_proj(torch.cat((avg, rec), dim=1))
289
 
290
  semantic_encoding = self._chunked_forward(
291
- _input_enc_nn, rectified_quads, recog_features, num_valid_pos,
 
 
 
292
  )
293
 
294
  h1 = original_quads[:, 3] - original_quads[:, 0]
@@ -324,6 +375,111 @@ class GlobalRelationalModel(nn.Module):
324
 
325
  return full_encoding, ex_offsets, region_counts, mid_pts
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  def forward(
328
  self,
329
  rectified_quads: torch.Tensor,
@@ -334,8 +490,9 @@ class GlobalRelationalModel(nn.Module):
334
  rectified_quads = rectified_quads.float()
335
  recog_features = recog_features.float()
336
 
337
- assert torch.all(torch.isfinite(rectified_quads))
338
- assert torch.all(torch.isfinite(recog_features))
 
339
 
340
  proj_rects, _, region_counts, mid_pts = self.get_input_encoding(
341
  rectified_quads, original_quads, region_counts, recog_features
@@ -344,9 +501,15 @@ class GlobalRelationalModel(nn.Module):
344
  quads = original_quads / self.quad_downscale
345
 
346
  if not self.inference_mode:
347
- assert torch.all(torch.isfinite(proj_rects)), "Not all proj_rects were finite!"
 
 
348
 
349
- counts_list = region_counts.tolist() if region_counts.dim() > 0 else [int(region_counts.item())]
 
 
 
 
350
  batch_size = len(counts_list)
351
  device = proj_rects.device
352
  dtype = proj_rects.dtype
@@ -356,10 +519,17 @@ class GlobalRelationalModel(nn.Module):
356
 
357
  if max(counts_list, default=0) == 0:
358
  return {
359
- "words": [torch.empty(0, 1, dtype=dtype, device=device) for _ in range(batch_size)],
360
- "lines": [torch.empty(0, 1, dtype=dtype, device=device) for _ in range(batch_size)],
 
 
 
 
 
 
361
  "line_log_var_unc": [
362
- torch.empty(0, 1, dtype=dtype, device=device) for _ in range(batch_size)
 
363
  ],
364
  }
365
 
@@ -369,61 +539,109 @@ class GlobalRelationalModel(nn.Module):
369
  offsets.append(offsets[-1] + c)
370
  n_total = offsets[-1]
371
 
372
- enc_input_flat = torch.zeros(n_total, seq_len, 2 * feat_dim + 2, dtype=dtype, device=device)
373
- mask_flat = torch.ones(n_total, seq_len, dtype=torch.bool, device=device)
374
- closest_flat = torch.zeros(n_total, seq_len, dtype=torch.long, device=device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
- for i, n_i in enumerate(counts_list):
377
- if n_i == 0:
378
- continue
379
- s, e = offsets[i], offsets[i + 1]
380
- rects_i = proj_rects[s:e]
381
- centers_i = mid_pts[s:e]
382
- quads_i = quads[s:e]
383
- z_i = min(n_i - 1, z)
384
-
385
- from_r = rects_i.unsqueeze(1).expand(-1, seq_len, -1)
386
- enc_input_flat[s:e, 0, :feat_dim] = rects_i
387
- enc_input_flat[s:e, 0, 2 * feat_dim] = -1
388
- enc_input_flat[s:e, 0, 2 * feat_dim + 1] = -2
389
- mask_flat[s:e, 0] = False
390
-
391
- if z_i > 0:
392
- dists_i = get_cdist(quads_i, centers_i)
393
- topk_d, topk_idx = torch.topk(dists_i, k=z_i, dim=1, largest=False, sorted=False)
394
- nb_r = torch.gather(rects_i.unsqueeze(0).expand(n_i, -1, -1), 1, topk_idx.unsqueeze(2).expand(-1, -1, feat_dim))
395
- nb_c = torch.gather(centers_i.unsqueeze(0).expand(n_i, -1, -1), 1, topk_idx.unsqueeze(2).expand(-1, -1, 2))
396
- dirs_i = get_directions(quads_i, nb_c)
397
-
398
- enc_input_flat[s:e, 1:z_i + 1, :feat_dim] = from_r[:, 1:z_i + 1]
399
- enc_input_flat[s:e, 1:z_i + 1, feat_dim:2 * feat_dim] = nb_r
400
- enc_input_flat[s:e, 1:z_i + 1, 2 * feat_dim] = topk_d
401
- enc_input_flat[s:e, 1:z_i + 1, 2 * feat_dim + 1] = dirs_i
402
- mask_flat[s:e, 1:z_i + 1] = False
403
- closest_flat[s:e, 1:z_i + 1] = topk_idx + 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
 
405
  # Chunked encoder on flat regions — always sees [chunk_size, seq_len, dim]
406
  def _run_encoder(enc, mask):
407
  out = self.encoder[0](enc, src_key_padding_mask=mask)
408
  return self.encoder[1](out)
409
 
410
- dots_flat = self._chunked_forward(_run_encoder, enc_input_flat, mask_flat, pad_extra_ones=True)
 
 
411
 
412
  # Per-image: scatter encoder output into full relation matrices
413
  all_dots = dict(words=[], lines=[], line_log_var_unc=[])
414
  for i, n_i in enumerate(counts_list):
415
  if n_i == 0:
416
- all_dots["words"].append(torch.empty(0, 1, dtype=torch.float32, device=device))
417
- all_dots["lines"].append(torch.empty(0, 1, dtype=torch.float32, device=device))
418
- all_dots["line_log_var_unc"].append(torch.empty(0, 1, dtype=torch.float32, device=device))
 
 
 
 
 
 
419
  continue
420
  s, e = offsets[i], offsets[i + 1]
421
  dots_i = dots_flat[s:e].unsqueeze(0).permute(0, 3, 1, 2)
422
  cidx_i = closest_flat[s:e].unsqueeze(0)
423
  dots_i = self.prohibit_self_connection(dots_i, cidx_i)
424
- all_dots["words"].append(dots_i[0, 0, :n_i, :n_i + 1])
425
- all_dots["lines"].append(dots_i[0, 1, :n_i, :n_i + 1])
426
- all_dots["line_log_var_unc"].append(dots_i[0, 2, :n_i, :n_i + 1])
427
 
428
  return {
429
  "words": all_dots["words"],
@@ -433,9 +651,14 @@ class GlobalRelationalModel(nn.Module):
433
 
434
 
435
  def get_cdist(
436
- quads: torch.Tensor, centers: torch.Tensor, x_factor: float = 1.0, y_factor: float = 1.0
 
 
 
437
  ):
438
- region_counts = torch.tensor([quads.shape[0]], dtype=torch.int64, device=quads.device)
 
 
439
 
440
  ret = ragged_quad_all_2_all_distance_v2(
441
  quads.unsqueeze(0), region_counts, x_factor, y_factor, allow_self_distance=False
@@ -445,7 +668,10 @@ def get_cdist(
445
 
446
 
447
  def get_cdist_batched(
448
- quads: torch.Tensor, region_counts: torch.Tensor, x_factor: float = 1.0, y_factor: float = 1.0
 
 
 
449
  ):
450
  return ragged_quad_all_2_all_distance_v2(
451
  quads.contiguous(),
 
3
 
4
  import logging
5
  import math
6
+ import os
7
 
8
  import torch
9
  import torch.nn as nn
 
24
  DEFAULT_CHUNK = 128
25
 
26
 
27
+ def _pad_flat_to_batched(
28
+ flat: torch.Tensor,
29
+ region_counts: torch.Tensor,
30
+ k_max: int,
31
+ pad_value: float = 0.0,
32
+ ):
33
  """Reshape flat [N_total, ...] tensors into padded [B, k_max, ...] batches."""
34
  batch_size = int(region_counts.shape[0])
35
  rest = flat.shape[1:]
36
  out = flat.new_full((batch_size, k_max, *rest), pad_value)
37
+
38
+ if flat.shape[0] == 0:
39
+ return out
40
+
41
+ counts = region_counts.to(device=flat.device, dtype=torch.long)
42
+ offsets = torch.cumsum(counts, dim=0) - counts
43
+ batch_indices = torch.repeat_interleave(
44
+ torch.arange(batch_size, device=flat.device),
45
+ counts,
46
+ output_size=flat.shape[0],
47
+ )
48
+ positions = torch.arange(flat.shape[0], device=flat.device) - offsets[batch_indices]
49
+ out[batch_indices, positions] = flat
50
  return out
51
 
52
 
 
78
  dropout=0.1,
79
  num_layers=4,
80
  chunk_size=DEFAULT_CHUNK,
81
+ batched_geometry=False,
82
  ):
83
  super().__init__()
84
 
 
88
  self.total_steps = 1
89
  self.k = k
90
  self.chunk_size = chunk_size
91
+ self.batched_geometry = batched_geometry or os.environ.get(
92
+ "NEMOTRON_OCR_BATCHED_RELATIONAL_GEOMETRY", "0"
93
+ ).strip().lower() in {"1", "true", "yes", "on"}
94
  self.quad_rectify_grid_size = (2, 3)
95
  self.quad_downscale = 1024.0
96
  self.inference_mode = False
 
146
  xc = x[start:end]
147
  ec = [e[start:end] for e in extra]
148
  if real_n < cs:
149
+ xc = torch.cat(
150
+ (xc, xc[:1].expand(cs - real_n, *[-1] * (xc.ndim - 1))), dim=0
151
+ )
152
  for i, e in enumerate(ec):
153
  if pad_extra_ones and e.dtype == torch.bool:
154
+ ec[i] = torch.cat(
155
+ (
156
+ e,
157
+ torch.ones(
158
+ cs - real_n,
159
+ *e.shape[1:],
160
+ dtype=torch.bool,
161
+ device=e.device,
162
+ ),
163
+ ),
164
+ dim=0,
165
+ )
166
  else:
167
+ ec[i] = torch.cat(
168
+ (e, e[:1].expand(cs - real_n, *[-1] * (e.ndim - 1))), dim=0
169
+ )
170
  parts.append(fn(xc, *ec)[:real_n])
171
  return torch.cat(parts, dim=0)
172
 
 
200
  to_rects = torch.gather(
201
  to_rects,
202
  dim=1,
203
+ index=closest_other_idxs.unsqueeze(2).expand(
204
+ -1, -1, curr_rects.shape[1]
205
+ ),
206
  )
207
  # K,K-1
208
  all_dists = torch.gather(all_dists, dim=1, index=closest_other_idxs)
209
  # K,K-1,2
210
  to_centers = torch.gather(
211
+ to_centers,
212
+ dim=1,
213
+ index=closest_other_idxs.unsqueeze(2).expand(-1, -1, 2),
214
  )
215
 
216
  # Add the null column to rects
217
  to_rects = torch.cat(
218
  (
219
+ torch.zeros(
220
+ to_rects.shape[0], 1, to_rects.shape[2], **options(to_rects)
221
+ ),
222
  to_rects,
223
  ),
224
  dim=1,
 
255
  # Add the null column
256
  closest_other_idxs = torch.cat(
257
  [
258
+ torch.zeros(
259
+ closest_other_idxs.shape[0], 1, **options(closest_other_idxs)
260
+ ),
261
  closest_other_idxs + 1,
262
  ],
263
  dim=1,
 
265
 
266
  return to_rects, closest_other_idxs
267
 
268
+ def prohibit_self_connection(
269
+ self, dots: torch.Tensor, closest_other_idxs: torch.Tensor = None
270
+ ):
271
  dots = dots.float()
272
 
273
  if closest_other_idxs is None:
274
+ neg_inf = torch.full(
275
+ (dots.shape[-2],), NULL_CONNECTION_WEIGHT, **options(dots)
276
+ ).diag()
277
 
278
  neg_inf = torch.cat(
279
  (torch.zeros(neg_inf.shape[0], 1, **options(neg_inf)), neg_inf), dim=1
 
308
  recog_features: torch.Tensor,
309
  ):
310
  cs_rg = torch.cumsum(region_counts, 0)
311
+ cs_rg = torch.cat(
312
+ [torch.zeros(1, dtype=cs_rg.dtype, device=cs_rg.device), cs_rg]
313
+ )
314
  ex_offsets = cs_rg
315
 
316
  g_height, g_width = self.quad_rectify_grid_size
 
320
  original_quads, rectified_quads.shape[-2], 1, rectified_quads.shape[-1]
321
  )
322
  else:
323
+ quad_widths = torch.full(
324
+ (original_quads.shape[0],), g_width, **options(original_quads)
325
+ )
326
  num_valid_pos = (quad_widths * g_height).clamp_min(1)
327
 
328
  # Ensure that these values aren't very large
 
336
  return self.combined_proj(torch.cat((avg, rec), dim=1))
337
 
338
  semantic_encoding = self._chunked_forward(
339
+ _input_enc_nn,
340
+ rectified_quads,
341
+ recog_features,
342
+ num_valid_pos,
343
  )
344
 
345
  h1 = original_quads[:, 3] - original_quads[:, 0]
 
375
 
376
  return full_encoding, ex_offsets, region_counts, mid_pts
377
 
378
+ def _build_batched_geometry_inputs(
379
+ self,
380
+ proj_rects: torch.Tensor,
381
+ mid_pts: torch.Tensor,
382
+ quads: torch.Tensor,
383
+ region_counts: torch.Tensor,
384
+ counts_list: list[int],
385
+ ):
386
+ """Build relational inputs with one ragged distance launch.
387
+
388
+ Images are grouped by their exact region count for ``topk``. This keeps
389
+ the last-dimension width and ``k`` identical to the legacy per-image
390
+ calls, including the ordering produced by ``sorted=False``. CPU region
391
+ counts are required so choosing the padded extent never synchronizes a
392
+ CUDA scalar back to the host.
393
+ """
394
+ device = proj_rects.device
395
+ dtype = proj_rects.dtype
396
+ feat_dim = proj_rects.shape[1]
397
+ z = self.k - 1
398
+ seq_len = z + 1
399
+ n_total = proj_rects.shape[0]
400
+ k_max = max(counts_list, default=0)
401
+
402
+ enc_input_flat = torch.zeros(
403
+ n_total,
404
+ seq_len,
405
+ 2 * feat_dim + 2,
406
+ dtype=dtype,
407
+ device=device,
408
+ )
409
+ mask_flat = torch.ones(n_total, seq_len, dtype=torch.bool, device=device)
410
+ closest_flat = torch.zeros(n_total, seq_len, dtype=torch.long, device=device)
411
+
412
+ enc_input_flat[:, 0, :feat_dim] = proj_rects
413
+ enc_input_flat[:, 0, 2 * feat_dim] = -1
414
+ enc_input_flat[:, 0, 2 * feat_dim + 1] = -2
415
+ mask_flat[:, 0] = False
416
+
417
+ if n_total == 0 or z == 0:
418
+ return enc_input_flat, mask_flat, closest_flat
419
+
420
+ counts_device = region_counts.to(device=device, dtype=torch.long)
421
+ starts = torch.cumsum(counts_device, dim=0) - counts_device
422
+ padded_quads = _pad_flat_to_batched(quads, counts_device, k_max)
423
+ padded_rects = _pad_flat_to_batched(proj_rects, counts_device, k_max)
424
+ padded_centers = _pad_flat_to_batched(mid_pts, counts_device, k_max)
425
+ all_dists = get_cdist_batched(padded_quads, counts_device)
426
+
427
+ count_groups = {}
428
+ for image_index, count in enumerate(counts_list):
429
+ if count > 1:
430
+ count_groups.setdefault(count, []).append(image_index)
431
+
432
+ for count, image_indices in count_groups.items():
433
+ z_i = min(count - 1, z)
434
+ group_indices = torch.tensor(image_indices, dtype=torch.long, device=device)
435
+ group_rows = starts[group_indices, None] + torch.arange(
436
+ count, device=device
437
+ )
438
+
439
+ dists = all_dists.index_select(0, group_indices)[:, :count, :count]
440
+ topk_d, topk_idx = torch.topk(
441
+ dists,
442
+ k=z_i,
443
+ dim=2,
444
+ largest=False,
445
+ sorted=False,
446
+ )
447
+
448
+ rects = padded_rects.index_select(0, group_indices)[:, :count]
449
+ centers = padded_centers.index_select(0, group_indices)[:, :count]
450
+ group_quads = padded_quads.index_select(0, group_indices)[:, :count]
451
+
452
+ neighbor_rects = torch.gather(
453
+ rects.unsqueeze(1).expand(-1, count, -1, -1),
454
+ dim=2,
455
+ index=topk_idx.unsqueeze(3).expand(-1, -1, -1, feat_dim),
456
+ )
457
+ neighbor_centers = torch.gather(
458
+ centers.unsqueeze(1).expand(-1, count, -1, -1),
459
+ dim=2,
460
+ index=topk_idx.unsqueeze(3).expand(-1, -1, -1, 2),
461
+ )
462
+ directions = (
463
+ get_directions(
464
+ group_quads.reshape(-1, 4, 2),
465
+ neighbor_centers.reshape(-1, z_i, 2),
466
+ )
467
+ .reshape(-1, count, z_i)
468
+ .to(dtype=dtype)
469
+ )
470
+ from_rects = rects.unsqueeze(2).expand(-1, -1, z_i, -1)
471
+
472
+ enc_input_flat[group_rows, 1 : z_i + 1, :feat_dim] = from_rects
473
+ enc_input_flat[group_rows, 1 : z_i + 1, feat_dim : 2 * feat_dim] = (
474
+ neighbor_rects
475
+ )
476
+ enc_input_flat[group_rows, 1 : z_i + 1, 2 * feat_dim] = topk_d
477
+ enc_input_flat[group_rows, 1 : z_i + 1, 2 * feat_dim + 1] = directions
478
+ mask_flat[group_rows, 1 : z_i + 1] = False
479
+ closest_flat[group_rows, 1 : z_i + 1] = topk_idx + 1
480
+
481
+ return enc_input_flat, mask_flat, closest_flat
482
+
483
  def forward(
484
  self,
485
  rectified_quads: torch.Tensor,
 
490
  rectified_quads = rectified_quads.float()
491
  recog_features = recog_features.float()
492
 
493
+ if not self.inference_mode:
494
+ assert torch.all(torch.isfinite(rectified_quads))
495
+ assert torch.all(torch.isfinite(recog_features))
496
 
497
  proj_rects, _, region_counts, mid_pts = self.get_input_encoding(
498
  rectified_quads, original_quads, region_counts, recog_features
 
501
  quads = original_quads / self.quad_downscale
502
 
503
  if not self.inference_mode:
504
+ assert torch.all(torch.isfinite(proj_rects)), (
505
+ "Not all proj_rects were finite!"
506
+ )
507
 
508
+ counts_list = (
509
+ region_counts.tolist()
510
+ if region_counts.dim() > 0
511
+ else [int(region_counts.item())]
512
+ )
513
  batch_size = len(counts_list)
514
  device = proj_rects.device
515
  dtype = proj_rects.dtype
 
519
 
520
  if max(counts_list, default=0) == 0:
521
  return {
522
+ "words": [
523
+ torch.empty(0, 1, dtype=dtype, device=device)
524
+ for _ in range(batch_size)
525
+ ],
526
+ "lines": [
527
+ torch.empty(0, 1, dtype=dtype, device=device)
528
+ for _ in range(batch_size)
529
+ ],
530
  "line_log_var_unc": [
531
+ torch.empty(0, 1, dtype=dtype, device=device)
532
+ for _ in range(batch_size)
533
  ],
534
  }
535
 
 
539
  offsets.append(offsets[-1] + c)
540
  n_total = offsets[-1]
541
 
542
+ use_batched_geometry = (
543
+ self.batched_geometry
544
+ and region_counts.device.type == "cpu"
545
+ and region_counts.dim() == 1
546
+ and all(count >= 0 for count in counts_list)
547
+ and sum(counts_list) == n_total
548
+ )
549
+ if use_batched_geometry:
550
+ enc_input_flat, mask_flat, closest_flat = (
551
+ self._build_batched_geometry_inputs(
552
+ proj_rects,
553
+ mid_pts,
554
+ quads,
555
+ region_counts,
556
+ counts_list,
557
+ )
558
+ )
559
+ else:
560
+ enc_input_flat = torch.zeros(
561
+ n_total,
562
+ seq_len,
563
+ 2 * feat_dim + 2,
564
+ dtype=dtype,
565
+ device=device,
566
+ )
567
+ mask_flat = torch.ones(n_total, seq_len, dtype=torch.bool, device=device)
568
+ closest_flat = torch.zeros(
569
+ n_total, seq_len, dtype=torch.long, device=device
570
+ )
571
 
572
+ for i, n_i in enumerate(counts_list):
573
+ if n_i == 0:
574
+ continue
575
+ s, e = offsets[i], offsets[i + 1]
576
+ rects_i = proj_rects[s:e]
577
+ centers_i = mid_pts[s:e]
578
+ quads_i = quads[s:e]
579
+ z_i = min(n_i - 1, z)
580
+
581
+ from_r = rects_i.unsqueeze(1).expand(-1, seq_len, -1)
582
+ enc_input_flat[s:e, 0, :feat_dim] = rects_i
583
+ enc_input_flat[s:e, 0, 2 * feat_dim] = -1
584
+ enc_input_flat[s:e, 0, 2 * feat_dim + 1] = -2
585
+ mask_flat[s:e, 0] = False
586
+
587
+ if z_i > 0:
588
+ dists_i = get_cdist(quads_i, centers_i)
589
+ topk_d, topk_idx = torch.topk(
590
+ dists_i,
591
+ k=z_i,
592
+ dim=1,
593
+ largest=False,
594
+ sorted=False,
595
+ )
596
+ nb_r = torch.gather(
597
+ rects_i.unsqueeze(0).expand(n_i, -1, -1),
598
+ 1,
599
+ topk_idx.unsqueeze(2).expand(-1, -1, feat_dim),
600
+ )
601
+ nb_c = torch.gather(
602
+ centers_i.unsqueeze(0).expand(n_i, -1, -1),
603
+ 1,
604
+ topk_idx.unsqueeze(2).expand(-1, -1, 2),
605
+ )
606
+ dirs_i = get_directions(quads_i, nb_c)
607
+
608
+ enc_input_flat[s:e, 1 : z_i + 1, :feat_dim] = from_r[:, 1 : z_i + 1]
609
+ enc_input_flat[s:e, 1 : z_i + 1, feat_dim : 2 * feat_dim] = nb_r
610
+ enc_input_flat[s:e, 1 : z_i + 1, 2 * feat_dim] = topk_d
611
+ enc_input_flat[s:e, 1 : z_i + 1, 2 * feat_dim + 1] = dirs_i
612
+ mask_flat[s:e, 1 : z_i + 1] = False
613
+ closest_flat[s:e, 1 : z_i + 1] = topk_idx + 1
614
 
615
  # Chunked encoder on flat regions — always sees [chunk_size, seq_len, dim]
616
  def _run_encoder(enc, mask):
617
  out = self.encoder[0](enc, src_key_padding_mask=mask)
618
  return self.encoder[1](out)
619
 
620
+ dots_flat = self._chunked_forward(
621
+ _run_encoder, enc_input_flat, mask_flat, pad_extra_ones=True
622
+ )
623
 
624
  # Per-image: scatter encoder output into full relation matrices
625
  all_dots = dict(words=[], lines=[], line_log_var_unc=[])
626
  for i, n_i in enumerate(counts_list):
627
  if n_i == 0:
628
+ all_dots["words"].append(
629
+ torch.empty(0, 1, dtype=torch.float32, device=device)
630
+ )
631
+ all_dots["lines"].append(
632
+ torch.empty(0, 1, dtype=torch.float32, device=device)
633
+ )
634
+ all_dots["line_log_var_unc"].append(
635
+ torch.empty(0, 1, dtype=torch.float32, device=device)
636
+ )
637
  continue
638
  s, e = offsets[i], offsets[i + 1]
639
  dots_i = dots_flat[s:e].unsqueeze(0).permute(0, 3, 1, 2)
640
  cidx_i = closest_flat[s:e].unsqueeze(0)
641
  dots_i = self.prohibit_self_connection(dots_i, cidx_i)
642
+ all_dots["words"].append(dots_i[0, 0, :n_i, : n_i + 1])
643
+ all_dots["lines"].append(dots_i[0, 1, :n_i, : n_i + 1])
644
+ all_dots["line_log_var_unc"].append(dots_i[0, 2, :n_i, : n_i + 1])
645
 
646
  return {
647
  "words": all_dots["words"],
 
651
 
652
 
653
  def get_cdist(
654
+ quads: torch.Tensor,
655
+ centers: torch.Tensor,
656
+ x_factor: float = 1.0,
657
+ y_factor: float = 1.0,
658
  ):
659
+ region_counts = torch.tensor(
660
+ [quads.shape[0]], dtype=torch.int64, device=quads.device
661
+ )
662
 
663
  ret = ragged_quad_all_2_all_distance_v2(
664
  quads.unsqueeze(0), region_counts, x_factor, y_factor, allow_self_distance=False
 
668
 
669
 
670
  def get_cdist_batched(
671
+ quads: torch.Tensor,
672
+ region_counts: torch.Tensor,
673
+ x_factor: float = 1.0,
674
+ y_factor: float = 1.0,
675
  ):
676
  return ragged_quad_all_2_all_distance_v2(
677
  quads.contiguous(),
nemotron-ocr/src/nemotron_ocr/inference/pipeline_v2.py CHANGED
@@ -21,6 +21,13 @@ import torch.nn as nn
21
  import torch.nn.functional as F
22
  from torch import amp
23
 
 
 
 
 
 
 
 
24
  from nemotron_ocr.inference.pipeline import (
25
  NemotronOCR,
26
  DETECTOR_DOWNSAMPLE,
@@ -51,6 +58,57 @@ _DEFAULT_MAX_WIDTH = 32
51
  _DEFAULT_NUM_TOKENS = 858
52
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  class NemotronOCRV2(NemotronOCR):
55
  """Batched OCR inference pipeline.
56
 
@@ -109,6 +167,62 @@ class NemotronOCRV2(NemotronOCR):
109
 
110
  torch.backends.cuda.matmul.allow_tf32 = True
111
  torch.backends.cudnn.allow_tf32 = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
  # ── pad_color ────────────────────────────────────────────────
114
  if pad_color is not None:
@@ -140,11 +254,27 @@ class NemotronOCRV2(NemotronOCR):
140
  if hasattr(self, "relational"):
141
  self.relational.chunk_size = relational_chunk_size
142
 
143
- if verbose_post and hasattr(self, "relation_encoder") and hasattr(self.relation_encoder, "_verbose"):
 
 
 
 
144
  self.relation_encoder._verbose = True
145
 
146
  if not self._detector_only and hasattr(self, "recognizer"):
147
  self._pad_classifier_for_alignment(64)
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
  # ------------------------------------------------------------------
150
  # Tensor-core alignment
@@ -175,7 +305,9 @@ class NemotronOCRV2(NemotronOCR):
175
  self.recognizer.classifier = new_cls
176
  logger.info(
177
  "Padded recognizer classifier %d -> %d (alignment=%d)",
178
- real, padded, alignment,
 
 
179
  )
180
 
181
  # ------------------------------------------------------------------
@@ -257,7 +389,9 @@ class NemotronOCRV2(NemotronOCR):
257
  del tensor_gpu
258
 
259
  resized = interpolate_and_pad(
260
- padded.unsqueeze(0), self._pad_color, self.infer_length,
 
 
261
  ).squeeze(0)
262
  del padded
263
  resized_list.append(resized)
@@ -287,14 +421,22 @@ class NemotronOCRV2(NemotronOCR):
287
  if t.shape[0] == 4:
288
  t = t[:3]
289
  if t.dtype != torch.uint8:
290
- t = (t * 255).clamp(0, 255).to(torch.uint8) if t.is_floating_point() else t.to(torch.uint8)
 
 
 
 
291
  return t
292
  if t.ndim == 3 and t.shape[2] in (1, 3, 4):
293
  t = t.permute(2, 0, 1)
294
  if t.shape[0] == 4:
295
  t = t[:3]
296
  if t.dtype != torch.uint8:
297
- t = (t * 255).clamp(0, 255).to(torch.uint8) if t.is_floating_point() else t.to(torch.uint8)
 
 
 
 
298
  return t
299
  raise ValueError(f"Unsupported tensor shape: {image.shape}")
300
 
@@ -304,19 +446,29 @@ class NemotronOCRV2(NemotronOCR):
304
  if image.shape[2] == 4:
305
  image = image[..., :3]
306
  if image.dtype != np.uint8:
307
- image = (image * 255).clip(0, 255).astype(np.uint8) if image.max() <= 1.0 else image.astype(np.uint8)
 
 
 
 
308
  return torch.from_numpy(image).permute(2, 0, 1)
309
 
310
  from torchvision.io import read_image, decode_image
 
311
  if isinstance(image, (str, os.PathLike)):
312
  return read_image(str(image), mode="RGB")
313
  if isinstance(image, bytes):
314
  import base64
 
315
  img_bytes = base64.b64decode(image)
316
- return decode_image(torch.frombuffer(img_bytes, dtype=torch.uint8), mode="RGB")
317
- if hasattr(image, 'read'):
 
 
318
  image.seek(0)
319
- return decode_image(torch.frombuffer(image.getvalue(), dtype=torch.uint8), mode="RGB")
 
 
320
 
321
  raise TypeError(f"Unsupported input type: {type(image)}")
322
 
@@ -339,6 +491,8 @@ class NemotronOCRV2(NemotronOCR):
339
  for start in range(0, n, self.detector_max_batch_size):
340
  end = min(start + self.detector_max_batch_size, n)
341
  chunk = resized_batch[start:end]
 
 
342
  c, _, r, f = self.detector(chunk)
343
  conf_parts.append(c)
344
  rbox_parts.append(r)
@@ -363,8 +517,13 @@ class NemotronOCRV2(NemotronOCR):
363
  to suppress edge pixels, then keeps only local maxima. This prevents
364
  the O(n^2) NMS adjacency blowup on images with large text regions
365
  while preserving all real detection peaks.
 
 
 
 
366
  """
367
  with torch.inference_mode():
 
368
  d_top = det_rboxes[..., 0].float()
369
  d_right = det_rboxes[..., 1].float()
370
  d_bottom = det_rboxes[..., 2].float()
@@ -377,31 +536,42 @@ class NemotronOCRV2(NemotronOCR):
377
 
378
  centerness = torch.sqrt((lr_min / lr_max) * (tb_min / tb_max))
379
 
380
- conf_sigmoid = torch.sigmoid(det_conf.float())
381
  adjusted = conf_sigmoid * centerness
382
 
383
  k = self._prefilter_peak_kernel
384
  pad = k // 2
385
  adj_4d = adjusted.unsqueeze(1)
386
  pooled = F.max_pool2d(adj_4d, k, stride=1, padding=pad)
387
- pooled = pooled[:, 0, :det_conf.shape[1], :det_conf.shape[2]]
388
 
389
  peaks = (adjusted == pooled) & (adjusted > NMS_PROB_THRESHOLD)
390
 
391
- filtered = det_conf.clone()
392
- filtered[~peaks] = -100.0
393
-
394
- return filtered
395
-
396
- def _run_nms(self, det_conf, det_rboxes):
 
 
 
 
 
 
 
 
 
 
 
397
  """Sigmoid + rrect_to_quads + NMS.
398
 
399
  Returns:
400
  (quads, confidence, region_counts, e2e_det_conf) or None if
401
  zero detections.
402
  """
403
- with torch.inference_mode():
404
- e2e_det_conf = torch.sigmoid(det_conf)
 
405
 
406
  with amp.autocast("cuda", enabled=True), torch.inference_mode():
407
  e2e_det_coords = rrect_to_quads(det_rboxes.float(), DETECTOR_DOWNSAMPLE)
@@ -443,13 +613,19 @@ class NemotronOCRV2(NemotronOCR):
443
  input_indices_cuda = input_indices.cuda(non_blocking=True)
444
  region_counts_cpu = region_counts.cpu()
445
 
446
- det_fp32 = det_features.float()
447
- rec_quads = self.grid_sampler(det_fp32, rec_rectified, input_indices_cuda)
 
 
 
 
448
 
449
  rel_quads = None
450
  if not self._skip_relational:
451
  rel_rectified = self.relational_quad_rectifier(quads_cuda, h, w)
452
- rel_quads = self.grid_sampler(det_fp32, rel_rectified, input_indices_cuda)
 
 
453
 
454
  return quads_cuda, rec_quads, rel_quads, region_counts_cpu
455
 
@@ -478,7 +654,13 @@ class NemotronOCRV2(NemotronOCR):
478
  return (
479
  torch.empty(0, self.max_width, dtype=torch.int64, device=device),
480
  torch.empty(0, self.max_width, dtype=torch.float32, device=device),
481
- torch.empty(0, self.max_width, self.recognizer.feature_depth, dtype=torch.float16, device=device),
 
 
 
 
 
 
482
  )
483
 
484
  cs = self.recognizer_chunk_size
@@ -489,23 +671,32 @@ class NemotronOCRV2(NemotronOCR):
489
  chunk = rec_quads[start:end].half()
490
  real_n = chunk.shape[0]
491
  if real_n < cs:
492
- pad = torch.zeros(cs - real_n, *chunk.shape[1:], dtype=chunk.dtype, device=chunk.device)
 
 
 
 
 
493
  chunk = torch.cat([chunk, pad], dim=0)
494
  logits, feats = self.recognizer(chunk)
495
 
496
- ids = logits.argmax(dim=2)
497
- probs = torch.softmax(logits, dim=2).gather(
498
- 2, ids.unsqueeze(2)
499
- ).squeeze(2).float()
500
 
501
  ids_parts.append(ids[:real_n])
502
  probs_parts.append(probs[:real_n])
503
- feat_parts.append(feats[:real_n])
 
 
 
504
 
505
  def _cat_or_single(parts):
506
  return parts[0] if len(parts) == 1 else torch.cat(parts, dim=0)
507
 
508
- return _cat_or_single(ids_parts), _cat_or_single(probs_parts), _cat_or_single(feat_parts)
 
 
 
 
509
 
510
  # ------------------------------------------------------------------
511
  # Phase 6 — Relational model + build output dict
@@ -545,10 +736,17 @@ class NemotronOCRV2(NemotronOCR):
545
 
546
  # Vectorized quad scaling
547
  scale_factors = torch.as_tensor(
548
- padded_lengths, dtype=torch.float32, device=quads_cuda.device,
 
 
549
  ) / float(self.infer_length)
550
  counts_long = region_counts.to(dtype=torch.long, device=quads_cuda.device)
551
- scale_per_region = torch.repeat_interleave(scale_factors, counts_long, dim=0)
 
 
 
 
 
552
  quads_scaled = quads_cuda * scale_per_region.view(-1, 1, 1)
553
 
554
  seq_ids = rec_ids
@@ -562,11 +760,9 @@ class NemotronOCRV2(NemotronOCR):
562
 
563
  output = {
564
  "sequences": seq_ids.cpu(),
565
- "sequence_probs": seq_probs.cpu(),
566
  "text_confidence": text_confidence.cpu(),
567
- "region_counts": region_counts.cpu(),
568
  "quads": quads_scaled.cpu(),
569
- "raw_detector_confidence": e2e_det_conf,
570
  "confidence": confidence.cpu(),
571
  "relations": words,
572
  "line_relations": lines,
@@ -574,7 +770,6 @@ class NemotronOCRV2(NemotronOCR):
574
  "fg_colors": None,
575
  "fonts": None,
576
  "tt_log_var_uncertainty": None,
577
- "e2e_recog_features": rec_features,
578
  }
579
 
580
  return output
@@ -586,24 +781,35 @@ class NemotronOCRV2(NemotronOCR):
586
  def _decode_with_fallback(self, output):
587
  """Decode recognized sequences using pre-computed argmax indices."""
588
  return self.recog_encoder.convert_targets_to_labels(
589
- output, image_size=None, is_gt=False,
 
 
590
  )
591
 
592
- def _postprocess_batch(self, output, original_shapes, merge_level, include_invalid, timings=None):
 
 
593
  """Decode sequences, build relation graphs, format per-image results."""
594
  import gc
 
595
  gc_was_enabled = gc.isenabled()
596
  gc.disable()
597
 
598
  try:
599
  return self._postprocess_batch_inner(
600
- output, original_shapes, merge_level, include_invalid, timings,
 
 
 
 
601
  )
602
  finally:
603
  if gc_was_enabled:
604
  gc.enable()
605
 
606
- def _postprocess_batch_inner(self, output, original_shapes, merge_level, include_invalid, timings=None):
 
 
607
  _t = time.perf_counter
608
 
609
  t0 = _t()
@@ -611,7 +817,9 @@ class NemotronOCRV2(NemotronOCR):
611
  recog_decode_ms = (_t() - t0) * 1000
612
 
613
  t0 = _t()
614
- relation_batch = self.relation_encoder.convert_targets_to_labels(output, image_size=None, is_gt=False)
 
 
615
  rel_decode_ms = (_t() - t0) * 1000
616
 
617
  t0 = _t()
@@ -634,7 +842,9 @@ class NemotronOCRV2(NemotronOCR):
634
  for example in batch:
635
  for text_region in example:
636
  v = text_region.region.vertices
637
- text_region.region = v.cpu().numpy() if hasattr(v, 'cpu') else np.asarray(v)
 
 
638
  graph_build_ms = (_t() - t0) * 1000
639
 
640
  t0 = _t()
@@ -647,7 +857,9 @@ class NemotronOCRV2(NemotronOCR):
647
  continue
648
 
649
  boxes, texts, scores = parse_relational_results(example, level=merge_level)
650
- boxes, texts, scores = reorder_boxes(boxes, texts, scores, mode="top_left", dbscan_eps=10)
 
 
651
 
652
  if len(boxes) == 0:
653
  all_predictions.append([])
@@ -659,14 +871,16 @@ class NemotronOCRV2(NemotronOCR):
659
 
660
  preds = []
661
  for box, text, conf in zip(boxes_array, texts, scores):
662
- preds.append({
663
- "text": text,
664
- "confidence": float(conf),
665
- "left": float(box[:, 0].min()),
666
- "upper": float(box[:, 1].max()),
667
- "right": float(box[:, 0].max()),
668
- "lower": float(box[:, 1].min()),
669
- })
 
 
670
  all_predictions.append(preds)
671
  format_ms = (_t() - t0) * 1000
672
 
@@ -701,7 +915,8 @@ class NemotronOCRV2(NemotronOCR):
701
  if profile:
702
  t0 = time.perf_counter()
703
  resized_batch, original_shapes, padded_lengths = self._preprocess_batch(
704
- images, timings=T,
 
705
  )
706
  if profile:
707
  torch.cuda.synchronize()
@@ -717,10 +932,11 @@ class NemotronOCRV2(NemotronOCR):
717
  T["detector"] = (time.perf_counter() - t0) * 1000
718
 
719
  # Phase 2.5: centerness + peak prefilter
 
720
  if self._use_prefilter:
721
  if profile:
722
  t0 = time.perf_counter()
723
- det_conf = self._prefilter_detections(det_conf, det_rboxes)
724
  if profile:
725
  torch.cuda.synchronize()
726
  T["prefilter"] = (time.perf_counter() - t0) * 1000
@@ -728,7 +944,7 @@ class NemotronOCRV2(NemotronOCR):
728
  # Phase 3: NMS
729
  if profile:
730
  t0 = time.perf_counter()
731
- nms_result = self._run_nms(det_conf, det_rboxes)
732
  del det_conf, det_rboxes
733
  if profile:
734
  torch.cuda.synchronize()
@@ -739,7 +955,10 @@ class NemotronOCRV2(NemotronOCR):
739
  T["total"] = (time.perf_counter() - t_wall) * 1000
740
  logger.info(
741
  "batch=%d regions=0 det=%.1f nms=%.1f total=%.1f ms (no detections)",
742
- num_images, T["detector"], T["nms"], T["total"],
 
 
 
743
  )
744
  return [[] for _ in range(num_images)]
745
 
@@ -750,10 +969,17 @@ class NemotronOCRV2(NemotronOCR):
750
  if self._detector_only:
751
  with torch.inference_mode():
752
  scale_factors = torch.as_tensor(
753
- padded_lengths, dtype=torch.float32, device=quads.device,
 
 
754
  ) / float(self.infer_length)
755
  counts_long = region_counts.to(dtype=torch.long, device=quads.device)
756
- scale_per_region = torch.repeat_interleave(scale_factors, counts_long, dim=0)
 
 
 
 
 
757
  quads_scaled = quads * scale_per_region.view(-1, 1, 1)
758
 
759
  region_counts_cpu = region_counts.cpu()
@@ -768,14 +994,16 @@ class NemotronOCRV2(NemotronOCR):
768
  predictions = []
769
  for i in range(n):
770
  q = quads_np[offset + i]
771
- predictions.append({
772
- "quad": q.tolist(),
773
- "confidence": float(confs_np[offset + i]),
774
- "left": float(q[:, 0].min() / orig_w),
775
- "right": float(q[:, 0].max() / orig_w),
776
- "upper": float(q[:, 1].max() / orig_h),
777
- "lower": float(q[:, 1].min() / orig_h),
778
- })
 
 
779
  offset += n
780
  all_predictions.append(predictions)
781
 
@@ -783,15 +1011,23 @@ class NemotronOCRV2(NemotronOCR):
783
  T["total"] = (time.perf_counter() - t_wall) * 1000
784
  logger.info(
785
  "batch=%d regions=%d det=%.1f nms=%.1f total=%.1f ms (detector_only)",
786
- num_images, total_regions, T["detector"], T["nms"], T["total"],
 
 
 
 
787
  )
788
  return all_predictions
789
 
790
  # Phase 4: rectify + grid sample
791
  if profile:
792
  t0 = time.perf_counter()
793
- quads_cuda, rec_quads, rel_quads, region_counts_cpu = self._run_rectify_and_sample(
794
- quads, region_counts, det_features,
 
 
 
 
795
  )
796
  del det_features
797
  if profile:
@@ -811,10 +1047,19 @@ class NemotronOCRV2(NemotronOCR):
811
  if self._skip_relational:
812
  with amp.autocast("cuda", enabled=True), torch.inference_mode():
813
  scale_factors = torch.as_tensor(
814
- padded_lengths, dtype=torch.float32, device=quads_cuda.device,
 
 
815
  ) / float(self.infer_length)
816
- counts_long = region_counts.to(dtype=torch.long, device=quads_cuda.device)
817
- scale_per_region = torch.repeat_interleave(scale_factors, counts_long, dim=0)
 
 
 
 
 
 
 
818
  quads_scaled = quads_cuda * scale_per_region.view(-1, 1, 1)
819
 
820
  seq_ids = rec_ids
@@ -822,35 +1067,41 @@ class NemotronOCRV2(NemotronOCR):
822
  _before_eos = (seq_ids == 1).cumsum(dim=1) == 0
823
  _real = (seq_ids != 0) & _before_eos
824
  _counts = _real.sum(dim=1).clamp(min=1).float()
825
- _log_sum = (torch.log(seq_probs.clamp(min=1e-8)) * _real.float()).sum(dim=1)
 
 
826
  text_confidence = torch.exp(_log_sum / _counts)
827
 
828
- batch = self._decode_with_fallback({
829
- "sequences": seq_ids.cpu(),
830
- "sequence_probs": seq_probs.cpu(),
831
- "text_confidence": text_confidence.cpu(),
832
- "region_counts": region_counts.cpu(),
833
- "quads": quads_scaled.cpu(),
834
- "confidence": confidence.cpu(),
835
- })
 
 
836
  all_predictions = []
837
  for img_idx, example in enumerate(batch):
838
  orig_h, orig_w = original_shapes[img_idx]
839
  predictions = []
840
  for text_region in example:
841
  v = text_region.region.vertices
842
- v = v.cpu().numpy() if hasattr(v, 'cpu') else np.asarray(v)
843
  v_norm = v.copy()
844
  v_norm[:, 0] /= orig_w
845
  v_norm[:, 1] /= orig_h
846
- predictions.append({
847
- "text": text_region.text,
848
- "confidence": text_region.confidence,
849
- "left": float(v_norm[:, 0].min()),
850
- "right": float(v_norm[:, 0].max()),
851
- "upper": float(v_norm[:, 1].max()),
852
- "lower": float(v_norm[:, 1].min()),
853
- })
 
 
854
  all_predictions.append(predictions)
855
 
856
  if profile:
@@ -859,9 +1110,14 @@ class NemotronOCRV2(NemotronOCR):
859
  logger.info(
860
  "batch=%d regions=%d(%s) det=%.1f nms=%.1f "
861
  "rectify=%.1f recog=%.1f total=%.1f ms (skip_relational)",
862
- num_images, total_regions, counts_str,
863
- T["detector"], T["nms"],
864
- T["rectify"], T["recognizer"], T["total"],
 
 
 
 
 
865
  )
866
  return all_predictions
867
 
@@ -889,7 +1145,11 @@ class NemotronOCRV2(NemotronOCR):
889
  if profile:
890
  t0 = time.perf_counter()
891
  results = self._postprocess_batch(
892
- output, original_shapes, merge_level, include_invalid, timings=T,
 
 
 
 
893
  )
894
  if profile:
895
  T["post_total"] = (time.perf_counter() - t0) * 1000
@@ -902,12 +1162,21 @@ class NemotronOCRV2(NemotronOCR):
902
  "rectify=%.1f recog=%.1f rel=%.1f "
903
  "post[decode=%.1f rel=%.1f graph=%.1f fmt=%.1f]=%.1f "
904
  "total=%.1f ms",
905
- num_images, total_regions, counts_str,
906
- T.get("img_load", 0), T.get("gpu_preproc", 0),
907
- T.get("detector", 0), T.get("prefilter", 0), T.get("nms", 0),
908
- T.get("rectify", 0), T.get("recognizer", 0), T.get("relational", 0),
909
- T.get("post_recog_decode", 0), T.get("post_rel_decode", 0),
910
- T.get("post_graph_build", 0), T.get("post_format", 0),
 
 
 
 
 
 
 
 
 
911
  T.get("post_total", 0),
912
  T["total"],
913
  )
 
21
  import torch.nn.functional as F
22
  from torch import amp
23
 
24
+ try:
25
+ import triton
26
+ import triton.language as tl
27
+ except ImportError: # The standalone model package does not require Triton.
28
+ triton = None
29
+ tl = None
30
+
31
  from nemotron_ocr.inference.pipeline import (
32
  NemotronOCR,
33
  DETECTOR_DOWNSAMPLE,
 
58
  _DEFAULT_NUM_TOKENS = 858
59
 
60
 
61
+ if triton is not None:
62
+
63
+ @triton.jit
64
+ def _argmax_softmax_probability_kernel(
65
+ logits_ptr,
66
+ ids_ptr,
67
+ probabilities_ptr,
68
+ row_stride: tl.constexpr,
69
+ num_columns: tl.constexpr,
70
+ BLOCK_SIZE: tl.constexpr,
71
+ ):
72
+ row = tl.program_id(0)
73
+ offsets = tl.arange(0, BLOCK_SIZE)
74
+ values = tl.load(
75
+ logits_ptr + row * row_stride + offsets,
76
+ mask=offsets < num_columns,
77
+ other=-float("inf"),
78
+ ).to(tl.float32)
79
+ max_value = tl.max(values, axis=0)
80
+ max_index = tl.argmax(values, axis=0)
81
+ denominator = tl.sum(tl.exp(values - max_value), axis=0)
82
+ tl.store(ids_ptr + row, max_index)
83
+ tl.store(probabilities_ptr + row, 1.0 / denominator)
84
+
85
+
86
+ def _argmax_softmax_probability(logits):
87
+ """Return argmax IDs and their softmax probabilities without a full output."""
88
+ if triton is None or not logits.is_cuda:
89
+ ids = logits.argmax(dim=2)
90
+ probabilities = (
91
+ torch.softmax(logits, dim=2).gather(2, ids.unsqueeze(2)).squeeze(2)
92
+ )
93
+ return ids, probabilities.float()
94
+
95
+ rows = logits.reshape(-1, logits.shape[-1])
96
+ ids = torch.empty(rows.shape[0], dtype=torch.int64, device=logits.device)
97
+ probabilities = torch.empty(rows.shape[0], dtype=logits.dtype, device=logits.device)
98
+ block_size = triton.next_power_of_2(rows.shape[1])
99
+ _argmax_softmax_probability_kernel[(rows.shape[0],)](
100
+ rows,
101
+ ids,
102
+ probabilities,
103
+ rows.stride(0),
104
+ rows.shape[1],
105
+ BLOCK_SIZE=block_size,
106
+ num_warps=8,
107
+ )
108
+ output_shape = logits.shape[:-1]
109
+ return ids.view(output_shape), probabilities.view(output_shape).float()
110
+
111
+
112
  class NemotronOCRV2(NemotronOCR):
113
  """Batched OCR inference pipeline.
114
 
 
167
 
168
  torch.backends.cuda.matmul.allow_tf32 = True
169
  torch.backends.cudnn.allow_tf32 = True
170
+ torch.backends.cudnn.benchmark = (
171
+ os.environ.get("NEMOTRON_OCR_CUDNN_BENCHMARK", "0") == "1"
172
+ )
173
+ self._detector_channels_last = (
174
+ os.environ.get("NEMOTRON_OCR_DETECTOR_CHANNELS_LAST", "0") == "1"
175
+ )
176
+ self._mixed_grid_sample = (
177
+ os.environ.get("NEMOTRON_OCR_MIXED_GRID_SAMPLE", "0") == "1"
178
+ )
179
+ self._fused_batch_norm_relu_count = 0
180
+ self._fused_residual_norm_count = 0
181
+ self._fused_aspp_norm_add_count = 0
182
+ if os.environ.get("NEMOTRON_OCR_FUSED_ASPP_NORM_ADD", "0") == "1":
183
+ from nemotron_ocr.inference.models.detector.fast_batch_norm import (
184
+ fuse_aspp_batch_norm_relu_add,
185
+ )
186
+
187
+ self._fused_aspp_norm_add_count = fuse_aspp_batch_norm_relu_add(
188
+ self.detector
189
+ )
190
+ logger.info(
191
+ "Replaced %d ASPP norm/ReLU/residual-add paths",
192
+ self._fused_aspp_norm_add_count,
193
+ )
194
+ if os.environ.get("NEMOTRON_OCR_FUSED_RESIDUAL_NORM", "0") == "1":
195
+ from nemotron_ocr.inference.models.detector.fast_batch_norm import (
196
+ fuse_residual_batch_norm_add_relu,
197
+ )
198
+
199
+ self._fused_residual_norm_count = fuse_residual_batch_norm_add_relu(
200
+ self.detector
201
+ )
202
+ logger.info(
203
+ "Replaced %d detector residual norm/add/ReLU blocks",
204
+ self._fused_residual_norm_count,
205
+ )
206
+ if os.environ.get("NEMOTRON_OCR_FUSED_BATCH_NORM_RELU", "0") == "1":
207
+ from nemotron_ocr.inference.models.detector.fast_batch_norm import (
208
+ fuse_batch_norm_relu,
209
+ )
210
+
211
+ self._fused_batch_norm_relu_count = fuse_batch_norm_relu(self.detector)
212
+ logger.info(
213
+ "Replaced %d detector BatchNorm+ReLU pairs with fused kernels",
214
+ self._fused_batch_norm_relu_count,
215
+ )
216
+ if self._detector_channels_last:
217
+ self.detector.to(memory_format=torch.channels_last)
218
+ detector_compile_mode = os.environ.get("NEMOTRON_OCR_COMPILE_DETECTOR")
219
+ if detector_compile_mode:
220
+ self.detector = torch.compile(
221
+ self.detector,
222
+ mode=detector_compile_mode,
223
+ dynamic=False,
224
+ fullgraph=False,
225
+ )
226
 
227
  # ── pad_color ────────────────────────────────────────────────
228
  if pad_color is not None:
 
254
  if hasattr(self, "relational"):
255
  self.relational.chunk_size = relational_chunk_size
256
 
257
+ if (
258
+ verbose_post
259
+ and hasattr(self, "relation_encoder")
260
+ and hasattr(self.relation_encoder, "_verbose")
261
+ ):
262
  self.relation_encoder._verbose = True
263
 
264
  if not self._detector_only and hasattr(self, "recognizer"):
265
  self._pad_classifier_for_alignment(64)
266
+ recognizer_compile_mode = os.environ.get("NEMOTRON_OCR_COMPILE_RECOGNIZER")
267
+ self._recognizer_clone_compiled_outputs = recognizer_compile_mode in {
268
+ "reduce-overhead",
269
+ "max-autotune",
270
+ }
271
+ if recognizer_compile_mode:
272
+ self.recognizer = torch.compile(
273
+ self.recognizer,
274
+ mode=recognizer_compile_mode,
275
+ dynamic=False,
276
+ fullgraph=False,
277
+ )
278
 
279
  # ------------------------------------------------------------------
280
  # Tensor-core alignment
 
305
  self.recognizer.classifier = new_cls
306
  logger.info(
307
  "Padded recognizer classifier %d -> %d (alignment=%d)",
308
+ real,
309
+ padded,
310
+ alignment,
311
  )
312
 
313
  # ------------------------------------------------------------------
 
389
  del tensor_gpu
390
 
391
  resized = interpolate_and_pad(
392
+ padded.unsqueeze(0),
393
+ self._pad_color,
394
+ self.infer_length,
395
  ).squeeze(0)
396
  del padded
397
  resized_list.append(resized)
 
421
  if t.shape[0] == 4:
422
  t = t[:3]
423
  if t.dtype != torch.uint8:
424
+ t = (
425
+ (t * 255).clamp(0, 255).to(torch.uint8)
426
+ if t.is_floating_point()
427
+ else t.to(torch.uint8)
428
+ )
429
  return t
430
  if t.ndim == 3 and t.shape[2] in (1, 3, 4):
431
  t = t.permute(2, 0, 1)
432
  if t.shape[0] == 4:
433
  t = t[:3]
434
  if t.dtype != torch.uint8:
435
+ t = (
436
+ (t * 255).clamp(0, 255).to(torch.uint8)
437
+ if t.is_floating_point()
438
+ else t.to(torch.uint8)
439
+ )
440
  return t
441
  raise ValueError(f"Unsupported tensor shape: {image.shape}")
442
 
 
446
  if image.shape[2] == 4:
447
  image = image[..., :3]
448
  if image.dtype != np.uint8:
449
+ image = (
450
+ (image * 255).clip(0, 255).astype(np.uint8)
451
+ if image.max() <= 1.0
452
+ else image.astype(np.uint8)
453
+ )
454
  return torch.from_numpy(image).permute(2, 0, 1)
455
 
456
  from torchvision.io import read_image, decode_image
457
+
458
  if isinstance(image, (str, os.PathLike)):
459
  return read_image(str(image), mode="RGB")
460
  if isinstance(image, bytes):
461
  import base64
462
+
463
  img_bytes = base64.b64decode(image)
464
+ return decode_image(
465
+ torch.frombuffer(img_bytes, dtype=torch.uint8), mode="RGB"
466
+ )
467
+ if hasattr(image, "read"):
468
  image.seek(0)
469
+ return decode_image(
470
+ torch.frombuffer(image.getvalue(), dtype=torch.uint8), mode="RGB"
471
+ )
472
 
473
  raise TypeError(f"Unsupported input type: {type(image)}")
474
 
 
491
  for start in range(0, n, self.detector_max_batch_size):
492
  end = min(start + self.detector_max_batch_size, n)
493
  chunk = resized_batch[start:end]
494
+ if self._detector_channels_last:
495
+ chunk = chunk.contiguous(memory_format=torch.channels_last)
496
  c, _, r, f = self.detector(chunk)
497
  conf_parts.append(c)
498
  rbox_parts.append(r)
 
517
  to suppress edge pixels, then keeps only local maxima. This prevents
518
  the O(n^2) NMS adjacency blowup on images with large text regions
519
  while preserving all real detection peaks.
520
+
521
+ Returns sigmoid probabilities with non-peaks masked exactly as in the
522
+ legacy filtered-logit path. NMS reuses them so the dense confidence
523
+ map is not passed through sigmoid a second time.
524
  """
525
  with torch.inference_mode():
526
+ conf_sigmoid = torch.sigmoid(det_conf.float())
527
  d_top = det_rboxes[..., 0].float()
528
  d_right = det_rboxes[..., 1].float()
529
  d_bottom = det_rboxes[..., 2].float()
 
536
 
537
  centerness = torch.sqrt((lr_min / lr_max) * (tb_min / tb_max))
538
 
 
539
  adjusted = conf_sigmoid * centerness
540
 
541
  k = self._prefilter_peak_kernel
542
  pad = k // 2
543
  adj_4d = adjusted.unsqueeze(1)
544
  pooled = F.max_pool2d(adj_4d, k, stride=1, padding=pad)
545
+ pooled = pooled[:, 0, : det_conf.shape[1], : det_conf.shape[2]]
546
 
547
  peaks = (adjusted == pooled) & (adjusted > NMS_PROB_THRESHOLD)
548
 
549
+ # Match ``torch.sigmoid(filtered_logits)`` exactly while reusing
550
+ # the probabilities already computed for peak selection.
551
+ # Detector logits can be fp16/bf16 under autocast, so cast back to
552
+ # their dtype before NMS. Sigmoid(-100) is exactly zero in each
553
+ # detector dtype. Keep a legacy fallback for non-detector dtypes
554
+ # (notably float64, where sigmoid(-100) remains nonzero).
555
+ if det_conf.dtype in (torch.float16, torch.bfloat16, torch.float32):
556
+ e2e_det_conf = conf_sigmoid.to(dtype=det_conf.dtype)
557
+ e2e_det_conf.masked_fill_(~peaks, 0.0)
558
+ else:
559
+ filtered = det_conf.clone()
560
+ filtered[~peaks] = -100.0
561
+ e2e_det_conf = torch.sigmoid(filtered)
562
+
563
+ return e2e_det_conf
564
+
565
+ def _run_nms(self, det_conf, det_rboxes, e2e_det_conf=None):
566
  """Sigmoid + rrect_to_quads + NMS.
567
 
568
  Returns:
569
  (quads, confidence, region_counts, e2e_det_conf) or None if
570
  zero detections.
571
  """
572
+ if e2e_det_conf is None:
573
+ with torch.inference_mode():
574
+ e2e_det_conf = torch.sigmoid(det_conf)
575
 
576
  with amp.autocast("cuda", enabled=True), torch.inference_mode():
577
  e2e_det_coords = rrect_to_quads(det_rboxes.float(), DETECTOR_DOWNSAMPLE)
 
613
  input_indices_cuda = input_indices.cuda(non_blocking=True)
614
  region_counts_cpu = region_counts.cpu()
615
 
616
+ grid_features = (
617
+ det_features if self._mixed_grid_sample else det_features.float()
618
+ )
619
+ rec_quads = self.grid_sampler(
620
+ grid_features, rec_rectified, input_indices_cuda
621
+ )
622
 
623
  rel_quads = None
624
  if not self._skip_relational:
625
  rel_rectified = self.relational_quad_rectifier(quads_cuda, h, w)
626
+ rel_quads = self.grid_sampler(
627
+ grid_features, rel_rectified, input_indices_cuda
628
+ )
629
 
630
  return quads_cuda, rec_quads, rel_quads, region_counts_cpu
631
 
 
654
  return (
655
  torch.empty(0, self.max_width, dtype=torch.int64, device=device),
656
  torch.empty(0, self.max_width, dtype=torch.float32, device=device),
657
+ torch.empty(
658
+ 0,
659
+ self.max_width,
660
+ self.recognizer.feature_depth,
661
+ dtype=torch.float16,
662
+ device=device,
663
+ ),
664
  )
665
 
666
  cs = self.recognizer_chunk_size
 
671
  chunk = rec_quads[start:end].half()
672
  real_n = chunk.shape[0]
673
  if real_n < cs:
674
+ pad = torch.zeros(
675
+ cs - real_n,
676
+ *chunk.shape[1:],
677
+ dtype=chunk.dtype,
678
+ device=chunk.device,
679
+ )
680
  chunk = torch.cat([chunk, pad], dim=0)
681
  logits, feats = self.recognizer(chunk)
682
 
683
+ ids, probs = _argmax_softmax_probability(logits)
 
 
 
684
 
685
  ids_parts.append(ids[:real_n])
686
  probs_parts.append(probs[:real_n])
687
+ real_features = feats[:real_n]
688
+ if self._recognizer_clone_compiled_outputs:
689
+ real_features = real_features.clone()
690
+ feat_parts.append(real_features)
691
 
692
  def _cat_or_single(parts):
693
  return parts[0] if len(parts) == 1 else torch.cat(parts, dim=0)
694
 
695
+ return (
696
+ _cat_or_single(ids_parts),
697
+ _cat_or_single(probs_parts),
698
+ _cat_or_single(feat_parts),
699
+ )
700
 
701
  # ------------------------------------------------------------------
702
  # Phase 6 — Relational model + build output dict
 
736
 
737
  # Vectorized quad scaling
738
  scale_factors = torch.as_tensor(
739
+ padded_lengths,
740
+ dtype=torch.float32,
741
+ device=quads_cuda.device,
742
  ) / float(self.infer_length)
743
  counts_long = region_counts.to(dtype=torch.long, device=quads_cuda.device)
744
+ scale_per_region = torch.repeat_interleave(
745
+ scale_factors,
746
+ counts_long,
747
+ dim=0,
748
+ output_size=quads_cuda.shape[0],
749
+ )
750
  quads_scaled = quads_cuda * scale_per_region.view(-1, 1, 1)
751
 
752
  seq_ids = rec_ids
 
760
 
761
  output = {
762
  "sequences": seq_ids.cpu(),
 
763
  "text_confidence": text_confidence.cpu(),
764
+ "region_counts": region_counts_cpu,
765
  "quads": quads_scaled.cpu(),
 
766
  "confidence": confidence.cpu(),
767
  "relations": words,
768
  "line_relations": lines,
 
770
  "fg_colors": None,
771
  "fonts": None,
772
  "tt_log_var_uncertainty": None,
 
773
  }
774
 
775
  return output
 
781
  def _decode_with_fallback(self, output):
782
  """Decode recognized sequences using pre-computed argmax indices."""
783
  return self.recog_encoder.convert_targets_to_labels(
784
+ output,
785
+ image_size=None,
786
+ is_gt=False,
787
  )
788
 
789
+ def _postprocess_batch(
790
+ self, output, original_shapes, merge_level, include_invalid, timings=None
791
+ ):
792
  """Decode sequences, build relation graphs, format per-image results."""
793
  import gc
794
+
795
  gc_was_enabled = gc.isenabled()
796
  gc.disable()
797
 
798
  try:
799
  return self._postprocess_batch_inner(
800
+ output,
801
+ original_shapes,
802
+ merge_level,
803
+ include_invalid,
804
+ timings,
805
  )
806
  finally:
807
  if gc_was_enabled:
808
  gc.enable()
809
 
810
+ def _postprocess_batch_inner(
811
+ self, output, original_shapes, merge_level, include_invalid, timings=None
812
+ ):
813
  _t = time.perf_counter
814
 
815
  t0 = _t()
 
817
  recog_decode_ms = (_t() - t0) * 1000
818
 
819
  t0 = _t()
820
+ relation_batch = self.relation_encoder.convert_targets_to_labels(
821
+ output, image_size=None, is_gt=False
822
+ )
823
  rel_decode_ms = (_t() - t0) * 1000
824
 
825
  t0 = _t()
 
842
  for example in batch:
843
  for text_region in example:
844
  v = text_region.region.vertices
845
+ text_region.region = (
846
+ v.cpu().numpy() if hasattr(v, "cpu") else np.asarray(v)
847
+ )
848
  graph_build_ms = (_t() - t0) * 1000
849
 
850
  t0 = _t()
 
857
  continue
858
 
859
  boxes, texts, scores = parse_relational_results(example, level=merge_level)
860
+ boxes, texts, scores = reorder_boxes(
861
+ boxes, texts, scores, mode="top_left", dbscan_eps=10
862
+ )
863
 
864
  if len(boxes) == 0:
865
  all_predictions.append([])
 
871
 
872
  preds = []
873
  for box, text, conf in zip(boxes_array, texts, scores):
874
+ preds.append(
875
+ {
876
+ "text": text,
877
+ "confidence": float(conf),
878
+ "left": float(box[:, 0].min()),
879
+ "upper": float(box[:, 1].max()),
880
+ "right": float(box[:, 0].max()),
881
+ "lower": float(box[:, 1].min()),
882
+ }
883
+ )
884
  all_predictions.append(preds)
885
  format_ms = (_t() - t0) * 1000
886
 
 
915
  if profile:
916
  t0 = time.perf_counter()
917
  resized_batch, original_shapes, padded_lengths = self._preprocess_batch(
918
+ images,
919
+ timings=T,
920
  )
921
  if profile:
922
  torch.cuda.synchronize()
 
932
  T["detector"] = (time.perf_counter() - t0) * 1000
933
 
934
  # Phase 2.5: centerness + peak prefilter
935
+ e2e_det_conf = None
936
  if self._use_prefilter:
937
  if profile:
938
  t0 = time.perf_counter()
939
+ e2e_det_conf = self._prefilter_detections(det_conf, det_rboxes)
940
  if profile:
941
  torch.cuda.synchronize()
942
  T["prefilter"] = (time.perf_counter() - t0) * 1000
 
944
  # Phase 3: NMS
945
  if profile:
946
  t0 = time.perf_counter()
947
+ nms_result = self._run_nms(det_conf, det_rboxes, e2e_det_conf)
948
  del det_conf, det_rboxes
949
  if profile:
950
  torch.cuda.synchronize()
 
955
  T["total"] = (time.perf_counter() - t_wall) * 1000
956
  logger.info(
957
  "batch=%d regions=0 det=%.1f nms=%.1f total=%.1f ms (no detections)",
958
+ num_images,
959
+ T["detector"],
960
+ T["nms"],
961
+ T["total"],
962
  )
963
  return [[] for _ in range(num_images)]
964
 
 
969
  if self._detector_only:
970
  with torch.inference_mode():
971
  scale_factors = torch.as_tensor(
972
+ padded_lengths,
973
+ dtype=torch.float32,
974
+ device=quads.device,
975
  ) / float(self.infer_length)
976
  counts_long = region_counts.to(dtype=torch.long, device=quads.device)
977
+ scale_per_region = torch.repeat_interleave(
978
+ scale_factors,
979
+ counts_long,
980
+ dim=0,
981
+ output_size=quads.shape[0],
982
+ )
983
  quads_scaled = quads * scale_per_region.view(-1, 1, 1)
984
 
985
  region_counts_cpu = region_counts.cpu()
 
994
  predictions = []
995
  for i in range(n):
996
  q = quads_np[offset + i]
997
+ predictions.append(
998
+ {
999
+ "quad": q.tolist(),
1000
+ "confidence": float(confs_np[offset + i]),
1001
+ "left": float(q[:, 0].min() / orig_w),
1002
+ "right": float(q[:, 0].max() / orig_w),
1003
+ "upper": float(q[:, 1].max() / orig_h),
1004
+ "lower": float(q[:, 1].min() / orig_h),
1005
+ }
1006
+ )
1007
  offset += n
1008
  all_predictions.append(predictions)
1009
 
 
1011
  T["total"] = (time.perf_counter() - t_wall) * 1000
1012
  logger.info(
1013
  "batch=%d regions=%d det=%.1f nms=%.1f total=%.1f ms (detector_only)",
1014
+ num_images,
1015
+ total_regions,
1016
+ T["detector"],
1017
+ T["nms"],
1018
+ T["total"],
1019
  )
1020
  return all_predictions
1021
 
1022
  # Phase 4: rectify + grid sample
1023
  if profile:
1024
  t0 = time.perf_counter()
1025
+ quads_cuda, rec_quads, rel_quads, region_counts_cpu = (
1026
+ self._run_rectify_and_sample(
1027
+ quads,
1028
+ region_counts,
1029
+ det_features,
1030
+ )
1031
  )
1032
  del det_features
1033
  if profile:
 
1047
  if self._skip_relational:
1048
  with amp.autocast("cuda", enabled=True), torch.inference_mode():
1049
  scale_factors = torch.as_tensor(
1050
+ padded_lengths,
1051
+ dtype=torch.float32,
1052
+ device=quads_cuda.device,
1053
  ) / float(self.infer_length)
1054
+ counts_long = region_counts.to(
1055
+ dtype=torch.long, device=quads_cuda.device
1056
+ )
1057
+ scale_per_region = torch.repeat_interleave(
1058
+ scale_factors,
1059
+ counts_long,
1060
+ dim=0,
1061
+ output_size=quads_cuda.shape[0],
1062
+ )
1063
  quads_scaled = quads_cuda * scale_per_region.view(-1, 1, 1)
1064
 
1065
  seq_ids = rec_ids
 
1067
  _before_eos = (seq_ids == 1).cumsum(dim=1) == 0
1068
  _real = (seq_ids != 0) & _before_eos
1069
  _counts = _real.sum(dim=1).clamp(min=1).float()
1070
+ _log_sum = (torch.log(seq_probs.clamp(min=1e-8)) * _real.float()).sum(
1071
+ dim=1
1072
+ )
1073
  text_confidence = torch.exp(_log_sum / _counts)
1074
 
1075
+ batch = self._decode_with_fallback(
1076
+ {
1077
+ "sequences": seq_ids.cpu(),
1078
+ "sequence_probs": seq_probs.cpu(),
1079
+ "text_confidence": text_confidence.cpu(),
1080
+ "region_counts": region_counts.cpu(),
1081
+ "quads": quads_scaled.cpu(),
1082
+ "confidence": confidence.cpu(),
1083
+ }
1084
+ )
1085
  all_predictions = []
1086
  for img_idx, example in enumerate(batch):
1087
  orig_h, orig_w = original_shapes[img_idx]
1088
  predictions = []
1089
  for text_region in example:
1090
  v = text_region.region.vertices
1091
+ v = v.cpu().numpy() if hasattr(v, "cpu") else np.asarray(v)
1092
  v_norm = v.copy()
1093
  v_norm[:, 0] /= orig_w
1094
  v_norm[:, 1] /= orig_h
1095
+ predictions.append(
1096
+ {
1097
+ "text": text_region.text,
1098
+ "confidence": text_region.confidence,
1099
+ "left": float(v_norm[:, 0].min()),
1100
+ "right": float(v_norm[:, 0].max()),
1101
+ "upper": float(v_norm[:, 1].max()),
1102
+ "lower": float(v_norm[:, 1].min()),
1103
+ }
1104
+ )
1105
  all_predictions.append(predictions)
1106
 
1107
  if profile:
 
1110
  logger.info(
1111
  "batch=%d regions=%d(%s) det=%.1f nms=%.1f "
1112
  "rectify=%.1f recog=%.1f total=%.1f ms (skip_relational)",
1113
+ num_images,
1114
+ total_regions,
1115
+ counts_str,
1116
+ T["detector"],
1117
+ T["nms"],
1118
+ T["rectify"],
1119
+ T["recognizer"],
1120
+ T["total"],
1121
  )
1122
  return all_predictions
1123
 
 
1145
  if profile:
1146
  t0 = time.perf_counter()
1147
  results = self._postprocess_batch(
1148
+ output,
1149
+ original_shapes,
1150
+ merge_level,
1151
+ include_invalid,
1152
+ timings=T,
1153
  )
1154
  if profile:
1155
  T["post_total"] = (time.perf_counter() - t0) * 1000
 
1162
  "rectify=%.1f recog=%.1f rel=%.1f "
1163
  "post[decode=%.1f rel=%.1f graph=%.1f fmt=%.1f]=%.1f "
1164
  "total=%.1f ms",
1165
+ num_images,
1166
+ total_regions,
1167
+ counts_str,
1168
+ T.get("img_load", 0),
1169
+ T.get("gpu_preproc", 0),
1170
+ T.get("detector", 0),
1171
+ T.get("prefilter", 0),
1172
+ T.get("nms", 0),
1173
+ T.get("rectify", 0),
1174
+ T.get("recognizer", 0),
1175
+ T.get("relational", 0),
1176
+ T.get("post_recog_decode", 0),
1177
+ T.get("post_rel_decode", 0),
1178
+ T.get("post_graph_build", 0),
1179
+ T.get("post_format", 0),
1180
  T.get("post_total", 0),
1181
  T["total"],
1182
  )
nemotron-ocr/tests/test_detector_exact_copy_helpers.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import pytest
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+ import nemotron_ocr.inference.models.detector.aspp as aspp_module
9
+ import nemotron_ocr.inference.models.detector.fots_detector as fots_module
10
+ from nemotron_ocr.inference.models.detector.aspp import ASPP, _aspp_concat
11
+ from nemotron_ocr.inference.models.detector.fots_detector import (
12
+ _upsample_concat_nearest,
13
+ merge,
14
+ )
15
+
16
+
17
+ @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32])
18
+ @pytest.mark.parametrize(
19
+ ("batch", "input_channels", "side_channels", "height", "width"),
20
+ [(1, 1, 2, 2, 3), (2, 3, 1, 3, 2), (1, 2, 3, 1, 5)],
21
+ )
22
+ def test_upsample_concat_cpu_fallback_is_exact(
23
+ dtype, batch, input_channels, side_channels, height, width
24
+ ):
25
+ input_tensor = torch.arange(
26
+ batch * input_channels * height * width, dtype=torch.float32
27
+ ).reshape(batch, input_channels, height, width)
28
+ input_tensor = input_tensor.to(dtype)
29
+ side_tensor = torch.arange(
30
+ batch * side_channels * height * 2 * width * 2, dtype=torch.float32
31
+ ).reshape(batch, side_channels, height * 2, width * 2)
32
+ side_tensor = side_tensor.to(dtype).add(100)
33
+
34
+ expected = torch.cat(
35
+ (F.interpolate(input_tensor, scale_factor=2, mode="nearest"), side_tensor),
36
+ dim=1,
37
+ )
38
+ actual = _upsample_concat_nearest(input_tensor, side_tensor)
39
+
40
+ assert actual.dtype == dtype
41
+ assert actual.is_contiguous()
42
+ assert torch.equal(actual, expected)
43
+
44
+
45
+ @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32])
46
+ @pytest.mark.parametrize(
47
+ ("batch", "channels", "height", "width"),
48
+ [(1, 2, 3, 5), (2, 1, 1, 4), (2, 3, 2, 2)],
49
+ )
50
+ def test_aspp_concat_cpu_fallback_is_exact(dtype, batch, channels, height, width):
51
+ elements = batch * channels * height * width
52
+ base = torch.arange(elements, dtype=torch.float32).reshape(
53
+ batch, channels, height, width
54
+ )
55
+ branches = [(base + branch * 100).to(dtype) for branch in range(7)]
56
+ pooled = torch.arange(batch * channels, dtype=torch.float32).reshape(
57
+ batch, channels, 1, 1
58
+ )
59
+ pooled = pooled.to(dtype).add(1000)
60
+
61
+ expected_pooled = pooled.expand(-1, -1, height, width)
62
+ expected = torch.cat([*branches, expected_pooled], dim=1)
63
+ actual = _aspp_concat(branches, pooled)
64
+
65
+ assert actual.dtype == dtype
66
+ assert actual.is_contiguous()
67
+ assert torch.equal(actual, expected)
68
+ assert torch.equal(actual[:, 7 * channels :], expected_pooled)
69
+
70
+
71
+ class _FakeCudaTensor:
72
+ def __init__(self, shape, *, contiguous=True):
73
+ self.shape = shape
74
+ self.is_cuda = True
75
+ self._contiguous = contiguous
76
+
77
+ def is_contiguous(self):
78
+ return self._contiguous
79
+
80
+
81
+ @pytest.mark.parametrize(
82
+ ("input_tensor", "side_tensor", "message"),
83
+ [
84
+ (
85
+ _FakeCudaTensor((1, 2, 2, 3)),
86
+ _FakeCudaTensor((2, 4, 4, 6)),
87
+ "fused upsample-concat requires an exact 2x side tensor",
88
+ ),
89
+ (
90
+ _FakeCudaTensor((1, 2, 2, 3)),
91
+ _FakeCudaTensor((1, 4, 5, 6)),
92
+ "fused upsample-concat requires an exact 2x side tensor",
93
+ ),
94
+ (
95
+ _FakeCudaTensor((1, 2, 2, 3)),
96
+ _FakeCudaTensor((1, 4, 4, 5)),
97
+ "fused upsample-concat requires an exact 2x side tensor",
98
+ ),
99
+ ],
100
+ )
101
+ def test_upsample_concat_validation_without_cuda(
102
+ monkeypatch, input_tensor, side_tensor, message
103
+ ):
104
+ monkeypatch.setattr(fots_module, "triton", object())
105
+
106
+ with pytest.raises(ValueError, match=message):
107
+ _upsample_concat_nearest(input_tensor, side_tensor)
108
+
109
+
110
+ def test_upsample_concat_noncontiguous_cuda_falls_back(monkeypatch):
111
+ input_tensor = _FakeCudaTensor((1, 2, 2, 3), contiguous=False)
112
+ side_tensor = _FakeCudaTensor((1, 4, 4, 6))
113
+ sentinel = object()
114
+ monkeypatch.setattr(fots_module, "triton", object())
115
+ monkeypatch.setattr(fots_module.F, "interpolate", lambda *args, **kwargs: sentinel)
116
+ monkeypatch.setattr(
117
+ fots_module.torch,
118
+ "cat",
119
+ lambda tensors, dim: (tensors, dim),
120
+ )
121
+
122
+ tensors, dim = _upsample_concat_nearest(input_tensor, side_tensor)
123
+
124
+ assert tensors == (sentinel, side_tensor)
125
+ assert dim == 1
126
+
127
+
128
+ def test_aspp_concat_requires_seven_branches_without_cuda(monkeypatch):
129
+ monkeypatch.setattr(aspp_module, "triton", object())
130
+ branch = _FakeCudaTensor((1, 2, 3, 4))
131
+
132
+ with pytest.raises(
133
+ ValueError, match="fused ASPP concat expects seven spatial branches"
134
+ ):
135
+ _aspp_concat([branch] * 6, _FakeCudaTensor((1, 2, 1, 1)))
136
+
137
+
138
+ def test_aspp_concat_noncontiguous_cuda_falls_back(monkeypatch):
139
+ monkeypatch.setattr(aspp_module, "triton", object())
140
+ branches = [_FakeCudaTensor((1, 2, 3, 4)) for _ in range(7)]
141
+ branches[3] = _FakeCudaTensor((1, 2, 3, 4), contiguous=False)
142
+ pooled = _FakeCudaTensor((1, 2, 1, 1))
143
+ sentinel = object()
144
+ monkeypatch.setattr(pooled, "expand", lambda *args: sentinel, raising=False)
145
+ monkeypatch.setattr(
146
+ aspp_module.torch,
147
+ "cat",
148
+ lambda tensors, dim: (tensors, dim),
149
+ )
150
+
151
+ tensors, dim = _aspp_concat(branches, pooled)
152
+
153
+ assert tensors == [*branches, sentinel]
154
+ assert dim == 1
155
+
156
+
157
+ def test_aspp_concat_validates_pooled_shape_without_cuda(monkeypatch):
158
+ monkeypatch.setattr(aspp_module, "triton", object())
159
+ branches = [_FakeCudaTensor((2, 3, 4, 5)) for _ in range(7)]
160
+
161
+ with pytest.raises(
162
+ ValueError, match="fused ASPP pooled branch has an unexpected shape"
163
+ ):
164
+ _aspp_concat(branches, _FakeCudaTensor((2, 3, 4, 5)))
165
+
166
+
167
+ def test_exact_copy_helpers_are_disabled_by_default(monkeypatch):
168
+ monkeypatch.delenv("NEMOTRON_OCR_FUSED_UPSAMPLE_CONCAT", raising=False)
169
+ monkeypatch.delenv("NEMOTRON_OCR_FUSED_ASPP_CONCAT", raising=False)
170
+
171
+ assert merge([4])._fused_upsample_concat is False
172
+ assert ASPP(in_channels=1, num_channels=1)._fused_concat is False
nemotron-ocr/tests/test_fast_batch_norm.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+
8
+ import pytest
9
+ import torch
10
+ from torch import nn
11
+
12
+ from nemotron_ocr.inference.models.detector.aspp import ASPP
13
+ from nemotron_ocr.inference.models.detector.fast_batch_norm import (
14
+ FusedBatchNormReLU,
15
+ FusedBatchNormReLUAdd,
16
+ FusedResidualBlock,
17
+ fuse_aspp_batch_norm_relu_add,
18
+ fuse_batch_norm_relu,
19
+ fuse_residual_batch_norm_add_relu,
20
+ )
21
+ from nemotron_ocr.inference.models.detector.regnet import ResBottleneckBlock
22
+
23
+
24
+ def _residual_block(*, projected: bool = False) -> ResBottleneckBlock:
25
+ return ResBottleneckBlock(
26
+ width_in=4,
27
+ width_out=8 if projected else 4,
28
+ stride=2 if projected else 1,
29
+ norm_layer=nn.BatchNorm2d,
30
+ activation_layer=nn.ReLU,
31
+ group_width=1,
32
+ bottleneck_multiplier=1.0,
33
+ se_ratio=None,
34
+ )
35
+
36
+
37
+ def test_batch_norm_relu_cpu_fallback_is_exact_and_idempotent():
38
+ original = nn.Sequential(nn.BatchNorm2d(3), nn.ReLU(inplace=True)).eval()
39
+ transformed = copy.deepcopy(original)
40
+ inputs = torch.randn(2, 3, 7, 9)
41
+
42
+ assert fuse_batch_norm_relu(transformed) == 1
43
+ assert fuse_batch_norm_relu(transformed) == 0
44
+ assert torch.equal(transformed(inputs), original(inputs))
45
+
46
+
47
+ @pytest.mark.parametrize("projected", [False, True])
48
+ def test_residual_cpu_fallback_is_exact(projected):
49
+ original = nn.Sequential(_residual_block(projected=projected)).eval()
50
+ transformed = copy.deepcopy(original)
51
+ inputs = torch.randn(2, 4, 8, 10)
52
+
53
+ assert fuse_residual_batch_norm_add_relu(transformed) == 1
54
+ assert torch.equal(transformed(inputs), original(inputs))
55
+
56
+
57
+ def test_aspp_fusion_requires_zero_dropout_eval_residual():
58
+ eligible = ASPP(in_channels=3, num_channels=3, dropout=0.0).eval()
59
+ nonzero_dropout = ASPP(in_channels=3, num_channels=3, dropout=0.5).eval()
60
+ training = ASPP(in_channels=3, num_channels=3, dropout=0.0).train()
61
+ nonresidual = ASPP(in_channels=4, num_channels=3, dropout=0.0).eval()
62
+
63
+ assert fuse_aspp_batch_norm_relu_add(eligible) == 1
64
+ assert fuse_aspp_batch_norm_relu_add(nonzero_dropout) == 0
65
+ assert fuse_aspp_batch_norm_relu_add(training) == 0
66
+ assert fuse_aspp_batch_norm_relu_add(nonresidual) == 0
67
+
68
+
69
+ @pytest.mark.parametrize(
70
+ "batch_norm",
71
+ [
72
+ nn.BatchNorm2d(3, affine=False).eval(),
73
+ nn.BatchNorm2d(3, track_running_stats=False).eval(),
74
+ nn.BatchNorm2d(3).train(),
75
+ ],
76
+ )
77
+ def test_generic_fusion_skips_unsupported_batch_norm(batch_norm):
78
+ module = nn.Sequential(batch_norm, nn.ReLU())
79
+
80
+ assert fuse_batch_norm_relu(module) == 0
81
+ assert module[0] is batch_norm
82
+
83
+
84
+ def test_inverse_std_is_nonpersistent_and_refreshes_on_eval():
85
+ module = FusedBatchNormReLU(nn.BatchNorm2d(3).eval())
86
+ original_inverse_std = module.inverse_std.clone()
87
+ assert "inverse_std" not in module.state_dict()
88
+
89
+ module.train()
90
+ module(torch.randn(4, 3, 5, 5))
91
+ module.eval()
92
+
93
+ assert not torch.equal(module.inverse_std, original_inverse_std)
94
+ assert torch.equal(
95
+ module.inverse_std,
96
+ torch.rsqrt(module.batch_norm.running_var + module.batch_norm.eps),
97
+ )
98
+
99
+
100
+ def test_original_checkpoint_must_load_before_fusion():
101
+ module = nn.Sequential(nn.BatchNorm2d(3), nn.ReLU()).eval()
102
+ original_state = copy.deepcopy(module.state_dict())
103
+ fuse_batch_norm_relu(module)
104
+
105
+ with pytest.raises(RuntimeError, match="Missing key"):
106
+ module.load_state_dict(original_state, strict=True)
107
+
108
+
109
+ def test_launch_parameters_are_validated_for_every_wrapper(monkeypatch):
110
+ monkeypatch.setenv("NEMOTRON_OCR_FUSED_BATCH_NORM_BLOCK_SIZE", "3")
111
+
112
+ with pytest.raises(ValueError, match="power of two"):
113
+ FusedBatchNormReLU(nn.BatchNorm2d(3).eval())
114
+ with pytest.raises(ValueError, match="power of two"):
115
+ FusedBatchNormReLUAdd(nn.BatchNorm2d(3).eval())
116
+ block = _residual_block().eval()
117
+ final_batch_norm = block.f.c[-1]
118
+ with pytest.raises(ValueError, match="power of two"):
119
+ FusedResidualBlock(block)
120
+ assert block.f.c[-1] is final_batch_norm
121
+
122
+
123
+ def test_num_warps_validation_is_shared(monkeypatch):
124
+ monkeypatch.setenv("NEMOTRON_OCR_FUSED_BATCH_NORM_NUM_WARPS", "16")
125
+
126
+ with pytest.raises(ValueError, match="must be one of"):
127
+ FusedBatchNormReLUAdd(nn.BatchNorm2d(3).eval())
nemotron-ocr/tests/test_pipeline_v2_prefilter.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import pytest
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+ import nemotron_ocr.inference.pipeline_v2 as pipeline_v2
9
+ from nemotron_ocr.inference.pipeline import NMS_PROB_THRESHOLD
10
+ from nemotron_ocr.inference.pipeline_v2 import NemotronOCRV2
11
+
12
+
13
+ def _pipeline(peak_kernel=3):
14
+ pipeline = NemotronOCRV2.__new__(NemotronOCRV2)
15
+ object.__setattr__(pipeline, "_prefilter_peak_kernel", peak_kernel)
16
+ return pipeline
17
+
18
+
19
+ def _legacy_prefilter(det_conf, det_rboxes, peak_kernel):
20
+ d_top = det_rboxes[..., 0].float()
21
+ d_right = det_rboxes[..., 1].float()
22
+ d_bottom = det_rboxes[..., 2].float()
23
+ d_left = det_rboxes[..., 3].float()
24
+
25
+ lr_min = torch.minimum(d_left, d_right)
26
+ lr_max = torch.maximum(d_left, d_right).clamp(min=1.0)
27
+ tb_min = torch.minimum(d_top, d_bottom)
28
+ tb_max = torch.maximum(d_top, d_bottom).clamp(min=1.0)
29
+ centerness = torch.sqrt((lr_min / lr_max) * (tb_min / tb_max))
30
+
31
+ adjusted = torch.sigmoid(det_conf.float()) * centerness
32
+ pooled = F.max_pool2d(
33
+ adjusted.unsqueeze(1),
34
+ peak_kernel,
35
+ stride=1,
36
+ padding=peak_kernel // 2,
37
+ )
38
+ pooled = pooled[:, 0, : det_conf.shape[1], : det_conf.shape[2]]
39
+ peaks = (adjusted == pooled) & (adjusted > NMS_PROB_THRESHOLD)
40
+
41
+ filtered = det_conf.clone()
42
+ filtered[~peaks] = -100.0
43
+ return filtered
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ "dtype", [torch.float64, torch.float32, torch.float16, torch.bfloat16]
48
+ )
49
+ def test_prefilter_reused_probability_matches_legacy_dense_sigmoid(dtype):
50
+ generator = torch.Generator().manual_seed(7)
51
+ det_conf = torch.randn((2, 9, 11), generator=generator).to(dtype)
52
+ det_rboxes = (
53
+ torch.rand((2, 9, 11, 5), generator=generator).mul_(80).add_(0.25)
54
+ ).to(dtype)
55
+
56
+ expected_probability = torch.sigmoid(
57
+ _legacy_prefilter(det_conf, det_rboxes, peak_kernel=3)
58
+ )
59
+
60
+ original_logits = det_conf.clone()
61
+ actual_probability = _pipeline()._prefilter_detections(det_conf, det_rboxes)
62
+
63
+ assert actual_probability.dtype == expected_probability.dtype
64
+ assert torch.equal(actual_probability, expected_probability)
65
+ assert torch.equal(det_conf, original_logits)
66
+
67
+
68
+ def test_run_nms_forwards_precomputed_probability_without_recomputing(monkeypatch):
69
+ supplied_probability = torch.rand((1, 2, 3), dtype=torch.float32)
70
+ captured = {}
71
+
72
+ def fake_rrect_to_quads(det_rboxes, downsample):
73
+ return torch.zeros((*det_rboxes.shape[:-1], 4, 2), dtype=torch.float32)
74
+
75
+ def fake_nms(coords, probability, **kwargs):
76
+ captured["probability"] = probability
77
+ return (
78
+ torch.zeros((1, 4, 2), dtype=torch.float32),
79
+ torch.ones(1, dtype=torch.float32),
80
+ torch.ones(1, dtype=torch.int64),
81
+ )
82
+
83
+ monkeypatch.setattr(pipeline_v2, "rrect_to_quads", fake_rrect_to_quads)
84
+ monkeypatch.setattr(pipeline_v2, "quad_non_maximal_suppression", fake_nms)
85
+
86
+ result = _pipeline()._run_nms(
87
+ torch.randn((1, 2, 3)),
88
+ torch.ones((1, 2, 3, 5)),
89
+ supplied_probability,
90
+ )
91
+
92
+ assert captured["probability"] is supplied_probability
93
+ assert result[3] is supplied_probability
nemotron-ocr/tests/test_relational_batched_geometry.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import torch
5
+
6
+ import nemotron_ocr.inference.models.relational as relational
7
+ from nemotron_ocr.inference.models.relational import (
8
+ GlobalRelationalModel,
9
+ _pad_flat_to_batched,
10
+ get_directions,
11
+ )
12
+
13
+
14
+ def _make_model(**kwargs):
15
+ return GlobalRelationalModel(
16
+ num_input_channels=[4],
17
+ recog_feature_depth=4,
18
+ k=4,
19
+ num_layers=1,
20
+ **kwargs,
21
+ )
22
+
23
+
24
+ def _distance_matrix(quads):
25
+ distances = torch.cdist(quads.mean(dim=1), quads.mean(dim=1))
26
+ distances.fill_diagonal_(torch.inf)
27
+ return distances
28
+
29
+
30
+ def _legacy_geometry_inputs(model, proj_rects, mid_pts, quads, counts):
31
+ feat_dim = proj_rects.shape[1]
32
+ z = model.k - 1
33
+ seq_len = z + 1
34
+ n_total = proj_rects.shape[0]
35
+ enc_input = torch.zeros(n_total, seq_len, 2 * feat_dim + 2)
36
+ mask = torch.ones(n_total, seq_len, dtype=torch.bool)
37
+ closest = torch.zeros(n_total, seq_len, dtype=torch.long)
38
+
39
+ offsets = [0]
40
+ for count in counts:
41
+ offsets.append(offsets[-1] + count)
42
+
43
+ for image_index, count in enumerate(counts):
44
+ if count == 0:
45
+ continue
46
+ start, end = offsets[image_index : image_index + 2]
47
+ rects = proj_rects[start:end]
48
+ centers = mid_pts[start:end]
49
+ image_quads = quads[start:end]
50
+ z_i = min(count - 1, z)
51
+
52
+ from_rects = rects.unsqueeze(1).expand(-1, seq_len, -1)
53
+ enc_input[start:end, 0, :feat_dim] = rects
54
+ enc_input[start:end, 0, 2 * feat_dim] = -1
55
+ enc_input[start:end, 0, 2 * feat_dim + 1] = -2
56
+ mask[start:end, 0] = False
57
+
58
+ if z_i == 0:
59
+ continue
60
+ topk_d, topk_idx = torch.topk(
61
+ _distance_matrix(image_quads),
62
+ k=z_i,
63
+ dim=1,
64
+ largest=False,
65
+ sorted=False,
66
+ )
67
+ neighbor_rects = torch.gather(
68
+ rects.unsqueeze(0).expand(count, -1, -1),
69
+ dim=1,
70
+ index=topk_idx.unsqueeze(2).expand(-1, -1, feat_dim),
71
+ )
72
+ neighbor_centers = torch.gather(
73
+ centers.unsqueeze(0).expand(count, -1, -1),
74
+ dim=1,
75
+ index=topk_idx.unsqueeze(2).expand(-1, -1, 2),
76
+ )
77
+ directions = get_directions(image_quads, neighbor_centers)
78
+
79
+ enc_input[start:end, 1 : z_i + 1, :feat_dim] = from_rects[:, 1 : z_i + 1]
80
+ enc_input[start:end, 1 : z_i + 1, feat_dim : 2 * feat_dim] = neighbor_rects
81
+ enc_input[start:end, 1 : z_i + 1, 2 * feat_dim] = topk_d
82
+ enc_input[start:end, 1 : z_i + 1, 2 * feat_dim + 1] = directions
83
+ mask[start:end, 1 : z_i + 1] = False
84
+ closest[start:end, 1 : z_i + 1] = topk_idx + 1
85
+
86
+ return enc_input, mask, closest
87
+
88
+
89
+ def test_pad_flat_to_batched_preserves_ragged_order_without_scalar_reads():
90
+ flat = torch.arange(9 * 2, dtype=torch.float32).reshape(9, 2)
91
+ counts = torch.tensor([3, 0, 2, 4], dtype=torch.long)
92
+
93
+ actual = _pad_flat_to_batched(flat, counts, k_max=4, pad_value=-1)
94
+
95
+ expected = torch.full((4, 4, 2), -1, dtype=torch.float32)
96
+ expected[0, :3] = flat[:3]
97
+ expected[2, :2] = flat[3:5]
98
+ expected[3, :4] = flat[5:]
99
+ torch.testing.assert_close(actual, expected, rtol=0, atol=0)
100
+
101
+
102
+ def test_batched_geometry_exactly_matches_per_image_topk_with_ties(monkeypatch):
103
+ counts = torch.tensor([4, 2, 4, 1, 0], dtype=torch.long)
104
+ counts_list = counts.tolist()
105
+ n_total = sum(counts_list)
106
+ proj_rects = torch.arange(n_total * 5, dtype=torch.float32).reshape(n_total, 5) / 17
107
+ mid_pts = torch.tensor(
108
+ [
109
+ [0.0, 0.0],
110
+ [1.0, 0.0],
111
+ [-1.0, 0.0],
112
+ [0.0, 2.0],
113
+ [3.0, 0.0],
114
+ [5.0, 0.0],
115
+ [10.0, 0.0],
116
+ [11.0, 0.0],
117
+ [9.0, 0.0],
118
+ [10.0, 2.0],
119
+ [20.0, 0.0],
120
+ ]
121
+ )
122
+ quads = mid_pts[:, None, :].expand(-1, 4, -1).clone()
123
+ quads[:, (0, 3), 0] -= 0.1
124
+ quads[:, (1, 2), 0] += 0.1
125
+
126
+ calls = []
127
+
128
+ def fake_batched_cdist(padded_quads, region_counts, *args, **kwargs):
129
+ calls.append((padded_quads.shape, region_counts.clone()))
130
+ batch_size, k_max = padded_quads.shape[:2]
131
+ output = padded_quads.new_zeros(batch_size, k_max, k_max)
132
+ for image_index, count in enumerate(region_counts.tolist()):
133
+ output[image_index, :count, :count] = _distance_matrix(
134
+ padded_quads[image_index, :count]
135
+ )
136
+ return output
137
+
138
+ monkeypatch.setattr(relational, "get_cdist_batched", fake_batched_cdist)
139
+ model = _make_model(batched_geometry=True)
140
+
141
+ actual = model._build_batched_geometry_inputs(
142
+ proj_rects,
143
+ mid_pts,
144
+ quads,
145
+ counts,
146
+ counts_list,
147
+ )
148
+ expected = _legacy_geometry_inputs(model, proj_rects, mid_pts, quads, counts_list)
149
+
150
+ assert len(calls) == 1
151
+ assert calls[0][0] == torch.Size([5, 4, 4, 2])
152
+ torch.testing.assert_close(calls[0][1], counts, rtol=0, atol=0)
153
+ for actual_tensor, expected_tensor in zip(actual, expected):
154
+ torch.testing.assert_close(actual_tensor, expected_tensor, rtol=0, atol=0)
155
+
156
+
157
+ def test_batched_geometry_is_default_off_and_environment_opt_in(monkeypatch):
158
+ monkeypatch.delenv("NEMOTRON_OCR_BATCHED_RELATIONAL_GEOMETRY", raising=False)
159
+ assert not _make_model().batched_geometry
160
+
161
+ monkeypatch.setenv("NEMOTRON_OCR_BATCHED_RELATIONAL_GEOMETRY", "true")
162
+ assert _make_model().batched_geometry
nemotron-ocr/tests/test_relational_encoder_staging.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import pytest
5
+ import torch
6
+
7
+ from nemotron_ocr.inference.encoders.relational_encoder import (
8
+ RelationalTargetEncoder,
9
+ _copy_tensors_to_cpu_batched,
10
+ )
11
+
12
+
13
+ pytestmark = pytest.mark.skipif(
14
+ not torch.cuda.is_available(), reason="CUDA is required for D2H staging tests"
15
+ )
16
+
17
+
18
+ def _canonical_graph(graph):
19
+ return tuple(
20
+ tuple(tuple(line) for line in paragraph) for paragraph in graph.paragraphs
21
+ )
22
+
23
+
24
+ def test_batched_cpu_copy_preserves_noncontiguous_views_on_current_stream():
25
+ expected_base = (
26
+ torch.arange(42, dtype=torch.float32).reshape(6, 7).mul_(3).add_(1)
27
+ )
28
+ expected = [expected_base[:, 1:6], expected_base.t()[1:5, ::2]]
29
+
30
+ stream = torch.cuda.Stream()
31
+ with torch.cuda.stream(stream):
32
+ base = torch.arange(42, dtype=torch.float32, device="cuda").reshape(6, 7)
33
+ base.mul_(3).add_(1)
34
+ sources = [base[:, 1:6], base.t()[1:5, ::2]]
35
+ actual = _copy_tensors_to_cpu_batched(sources)
36
+
37
+ for source, copied in zip(expected, actual):
38
+ assert copied.is_pinned()
39
+ torch.testing.assert_close(copied, source, rtol=0, atol=0)
40
+
41
+
42
+ def test_batched_relational_conversion_matches_sequential_graph_build():
43
+ encoder = RelationalTargetEncoder(input_size=[768, 768], is_train=False)
44
+
45
+ # The first matrix includes an exact 0.5 tie in row 0. The C++ graph
46
+ # conversion selects the first qualifying column; staging must not alter it.
47
+ word_relations = [
48
+ torch.tensor(
49
+ [
50
+ [0.0, 0.5, 0.5, 0.0],
51
+ [0.0, 0.0, 0.0, 0.0],
52
+ [0.0, 0.0, 0.0, 0.9],
53
+ [0.0, 0.0, 0.0, 0.0],
54
+ ],
55
+ dtype=torch.float32,
56
+ device="cuda",
57
+ ),
58
+ torch.tensor(
59
+ [
60
+ [0.0, 0.8, 0.0],
61
+ [0.0, 0.0, 0.8],
62
+ [0.0, 0.0, 0.0],
63
+ ],
64
+ dtype=torch.float32,
65
+ device="cuda",
66
+ ),
67
+ torch.empty((0, 0), dtype=torch.float32, device="cuda"),
68
+ ]
69
+ line_logits = [
70
+ torch.tensor(
71
+ [
72
+ [2.0, -torch.inf, 0.2, 3.0, 0.1],
73
+ [1.0, 0.3, -torch.inf, 0.2, 2.5],
74
+ [0.5, 2.0, 0.1, -torch.inf, 0.2],
75
+ [1.5, 0.2, 2.2, 0.1, -torch.inf],
76
+ ],
77
+ dtype=torch.float32,
78
+ device="cuda",
79
+ ),
80
+ torch.tensor(
81
+ [
82
+ [2.0, -torch.inf, 1.0, 0.2],
83
+ [0.5, 0.3, -torch.inf, 2.0],
84
+ [1.0, 2.0, 0.2, -torch.inf],
85
+ ],
86
+ dtype=torch.float32,
87
+ device="cuda",
88
+ ),
89
+ torch.empty((0, 1), dtype=torch.float32, device="cuda"),
90
+ ]
91
+ line_uncertainty = [
92
+ torch.tensor(
93
+ [
94
+ [0.1, 0.0, 0.2, -0.1, 0.3],
95
+ [0.2, 0.1, 0.0, 0.3, -0.2],
96
+ [0.0, 0.2, 0.1, -0.1, 0.2],
97
+ [0.3, -0.1, 0.2, 0.1, 0.0],
98
+ ],
99
+ dtype=torch.float32,
100
+ device="cuda",
101
+ ),
102
+ torch.tensor(
103
+ [
104
+ [0.0, 0.1, -0.1, 0.2],
105
+ [0.2, 0.0, 0.1, -0.2],
106
+ [0.1, -0.1, 0.2, 0.0],
107
+ ],
108
+ dtype=torch.float32,
109
+ device="cuda",
110
+ ),
111
+ torch.empty((0, 1), dtype=torch.float32, device="cuda"),
112
+ ]
113
+
114
+ region_counts = torch.tensor([4, 3, 0], dtype=torch.int64)
115
+ quads = torch.arange(7 * 4 * 2, dtype=torch.float32).reshape(7, 4, 2)
116
+
117
+ expected_graphs = []
118
+ for count, word_relation, line_relation, line_unc in zip(
119
+ region_counts.tolist(), word_relations, line_logits, line_uncertainty
120
+ ):
121
+ if count == 0:
122
+ expected_graphs.append(())
123
+ continue
124
+ expected_graphs.append(
125
+ _canonical_graph(
126
+ encoder.dense_relations_to_graph(
127
+ word_relation.cpu(), line_relation, line_unc, is_gt=False
128
+ )
129
+ )
130
+ )
131
+
132
+ batch = encoder.convert_targets_to_labels(
133
+ {
134
+ "relations": word_relations,
135
+ "line_relations": line_logits,
136
+ "line_rel_var": line_uncertainty,
137
+ "region_counts": region_counts,
138
+ "quads": quads,
139
+ },
140
+ image_size=None,
141
+ is_gt=False,
142
+ )
143
+
144
+ assert len(batch) == 3
145
+ assert [len(example) for example in batch] == [4, 3, 0]
146
+ assert [_canonical_graph(example.relation_graph) for example in batch] == expected_graphs
147
+ torch.testing.assert_close(
148
+ torch.stack([region.region.vertices for example in batch for region in example]),
149
+ quads,
150
+ rtol=0,
151
+ atol=0,
152
+ )