Skip to content

Commit c32dd8b

Browse files
committed
feature(extract-python:docling): avoid locking the GIL by using page batches and result buffering
1 parent 90c3c65 commit c32dd8b

17 files changed

Lines changed: 863 additions & 131 deletions

File tree

‎extract-core/extract_core/__init__.py‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from icij_common.pydantic_utils import make_enum_discriminator, tagged_union
44
from pydantic import Discriminator
55

6-
from .configs import BasePipelineConfig, PipelineType
6+
from .configs import BasePipelineConfig, PipelineType, ResultBufferConfig
77
from .objects import (
88
BaseModel,
99
ConversionOutput,
@@ -21,9 +21,24 @@
2121
from .pipeline import Pipeline
2222

2323
try:
24-
from .docling_ import DoclingFormatOption, DoclingPipelineConfig
24+
from .docling_ import (
25+
BatchConcurrencySettings,
26+
DoclingFormatOption,
27+
DoclingPipelineConfig,
28+
DoclingSettings,
29+
)
2530
except ModuleNotFoundError:
26-
DoclingPipelineConfig, DoclingFormatOption = None, None
31+
(
32+
BatchConcurrencySettings,
33+
DoclingFormatOption,
34+
DoclingPipelineConfig,
35+
DoclingSettings,
36+
) = (
37+
None,
38+
None,
39+
None,
40+
None,
41+
)
2742

2843
try:
2944
from .marker_ import MarkerPipelineConfig
@@ -66,4 +81,7 @@
6681
"Result",
6782
"Status",
6883
"SupportedExt",
84+
"ResultBufferConfig",
85+
"DoclingSettings",
86+
"BatchConcurrencySettings",
6987
]

‎extract-core/extract_core/configs.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from abc import ABC, abstractmethod
22
from enum import StrEnum
3+
from pathlib import Path
34
from typing import ClassVar
45

56
from icij_common.pydantic_utils import icij_config, merge_configs, no_enum_values_config
67
from icij_common.registrable import RegistrableConfig
7-
from pydantic import Field
8+
from pydantic import ByteSize, Field
89

9-
from .objects import Device, SupportedExt
10+
from .objects import BaseModel, Device, SupportedExt
1011

1112

1213
class PipelineType(StrEnum):
@@ -27,3 +28,8 @@ class BasePipelineConfig(RegistrableConfig, ABC):
2728
@classmethod
2829
@abstractmethod
2930
def supported_exts(cls) -> set[SupportedExt]: ...
31+
32+
33+
class ResultBufferConfig(BaseModel):
34+
max_size: ByteSize = "500MiB"
35+
root: Path | None = None

‎extract-core/extract_core/docling_.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import importlib
22
from functools import cache
3-
from typing import Annotated, Any, ClassVar, TypeVar, get_type_hints
3+
from typing import Annotated, Any, ClassVar, get_type_hints
44

55
from docling.datamodel.backend_options import BackendOptions, BaseBackendOptions
66
from docling.datamodel.base_models import (
@@ -21,7 +21,9 @@
2121
ThreadedPdfPipelineOptions,
2222
)
2323
from docling.datamodel.settings import (
24-
BatchConcurrencySettings,
24+
BatchConcurrencySettings as DoclingBatchConcurrencySettings,
25+
)
26+
from docling.datamodel.settings import (
2527
DebugSettings,
2628
InferenceSettings,
2729
)
@@ -40,7 +42,7 @@
4042
)
4143
from pydantic_core.core_schema import SerializerFunctionWrapHandler
4244

43-
from .configs import BasePipelineConfig, PipelineType
45+
from .configs import BasePipelineConfig, PipelineType, ResultBufferConfig
4446
from .objects import BaseModel, Device, SupportedExt
4547
from .utils import all_subclasses
4648

@@ -69,10 +71,7 @@ def _validate_pipeline_opts(v: PipelineOptions) -> PipelineOptions:
6971
return v
7072

7173

72-
T = TypeVar("T")
73-
74-
75-
def _find_subcls(cls: type[T], name: str) -> type[T]:
74+
def _find_subcls[T](cls: type[T], name: str) -> type[T]:
7675
# Check if the class available
7776
for c in all_subclasses(cls):
7877
if c.__name__ == name:
@@ -264,6 +263,13 @@ def _default_format_opts() -> dict[InputFormat, DoclingFormatOption]:
264263
}
265264

266265

266+
class BatchConcurrencySettings(DoclingBatchConcurrencySettings):
267+
# process up to 16 pages in || on GPU
268+
page_batch_size: int = 16
269+
# call convert_all with at most page_batch_size * page_batch_size
270+
max_page_batches: int = 2
271+
272+
267273
class DoclingSettings(BaseModel):
268274
perf: BatchConcurrencySettings = Field(default_factory=BatchConcurrencySettings)
269275
debug: DebugSettings = Field(default_factory=DebugSettings)
@@ -276,7 +282,9 @@ class DoclingPipelineConfig(BasePipelineConfig):
276282
format_options: dict[InputFormat, DoclingFormatOption] = Field(
277283
default_factory=_default_format_opts
278284
)
285+
279286
settings: DoclingSettings = Field(default_factory=DoclingSettings)
287+
result_buffer: ResultBufferConfig = Field(default_factory=ResultBufferConfig)
280288

281289
@classmethod
282290
@cache

‎extract-core/extract_core/objects.py‎

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,16 @@
88
from functools import cache
99
from io import BytesIO
1010
from pathlib import Path
11-
from typing import Annotated, Any, NoReturn, Self
11+
from typing import Any, NoReturn, Self
1212

1313
from docling.datamodel.accelerator_options import AcceleratorDevice
1414
from icij_common.pydantic_utils import (
1515
icij_config,
1616
merge_configs,
1717
no_enum_values_config,
18-
safe_copy,
1918
)
20-
from pydantic import AfterValidator, Field, TypeAdapter
2119
from pydantic import BaseModel as _BaseModel
20+
from pydantic import Field, TypeAdapter
2221

2322
logger = logging.getLogger(__name__)
2423
base_config = merge_configs(icij_config(), no_enum_values_config())
@@ -120,8 +119,8 @@ def to_marker(self) -> str:
120119

121120
class Status(StrEnum):
122121
FAILURE = "failure"
123-
SUCCESS = "success"
124122
PARTIAL_SUCCESS = "partial_success"
123+
SUCCESS = "success"
125124

126125
@classmethod
127126
def from_docling(cls, v: Any) -> Self:
@@ -139,6 +138,26 @@ def from_docling(cls, v: Any) -> Self:
139138
def allows_conversion(self) -> bool:
140139
return self is Status.SUCCESS or self is Status.PARTIAL_SUCCESS
141140

141+
def __add__(self, other: "Status") -> "Status":
142+
if not isinstance(other, Status):
143+
msg = (
144+
f"can't add {other} of type {other.__class__.__name__} "
145+
f"to {self.__class__.__name__}"
146+
)
147+
raise TypeError(msg)
148+
statuses = sorted((self, other), key=lambda x: x.value)
149+
match statuses:
150+
case (Status.FAILURE, Status.FAILURE):
151+
return Status.FAILURE
152+
case (Status.FAILURE, Status.SUCCESS):
153+
return Status.PARTIAL_SUCCESS
154+
case (_, Status.PARTIAL_SUCCESS) | (Status.PARTIAL_SUCCESS, _):
155+
return Status.PARTIAL_SUCCESS
156+
case (Status.SUCCESS, Status.SUCCESS):
157+
return Status.SUCCESS
158+
case _:
159+
raise ValueError(f"unexpected value {statuses}")
160+
142161

143162
class Error(BaseModel):
144163
id: str
@@ -179,30 +198,24 @@ def _id_title(title: str) -> str:
179198
class InputDoc(BaseModel):
180199
ext: SupportedExt
181200
path: Path
182-
content: bytes | None = None
201+
n_pages: int
183202

184203
@classmethod
185-
def from_path(cls, path: str | Path) -> Self:
204+
def from_path(cls, path: str | Path, n_pages: int) -> Self:
186205
if isinstance(path, str):
187206
path = Path(path)
188207
ext = SupportedExt(path.suffix)
189-
return cls(path=path, ext=ext)
208+
return cls(path=path, ext=ext, n_pages=n_pages)
190209

191210
def to_docling(self): # noqa: ANN201
192211
from docling_core.types.io import DocumentStream # noqa: PLC0415
193212

194-
if self.content is not None:
195-
return DocumentStream(name=str(self.path), stream=BytesIO(self.content))
196-
197213
if not self.path.suffix:
198214
return DocumentStream(
199215
name=str(self.path), stream=BytesIO(self.path.read_bytes())
200216
)
201217
return self.path
202218

203-
def without_content(self) -> Self:
204-
return safe_copy(self, update={"content": None})
205-
206219

207220
Ranges = list[tuple[int, int]]
208221

@@ -223,6 +236,7 @@ def from_pages_bytes_sizes(cls, sizes: Sequence[int]) -> Self:
223236
class ConversionOutput(BaseModel):
224237
path: Path
225238
pages: Pages = Field(default_factory=Pages)
239+
confidence: float | None
226240

227241

228242
class MarkdownDoc(ConversionOutput):
@@ -235,20 +249,14 @@ def _valid_conversion_statuses(cls) -> set:
235249
return {ConversionStatus.SUCCESS, ConversionStatus.PARTIAL_SUCCESS}
236250

237251

238-
def _input_should_not_have_content(value: InputDoc) -> InputDoc:
239-
if value.content is not None:
240-
raise ValueError(f"response input can't have content, but got {value}")
241-
return value
242-
243-
244252
class _BaseResult(BaseModel, ABC):
245253
input: InputDoc
246254
status: Status
247255
errors: list[Error] = []
248256

249257

250258
class ResponseResult(_BaseResult):
251-
input: Annotated[InputDoc, AfterValidator(func=_input_should_not_have_content)]
259+
input: InputDoc
252260
output_path: Path
253261

254262

@@ -258,7 +266,7 @@ class Result(_BaseResult):
258266

259267
def to_response(self) -> ResponseResult:
260268
return ResponseResult(
261-
input=self.input.without_content(),
269+
input=self.input,
262270
status=self.status,
263271
errors=self.errors,
264272
output_path=self.output.path,

‎extract-core/extract_core/pipeline.py‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
11
from abc import ABC, abstractmethod
2-
from collections.abc import AsyncGenerator, Iterable
2+
from collections.abc import AsyncIterable, Iterable
33
from pathlib import Path
4-
from typing import Generic, Self, TypeVar
4+
from typing import Self
55

66
from icij_common.registrable import RegistrableFromConfig
77

88
from extract_core import BasePipelineConfig
99

1010
from .objects import InputDoc, OutputFormat, Result
1111

12-
C = TypeVar("C", bound="BasePipelineConfig")
1312

14-
15-
class Pipeline(RegistrableFromConfig, Generic[C], ABC):
13+
class Pipeline[C: BasePipelineConfig](RegistrableFromConfig, ABC):
1614
def __init__(self, config: C):
1715
self._config = config
1816
self._device = self._config.device
1917

2018
@abstractmethod
2119
async def extract_content(
2220
self, docs: Iterable[InputDoc], output_format: OutputFormat, output_path: Path
23-
) -> AsyncGenerator[Result, None]: ...
21+
) -> AsyncIterable[Result]: ...
2422

2523
@classmethod
2624
def _from_config(cls, config: C) -> Self:

‎extract-core/extract_core/utils.py‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
from typing import TypeVar
2-
3-
T = TypeVar("T")
4-
5-
6-
def all_subclasses(cls: type[T]) -> set[type[T]]:
1+
def all_subclasses[T](cls: type[T]) -> set[type[T]]:
72
return set(cls.__subclasses__()).union(
83
[s for c in cls.__subclasses__() for s in all_subclasses(c)]
94
)

‎extract-core/tests/test_objects.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
from docling.datamodel.accelerator_options import AcceleratorDevice, AcceleratorOptions
23
from docling.datamodel.base_models import InputFormat
34
from docling.datamodel.pipeline_options import (
@@ -6,7 +7,7 @@
67
)
78
from docling.document_converter import PdfFormatOption
89
from extract_core import DoclingPipelineConfig, PipelineConfig
9-
from extract_core.objects import Device
10+
from extract_core.objects import Device, Status
1011
from pydantic import TypeAdapter
1112

1213

@@ -43,3 +44,24 @@ def test_docling_pipeline_config() -> None:
4344
)
4445
)
4546
assert pdf_pipeline_options.model_dump() == expected_options.model_dump()
47+
48+
49+
@pytest.mark.parametrize(
50+
("left", "right", "expected_status"),
51+
[
52+
(Status.FAILURE, Status.FAILURE, Status.FAILURE),
53+
(Status.FAILURE, Status.PARTIAL_SUCCESS, Status.PARTIAL_SUCCESS),
54+
(Status.FAILURE, Status.SUCCESS, Status.PARTIAL_SUCCESS),
55+
(Status.PARTIAL_SUCCESS, Status.FAILURE, Status.PARTIAL_SUCCESS),
56+
(Status.PARTIAL_SUCCESS, Status.PARTIAL_SUCCESS, Status.PARTIAL_SUCCESS),
57+
(Status.PARTIAL_SUCCESS, Status.SUCCESS, Status.PARTIAL_SUCCESS),
58+
(Status.SUCCESS, Status.FAILURE, Status.PARTIAL_SUCCESS),
59+
(Status.SUCCESS, Status.PARTIAL_SUCCESS, Status.PARTIAL_SUCCESS),
60+
(Status.SUCCESS, Status.SUCCESS, Status.SUCCESS),
61+
],
62+
)
63+
def test_add_statuses(left: Status, right: Status, expected_status: Status) -> None:
64+
# When
65+
status = left + right
66+
# Then
67+
assert status == expected_status

0 commit comments

Comments
 (0)