Image Hopper router v2-fast: faster serving and default-route image cap (same weights and calibration)
Browse files- Dockerfile +1 -1
- README.md +9 -2
- RELEASE-MANIFEST.json +11 -9
- open_decisions/image_jev/release/fastpath.py +620 -0
- open_decisions/image_jev/release/predictor.py +270 -32
- open_decisions/image_jev/release/server.py +11 -2
- open_decisions/image_jev/release/smoke.py +10 -10
- open_decisions/image_jev/vision.py +85 -4
Dockerfile
CHANGED
|
@@ -9,4 +9,4 @@ COPY requirements.lock requirements.lock.sha256 /app/
|
|
| 9 |
RUN sha256sum -c requirements.lock.sha256 && python -m pip install --no-cache-dir -r requirements.lock
|
| 10 |
COPY . /app
|
| 11 |
EXPOSE 8080
|
| 12 |
-
ENTRYPOINT ["python", "-m", "open_decisions.image_jev.release.server", "--host", "0.0.0.0", "--port", "8080", "--offline", "--adapter", "adapter"]
|
|
|
|
| 9 |
RUN sha256sum -c requirements.lock.sha256 && python -m pip install --no-cache-dir -r requirements.lock
|
| 10 |
COPY . /app
|
| 11 |
EXPOSE 8080
|
| 12 |
+
ENTRYPOINT ["python", "-m", "open_decisions.image_jev.release.server", "--host", "0.0.0.0", "--port", "8080", "--offline", "--adapter", "adapter", "--serving-options", "lean_lora,uint8_pixels,device_readout,image_token_cap_default=768"]
|
README.md
CHANGED
|
@@ -24,7 +24,8 @@ in one forward pass and without generating text. It is built on `Qwen/Qwen3.5-9B
|
|
| 24 |
for those letters at the last prompt position.
|
| 25 |
- **Calibration.** Logits are divided by a temperature chosen by route and answer type (fitted by maximum likelihood
|
| 26 |
with shrinkage toward a global temperature). Calibration never changes which option ranks first.
|
| 27 |
-
- **Images** use the base processor's native resolution route;
|
|
|
|
| 28 |
|
| 29 |
| Route / answer type | Temperature |
|
| 30 |
|---|---:|
|
|
@@ -58,10 +59,16 @@ checked against the benchmark's reference images for near-duplicates.
|
|
| 58 |
The default-route temperatures were fitted on a held-out calibration split of our own data; the screen/geometry
|
| 59 |
temperatures on held-out real screenshots and geometry diagrams that were never used for training.
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
## Usage
|
| 62 |
|
| 63 |
pip install -r requirements.txt
|
| 64 |
-
python -m open_decisions.image_jev.release.server --offline --adapter adapter --port 8080
|
| 65 |
|
| 66 |
`POST /v1/systemone`:
|
| 67 |
|
|
|
|
| 24 |
for those letters at the last prompt position.
|
| 25 |
- **Calibration.** Logits are divided by a temperature chosen by route and answer type (fitted by maximum likelihood
|
| 26 |
with shrinkage toward a global temperature). Calibration never changes which option ranks first.
|
| 27 |
+
- **Images** use the base processor's native resolution route; on the default route each image is capped at 768
|
| 28 |
+
image tokens (screen/geometry questions keep full resolution); up to four images per request.
|
| 29 |
|
| 30 |
| Route / answer type | Temperature |
|
| 31 |
|---|---:|
|
|
|
|
| 59 |
The default-route temperatures were fitted on a held-out calibration split of our own data; the screen/geometry
|
| 60 |
temperatures on held-out real screenshots and geometry diagrams that were never used for training.
|
| 61 |
|
| 62 |
+
## Serving
|
| 63 |
+
|
| 64 |
+
The Dockerfile starts the server with `--serving-options lean_lora,uint8_pixels,device_readout,image_token_cap_default=768`.
|
| 65 |
+
The first three options change only how the computation runs, not the outputs; the image cap on the default route
|
| 66 |
+
reduces cost per decision.
|
| 67 |
+
|
| 68 |
## Usage
|
| 69 |
|
| 70 |
pip install -r requirements.txt
|
| 71 |
+
python -m open_decisions.image_jev.release.server --offline --adapter adapter --port 8080 --serving-options "lean_lora,uint8_pixels,device_readout,image_token_cap_default=768"
|
| 72 |
|
| 73 |
`POST /v1/systemone`:
|
| 74 |
|
RELEASE-MANIFEST.json
CHANGED
|
@@ -15,14 +15,14 @@
|
|
| 15 |
},
|
| 16 |
"code": {
|
| 17 |
"dirty": false,
|
| 18 |
-
"git_commit": "
|
| 19 |
},
|
| 20 |
"files": {
|
| 21 |
"BASE-MODEL-LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
|
| 22 |
-
"Dockerfile": "
|
| 23 |
"LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
|
| 24 |
"NOTICE": "1d5e63ea05ed3c846593f1f4185b4355a25b2b25682bccac24995e094141b6c7",
|
| 25 |
-
"README.md": "
|
| 26 |
"adapter/README.md": "bb29528044c9409e183ce924e4ea6f29f2b705d749287781b8ff2777b5209a55",
|
| 27 |
"adapter/adapter_config.json": "4f6e55c1d151070a6e8444feff44bdcfbe4b87e14c3fde855f69159066162487",
|
| 28 |
"adapter/adapter_model.safetensors": "6201fcb1c08144e465a03fd6f5355c4394b3bffaad659440fa64063dd4f71509",
|
|
@@ -31,12 +31,13 @@
|
|
| 31 |
"open_decisions/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
| 32 |
"open_decisions/image_jev/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
| 33 |
"open_decisions/image_jev/release/__init__.py": "4a29d6b23b6021c0cc27d3fa5163ebbede7ab531b1a13814dfddf1fedcd71af4",
|
| 34 |
-
"open_decisions/image_jev/release/
|
| 35 |
-
"open_decisions/image_jev/release/
|
| 36 |
-
"open_decisions/image_jev/release/
|
|
|
|
| 37 |
"open_decisions/image_jev/router.py": "e99db6dde898e7f7be4babcec2f4e545eedff51b04db8c7fe98e47e884a27c47",
|
| 38 |
"open_decisions/image_jev/speed.py": "5671a56aa16d4647dca8f3c360e83f98747641d7722ae184110806375448c5cb",
|
| 39 |
-
"open_decisions/image_jev/vision.py": "
|
| 40 |
"open_decisions/scoring/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
| 41 |
"open_decisions/scoring/prompt.py": "131916c014979500e5d2daceb94817a8942de8148966d3309c8ecbc8547027ee",
|
| 42 |
"requirements.lock": "974e3a19773d67b4de84842ffa4a3a537e2886240cdfb97d94216159804aa7e2",
|
|
@@ -44,11 +45,12 @@
|
|
| 44 |
"requirements.txt": "321bb81a1bd43b4858cf724d22944ac3e5e93504a9bf231be88b6752b437a63c"
|
| 45 |
},
|
| 46 |
"image_route": "native",
|
| 47 |
-
"manifest_sha256": "
|
| 48 |
"measurement_artifact": null,
|
| 49 |
"name": "HopitAI/image-hopper",
|
| 50 |
"scorer": "one-prefill-option-letter",
|
| 51 |
-
"
|
|
|
|
| 52 |
"system_kind": "routed",
|
| 53 |
"version": "image-jev-release/v1"
|
| 54 |
}
|
|
|
|
| 15 |
},
|
| 16 |
"code": {
|
| 17 |
"dirty": false,
|
| 18 |
+
"git_commit": "78124a4fcb2123b8f5b3abb08d5751155c7d44be"
|
| 19 |
},
|
| 20 |
"files": {
|
| 21 |
"BASE-MODEL-LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
|
| 22 |
+
"Dockerfile": "46c5a925edee39410700d801ec0156904e8b5808373d5c77a3600f45bce29691",
|
| 23 |
"LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
|
| 24 |
"NOTICE": "1d5e63ea05ed3c846593f1f4185b4355a25b2b25682bccac24995e094141b6c7",
|
| 25 |
+
"README.md": "8486ae6e58abfeeed8aa515bb87277c88a36ba1ee7ea14c139cc9614a4f8481a",
|
| 26 |
"adapter/README.md": "bb29528044c9409e183ce924e4ea6f29f2b705d749287781b8ff2777b5209a55",
|
| 27 |
"adapter/adapter_config.json": "4f6e55c1d151070a6e8444feff44bdcfbe4b87e14c3fde855f69159066162487",
|
| 28 |
"adapter/adapter_model.safetensors": "6201fcb1c08144e465a03fd6f5355c4394b3bffaad659440fa64063dd4f71509",
|
|
|
|
| 31 |
"open_decisions/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
| 32 |
"open_decisions/image_jev/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
| 33 |
"open_decisions/image_jev/release/__init__.py": "4a29d6b23b6021c0cc27d3fa5163ebbede7ab531b1a13814dfddf1fedcd71af4",
|
| 34 |
+
"open_decisions/image_jev/release/fastpath.py": "4ad3e5e869e80393b30fd1fd90a377cae244a25ceca249c8aeb5eb0d44d61236",
|
| 35 |
+
"open_decisions/image_jev/release/predictor.py": "bbc178c08fb2b9a92684d759968b8792391a87ff21b804c3b723c68af61e76a3",
|
| 36 |
+
"open_decisions/image_jev/release/server.py": "bb994fe1abe7d29a425c4357eb80973565d6105130307f04597e8803ee97c168",
|
| 37 |
+
"open_decisions/image_jev/release/smoke.py": "773c339d4393d12953264918bfd3decfb46a67cee06feb2e61d29611c0313e3b",
|
| 38 |
"open_decisions/image_jev/router.py": "e99db6dde898e7f7be4babcec2f4e545eedff51b04db8c7fe98e47e884a27c47",
|
| 39 |
"open_decisions/image_jev/speed.py": "5671a56aa16d4647dca8f3c360e83f98747641d7722ae184110806375448c5cb",
|
| 40 |
+
"open_decisions/image_jev/vision.py": "de4ed892c0513ec86f954514476560cf9b20b7dd1d0e0afca548fcdf576aa882",
|
| 41 |
"open_decisions/scoring/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
| 42 |
"open_decisions/scoring/prompt.py": "131916c014979500e5d2daceb94817a8942de8148966d3309c8ecbc8547027ee",
|
| 43 |
"requirements.lock": "974e3a19773d67b4de84842ffa4a3a537e2886240cdfb97d94216159804aa7e2",
|
|
|
|
| 45 |
"requirements.txt": "321bb81a1bd43b4858cf724d22944ac3e5e93504a9bf231be88b6752b437a63c"
|
| 46 |
},
|
| 47 |
"image_route": "native",
|
| 48 |
+
"manifest_sha256": "be3f472213dffc9678155bdd91480bb87f63edd7bebcb8737c960646a1ed23ff",
|
| 49 |
"measurement_artifact": null,
|
| 50 |
"name": "HopitAI/image-hopper",
|
| 51 |
"scorer": "one-prefill-option-letter",
|
| 52 |
+
"serving_options": "lean_lora,uint8_pixels,device_readout,image_token_cap_default=768",
|
| 53 |
+
"status": "DRAFT - not published; public text needs owner approval",
|
| 54 |
"system_kind": "routed",
|
| 55 |
"version": "image-jev-release/v1"
|
| 56 |
}
|
open_decisions/image_jev/release/fastpath.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Opt-in serving optimisations for the Image Hopper release server (cost audit, step 1).
|
| 2 |
+
|
| 3 |
+
Every option is off by default, so the published path is unchanged unless the server is started
|
| 4 |
+
with ``--serving-options``. Options are either
|
| 5 |
+
|
| 6 |
+
* **exact**: the served function is bit-identical by construction (same operations in the same
|
| 7 |
+
order, only Python overhead, copies or host transfers removed); or
|
| 8 |
+
* **tolerance-bound**: numerically close but not bit-identical. The cost audit registers the
|
| 9 |
+
shipping rule before measuring: argmax identical on every audit item and max |dp| <= 1e-3.
|
| 10 |
+
|
| 11 |
+
Options (``OPTIONS`` holds the one-line descriptions):
|
| 12 |
+
|
| 13 |
+
``lean_lora`` exact. PEFT's LoRA wrappers are replaced by plain modules that call the
|
| 14 |
+
original base layer, plus the screen_geometry LoRA term computed with PEFT's
|
| 15 |
+
exact operations (input cast to the adapter dtype, B(A(x)) * scaling, add,
|
| 16 |
+
cast back) only while that route is active. Route change flips one flag.
|
| 17 |
+
``lora_bf16`` tolerance. As lean_lora, LoRA factors held in the base dtype (bf16).
|
| 18 |
+
``merged_views`` tolerance. The screen_geometry route reads a second, merged bf16 copy of each
|
| 19 |
+
targeted weight (W + s*B@A rounded once); the default route reads W.
|
| 20 |
+
``uint8_pixels`` exact. The processor resizes and patchifies as before but returns uint8
|
| 21 |
+
patches; they are uploaded from pinned memory and normalised on the device with
|
| 22 |
+
the processor's own fused mean/std arithmetic (elementwise, so identical).
|
| 23 |
+
``gpu_preprocess`` tolerance. uint8_pixels plus the resize itself on the device.
|
| 24 |
+
``device_readout`` exact. Option rows gathered with a cached device index tensor; one host copy.
|
| 25 |
+
``cuda_graphs`` tolerance. The language model (and the last-position vocabulary row) is
|
| 26 |
+
replayed from CUDA graphs recorded at padded lengths, one set per route.
|
| 27 |
+
Right padding after the decision position cannot change a causal model's
|
| 28 |
+
output there; kernels chosen for the padded shape can round differently.
|
| 29 |
+
``sdpa_flash`` tolerance. Scaled-dot-product attention restricted to the flash kernel.
|
| 30 |
+
``compile_text`` tolerance. ``torch.compile(dynamic=True)`` on the language model.
|
| 31 |
+
|
| 32 |
+
One parameterised option changes outputs by design (it is neither exact nor tolerance-bound):
|
| 33 |
+
|
| 34 |
+
``image_token_cap_default=N`` output-changing. As below, but only for questions whose
|
| 35 |
+
router-rules/v1 text route is ``default``; ``screen_geometry`` images stay
|
| 36 |
+
native. The two caps are alternatives.
|
| 37 |
+
``image_token_cap=N`` output-changing. The native route's processor max pixels drop to
|
| 38 |
+
``N * 32 * 32`` so every image keeps at most N merged image tokens
|
| 39 |
+
(``vision.native_image_tokens``); N in [256, 16384]. Weights, router and
|
| 40 |
+
calibration are unchanged. Off (no cap) unless given.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
from __future__ import annotations
|
| 44 |
+
|
| 45 |
+
import contextlib
|
| 46 |
+
import dataclasses
|
| 47 |
+
import time
|
| 48 |
+
from typing import Any, Callable, Iterable, Mapping, Sequence
|
| 49 |
+
|
| 50 |
+
OPTIONS: Mapping[str, str] = {
|
| 51 |
+
"lean_lora": "exact: plain base layers + route-switched LoRA term with PEFT's operations",
|
| 52 |
+
"lora_bf16": "tolerance: route-switched LoRA term with bf16 factors",
|
| 53 |
+
"merged_views": "tolerance: screen_geometry route reads merged bf16 weight copies",
|
| 54 |
+
"uint8_pixels": "exact: uint8 patches, pinned upload, device normalisation",
|
| 55 |
+
"gpu_preprocess": "tolerance: uint8_pixels plus device resize",
|
| 56 |
+
"device_readout": "exact: cached device index for option rows, one host copy",
|
| 57 |
+
"cuda_graphs": "tolerance: language model replayed from CUDA graphs at padded lengths",
|
| 58 |
+
"sdpa_flash": "tolerance: SDPA restricted to the flash kernel",
|
| 59 |
+
"compile_text": "tolerance: torch.compile(dynamic=True) on the language model",
|
| 60 |
+
}
|
| 61 |
+
EXACT_OPTIONS = frozenset({"lean_lora", "uint8_pixels", "device_readout"})
|
| 62 |
+
IMAGE_TOKEN_CAP = "image_token_cap"
|
| 63 |
+
IMAGE_TOKEN_CAP_DEFAULT = "image_token_cap_default"
|
| 64 |
+
PARAMETERISED_OPTIONS: Mapping[str, str] = {
|
| 65 |
+
IMAGE_TOKEN_CAP: "output-changing: native-route processor max pixels = N x 32 x 32 (N tokens)",
|
| 66 |
+
IMAGE_TOKEN_CAP_DEFAULT: "output-changing: as image_token_cap, only for questions the text "
|
| 67 |
+
"rule routes to default (screen_geometry stays native)",
|
| 68 |
+
}
|
| 69 |
+
ROUTE_MODES = ("lean_lora", "lora_bf16", "merged_views")
|
| 70 |
+
# Padded lengths for the recorded graphs: 64-token steps to 1,024, then 128, 256 and 512.
|
| 71 |
+
GRAPH_LENGTHS = tuple(list(range(64, 1025, 64)) + list(range(1152, 2049, 128))
|
| 72 |
+
+ list(range(2304, 4097, 256)) + list(range(4608, 8193, 512)))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ServingOptionError(ValueError):
|
| 76 |
+
"""Unknown or conflicting serving options."""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _parameter(item: str) -> tuple[str, int] | None:
|
| 80 |
+
name, sep, raw = item.partition("=")
|
| 81 |
+
if not sep:
|
| 82 |
+
return None
|
| 83 |
+
name, raw = name.strip(), raw.strip()
|
| 84 |
+
if name not in PARAMETERISED_OPTIONS:
|
| 85 |
+
raise ServingOptionError(f"unknown serving option {name!r}; parameterised options: "
|
| 86 |
+
f"{sorted(PARAMETERISED_OPTIONS)}")
|
| 87 |
+
if not raw.isdigit():
|
| 88 |
+
raise ServingOptionError(f"{name} needs a positive integer, got {raw!r}")
|
| 89 |
+
from open_decisions.image_jev import vision
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
return name, vision.validate_image_token_cap(int(raw))
|
| 93 |
+
except ValueError as error:
|
| 94 |
+
raise ServingOptionError(str(error)) from None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def parse_options(value: str | Iterable[str] | None) -> tuple[str, ...]:
|
| 98 |
+
"""Validate options; return them in the canonical (``OPTIONS``) order.
|
| 99 |
+
|
| 100 |
+
A parameterised option (``image_token_cap=N``) follows the named options, normalised to
|
| 101 |
+
``name=N``; giving it twice is refused.
|
| 102 |
+
"""
|
| 103 |
+
if value is None:
|
| 104 |
+
return ()
|
| 105 |
+
items = [item.strip() for item in value.split(",")] if isinstance(value, str) else list(value)
|
| 106 |
+
items = [item for item in items if item]
|
| 107 |
+
parameters: dict[str, int] = {}
|
| 108 |
+
named = []
|
| 109 |
+
for item in items:
|
| 110 |
+
parsed = _parameter(item)
|
| 111 |
+
if parsed is None:
|
| 112 |
+
named.append(item)
|
| 113 |
+
continue
|
| 114 |
+
name, number = parsed
|
| 115 |
+
if name in parameters and parameters[name] != number:
|
| 116 |
+
raise ServingOptionError(f"{name} given twice ({parameters[name]} and {number})")
|
| 117 |
+
parameters[name] = number
|
| 118 |
+
if len(parameters) > 1:
|
| 119 |
+
raise ServingOptionError("image_token_cap and image_token_cap_default are alternatives")
|
| 120 |
+
items = named
|
| 121 |
+
unknown = sorted(set(items) - set(OPTIONS))
|
| 122 |
+
if unknown:
|
| 123 |
+
raise ServingOptionError(f"unknown serving options {unknown}; known: {sorted(OPTIONS)}"
|
| 124 |
+
f" and {sorted(f'{k}=N' for k in PARAMETERISED_OPTIONS)}")
|
| 125 |
+
chosen = set(items)
|
| 126 |
+
modes = [name for name in ROUTE_MODES if name in chosen]
|
| 127 |
+
if "lora_bf16" in chosen and "merged_views" in chosen:
|
| 128 |
+
raise ServingOptionError("lora_bf16 and merged_views are alternative route modes")
|
| 129 |
+
if "compile_text" in chosen and "cuda_graphs" in chosen:
|
| 130 |
+
raise ServingOptionError("compile_text and cuda_graphs are alternatives")
|
| 131 |
+
if "gpu_preprocess" in chosen:
|
| 132 |
+
chosen.add("uint8_pixels")
|
| 133 |
+
if modes and "lean_lora" not in chosen:
|
| 134 |
+
chosen.add("lean_lora") # lora_bf16/merged_views use the same route modules
|
| 135 |
+
return (tuple(name for name in OPTIONS if name in chosen)
|
| 136 |
+
+ tuple(f"{name}={parameters[name]}" for name in PARAMETERISED_OPTIONS
|
| 137 |
+
if name in parameters))
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def image_token_cap(options: Iterable[str], name: str = IMAGE_TOKEN_CAP) -> int | None:
|
| 141 |
+
"""The ``image_token_cap=N`` value among parsed options, or None (no cap: the default)."""
|
| 142 |
+
for item in options:
|
| 143 |
+
parsed = _parameter(item)
|
| 144 |
+
if parsed is not None and parsed[0] == name:
|
| 145 |
+
return parsed[1]
|
| 146 |
+
return None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def image_token_cap_default(options: Iterable[str]) -> int | None:
|
| 150 |
+
"""The route-conditioned ``image_token_cap_default=N`` value, or None."""
|
| 151 |
+
return image_token_cap(options, IMAGE_TOKEN_CAP_DEFAULT)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def is_exact(options: Iterable[str]) -> bool:
|
| 155 |
+
"""True only for options that keep the served function bit-identical (no cap)."""
|
| 156 |
+
return set(options) <= EXACT_OPTIONS
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def route_mode(options: Iterable[str]) -> str | None:
|
| 160 |
+
chosen = set(options)
|
| 161 |
+
if "merged_views" in chosen:
|
| 162 |
+
return "merged"
|
| 163 |
+
if "lora_bf16" in chosen:
|
| 164 |
+
return "bf16"
|
| 165 |
+
if "lean_lora" in chosen:
|
| 166 |
+
return "exact"
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ---------------------------------------------------------------------------------- route modules
|
| 171 |
+
class RouteState:
|
| 172 |
+
"""One shared flag: True while the screen_geometry route is served."""
|
| 173 |
+
|
| 174 |
+
__slots__ = ("on",)
|
| 175 |
+
|
| 176 |
+
def __init__(self, on: bool = True):
|
| 177 |
+
self.on = bool(on)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _route_linear_class():
|
| 181 |
+
import torch
|
| 182 |
+
from torch import nn
|
| 183 |
+
from torch.nn import functional as F
|
| 184 |
+
|
| 185 |
+
class RouteLinear(nn.Module):
|
| 186 |
+
"""A LoRA-targeted linear layer with the adapter switched by ``RouteState``.
|
| 187 |
+
|
| 188 |
+
``exact`` replicates PEFT's vanilla LoRA forward operation by operation:
|
| 189 |
+
``result = base(x); x = x.to(A.dtype); result = result + B(A(x)) * scaling;
|
| 190 |
+
result.to(result_dtype)``. Off-route it calls the original base layer only.
|
| 191 |
+
"""
|
| 192 |
+
|
| 193 |
+
def __init__(self, base, lora_a, lora_b, scaling: float, state: RouteState, mode: str):
|
| 194 |
+
super().__init__()
|
| 195 |
+
if mode not in ("exact", "bf16", "merged"):
|
| 196 |
+
raise ValueError("unknown route mode")
|
| 197 |
+
self.base = base
|
| 198 |
+
self.state = state
|
| 199 |
+
self.mode = mode
|
| 200 |
+
self.scaling = float(scaling)
|
| 201 |
+
weight = base.weight
|
| 202 |
+
if mode == "merged":
|
| 203 |
+
delta = (lora_b.detach().float() @ lora_a.detach().float()) * self.scaling
|
| 204 |
+
merged = (weight.detach().float() + delta.to(weight.device)).to(weight.dtype)
|
| 205 |
+
self.register_buffer("merged_weight", merged, persistent=False)
|
| 206 |
+
self.lora_a = self.lora_b = None
|
| 207 |
+
else:
|
| 208 |
+
dtype = weight.dtype if mode == "bf16" else lora_a.dtype
|
| 209 |
+
self.register_buffer("lora_a", lora_a.detach().to(dtype), persistent=False)
|
| 210 |
+
self.register_buffer("lora_b", lora_b.detach().to(dtype), persistent=False)
|
| 211 |
+
|
| 212 |
+
@property
|
| 213 |
+
def weight(self): # modules that inspect .weight keep working
|
| 214 |
+
return self.base.weight
|
| 215 |
+
|
| 216 |
+
def forward(self, x):
|
| 217 |
+
if not self.state.on:
|
| 218 |
+
return self.base(x)
|
| 219 |
+
if self.mode == "merged":
|
| 220 |
+
return F.linear(x, self.merged_weight, self.base.bias)
|
| 221 |
+
result = self.base(x)
|
| 222 |
+
result_dtype = result.dtype
|
| 223 |
+
if x.dtype != self.lora_a.dtype:
|
| 224 |
+
x = x.to(dtype=self.lora_a.dtype)
|
| 225 |
+
result = result + F.linear(F.linear(x, self.lora_a), self.lora_b) * self.scaling
|
| 226 |
+
return result.to(result_dtype)
|
| 227 |
+
|
| 228 |
+
return RouteLinear, torch
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _lora_layers(root) -> list[tuple[str, Any]]:
|
| 232 |
+
return [(name, module) for name, module in root.named_modules()
|
| 233 |
+
if hasattr(module, "base_layer") and hasattr(module, "lora_A")
|
| 234 |
+
and hasattr(module, "lora_B") and hasattr(module, "scaling")]
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def install_route_modules(peft_model, *, adapter_name: str, mode: str):
|
| 238 |
+
"""Replace PEFT LoRA wrappers by ``RouteLinear``; return (plain model, state, record)."""
|
| 239 |
+
from torch import nn
|
| 240 |
+
|
| 241 |
+
RouteLinear, torch = _route_linear_class()
|
| 242 |
+
root = peft_model.get_base_model() if hasattr(peft_model, "get_base_model") else peft_model
|
| 243 |
+
state = RouteState(True)
|
| 244 |
+
layers = _lora_layers(root)
|
| 245 |
+
if not layers:
|
| 246 |
+
raise ServingOptionError("no LoRA layers found to replace")
|
| 247 |
+
extra_bytes = 0
|
| 248 |
+
for name, module in layers:
|
| 249 |
+
if not isinstance(module.base_layer, nn.Linear):
|
| 250 |
+
raise ServingOptionError(f"{name}: LoRA on a non-linear layer is not supported")
|
| 251 |
+
if adapter_name not in module.lora_A or adapter_name not in module.lora_B:
|
| 252 |
+
raise ServingOptionError(f"{name}: adapter {adapter_name!r} missing")
|
| 253 |
+
if adapter_name in getattr(module, "lora_variant", {}) or getattr(module, "merged", False):
|
| 254 |
+
raise ServingOptionError(f"{name}: DoRA/variant or merged LoRA is not supported")
|
| 255 |
+
if getattr(module, "fan_in_fan_out", False):
|
| 256 |
+
raise ServingOptionError(f"{name}: fan_in_fan_out is not supported")
|
| 257 |
+
dropout = module.lora_dropout[adapter_name]
|
| 258 |
+
if not isinstance(dropout, nn.Identity) and getattr(dropout, "p", 0.0) != 0.0:
|
| 259 |
+
raise ServingOptionError(f"{name}: LoRA dropout must be zero at serving")
|
| 260 |
+
lora_a, lora_b = module.lora_A[adapter_name], module.lora_B[adapter_name]
|
| 261 |
+
if getattr(lora_a, "bias", None) is not None or getattr(lora_b, "bias", None) is not None:
|
| 262 |
+
raise ServingOptionError(f"{name}: LoRA bias is not supported")
|
| 263 |
+
replacement = RouteLinear(module.base_layer, lora_a.weight, lora_b.weight,
|
| 264 |
+
module.scaling[adapter_name], state, mode)
|
| 265 |
+
if mode == "merged":
|
| 266 |
+
extra_bytes += replacement.merged_weight.numel() * replacement.merged_weight.element_size()
|
| 267 |
+
parent_name, _, child = name.rpartition(".")
|
| 268 |
+
parent = root.get_submodule(parent_name) if parent_name else root
|
| 269 |
+
setattr(parent, child, replacement)
|
| 270 |
+
del peft_model
|
| 271 |
+
if torch.cuda.is_available():
|
| 272 |
+
torch.cuda.empty_cache()
|
| 273 |
+
record = {"route_mode": mode, "replaced_layers": len(layers),
|
| 274 |
+
"merged_view_bytes": extra_bytes}
|
| 275 |
+
return root.eval(), state, record
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
class RouteSwitch:
|
| 279 |
+
"""Adapter context for ``RouteLinear`` models: one flag assignment per route change."""
|
| 280 |
+
|
| 281 |
+
def __init__(self, state: RouteState):
|
| 282 |
+
self.state = state
|
| 283 |
+
|
| 284 |
+
def __call__(self, route):
|
| 285 |
+
from open_decisions.image_jev import router
|
| 286 |
+
|
| 287 |
+
if route not in router.ROUTES:
|
| 288 |
+
raise ValueError("unknown adapter route")
|
| 289 |
+
self.state.on = route == router.SCREEN_GEOMETRY
|
| 290 |
+
return contextlib.nullcontext()
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# ------------------------------------------------------------------------------------ processor
|
| 294 |
+
class PixelProcessor:
|
| 295 |
+
"""Processor proxy returning device-normalised pixels from uint8 patches.
|
| 296 |
+
|
| 297 |
+
The image processor normalises before patchifying; normalisation is per channel and
|
| 298 |
+
elementwise and patchify is a permutation plus temporal duplication, so normalising the
|
| 299 |
+
uint8 patches with the same fused mean/std tensors gives identical float32 values.
|
| 300 |
+
"""
|
| 301 |
+
|
| 302 |
+
def __init__(self, processor, device, *, gpu_resize: bool = False):
|
| 303 |
+
object.__setattr__(self, "_inner", processor)
|
| 304 |
+
object.__setattr__(self, "_device", device)
|
| 305 |
+
object.__setattr__(self, "_gpu_resize", bool(gpu_resize))
|
| 306 |
+
object.__setattr__(self, "_norm", None)
|
| 307 |
+
|
| 308 |
+
def __getattr__(self, name):
|
| 309 |
+
return getattr(self._inner, name)
|
| 310 |
+
|
| 311 |
+
def __setattr__(self, name, value):
|
| 312 |
+
setattr(self._inner, name, value)
|
| 313 |
+
|
| 314 |
+
def _mean_std(self, device):
|
| 315 |
+
import torch
|
| 316 |
+
|
| 317 |
+
if self._norm is None or self._norm[0] != device:
|
| 318 |
+
ip = self._inner.image_processor
|
| 319 |
+
mean = torch.tensor(ip.image_mean, device=device) * (1.0 / ip.rescale_factor)
|
| 320 |
+
std = torch.tensor(ip.image_std, device=device) * (1.0 / ip.rescale_factor)
|
| 321 |
+
per = int(ip.temporal_patch_size) * int(ip.patch_size) * int(ip.patch_size)
|
| 322 |
+
object.__setattr__(self, "_norm", (device, mean.to(torch.float32).repeat_interleave(per),
|
| 323 |
+
std.to(torch.float32).repeat_interleave(per)))
|
| 324 |
+
return self._norm[1], self._norm[2]
|
| 325 |
+
|
| 326 |
+
def normalize(self, patches):
|
| 327 |
+
import torch
|
| 328 |
+
|
| 329 |
+
ip = self._inner.image_processor
|
| 330 |
+
if not (getattr(ip, "do_rescale", True) and getattr(ip, "do_normalize", True)):
|
| 331 |
+
raise RuntimeError("uint8_pixels expects the processor's default rescale+normalize")
|
| 332 |
+
mean, std = self._mean_std(patches.device)
|
| 333 |
+
return patches.to(dtype=torch.float32).sub_(mean).div_(std)
|
| 334 |
+
|
| 335 |
+
def __call__(self, *args, **kwargs):
|
| 336 |
+
import torch
|
| 337 |
+
|
| 338 |
+
if kwargs.get("images") is None and len(args) < 2:
|
| 339 |
+
return self._inner(*args, **kwargs)
|
| 340 |
+
images_kwargs = dict(kwargs.pop("images_kwargs", None) or {})
|
| 341 |
+
images_kwargs.update(do_rescale=False, do_normalize=False)
|
| 342 |
+
if self._gpu_resize and self._device is not None:
|
| 343 |
+
images_kwargs["device"] = self._device
|
| 344 |
+
output = self._inner(*args, images_kwargs=images_kwargs, **kwargs)
|
| 345 |
+
pixels = output["pixel_values"]
|
| 346 |
+
if pixels.dtype != torch.uint8:
|
| 347 |
+
raise RuntimeError(f"expected uint8 patches, got {pixels.dtype}")
|
| 348 |
+
device = self._device
|
| 349 |
+
if device is not None and str(device).startswith("cuda") and pixels.device.type == "cpu":
|
| 350 |
+
pixels = pixels.pin_memory().to(device, non_blocking=True)
|
| 351 |
+
elif device is not None and pixels.device != torch.device(device):
|
| 352 |
+
pixels = pixels.to(device)
|
| 353 |
+
output["pixel_values"] = self.normalize(pixels)
|
| 354 |
+
return output
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
# --------------------------------------------------------------------------------------- readout
|
| 358 |
+
class DeviceReadout:
|
| 359 |
+
"""``vision.option_probs`` with a cached device index (same operations after the gather)."""
|
| 360 |
+
|
| 361 |
+
def __init__(self):
|
| 362 |
+
self._index: dict[tuple, Any] = {}
|
| 363 |
+
|
| 364 |
+
def __call__(self, logits, letter_token_ids, temperature: float) -> list[float]:
|
| 365 |
+
import math
|
| 366 |
+
|
| 367 |
+
import torch
|
| 368 |
+
|
| 369 |
+
if not math.isfinite(temperature) or temperature <= 0:
|
| 370 |
+
raise ValueError("temperature must be finite and greater than zero")
|
| 371 |
+
key = (str(logits.device), tuple(letter_token_ids))
|
| 372 |
+
index = self._index.get(key)
|
| 373 |
+
if index is None:
|
| 374 |
+
if any(not 0 <= int(i) < logits.numel() for i in letter_token_ids):
|
| 375 |
+
raise ValueError("letter_token_ids must be valid vocabulary indices")
|
| 376 |
+
index = torch.tensor(list(letter_token_ids), dtype=torch.long, device=logits.device)
|
| 377 |
+
self._index[key] = index
|
| 378 |
+
selected = logits.index_select(0, index).to(dtype=torch.float32) / temperature
|
| 379 |
+
values = torch.log_softmax(selected, dim=-1).exp().detach().cpu().tolist()
|
| 380 |
+
if not all(math.isfinite(value) for value in values):
|
| 381 |
+
raise ValueError("option probabilities are non-finite")
|
| 382 |
+
return values
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
# ---------------------------------------------------------------------------------------- graphs
|
| 386 |
+
def hf_model(model):
|
| 387 |
+
return model.get_base_model() if hasattr(model, "get_base_model") else model
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def _move(value, device):
|
| 391 |
+
from open_decisions.image_jev.speed import move_to_device
|
| 392 |
+
|
| 393 |
+
return move_to_device(value, device)
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def embeds_positions(model, prepared, device):
|
| 397 |
+
"""The multimodal forward up to the language model (the model's own methods, same order)."""
|
| 398 |
+
import torch
|
| 399 |
+
|
| 400 |
+
top = hf_model(model)
|
| 401 |
+
core = top.model
|
| 402 |
+
ids = _move(prepared.input_ids, device)
|
| 403 |
+
pixels = _move(prepared.pixel_values, device)
|
| 404 |
+
grid = _move(prepared.image_grid_thw, device)
|
| 405 |
+
mm = getattr(prepared, "mm_token_type_ids", None)
|
| 406 |
+
mm = None if mm is None else _move(mm, device)
|
| 407 |
+
embeds = core.get_input_embeddings()(ids)
|
| 408 |
+
if pixels is not None:
|
| 409 |
+
features = core.get_image_features(pixels, grid, return_dict=True).pooler_output
|
| 410 |
+
features = torch.cat(features, dim=0).to(embeds.device, embeds.dtype)
|
| 411 |
+
mask, _ = core.get_placeholder_mask(ids, inputs_embeds=embeds, image_features=features)
|
| 412 |
+
embeds = embeds.masked_scatter(mask, features)
|
| 413 |
+
positions = core.compute_3d_position_ids(
|
| 414 |
+
input_ids=ids, inputs_embeds=embeds, image_grid_thw=grid, video_grid_thw=None,
|
| 415 |
+
attention_mask=None, past_key_values=None, mm_token_type_ids=mm)
|
| 416 |
+
return embeds, positions
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def pad_positions(positions, size: int):
|
| 420 |
+
"""Right-pad (3, 1, n) position ids to ``size`` by continuing each axis from its last value."""
|
| 421 |
+
import torch
|
| 422 |
+
|
| 423 |
+
n = positions.shape[-1]
|
| 424 |
+
if size < n:
|
| 425 |
+
raise ValueError("padded size is shorter than the sequence")
|
| 426 |
+
if size == n:
|
| 427 |
+
return positions
|
| 428 |
+
tail = positions[..., -1:] + torch.arange(1, size - n + 1, device=positions.device,
|
| 429 |
+
dtype=positions.dtype)
|
| 430 |
+
return torch.cat((positions, tail), dim=-1)
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def eager_last_logits(model, embeds, positions):
|
| 434 |
+
top = hf_model(model)
|
| 435 |
+
hidden = top.model.language_model(input_ids=None, position_ids=positions, attention_mask=None,
|
| 436 |
+
past_key_values=None, inputs_embeds=embeds,
|
| 437 |
+
use_cache=False).last_hidden_state
|
| 438 |
+
return top.lm_head(hidden[:, -1:, :])[0, 0]
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
class DecoderGraphs:
|
| 442 |
+
"""CUDA graphs of language model + last-row vocabulary projection at padded lengths."""
|
| 443 |
+
|
| 444 |
+
def __init__(self, model, *, lengths: Sequence[int], routes: Sequence[Any],
|
| 445 |
+
set_route: Callable[[Any], Any] | None, device, log=print):
|
| 446 |
+
import torch
|
| 447 |
+
|
| 448 |
+
top = hf_model(model)
|
| 449 |
+
self.model = model
|
| 450 |
+
lm, head = top.model.language_model, top.lm_head
|
| 451 |
+
hidden = int(top.config.text_config.hidden_size)
|
| 452 |
+
dtype = next(lm.parameters()).dtype
|
| 453 |
+
self.graphs: dict[tuple[Any, int], tuple] = {}
|
| 454 |
+
self.lengths = sorted(set(int(n) for n in lengths))
|
| 455 |
+
started = time.monotonic()
|
| 456 |
+
pool = None
|
| 457 |
+
with torch.inference_mode():
|
| 458 |
+
for route in routes:
|
| 459 |
+
if set_route is not None:
|
| 460 |
+
set_route(route)
|
| 461 |
+
for n in sorted(self.lengths, reverse=True): # longest first; shorter reuse pool
|
| 462 |
+
embeds = torch.zeros(1, n, hidden, device=device, dtype=dtype)
|
| 463 |
+
positions = torch.arange(n, device=device).view(1, 1, n).expand(3, 1, n).contiguous()
|
| 464 |
+
last = torch.zeros(1, dtype=torch.long, device=device)
|
| 465 |
+
|
| 466 |
+
def step(embeds=embeds, positions=positions, last=last):
|
| 467 |
+
state = lm(input_ids=None, position_ids=positions, attention_mask=None,
|
| 468 |
+
past_key_values=None, inputs_embeds=embeds,
|
| 469 |
+
use_cache=False).last_hidden_state
|
| 470 |
+
return head(state[0].index_select(0, last))[0]
|
| 471 |
+
|
| 472 |
+
side = torch.cuda.Stream()
|
| 473 |
+
side.wait_stream(torch.cuda.current_stream())
|
| 474 |
+
with torch.cuda.stream(side):
|
| 475 |
+
for _ in range(3):
|
| 476 |
+
step()
|
| 477 |
+
torch.cuda.current_stream().wait_stream(side)
|
| 478 |
+
graph = torch.cuda.CUDAGraph()
|
| 479 |
+
with torch.cuda.graph(graph, pool=pool):
|
| 480 |
+
out = step()
|
| 481 |
+
pool = graph.pool()
|
| 482 |
+
self.graphs[(route, n)] = (graph, embeds, positions, last, out)
|
| 483 |
+
self.capture_seconds = time.monotonic() - started
|
| 484 |
+
self.routes = list(routes)
|
| 485 |
+
log(f"captured {len(self.graphs)} graphs in {self.capture_seconds:.1f} s")
|
| 486 |
+
|
| 487 |
+
def fits(self, n: int) -> bool:
|
| 488 |
+
return bool(self.lengths) and n <= self.lengths[-1]
|
| 489 |
+
|
| 490 |
+
def run(self, route, embeds, positions):
|
| 491 |
+
n = embeds.shape[1]
|
| 492 |
+
size = next(x for x in self.lengths if x >= n)
|
| 493 |
+
graph, static_embeds, static_positions, last, out = self.graphs[(route, size)]
|
| 494 |
+
static_embeds.zero_()
|
| 495 |
+
static_embeds[:, :n].copy_(embeds)
|
| 496 |
+
static_positions.copy_(pad_positions(positions, size))
|
| 497 |
+
last.fill_(n - 1)
|
| 498 |
+
graph.replay()
|
| 499 |
+
return out.clone()
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
class GraphLogits:
|
| 503 |
+
"""``logits_fn`` for the Predictor: eager multimodal front, graph-replayed language model."""
|
| 504 |
+
|
| 505 |
+
def __init__(self, graphs: DecoderGraphs | None, device, *, route_key: Callable[[Any], Any]):
|
| 506 |
+
self.graphs, self.device, self.route_key = graphs, device, route_key
|
| 507 |
+
self.replayed = self.eager = 0
|
| 508 |
+
|
| 509 |
+
def __call__(self, model, prepared, *, timer, route=None):
|
| 510 |
+
from open_decisions.image_jev.speed import PhaseTimer
|
| 511 |
+
|
| 512 |
+
timer = timer or PhaseTimer()
|
| 513 |
+
with timer.phase("forward"):
|
| 514 |
+
embeds, positions = embeds_positions(model, prepared, self.device)
|
| 515 |
+
n = embeds.shape[1]
|
| 516 |
+
if self.graphs is not None and self.graphs.fits(n):
|
| 517 |
+
self.replayed += 1
|
| 518 |
+
return self.graphs.run(self.route_key(route), embeds, positions)
|
| 519 |
+
self.eager += 1
|
| 520 |
+
return eager_last_logits(model, embeds, positions)
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
# ------------------------------------------------------------------------------------------ apply
|
| 524 |
+
@dataclasses.dataclass
|
| 525 |
+
class Serving:
|
| 526 |
+
model: Any
|
| 527 |
+
processor: Any
|
| 528 |
+
adapter_context: Callable | None
|
| 529 |
+
inference_context: Callable[[], Any] | None
|
| 530 |
+
logits_fn: Callable | None
|
| 531 |
+
readout_fn: Callable | None
|
| 532 |
+
record: dict
|
| 533 |
+
image_token_cap: int | None = None
|
| 534 |
+
image_token_cap_default: int | None = None
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def _sdpa_flash_context():
|
| 538 |
+
from torch.nn.attention import SDPBackend, sdpa_kernel
|
| 539 |
+
|
| 540 |
+
return sdpa_kernel([SDPBackend.FLASH_ATTENTION])
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def apply_serving_options(model, processor, options: Sequence[str], *, device, routed: bool,
|
| 544 |
+
adapter_name: str | None, adapter_context: Callable | None,
|
| 545 |
+
inference_context: Callable[[], Any] | None,
|
| 546 |
+
graph_lengths: Sequence[int] = GRAPH_LENGTHS, log=print) -> Serving:
|
| 547 |
+
"""Apply validated ``options`` to a loaded (PEFT or plain) model and its processor."""
|
| 548 |
+
import torch
|
| 549 |
+
|
| 550 |
+
options = parse_options(options)
|
| 551 |
+
record: dict[str, Any] = {"serving_options": list(options), "exact": is_exact(options)}
|
| 552 |
+
cap = image_token_cap(options)
|
| 553 |
+
if cap is not None:
|
| 554 |
+
from open_decisions.image_jev import vision
|
| 555 |
+
|
| 556 |
+
record["image_token_cap"] = cap
|
| 557 |
+
record["native_max_pixels"] = vision.native_max_pixels(cap)
|
| 558 |
+
cap_default = image_token_cap_default(options)
|
| 559 |
+
if cap_default is not None:
|
| 560 |
+
from open_decisions.image_jev import vision
|
| 561 |
+
|
| 562 |
+
record["image_token_cap_default"] = cap_default
|
| 563 |
+
record["native_max_pixels_default_route"] = vision.native_max_pixels(cap_default)
|
| 564 |
+
started = time.monotonic()
|
| 565 |
+
mode = route_mode(options)
|
| 566 |
+
if mode is not None:
|
| 567 |
+
if not routed or adapter_name is None:
|
| 568 |
+
raise ServingOptionError("route modes need the routed single-adapter system")
|
| 569 |
+
model, state, route_record = install_route_modules(model, adapter_name=adapter_name,
|
| 570 |
+
mode=mode)
|
| 571 |
+
adapter_context = RouteSwitch(state)
|
| 572 |
+
record.update(route_record)
|
| 573 |
+
if "uint8_pixels" in options:
|
| 574 |
+
processor = PixelProcessor(processor, device, gpu_resize="gpu_preprocess" in options)
|
| 575 |
+
readout_fn = DeviceReadout() if "device_readout" in options else None
|
| 576 |
+
if "sdpa_flash" in options:
|
| 577 |
+
base_context = inference_context
|
| 578 |
+
|
| 579 |
+
@contextlib.contextmanager
|
| 580 |
+
def flash_context():
|
| 581 |
+
with (base_context() if base_context is not None else contextlib.nullcontext()):
|
| 582 |
+
with _sdpa_flash_context():
|
| 583 |
+
yield
|
| 584 |
+
|
| 585 |
+
inference_context = flash_context
|
| 586 |
+
if "compile_text" in options:
|
| 587 |
+
core = hf_model(model).model
|
| 588 |
+
core.language_model = torch.compile(core.language_model, dynamic=True)
|
| 589 |
+
record["compile"] = "torch.compile(language_model, dynamic=True)"
|
| 590 |
+
logits_fn = None
|
| 591 |
+
if "cuda_graphs" in options:
|
| 592 |
+
if not str(device).startswith("cuda"):
|
| 593 |
+
raise ServingOptionError("cuda_graphs needs a CUDA device")
|
| 594 |
+
from open_decisions.image_jev import router
|
| 595 |
+
|
| 596 |
+
routes = list(router.ROUTES) if routed else [None]
|
| 597 |
+
set_route = adapter_context if routed else None
|
| 598 |
+
context = inference_context() if inference_context is not None else contextlib.nullcontext()
|
| 599 |
+
with context:
|
| 600 |
+
graphs = DecoderGraphs(model, lengths=graph_lengths, routes=routes,
|
| 601 |
+
set_route=set_route, device=device, log=log)
|
| 602 |
+
logits_fn = GraphLogits(graphs, device, route_key=(lambda route: route) if routed
|
| 603 |
+
else (lambda route: None))
|
| 604 |
+
record.update({"graph_count": len(graphs.graphs), "graph_lengths": list(graphs.lengths),
|
| 605 |
+
"graph_capture_seconds": round(graphs.capture_seconds, 2)})
|
| 606 |
+
record["apply_seconds"] = round(time.monotonic() - started, 2)
|
| 607 |
+
return Serving(model=model, processor=processor, adapter_context=adapter_context,
|
| 608 |
+
inference_context=inference_context, logits_fn=logits_fn,
|
| 609 |
+
readout_fn=readout_fn, record=record, image_token_cap=cap,
|
| 610 |
+
image_token_cap_default=cap_default)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
__all__ = [
|
| 614 |
+
"DecoderGraphs", "DeviceReadout", "EXACT_OPTIONS", "GRAPH_LENGTHS", "GraphLogits",
|
| 615 |
+
"IMAGE_TOKEN_CAP", "IMAGE_TOKEN_CAP_DEFAULT", "OPTIONS", "PARAMETERISED_OPTIONS",
|
| 616 |
+
"image_token_cap", "image_token_cap_default",
|
| 617 |
+
"PixelProcessor", "RouteState", "RouteSwitch", "Serving", "ServingOptionError",
|
| 618 |
+
"apply_serving_options", "install_route_modules", "is_exact", "pad_positions",
|
| 619 |
+
"parse_options", "route_mode",
|
| 620 |
+
]
|
open_decisions/image_jev/release/predictor.py
CHANGED
|
@@ -228,16 +228,134 @@ class RoutedCalibration:
|
|
| 228 |
return self.route_type_temperatures[route_name].get(bucket, self.global_temperature)
|
| 229 |
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
def serving_calibration_from_artifact(
|
| 232 |
value: Mapping[str, Any], *, checkpoint_sha256: str | None = "",
|
| 233 |
adapter_sha256: str | None = None, base: Mapping[str, str] | None = None,
|
| 234 |
-
|
|
|
|
| 235 |
if not isinstance(value, Mapping):
|
| 236 |
raise ValueError("calibration artifact must be an object")
|
| 237 |
if value.get("version") == "image-jev/router-calibration/v1":
|
|
|
|
|
|
|
| 238 |
return RoutedCalibration.from_artifact(
|
| 239 |
value, adapter_sha256=adapter_sha256, base=base
|
| 240 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
return Calibration.from_artifact(value, checkpoint_sha256=checkpoint_sha256)
|
| 242 |
|
| 243 |
|
|
@@ -353,13 +471,29 @@ def _length(value: Any) -> int:
|
|
| 353 |
class Predictor:
|
| 354 |
"""One prefill per question; calibrated option probabilities in the board's shape."""
|
| 355 |
|
| 356 |
-
def __init__(self, model, processor, calibration: Calibration | RoutedCalibration, *, model_name: str,
|
| 357 |
context_policy: str = "prepend", max_input_tokens: int | None = None,
|
| 358 |
inference_context: Callable[[], Any] | None = None,
|
| 359 |
adapter_context: Callable[[str], Any] | None = None,
|
| 360 |
-
synchronize: Callable[[], Any] | None = None, forward_call=None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
if context_policy not in CONTEXT_POLICIES:
|
| 362 |
raise ValueError(f"context_policy must be one of {CONTEXT_POLICIES}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
self.model, self.processor, self.calibration = model, processor, calibration
|
| 364 |
self.model_name = model_name
|
| 365 |
self.context_policy = context_policy
|
|
@@ -368,18 +502,28 @@ class Predictor:
|
|
| 368 |
self._adapter_context = adapter_context
|
| 369 |
self._synchronize = synchronize
|
| 370 |
self._forward_call = forward_call
|
|
|
|
|
|
|
|
|
|
| 371 |
self._cache = NativeCache(processor)
|
| 372 |
try:
|
| 373 |
self.device = next(model.parameters()).device
|
| 374 |
except (StopIteration, AttributeError):
|
| 375 |
self.device = None
|
| 376 |
|
| 377 |
-
def _read(self, images: Sequence, question: Question, *, loaded_images=None, timer=None
|
|
|
|
| 378 |
example = {"question": question.instructions, "options": list(question.options),
|
| 379 |
"images": list(images)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
try:
|
| 381 |
prepared = vision.prepare_native(example, self.processor, loaded_images=loaded_images,
|
| 382 |
-
cache=self._cache, timer=timer, device=self.device
|
|
|
|
| 383 |
except (TypeError, ValueError) as error:
|
| 384 |
raise RequestError(str(error)) from error
|
| 385 |
tokens = _length(prepared.input_ids)
|
|
@@ -390,8 +534,8 @@ class Predictor:
|
|
| 390 |
)
|
| 391 |
with timer.phase("route_adapter_switch"):
|
| 392 |
route_name = None
|
| 393 |
-
if isinstance(self.calibration,
|
| 394 |
-
route_name = router.route(
|
| 395 |
question.route_instructions or question.instructions, question.options
|
| 396 |
)
|
| 397 |
temperature = self.calibration.temperature(len(question.options), route_name)
|
|
@@ -406,11 +550,18 @@ class Predictor:
|
|
| 406 |
)
|
| 407 |
with route_context:
|
| 408 |
with inference_context:
|
| 409 |
-
|
| 410 |
-
|
|
|
|
|
|
|
|
|
|
| 411 |
with timer.phase("readout_calibration"):
|
| 412 |
-
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
with timer.phase("readout_calibration"):
|
| 415 |
total = math.fsum(probabilities)
|
| 416 |
return [value / total for value in probabilities], tokens
|
|
@@ -421,7 +572,16 @@ class Predictor:
|
|
| 421 |
images, questions = parse_request(body, context_policy=self.context_policy)
|
| 422 |
return self.predict_questions(images, questions, timer=timer)
|
| 423 |
|
| 424 |
-
def predict_questions(self, images, questions, *, timer=None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
timer = timer or PhaseTimer()
|
| 426 |
with timer.phase("decode_load"):
|
| 427 |
try:
|
|
@@ -431,7 +591,8 @@ class Predictor:
|
|
| 431 |
raise RequestError(str(error)) from error
|
| 432 |
answers, input_tokens = {}, 0
|
| 433 |
for question in questions:
|
| 434 |
-
probabilities, tokens = self._read(images, question, loaded_images=loaded, timer=timer
|
|
|
|
| 435 |
with timer.phase("response_build"):
|
| 436 |
input_tokens += tokens
|
| 437 |
names = [option["name"] for option in question.options]
|
|
@@ -507,9 +668,21 @@ def load_predictor(*, calibration_path: str | Path, model_name: str,
|
|
| 507 |
adapter_path: str | Path | None = None,
|
| 508 |
base_hashes_path: str | Path | None = None, device: str = "cuda",
|
| 509 |
context_policy: str = "prepend", max_input_tokens: int | None = None,
|
| 510 |
-
allow_slow_path: bool = False
|
| 511 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
import platform
|
|
|
|
| 513 |
|
| 514 |
import torch
|
| 515 |
import transformers
|
|
@@ -518,19 +691,35 @@ def load_predictor(*, calibration_path: str | Path, model_name: str,
|
|
| 518 |
|
| 519 |
if not allow_slow_path:
|
| 520 |
check_fast_path()
|
|
|
|
|
|
|
| 521 |
adapter = None if adapter_path is None else Path(adapter_path)
|
| 522 |
adapter_sha256 = "" if adapter is None else tree_sha256(adapter)
|
|
|
|
|
|
|
|
|
|
| 523 |
try:
|
| 524 |
calibration_value = json.loads(Path(calibration_path).read_text(encoding="utf-8"))
|
| 525 |
except (OSError, json.JSONDecodeError) as error:
|
| 526 |
raise ValueError(f"cannot load calibration {calibration_path}: {error}") from error
|
| 527 |
base_pin = {"repo": BASE_REPO, "revision": BASE_REVISION}
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
raise ValueError("routed calibration requires the screen_geometry adapter")
|
| 535 |
snapshot = Path(snapshot_download(BASE_REPO, revision=BASE_REVISION, cache_dir=model_cache,
|
| 536 |
local_files_only=offline))
|
|
@@ -545,20 +734,53 @@ def load_predictor(*, calibration_path: str | Path, model_name: str,
|
|
| 545 |
transformers, "AutoModelForImageTextToText")
|
| 546 |
model = auto_model.from_pretrained(str(snapshot), torch_dtype=torch.bfloat16,
|
| 547 |
device_map=device, trust_remote_code=False, attn_implementation="sdpa")
|
| 548 |
-
|
| 549 |
-
|
|
|
|
| 550 |
|
| 551 |
-
|
| 552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
)
|
| 554 |
-
model =
|
| 555 |
-
|
| 556 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
predictor = Predictor(model, processor, calibration, model_name=model_name,
|
| 558 |
context_policy=context_policy, max_input_tokens=max_input_tokens,
|
| 559 |
-
inference_context=
|
| 560 |
adapter_context=adapter_context,
|
| 561 |
-
synchronize=(torch.cuda.synchronize if str(device).startswith("cuda") else None)
|
|
|
|
|
|
|
| 562 |
provenance = {
|
| 563 |
"base_repo": BASE_REPO, "base_revision": BASE_REVISION,
|
| 564 |
"base_files_verified": verified,
|
|
@@ -570,17 +792,33 @@ def load_predictor(*, calibration_path: str | Path, model_name: str,
|
|
| 570 |
"system_kind": "routed" if routed else "single-route",
|
| 571 |
"router_rules_version": router.RULES_VERSION if routed else None,
|
| 572 |
"router_rules_sha256": router.RULES_SHA256 if routed else None,
|
| 573 |
-
"adapter_switching":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
"precision": "bfloat16", "attention": "sdpa", "processor_use_fast": True, "scorer": "one-prefill-option-letter",
|
| 575 |
"context_policy": context_policy, "max_input_tokens": max_input_tokens,
|
| 576 |
"torch_version": torch.__version__, "transformers_version": transformers.__version__,
|
| 577 |
"python_version": platform.python_version(),
|
| 578 |
}
|
|
|
|
|
|
|
| 579 |
return predictor, provenance
|
| 580 |
|
| 581 |
|
| 582 |
__all__ = [
|
| 583 |
-
"BASE_REPO", "BASE_REVISION", "Calibration", "
|
|
|
|
| 584 |
"TYPE_BUCKET_BY_OPTION_COUNT", "check_fast_path", "load_predictor", "parse_request",
|
| 585 |
"serving_calibration_from_artifact", "tree_sha256", "verify_snapshot",
|
| 586 |
]
|
|
|
|
| 228 |
return self.route_type_temperatures[route_name].get(bucket, self.global_temperature)
|
| 229 |
|
| 230 |
|
| 231 |
+
_ROUTED_V2_CALIBRATION_FIELDS = {
|
| 232 |
+
"adapter_sha256_by_route", "artifact_sha256", "base", "image_route",
|
| 233 |
+
"image_route_config_sha256", "regularization", "route_global_temperatures",
|
| 234 |
+
"route_type_counts", "route_type_temperatures", "rules_sha256", "rules_version",
|
| 235 |
+
"sources", "version",
|
| 236 |
+
}
|
| 237 |
+
ROUTED_V2_CALIBRATION_VERSION = "image-jev/router-calibration/v2"
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _sha256_text(value: Any) -> bool:
|
| 241 |
+
return (isinstance(value, str) and len(value) == 64
|
| 242 |
+
and all(char in "0123456789abcdef" for char in value))
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@dataclass(frozen=True)
|
| 246 |
+
class RoutedCalibrationV2:
|
| 247 |
+
"""Serving view of ``image-jev/router-calibration/v2`` (one adapter per route)."""
|
| 248 |
+
|
| 249 |
+
route_global_temperatures: Mapping[str, float]
|
| 250 |
+
route_type_temperatures: Mapping[str, Mapping[str, float]]
|
| 251 |
+
artifact_sha256: str
|
| 252 |
+
identity_sha256: str
|
| 253 |
+
adapter_sha256_by_route: Mapping[str, str]
|
| 254 |
+
base: Mapping[str, str]
|
| 255 |
+
rules_sha256: str
|
| 256 |
+
|
| 257 |
+
@classmethod
|
| 258 |
+
def from_artifact(
|
| 259 |
+
cls, value: Mapping[str, Any], *,
|
| 260 |
+
adapter_sha256_by_route: Mapping[str, str] | None = None,
|
| 261 |
+
base: Mapping[str, str] | None = None,
|
| 262 |
+
) -> "RoutedCalibrationV2":
|
| 263 |
+
if not isinstance(value, Mapping) or set(value) != _ROUTED_V2_CALIBRATION_FIELDS:
|
| 264 |
+
raise ValueError("routed calibration v2 artifact fields differ")
|
| 265 |
+
body = {key: item for key, item in value.items() if key != "artifact_sha256"}
|
| 266 |
+
if value["artifact_sha256"] != hashlib.sha256(_compact(body)).hexdigest():
|
| 267 |
+
raise ValueError("routed calibration artifact hash differs")
|
| 268 |
+
if value["version"] != ROUTED_V2_CALIBRATION_VERSION:
|
| 269 |
+
raise ValueError("routed calibration artifact version differs")
|
| 270 |
+
if value["rules_version"] != router.RULES_VERSION \
|
| 271 |
+
or value["rules_sha256"] != router.RULES_SHA256:
|
| 272 |
+
raise ValueError("routed calibration rules binding differs")
|
| 273 |
+
if value["image_route"] != IMAGE_ROUTE \
|
| 274 |
+
or value["image_route_config_sha256"] != vision.IMAGE_ROUTE_CONFIG_SHA256[IMAGE_ROUTE]:
|
| 275 |
+
raise ValueError("routed calibration image-route binding differs")
|
| 276 |
+
hashes = value["adapter_sha256_by_route"]
|
| 277 |
+
if (not isinstance(hashes, Mapping) or set(hashes) != set(router.ROUTES)
|
| 278 |
+
or not all(_sha256_text(hashes[name]) for name in router.ROUTES)):
|
| 279 |
+
raise ValueError("routed calibration adapter hashes are invalid")
|
| 280 |
+
if adapter_sha256_by_route is not None and dict(hashes) != dict(adapter_sha256_by_route):
|
| 281 |
+
raise ValueError("routed calibration adapter binding differs")
|
| 282 |
+
if not isinstance(value["base"], Mapping) or set(value["base"]) != {"repo", "revision"}:
|
| 283 |
+
raise ValueError("routed calibration base pin differs")
|
| 284 |
+
if base is not None and value["base"] != dict(base):
|
| 285 |
+
raise ValueError("routed calibration base binding differs")
|
| 286 |
+
globals_ = value["route_global_temperatures"]
|
| 287 |
+
route_temperatures = value["route_type_temperatures"]
|
| 288 |
+
route_counts = value["route_type_counts"]
|
| 289 |
+
for name, mapping in (("global", globals_), ("temperature", route_temperatures),
|
| 290 |
+
("count", route_counts), ("source", value["sources"])):
|
| 291 |
+
if not isinstance(mapping, Mapping) or set(mapping) != set(router.ROUTES):
|
| 292 |
+
raise ValueError(f"routed calibration {name} routes differ")
|
| 293 |
+
checked = {}
|
| 294 |
+
for route_name in router.ROUTES:
|
| 295 |
+
values = route_temperatures[route_name]
|
| 296 |
+
counts = route_counts[route_name]
|
| 297 |
+
if (
|
| 298 |
+
not isinstance(values, Mapping)
|
| 299 |
+
or set(values) != set(TYPE_BUCKET_BY_OPTION_COUNT.values())
|
| 300 |
+
or not isinstance(counts, Mapping)
|
| 301 |
+
or set(counts) != set(TYPE_BUCKET_BY_OPTION_COUNT.values())
|
| 302 |
+
):
|
| 303 |
+
raise ValueError("routed calibration temperatures must be objects")
|
| 304 |
+
if any(
|
| 305 |
+
isinstance(count, bool) or not isinstance(count, int) or count < 0
|
| 306 |
+
for count in counts.values()
|
| 307 |
+
):
|
| 308 |
+
raise ValueError("routed calibration counts are invalid")
|
| 309 |
+
checked[route_name] = {
|
| 310 |
+
str(kind): _temperature(item, f"route_type_temperatures[{route_name}][{kind}]")
|
| 311 |
+
for kind, item in values.items()
|
| 312 |
+
}
|
| 313 |
+
return cls(
|
| 314 |
+
route_global_temperatures={
|
| 315 |
+
name: _temperature(globals_[name], f"route_global_temperatures[{name}]")
|
| 316 |
+
for name in router.ROUTES
|
| 317 |
+
},
|
| 318 |
+
route_type_temperatures=checked,
|
| 319 |
+
artifact_sha256=str(value["artifact_sha256"]),
|
| 320 |
+
identity_sha256=hashlib.sha256(_compact(dict(value))).hexdigest(),
|
| 321 |
+
adapter_sha256_by_route=dict(hashes), base=dict(value["base"]),
|
| 322 |
+
rules_sha256=str(value["rules_sha256"]),
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
def temperature(self, n_options: int, route_name: str) -> float:
|
| 326 |
+
if route_name not in router.ROUTES:
|
| 327 |
+
raise ValueError("unknown routed calibration route")
|
| 328 |
+
bucket = TYPE_BUCKET_BY_OPTION_COUNT.get(int(n_options))
|
| 329 |
+
if bucket is None:
|
| 330 |
+
return self.route_global_temperatures[route_name]
|
| 331 |
+
return self.route_type_temperatures[route_name].get(
|
| 332 |
+
bucket, self.route_global_temperatures[route_name])
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
ROUTED_CALIBRATIONS = (RoutedCalibration, RoutedCalibrationV2)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
def serving_calibration_from_artifact(
|
| 339 |
value: Mapping[str, Any], *, checkpoint_sha256: str | None = "",
|
| 340 |
adapter_sha256: str | None = None, base: Mapping[str, str] | None = None,
|
| 341 |
+
adapter_sha256_by_route: Mapping[str, str] | None = None,
|
| 342 |
+
) -> Calibration | RoutedCalibration | RoutedCalibrationV2:
|
| 343 |
if not isinstance(value, Mapping):
|
| 344 |
raise ValueError("calibration artifact must be an object")
|
| 345 |
if value.get("version") == "image-jev/router-calibration/v1":
|
| 346 |
+
if adapter_sha256_by_route is not None:
|
| 347 |
+
raise ValueError("router-calibration/v1 binds one adapter, not one per route")
|
| 348 |
return RoutedCalibration.from_artifact(
|
| 349 |
value, adapter_sha256=adapter_sha256, base=base
|
| 350 |
)
|
| 351 |
+
if value.get("version") == ROUTED_V2_CALIBRATION_VERSION:
|
| 352 |
+
if adapter_sha256 is not None:
|
| 353 |
+
raise ValueError("router-calibration/v2 binds one adapter per route")
|
| 354 |
+
return RoutedCalibrationV2.from_artifact(
|
| 355 |
+
value, adapter_sha256_by_route=adapter_sha256_by_route, base=base
|
| 356 |
+
)
|
| 357 |
+
if adapter_sha256_by_route is not None:
|
| 358 |
+
raise ValueError("single-route calibration cannot bind route adapters")
|
| 359 |
return Calibration.from_artifact(value, checkpoint_sha256=checkpoint_sha256)
|
| 360 |
|
| 361 |
|
|
|
|
| 471 |
class Predictor:
|
| 472 |
"""One prefill per question; calibrated option probabilities in the board's shape."""
|
| 473 |
|
| 474 |
+
def __init__(self, model, processor, calibration: Calibration | RoutedCalibration | RoutedCalibrationV2, *, model_name: str,
|
| 475 |
context_policy: str = "prepend", max_input_tokens: int | None = None,
|
| 476 |
inference_context: Callable[[], Any] | None = None,
|
| 477 |
adapter_context: Callable[[str], Any] | None = None,
|
| 478 |
+
synchronize: Callable[[], Any] | None = None, forward_call=None,
|
| 479 |
+
logits_fn: Callable[..., Any] | None = None,
|
| 480 |
+
readout_fn: Callable[..., list[float]] | None = None,
|
| 481 |
+
image_token_cap: int | None = None,
|
| 482 |
+
image_token_cap_default: int | None = None) -> None:
|
| 483 |
if context_policy not in CONTEXT_POLICIES:
|
| 484 |
raise ValueError(f"context_policy must be one of {CONTEXT_POLICIES}")
|
| 485 |
+
# Optional native-route image-token cap (``image_token_cap=N`` serving option); None keeps
|
| 486 |
+
# the processor's own maximum, i.e. the published behaviour.
|
| 487 |
+
self.image_token_cap = (None if image_token_cap is None
|
| 488 |
+
else vision.validate_image_token_cap(image_token_cap))
|
| 489 |
+
# Route-conditioned variant (``image_token_cap_default=N``): the cap applies only when the
|
| 490 |
+
# question's text rule (router-rules/v1) routes it to ``default``; screen_geometry stays
|
| 491 |
+
# native. Decided on the text route even under ``route_override``.
|
| 492 |
+
self.image_token_cap_default = (
|
| 493 |
+
None if image_token_cap_default is None
|
| 494 |
+
else vision.validate_image_token_cap(image_token_cap_default))
|
| 495 |
+
if self.image_token_cap is not None and self.image_token_cap_default is not None:
|
| 496 |
+
raise ValueError("image_token_cap and image_token_cap_default are alternatives")
|
| 497 |
self.model, self.processor, self.calibration = model, processor, calibration
|
| 498 |
self.model_name = model_name
|
| 499 |
self.context_policy = context_policy
|
|
|
|
| 502 |
self._adapter_context = adapter_context
|
| 503 |
self._synchronize = synchronize
|
| 504 |
self._forward_call = forward_call
|
| 505 |
+
# Optional serving fast paths (``release.fastpath``); None keeps the published path.
|
| 506 |
+
self._logits_fn = logits_fn
|
| 507 |
+
self._readout_fn = readout_fn
|
| 508 |
self._cache = NativeCache(processor)
|
| 509 |
try:
|
| 510 |
self.device = next(model.parameters()).device
|
| 511 |
except (StopIteration, AttributeError):
|
| 512 |
self.device = None
|
| 513 |
|
| 514 |
+
def _read(self, images: Sequence, question: Question, *, loaded_images=None, timer=None,
|
| 515 |
+
route_override: str | None = None) -> tuple[list[float], int]:
|
| 516 |
example = {"question": question.instructions, "options": list(question.options),
|
| 517 |
"images": list(images)}
|
| 518 |
+
cap = {} if self.image_token_cap is None else {"image_token_cap": self.image_token_cap}
|
| 519 |
+
if self.image_token_cap_default is not None and router.route(
|
| 520 |
+
question.route_instructions or question.instructions,
|
| 521 |
+
question.options) == router.DEFAULT:
|
| 522 |
+
cap = {"image_token_cap": self.image_token_cap_default}
|
| 523 |
try:
|
| 524 |
prepared = vision.prepare_native(example, self.processor, loaded_images=loaded_images,
|
| 525 |
+
cache=self._cache, timer=timer, device=self.device,
|
| 526 |
+
**cap)
|
| 527 |
except (TypeError, ValueError) as error:
|
| 528 |
raise RequestError(str(error)) from error
|
| 529 |
tokens = _length(prepared.input_ids)
|
|
|
|
| 534 |
)
|
| 535 |
with timer.phase("route_adapter_switch"):
|
| 536 |
route_name = None
|
| 537 |
+
if isinstance(self.calibration, ROUTED_CALIBRATIONS):
|
| 538 |
+
route_name = route_override or router.route(
|
| 539 |
question.route_instructions or question.instructions, question.options
|
| 540 |
)
|
| 541 |
temperature = self.calibration.temperature(len(question.options), route_name)
|
|
|
|
| 550 |
)
|
| 551 |
with route_context:
|
| 552 |
with inference_context:
|
| 553 |
+
if self._logits_fn is None:
|
| 554 |
+
logits = vision.last_position_logits(
|
| 555 |
+
self.model, prepared, timer=timer, forward_call=self._forward_call)
|
| 556 |
+
else:
|
| 557 |
+
logits = self._logits_fn(self.model, prepared, timer=timer, route=route_name)
|
| 558 |
with timer.phase("readout_calibration"):
|
| 559 |
+
if self._readout_fn is None:
|
| 560 |
+
probabilities = vision.option_probs(
|
| 561 |
+
logits, prepared.letter_token_ids, temperature=temperature)
|
| 562 |
+
else:
|
| 563 |
+
probabilities = self._readout_fn(
|
| 564 |
+
logits, prepared.letter_token_ids, temperature)
|
| 565 |
with timer.phase("readout_calibration"):
|
| 566 |
total = math.fsum(probabilities)
|
| 567 |
return [value / total for value in probabilities], tokens
|
|
|
|
| 572 |
images, questions = parse_request(body, context_policy=self.context_policy)
|
| 573 |
return self.predict_questions(images, questions, timer=timer)
|
| 574 |
|
| 575 |
+
def predict_questions(self, images, questions, *, timer=None,
|
| 576 |
+
route_override: str | None = None) -> dict[str, Any]:
|
| 577 |
+
"""Answer parsed questions. ``route_override`` (evaluation only; never set by the
|
| 578 |
+
server) serves a routed system on one named route, e.g. ``default`` = the frozen base
|
| 579 |
+
with its default-route temperatures."""
|
| 580 |
+
if route_override is not None:
|
| 581 |
+
if route_override not in router.ROUTES:
|
| 582 |
+
raise ValueError(f"route_override must be one of {router.ROUTES}")
|
| 583 |
+
if not isinstance(self.calibration, ROUTED_CALIBRATIONS):
|
| 584 |
+
raise ValueError("route_override needs a routed system")
|
| 585 |
timer = timer or PhaseTimer()
|
| 586 |
with timer.phase("decode_load"):
|
| 587 |
try:
|
|
|
|
| 591 |
raise RequestError(str(error)) from error
|
| 592 |
answers, input_tokens = {}, 0
|
| 593 |
for question in questions:
|
| 594 |
+
probabilities, tokens = self._read(images, question, loaded_images=loaded, timer=timer,
|
| 595 |
+
route_override=route_override)
|
| 596 |
with timer.phase("response_build"):
|
| 597 |
input_tokens += tokens
|
| 598 |
names = [option["name"] for option in question.options]
|
|
|
|
| 668 |
adapter_path: str | Path | None = None,
|
| 669 |
base_hashes_path: str | Path | None = None, device: str = "cuda",
|
| 670 |
context_policy: str = "prepend", max_input_tokens: int | None = None,
|
| 671 |
+
allow_slow_path: bool = False, adapters_path: str | Path | None = None,
|
| 672 |
+
serving_form: str = "U",
|
| 673 |
+
adapter_work_dir: str | Path | None = None,
|
| 674 |
+
serving_options: Sequence[str] | str | None = None,
|
| 675 |
+
) -> tuple[Predictor, dict[str, Any]]:
|
| 676 |
+
"""Load the pinned frozen base in bf16 and return the predictor and its provenance.
|
| 677 |
+
|
| 678 |
+
``adapters_path`` (router v3) is a directory holding ``default/`` and
|
| 679 |
+
``screen_geometry/`` adapters bound by a ``router-calibration/v2`` artifact;
|
| 680 |
+
``serving_form`` selects form U (both attached) or M (merged default).
|
| 681 |
+
``serving_options`` (``release.fastpath``) are off by default; any option is recorded in
|
| 682 |
+
the provenance and none changes weights or calibration.
|
| 683 |
+
"""
|
| 684 |
import platform
|
| 685 |
+
import sys
|
| 686 |
|
| 687 |
import torch
|
| 688 |
import transformers
|
|
|
|
| 691 |
|
| 692 |
if not allow_slow_path:
|
| 693 |
check_fast_path()
|
| 694 |
+
if adapters_path is not None and adapter_path is not None:
|
| 695 |
+
raise ValueError("pass either one adapter or a per-route adapters directory, not both")
|
| 696 |
adapter = None if adapter_path is None else Path(adapter_path)
|
| 697 |
adapter_sha256 = "" if adapter is None else tree_sha256(adapter)
|
| 698 |
+
route_dirs = None
|
| 699 |
+
if adapters_path is not None:
|
| 700 |
+
route_dirs = {name: Path(adapters_path) / name for name in router.ROUTES}
|
| 701 |
try:
|
| 702 |
calibration_value = json.loads(Path(calibration_path).read_text(encoding="utf-8"))
|
| 703 |
except (OSError, json.JSONDecodeError) as error:
|
| 704 |
raise ValueError(f"cannot load calibration {calibration_path}: {error}") from error
|
| 705 |
base_pin = {"repo": BASE_REPO, "revision": BASE_REVISION}
|
| 706 |
+
if route_dirs is not None:
|
| 707 |
+
route_hashes = {name: tree_sha256(path) for name, path in route_dirs.items()}
|
| 708 |
+
calibration = serving_calibration_from_artifact(
|
| 709 |
+
calibration_value, base=base_pin, adapter_sha256_by_route=route_hashes,
|
| 710 |
+
)
|
| 711 |
+
if not isinstance(calibration, RoutedCalibrationV2):
|
| 712 |
+
raise ValueError("per-route adapters require router-calibration/v2")
|
| 713 |
+
else:
|
| 714 |
+
route_hashes = None
|
| 715 |
+
calibration = serving_calibration_from_artifact(
|
| 716 |
+
calibration_value, checkpoint_sha256=adapter_sha256,
|
| 717 |
+
adapter_sha256=adapter_sha256 or None, base=base_pin,
|
| 718 |
+
)
|
| 719 |
+
if isinstance(calibration, RoutedCalibrationV2):
|
| 720 |
+
raise ValueError("router-calibration/v2 requires the per-route adapters directory")
|
| 721 |
+
routed = isinstance(calibration, ROUTED_CALIBRATIONS)
|
| 722 |
+
if isinstance(calibration, RoutedCalibration) and adapter is None:
|
| 723 |
raise ValueError("routed calibration requires the screen_geometry adapter")
|
| 724 |
snapshot = Path(snapshot_download(BASE_REPO, revision=BASE_REVISION, cache_dir=model_cache,
|
| 725 |
local_files_only=offline))
|
|
|
|
| 734 |
transformers, "AutoModelForImageTextToText")
|
| 735 |
model = auto_model.from_pretrained(str(snapshot), torch_dtype=torch.bfloat16,
|
| 736 |
device_map=device, trust_remote_code=False, attn_implementation="sdpa")
|
| 737 |
+
route_record = None
|
| 738 |
+
if route_dirs is not None:
|
| 739 |
+
import tempfile
|
| 740 |
|
| 741 |
+
from open_decisions.image_jev.release.adapters import attach_route_adapters
|
| 742 |
+
|
| 743 |
+
model, adapter_context, route_record = attach_route_adapters(
|
| 744 |
+
model, default_dir=route_dirs[router.DEFAULT],
|
| 745 |
+
screen_dir=route_dirs[router.SCREEN_GEOMETRY], form=serving_form,
|
| 746 |
+
work_dir=adapter_work_dir or tempfile.mkdtemp(prefix="image-hopper-adapters-"),
|
| 747 |
)
|
| 748 |
+
model = model.eval()
|
| 749 |
+
else:
|
| 750 |
+
if adapter is not None:
|
| 751 |
+
from peft import PeftModel
|
| 752 |
+
|
| 753 |
+
loaded = PeftModel.from_pretrained(
|
| 754 |
+
model, str(adapter), **({"adapter_name": router.SCREEN_GEOMETRY} if routed else {})
|
| 755 |
+
)
|
| 756 |
+
model = loaded if routed else loaded.merge_and_unload()
|
| 757 |
+
model = model.eval()
|
| 758 |
+
adapter_context = AdapterSwitch(model) if routed else None
|
| 759 |
+
from open_decisions.image_jev.release import fastpath
|
| 760 |
+
|
| 761 |
+
options = fastpath.parse_options(serving_options)
|
| 762 |
+
inference_context, logits_fn, readout_fn, serving_record = torch.inference_mode, None, None, None
|
| 763 |
+
token_cap = token_cap_default = None
|
| 764 |
+
if options:
|
| 765 |
+
if route_dirs is not None:
|
| 766 |
+
raise ValueError("serving options are implemented for the single-adapter systems")
|
| 767 |
+
serving = fastpath.apply_serving_options(
|
| 768 |
+
model, processor, options, device=device, routed=routed,
|
| 769 |
+
adapter_name=router.SCREEN_GEOMETRY if routed else None,
|
| 770 |
+
adapter_context=adapter_context, inference_context=torch.inference_mode,
|
| 771 |
+
log=lambda text: print(text, file=sys.stderr))
|
| 772 |
+
model, processor = serving.model, serving.processor
|
| 773 |
+
adapter_context, inference_context = serving.adapter_context, serving.inference_context
|
| 774 |
+
logits_fn, readout_fn, serving_record = serving.logits_fn, serving.readout_fn, serving.record
|
| 775 |
+
token_cap = serving.image_token_cap
|
| 776 |
+
token_cap_default = serving.image_token_cap_default
|
| 777 |
predictor = Predictor(model, processor, calibration, model_name=model_name,
|
| 778 |
context_policy=context_policy, max_input_tokens=max_input_tokens,
|
| 779 |
+
inference_context=inference_context,
|
| 780 |
adapter_context=adapter_context,
|
| 781 |
+
synchronize=(torch.cuda.synchronize if str(device).startswith("cuda") else None),
|
| 782 |
+
logits_fn=logits_fn, readout_fn=readout_fn, image_token_cap=token_cap,
|
| 783 |
+
image_token_cap_default=token_cap_default)
|
| 784 |
provenance = {
|
| 785 |
"base_repo": BASE_REPO, "base_revision": BASE_REVISION,
|
| 786 |
"base_files_verified": verified,
|
|
|
|
| 792 |
"system_kind": "routed" if routed else "single-route",
|
| 793 |
"router_rules_version": router.RULES_VERSION if routed else None,
|
| 794 |
"router_rules_sha256": router.RULES_SHA256 if routed else None,
|
| 795 |
+
"adapter_switching": (
|
| 796 |
+
route_record["adapter_switching"] if route_record is not None
|
| 797 |
+
else "PEFT layer enable/disable on route transition" if routed else None
|
| 798 |
+
),
|
| 799 |
+
"adapters_by_route": (
|
| 800 |
+
None if route_dirs is None else {
|
| 801 |
+
name: {"path": str(path), "sha256": route_hashes[name]}
|
| 802 |
+
for name, path in route_dirs.items()
|
| 803 |
+
}
|
| 804 |
+
),
|
| 805 |
+
"serving_form": None if route_record is None else route_record["serving_form"],
|
| 806 |
+
"difference_adapter": (
|
| 807 |
+
None if route_record is None else route_record.get("difference_adapter")
|
| 808 |
+
),
|
| 809 |
"precision": "bfloat16", "attention": "sdpa", "processor_use_fast": True, "scorer": "one-prefill-option-letter",
|
| 810 |
"context_policy": context_policy, "max_input_tokens": max_input_tokens,
|
| 811 |
"torch_version": torch.__version__, "transformers_version": transformers.__version__,
|
| 812 |
"python_version": platform.python_version(),
|
| 813 |
}
|
| 814 |
+
if options: # absent unless enabled: the default provenance is unchanged
|
| 815 |
+
provenance["serving_options"] = serving_record
|
| 816 |
return predictor, provenance
|
| 817 |
|
| 818 |
|
| 819 |
__all__ = [
|
| 820 |
+
"BASE_REPO", "BASE_REVISION", "Calibration", "ROUTED_CALIBRATIONS", "RoutedCalibration",
|
| 821 |
+
"RoutedCalibrationV2", "Predictor", "Question", "RequestError",
|
| 822 |
"TYPE_BUCKET_BY_OPTION_COUNT", "check_fast_path", "load_predictor", "parse_request",
|
| 823 |
"serving_calibration_from_artifact", "tree_sha256", "verify_snapshot",
|
| 824 |
]
|
open_decisions/image_jev/release/server.py
CHANGED
|
@@ -74,6 +74,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 74 |
parser.add_argument("--calibration", default="calibration.json")
|
| 75 |
parser.add_argument("--adapter", default=None,
|
| 76 |
help="optional local adapter directory; calibration must bind its tree hash")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
parser.add_argument("--base-hashes", default="base-model-files.json",
|
| 78 |
help="hash listing of the pinned base snapshot; '' skips the check")
|
| 79 |
parser.add_argument("--model-cache", default=None, help="Hugging Face cache directory")
|
|
@@ -87,6 +93,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 87 |
parser.add_argument("--max-input-tokens", type=int, default=None,
|
| 88 |
help="refuse (HTTP 400) requests above this many processed tokens")
|
| 89 |
parser.add_argument("--allow-slow-path", action="store_true")
|
|
|
|
|
|
|
|
|
|
| 90 |
parser.add_argument("--phase-timing", action="store_true",
|
| 91 |
help="diagnostic synchronized phase_seconds in responses; default off")
|
| 92 |
return parser
|
|
@@ -98,10 +107,10 @@ def main(argv=None) -> int:
|
|
| 98 |
args = build_parser().parse_args(argv)
|
| 99 |
predictor, provenance = load_predictor(
|
| 100 |
calibration_path=args.calibration, model_name=args.name, model_cache=args.model_cache,
|
| 101 |
-
adapter_path=args.adapter,
|
| 102 |
offline=args.offline, base_hashes_path=args.base_hashes or None, device=args.device,
|
| 103 |
context_policy=args.context_policy, max_input_tokens=args.max_input_tokens,
|
| 104 |
-
allow_slow_path=args.allow_slow_path,
|
| 105 |
)
|
| 106 |
health = {"model": args.name, **provenance}
|
| 107 |
print(json.dumps(health, indent=2, sort_keys=True), file=sys.stderr)
|
|
|
|
| 74 |
parser.add_argument("--calibration", default="calibration.json")
|
| 75 |
parser.add_argument("--adapter", default=None,
|
| 76 |
help="optional local adapter directory; calibration must bind its tree hash")
|
| 77 |
+
parser.add_argument("--adapters", default=None,
|
| 78 |
+
help="router v3: directory with default/ and screen_geometry/ adapters; "
|
| 79 |
+
"calibration must be router-calibration/v2 bound to both")
|
| 80 |
+
parser.add_argument("--serving-form", choices=("U", "M"), default="U",
|
| 81 |
+
help="router v3: U keeps both adapters attached (set_adapter on route "
|
| 82 |
+
"change); M merges default and serves an exact difference adapter")
|
| 83 |
parser.add_argument("--base-hashes", default="base-model-files.json",
|
| 84 |
help="hash listing of the pinned base snapshot; '' skips the check")
|
| 85 |
parser.add_argument("--model-cache", default=None, help="Hugging Face cache directory")
|
|
|
|
| 93 |
parser.add_argument("--max-input-tokens", type=int, default=None,
|
| 94 |
help="refuse (HTTP 400) requests above this many processed tokens")
|
| 95 |
parser.add_argument("--allow-slow-path", action="store_true")
|
| 96 |
+
parser.add_argument("--serving-options", default="",
|
| 97 |
+
help="comma-separated release.fastpath options (default: none, the "
|
| 98 |
+
"published path); recorded in /health")
|
| 99 |
parser.add_argument("--phase-timing", action="store_true",
|
| 100 |
help="diagnostic synchronized phase_seconds in responses; default off")
|
| 101 |
return parser
|
|
|
|
| 107 |
args = build_parser().parse_args(argv)
|
| 108 |
predictor, provenance = load_predictor(
|
| 109 |
calibration_path=args.calibration, model_name=args.name, model_cache=args.model_cache,
|
| 110 |
+
adapter_path=args.adapter, adapters_path=args.adapters, serving_form=args.serving_form,
|
| 111 |
offline=args.offline, base_hashes_path=args.base_hashes or None, device=args.device,
|
| 112 |
context_policy=args.context_policy, max_input_tokens=args.max_input_tokens,
|
| 113 |
+
allow_slow_path=args.allow_slow_path, serving_options=args.serving_options or None,
|
| 114 |
)
|
| 115 |
health = {"model": args.name, **provenance}
|
| 116 |
print(json.dumps(health, indent=2, sort_keys=True), file=sys.stderr)
|
open_decisions/image_jev/release/smoke.py
CHANGED
|
@@ -127,7 +127,7 @@ def _exchange(handler_type, method: str, path: str, body=None) -> tuple[int, dic
|
|
| 127 |
def run(calibration_path: str | Path) -> dict:
|
| 128 |
from open_decisions.image_jev import router
|
| 129 |
from open_decisions.image_jev.release.predictor import (
|
| 130 |
-
|
| 131 |
)
|
| 132 |
from open_decisions.image_jev.release.server import handler
|
| 133 |
|
|
@@ -139,6 +139,12 @@ def run(calibration_path: str | Path) -> dict:
|
|
| 139 |
)
|
| 140 |
model = _fake_model()
|
| 141 |
predictor = Predictor(model, FakeProcessor(), calibration, model_name="smoke")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
handler_type = handler(predictor, {"model": "smoke"})
|
| 143 |
|
| 144 |
def post(body) -> tuple[int, dict]:
|
|
@@ -172,19 +178,13 @@ def run(calibration_path: str | Path) -> dict:
|
|
| 172 |
four = answers["four"]["probabilities"]
|
| 173 |
assert list(four) == ["top", "middle", "bottom", "none"]
|
| 174 |
assert answers["four"]["choice"] == "top"
|
| 175 |
-
four_temperature = (
|
| 176 |
-
calibration.temperature(4, router.DEFAULT)
|
| 177 |
-
if isinstance(calibration, RoutedCalibration) else calibration.temperature(4)
|
| 178 |
-
)
|
| 179 |
for name, got, want in zip(four, four.values(), _expected(4, four_temperature)):
|
| 180 |
assert abs(got - want) < 1e-6, (name, got, want)
|
| 181 |
three = answers["three"]["probabilities"]
|
| 182 |
-
for got, want in zip(three.values(), _expected(3,
|
| 183 |
assert abs(got - want) < 1e-6
|
| 184 |
-
binary_temperature = (
|
| 185 |
-
calibration.temperature(2, router.DEFAULT)
|
| 186 |
-
if isinstance(calibration, RoutedCalibration) else calibration.temperature(2)
|
| 187 |
-
)
|
| 188 |
want_yes = _expected(2, binary_temperature)[0]
|
| 189 |
assert abs(answers["binary"]["noul"] - want_yes) < 1e-6
|
| 190 |
assert body["usage"]["output_tokens"] == 0 and body["usage"]["input_tokens"] > 0
|
|
|
|
| 127 |
def run(calibration_path: str | Path) -> dict:
|
| 128 |
from open_decisions.image_jev import router
|
| 129 |
from open_decisions.image_jev.release.predictor import (
|
| 130 |
+
ROUTED_CALIBRATIONS, Predictor, serving_calibration_from_artifact,
|
| 131 |
)
|
| 132 |
from open_decisions.image_jev.release.server import handler
|
| 133 |
|
|
|
|
| 139 |
)
|
| 140 |
model = _fake_model()
|
| 141 |
predictor = Predictor(model, FakeProcessor(), calibration, model_name="smoke")
|
| 142 |
+
|
| 143 |
+
def temperature(n_options: int) -> float:
|
| 144 |
+
# Every smoke question routes to ``default`` (no screen/geometry cues).
|
| 145 |
+
if isinstance(calibration, ROUTED_CALIBRATIONS):
|
| 146 |
+
return calibration.temperature(n_options, router.DEFAULT)
|
| 147 |
+
return calibration.temperature(n_options)
|
| 148 |
handler_type = handler(predictor, {"model": "smoke"})
|
| 149 |
|
| 150 |
def post(body) -> tuple[int, dict]:
|
|
|
|
| 178 |
four = answers["four"]["probabilities"]
|
| 179 |
assert list(four) == ["top", "middle", "bottom", "none"]
|
| 180 |
assert answers["four"]["choice"] == "top"
|
| 181 |
+
four_temperature = temperature(4)
|
|
|
|
|
|
|
|
|
|
| 182 |
for name, got, want in zip(four, four.values(), _expected(4, four_temperature)):
|
| 183 |
assert abs(got - want) < 1e-6, (name, got, want)
|
| 184 |
three = answers["three"]["probabilities"]
|
| 185 |
+
for got, want in zip(three.values(), _expected(3, temperature(3))):
|
| 186 |
assert abs(got - want) < 1e-6
|
| 187 |
+
binary_temperature = temperature(2)
|
|
|
|
|
|
|
|
|
|
| 188 |
want_yes = _expected(2, binary_temperature)[0]
|
| 189 |
assert abs(answers["binary"]["noul"] - want_yes) < 1e-6
|
| 190 |
assert body["usage"]["output_tokens"] == 0 and body["usage"]["input_tokens"] > 0
|
open_decisions/image_jev/vision.py
CHANGED
|
@@ -35,6 +35,12 @@ MIN_PIXELS = 65_536
|
|
| 35 |
MAX_PIXELS = 16_777_216
|
| 36 |
MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
| 37 |
MAX_IMAGE_PIXELS = 20_000_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
BUDGET_VERSION = "b1"
|
| 39 |
BUDGETS = MappingProxyType({"photo": 160, "scene": 224, "document": 384, "dense": 768})
|
| 40 |
ROUTING_VERSION = "image-jev/content-routing/v1"
|
|
@@ -128,6 +134,29 @@ def image_tokens(height: int, width: int, factor: int = FACTOR) -> int:
|
|
| 128 |
return (resized_h // factor) * (resized_w // factor)
|
| 129 |
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
def budget_pixels(content_class: str) -> int:
|
| 132 |
"""Return the explicit pixel-area budget for a named content class."""
|
| 133 |
try:
|
|
@@ -325,12 +354,40 @@ def _output_field(output, name: str):
|
|
| 325 |
return value
|
| 326 |
|
| 327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
def _prepare(example: dict, processor, *, image_route: str,
|
| 329 |
content_class: str | None = None, loaded_images=None, cache=None,
|
| 330 |
-
timer=None, device=None) -> PreparedInput:
|
| 331 |
-
"""Prepare one decision after validating an explicit registered image route.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
if image_route not in IMAGE_ROUTES:
|
| 333 |
raise ValueError(f"image_route must be one of {IMAGE_ROUTES}, got {image_route!r}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
try:
|
| 335 |
question, options = example["question"], example["options"]
|
| 336 |
except (KeyError, TypeError) as error:
|
|
@@ -366,6 +423,11 @@ def _prepare(example: dict, processor, *, image_route: str,
|
|
| 366 |
"new_size": ready.size,
|
| 367 |
"image_tokens": image_tokens(ready.height, ready.width),
|
| 368 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
ready_images.append(ready)
|
| 370 |
records.append(record)
|
| 371 |
|
|
@@ -390,12 +452,28 @@ def _prepare(example: dict, processor, *, image_route: str,
|
|
| 390 |
getattr(image_processor, "backend", None) == "torch" or
|
| 391 |
"Fast" in type(image_processor).__name__ or
|
| 392 |
any(base.__name__ == "BaseImageProcessorFast" for base in type(image_processor).__mro__))
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
if cache is not None:
|
| 395 |
cache.tokenizer.timer = timer
|
| 396 |
token_before = timer.seconds["tokenise_chat_template"] if timer.seconds is not None else 0.0
|
| 397 |
with timer.phase("processor_preprocess"):
|
| 398 |
output = processor(text=[prompt], images=ready_images, padding=True, return_tensors="pt", **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
if timer.seconds is not None:
|
| 400 |
timer.seconds["processor_preprocess"] -= timer.seconds["tokenise_chat_template"] - token_before
|
| 401 |
return PreparedInput(input_ids=_output_field(output, "input_ids"),
|
|
@@ -413,7 +491,10 @@ def prepare(example: dict, processor, content_class: str | None = None) -> Prepa
|
|
| 413 |
|
| 414 |
|
| 415 |
def prepare_native(example: dict, processor, **kwargs) -> PreparedInput:
|
| 416 |
-
"""Prepare processor-default full-resolution images without budget resizing.
|
|
|
|
|
|
|
|
|
|
| 417 |
return _prepare(example, processor, image_route="native", **kwargs)
|
| 418 |
|
| 419 |
|
|
|
|
| 35 |
MAX_PIXELS = 16_777_216
|
| 36 |
MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
| 37 |
MAX_IMAGE_PIXELS = 20_000_000
|
| 38 |
+
# Optional native-route image-token cap (a serving option, off by default). The cap lowers the
|
| 39 |
+
# processor's own max pixels to ``cap * FACTOR**2`` so its smart resize keeps at most ``cap``
|
| 40 |
+
# merged image tokens per image. Below 256 tokens the processor's minimum-area rule and its
|
| 41 |
+
# one-factor edge clamp can exceed the cap for extreme aspect ratios, so smaller caps are refused.
|
| 42 |
+
MIN_IMAGE_TOKEN_CAP = 256
|
| 43 |
+
MAX_IMAGE_TOKEN_CAP = MAX_PIXELS // (FACTOR * FACTOR)
|
| 44 |
BUDGET_VERSION = "b1"
|
| 45 |
BUDGETS = MappingProxyType({"photo": 160, "scene": 224, "document": 384, "dense": 768})
|
| 46 |
ROUTING_VERSION = "image-jev/content-routing/v1"
|
|
|
|
| 134 |
return (resized_h // factor) * (resized_w // factor)
|
| 135 |
|
| 136 |
|
| 137 |
+
def validate_image_token_cap(value: Any) -> int:
|
| 138 |
+
"""An explicit native-route image-token cap: an integer in [256, 16384]."""
|
| 139 |
+
if isinstance(value, bool) or not isinstance(value, int):
|
| 140 |
+
raise ValueError(f"image_token_cap must be an integer, got {value!r}")
|
| 141 |
+
if not MIN_IMAGE_TOKEN_CAP <= value <= MAX_IMAGE_TOKEN_CAP:
|
| 142 |
+
raise ValueError(f"image_token_cap must be between {MIN_IMAGE_TOKEN_CAP} and "
|
| 143 |
+
f"{MAX_IMAGE_TOKEN_CAP}, got {value}")
|
| 144 |
+
return value
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def native_max_pixels(image_token_cap: int | None = None) -> int:
|
| 148 |
+
"""The processor max pixels of the native route: its default, or ``cap * FACTOR**2``."""
|
| 149 |
+
if image_token_cap is None:
|
| 150 |
+
return MAX_PIXELS
|
| 151 |
+
return validate_image_token_cap(image_token_cap) * FACTOR * FACTOR
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def native_image_tokens(height: int, width: int, image_token_cap: int | None = None) -> int:
|
| 155 |
+
"""Merged image tokens of one native-route image, with the optional cap applied."""
|
| 156 |
+
resized_h, resized_w = smart_resize(height, width, max_pixels=native_max_pixels(image_token_cap))
|
| 157 |
+
return (resized_h // FACTOR) * (resized_w // FACTOR)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
def budget_pixels(content_class: str) -> int:
|
| 161 |
"""Return the explicit pixel-area budget for a named content class."""
|
| 162 |
try:
|
|
|
|
| 354 |
return value
|
| 355 |
|
| 356 |
|
| 357 |
+
def _processor_min_pixels(processor) -> int:
|
| 358 |
+
"""The processor's own minimum area (kept unchanged when a token cap lowers the maximum)."""
|
| 359 |
+
image_processor = getattr(processor, "image_processor", None)
|
| 360 |
+
size = getattr(image_processor, "size", None)
|
| 361 |
+
for getter in (lambda: size["shortest_edge"], lambda: getattr(size, "shortest_edge"),
|
| 362 |
+
lambda: getattr(image_processor, "min_pixels")):
|
| 363 |
+
try:
|
| 364 |
+
value = getter()
|
| 365 |
+
except (KeyError, TypeError, AttributeError):
|
| 366 |
+
continue
|
| 367 |
+
if isinstance(value, int) and not isinstance(value, bool) and value > 0:
|
| 368 |
+
return value
|
| 369 |
+
return MIN_PIXELS
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _grid_rows(grid) -> list[list[int]]:
|
| 373 |
+
rows = grid.tolist() if hasattr(grid, "tolist") else [list(row) for row in grid]
|
| 374 |
+
return [[int(value) for value in row] for row in rows]
|
| 375 |
+
|
| 376 |
+
|
| 377 |
def _prepare(example: dict, processor, *, image_route: str,
|
| 378 |
content_class: str | None = None, loaded_images=None, cache=None,
|
| 379 |
+
timer=None, device=None, image_token_cap: int | None = None) -> PreparedInput:
|
| 380 |
+
"""Prepare one decision after validating an explicit registered image route.
|
| 381 |
+
|
| 382 |
+
``image_token_cap`` (native route only; default None = the processor's own maximum) lowers
|
| 383 |
+
the processor's max pixels so every image keeps at most that many merged image tokens.
|
| 384 |
+
"""
|
| 385 |
if image_route not in IMAGE_ROUTES:
|
| 386 |
raise ValueError(f"image_route must be one of {IMAGE_ROUTES}, got {image_route!r}")
|
| 387 |
+
if image_token_cap is not None:
|
| 388 |
+
if image_route != "native":
|
| 389 |
+
raise ValueError("image_token_cap applies to the native image route only")
|
| 390 |
+
image_token_cap = validate_image_token_cap(image_token_cap)
|
| 391 |
try:
|
| 392 |
question, options = example["question"], example["options"]
|
| 393 |
except (KeyError, TypeError) as error:
|
|
|
|
| 423 |
"new_size": ready.size,
|
| 424 |
"image_tokens": image_tokens(ready.height, ready.width),
|
| 425 |
}
|
| 426 |
+
if image_token_cap is not None:
|
| 427 |
+
# The processor, not this function, resizes; new_size stays the decoded size.
|
| 428 |
+
record["image_token_cap"] = image_token_cap
|
| 429 |
+
record["image_tokens"] = native_image_tokens(ready.height, ready.width,
|
| 430 |
+
image_token_cap)
|
| 431 |
ready_images.append(ready)
|
| 432 |
records.append(record)
|
| 433 |
|
|
|
|
| 452 |
getattr(image_processor, "backend", None) == "torch" or
|
| 453 |
"Fast" in type(image_processor).__name__ or
|
| 454 |
any(base.__name__ == "BaseImageProcessorFast" for base in type(image_processor).__mro__))
|
| 455 |
+
images_kwargs = {"device": device} if fast and device is not None else {}
|
| 456 |
+
if image_token_cap is not None:
|
| 457 |
+
# Every spelling the processor's kwargs accept, all consistent: min unchanged, max capped.
|
| 458 |
+
min_pixels, max_pixels = _processor_min_pixels(processor), native_max_pixels(image_token_cap)
|
| 459 |
+
images_kwargs.update(size={"shortest_edge": min_pixels, "longest_edge": max_pixels},
|
| 460 |
+
min_pixels=min_pixels, max_pixels=max_pixels)
|
| 461 |
+
kwargs = {"images_kwargs": images_kwargs} if images_kwargs else {}
|
| 462 |
if cache is not None:
|
| 463 |
cache.tokenizer.timer = timer
|
| 464 |
token_before = timer.seconds["tokenise_chat_template"] if timer.seconds is not None else 0.0
|
| 465 |
with timer.phase("processor_preprocess"):
|
| 466 |
output = processor(text=[prompt], images=ready_images, padding=True, return_tensors="pt", **kwargs)
|
| 467 |
+
if image_token_cap is not None:
|
| 468 |
+
# Fail closed if the processor ignored the cap (a library change would do that).
|
| 469 |
+
merge = int(getattr(getattr(processor, "image_processor", None), "merge_size", 2) or 2)
|
| 470 |
+
served = [t * h * w // (merge * merge)
|
| 471 |
+
for t, h, w in _grid_rows(_output_field(output, "image_grid_thw"))]
|
| 472 |
+
if len(served) != len(records) or any(tokens > image_token_cap for tokens in served):
|
| 473 |
+
raise RuntimeError(f"processor returned {served} image tokens for an "
|
| 474 |
+
f"{image_token_cap}-token cap")
|
| 475 |
+
for record, tokens in zip(records, served):
|
| 476 |
+
record["processor_image_tokens"] = tokens
|
| 477 |
if timer.seconds is not None:
|
| 478 |
timer.seconds["processor_preprocess"] -= timer.seconds["tokenise_chat_template"] - token_before
|
| 479 |
return PreparedInput(input_ids=_output_field(output, "input_ids"),
|
|
|
|
| 491 |
|
| 492 |
|
| 493 |
def prepare_native(example: dict, processor, **kwargs) -> PreparedInput:
|
| 494 |
+
"""Prepare processor-default full-resolution images without budget resizing.
|
| 495 |
+
|
| 496 |
+
``image_token_cap=N`` (optional, default off) lowers only the processor's max pixels.
|
| 497 |
+
"""
|
| 498 |
return _prepare(example, processor, image_route="native", **kwargs)
|
| 499 |
|
| 500 |
|