Skip to content

Deployment and local execution

Two separate questions get confused with each other, so they are answered separately here:

  1. Publishing the report as a static site on Cloudflare's free plan. Verified feasible, with room to spare.
  2. Running the recognizer -- on a laptop (section 3) and in the visitor's own browser (section 4), which is what /demo/ on the deployed site does.

Live at https://pic2smiles.pages.dev/, with the demo at https://pic2smiles.pages.dev/demo/. Everything below was measured rather than planned, including the three deployment faults in section 2.3 that only appeared once it was actually served by Cloudflare.

1. Cloudflare free-plan limits

Checked against Cloudflare's own documentation on 2026-08-20. Everything below is quoted from the linked pages, not recalled.

Limit Free plan Relevant because
Pages: static asset requests and bandwidth unlimited, free the report site has no per-visit cost
Pages: max size of one file 25 MiB caps any single figure or model file
Pages: files per deployment 20,000 the eval image sets would blow this
Pages: builds per month 500, 1 concurrent 20-minute build timeout, all plans
Pages: _headers rules 100 enough for the COOP/COEP pair WASM threads need
Workers / Pages Functions: requests 100,000/day, 1,000/min shared pool; static assets do not count
Workers: CPU time per request 10 ms the reason server-side inference is impossible
Workers: memory per isolate 128 MB same
Workers: script size, compressed 3 MB smaller than the ONNX runtime WASM binary alone
Workers: subrequests per invocation 50
R2: storage 10 GB/month not needed: the int8 graphs fit the per-file cap
R2: operations 1 M class A, 10 M class B per month
R2: egress free, always including via the public r2.dev domain

Sources: Pages limits, Pages Functions pricing, Workers limits, R2 pricing.

What these limits rule out

Server-side inference on the free plan is not close. Decoding is up to 160 sequential decoder passes (max_length, see src/pic2smiles/infer/decode.py), and section 3 below measures that at 0.17--0.32 s of native CPU per image. The free Worker budget is 10 ms of CPU with no accelerator and WASM-speed compute. The gap is two to three orders of magnitude, so the conclusion does not depend on the exact figure.

Also ruled out: Containers require a paid Workers plan; Python Workers run on Pyodide, which has no PyTorch.

Workers AI -- not confirmed, and it does not matter. The docs describe a curated catalog of ~50 open-source models; self-serve upload of an arbitrary custom ONNX model is not documented, and "private custom models" are routed through a Custom Requirements form, i.e. a sales conversation rather than the free plan. The older Constellation bring-your-own-ONNX beta no longer appears in the documentation. This was not pinned down to a quotable statement, unlike everything in the table above. It changes nothing either way: a 160-step autoregressive decoder is not a workload that platform is for.

2. The static report site

This is the deployment that fits. The site is Markdown, tables and PNGs; every Cloudflare limit above is met with two orders of magnitude of headroom.

Two things must be excluded deliberately, because publishing the repository wholesale would break the deployment:

  • data/eval/*/images/ -- 12,795 PNGs, 210 MB. Alone this is 64% of the 20,000-file cap and would dominate the site. Publish a curated gallery of ~20 examples instead; reports/figures/examples_*.png already are that.
  • checkpoints/*.pt -- 185 MB and 363 MB. Over the 25 MiB per-file cap, and they are optimizer + EMA pickles loaded with weights_only=False (src/pic2smiles/infer/predictor.py), which should not be served to the public in that form regardless.

Excluding them means publishing numbers nobody can reproduce. R2 is the answer: 10 GB free with free egress, no per-file cap of the Pages kind. Saved weights-only, the checkpoints are roughly 48 MB (small) and 95 MB (base) rather than 185/363 MB, and both fit the free tier with room to spare.

2.1 How the site is built

Pages serves static assets as-is, so publishing .md files directly would give visitors plain text rather than a rendered report. The generator is MkDocs with the Material theme, pinned in requirements-docs.txt. It is build-time only: it is deliberately not in pyproject.toml, so pip install -e ".[dev]" remains a model-development install and the pic2smiles package gains no dependency.

MkDocs reads a single docs_dir, while this project's content lives in three places -- README.md at the root, docs/ and reports/. scripts/build_site.py resolves that by copying an explicit allowlist into a staging directory, site-src/, which mkdocs.yml points at. The allowlist is the mechanism that enforces the exclusions above: data/eval/*/images/ and checkpoints/*.pt are never named, so they cannot be published by accident.

Two of the demo's inputs are generated rather than tracked -- the ONNX graphs and the vendored ONNX runtime -- so a clean checkout builds the site with these four commands:

pip install -r requirements-docs.txt

# The model the demo runs (see section 4), and the runtime that runs it.
python scripts/export_onnx.py checkpoints/molmini_small_best.pt --output web/models/small
python scripts/quantize_onnx.py web/models/small --output web/models/small-int8
npm pack onnxruntime-web@1.27.0 && tar xzf onnxruntime-web-1.27.0.tgz \
  && mkdir -p web/vendor \
  && cp package/dist/ort.wasm.bundle.min.mjs package/dist/ort-wasm-simd-threaded.mjs \
        package/dist/ort-wasm-simd-threaded.wasm web/vendor/

python scripts/build_site.py      # repo -> site-src/
mkdocs build                      # site-src/ -> site/

build_site.py fails loudly if any of those are missing, rather than quietly publishing a demo with no model in it.

Cloudflare Pages settings:

setting value
build command pip install -r requirements-docs.txt && python scripts/build_site.py && mkdocs build
build output directory site
environment variable PYTHON_VERSION = 3.11

PYTHON_VERSION is set explicitly rather than left to the platform default, so a change on Cloudflare's side cannot silently move the build to an interpreter this project has not been run on.

mkdocs.yml sets strict: true, which turns a broken internal link into a failed build rather than a 404 on a published report. Two link consequences were handled: README.md becomes index.md while docs/ keeps its name, so its existing docs/*.md links still resolve; and paths under src/ and scripts/ are cited as inline code rather than links, since the source tree is not published. The repository itself is linked from the site header.

The same build stages the browser demo of section 4 into /demo/, including its ONNX graphs and the WASM runtime, and copies web/_headers to the root of the deployment. Staged size, well inside every limit in section 1:

value
files 106 (cap 20,000)
total 40.0 MB
largest file 13.5 MB, the ONNX runtime WASM binary (cap 25 MiB)

The 25 MiB per-file cap is the one that came close to binding: the fp32 encoder graph is 26.3 MB, just over it. Quantization (section 4.2) removed the problem rather than sharding around it.

2.2 Deploying

Two routes, and they are alternatives rather than steps:

Direct upload from a machine that has already built site/. This is what suits a build whose model files are generated rather than tracked, because Cloudflare never has to reproduce the export.

wrangler pages project create pic2smiles --production-branch=main   # once
wrangler pages deploy site --project-name=pic2smiles --branch=main

wrangler authenticates through a browser, so wrangler login has to be run once in an interactive terminal; its OAuth session expires after about an hour and cannot be refreshed non-interactively.

Git integration. Connect the repository in the Cloudflare dashboard with the build command and output directory from the table above. Note that this route publishes the documentation site only: web/models/ and web/vendor/ are not in git, so build_site.py will fail rather than ship a demo with no model in it. Committing the exported graphs, or fetching them in the build command, would be a prerequisite.

scripts/serve_site.py previews the built site the way Pages serves it, _headers included:

python scripts/serve_site.py site --port 8792

That script exists because the first preview hardcoded the isolation headers instead of reading _headers, and so could not reproduce section 2.3.

2.3 Three faults that only appeared once Cloudflare served it

All three were invisible locally, and all three are recorded in web/_headers next to the lines that fix them.

Repeating a header on overlapping rules breaks it. Pages merges the headers of every matching _headers rule rather than letting the narrowest win. Setting Cross-Origin-Embedder-Policy on both /demo/* and /demo/vendor/* produced require-corp, require-corp, which is not a valid value, and the browser rejected the file. One block, no repetition.

Cross-origin isolation is not enough for a worker script. With COOP and COEP correct, crossOriginIsolated was true and SharedArrayBuffer was available -- and new Worker() on the ONNX runtime's script still failed with ERR_BLOCKED_BY_RESPONSE, so the page hung at "Downloading encoder...". Under require-corp a top-level worker script must pass a CORP check, and Cloudflare serves static assets with access-control-allow-origin: * and no Cross-Origin-Resource-Policy. Adding Cross-Origin-Resource-Policy: same-origin fixed it. What isolated the cause: importing the very same URL from inside a blob module worker succeeded, so the fetch was fine and only the worker-script check was failing.

A long max-age on a path that is not content-addressed is a trap. The first deployment cached the broken worker script for a day, so the fix could not reach a browser that had already loaded it. Cache-Control: public, no-cache is what ships instead: it means revalidate, not "do not cache", and with Cloudflare's ETag a repeat visit pays one conditional request per file and a 304 rather than re-downloading 28 MB.

The general lesson is the reason section 4.2's parity numbers exist at all: a thing is not working because it looks like it should work.

3. Running locally: measured

Measured on this repository's checkpoints, CPU only -- no MPS, no CUDA -- because "an ordinary laptop" is the question. Batch size 1 and greedy decoding, which is what interactive use looks like. 24 images sampled across the clean / varied / degraded tiers of chembl_test_linux.

Machine: Apple M3, 8 cores, macOS 27, PyTorch 2.12, torch.set_num_threads(4). The repository's own harness reproduces this row approximately:

python scripts/measure_latency.py checkpoints/molmini_small_best.pt \
  data/eval/chembl_test_linux/manifest.csv \
  --device cpu --n 24 --batch-sizes 1 --beam-sizes 1

Left at its defaults the script also sweeps batch sizes 4/8/16/24 and beam size 5; the flags above narrow it to the bs=1 greedy case reported here. One difference remains: the script takes the first --n manifest rows, which in this manifest are all clean, whereas the table below strided across all three tiers. The three tiers are the same 1000 molecules re-rendered, so the decode lengths match and the numbers should agree in range -- but not to the millisecond.

model params input CPU median, bs=1 peak RSS MPS median, bs=1 (from reports/)
small 12.1 M 320 px 0.17--0.23 s ~0.48 GB 0.24 s
base 23.8 M 384 px 0.25--0.32 s ~0.73 GB 0.25 s

The CPU range is the spread across repeated runs on a laptop with normal background load. Back-to-back repeats on an idle machine agreed to within a millisecond; the same measurement drifted by up to 40% when other work was running, which is why a range is reported rather than a single number.

Three things follow:

The GPU buys nothing at batch size 1. CPU and MPS land on the same number. At bs=1 this workload is bound by per-step overhead, not by FLOPs -- 160 tiny matrix multiplies in sequence. The same reading explains the model-size gap: base has 2x the parameters of small but is only ~1.2x slower at bs=1, and the gap only widens to ~1.4x at bs=24 (reports/latency_molmini_*.json), where there is finally enough work per step for compute to matter.

Thread count barely matters. Sweeping torch_threads over 1, 2, 4 and 8 on a fixed 16-image subset moved the median between 137 and 164 ms with no trend -- run-to-run noise exceeded the effect. Single-core speed decides this workload, not core count.

Memory is not a constraint. Under 0.5 GB for small. Most of that is the PyTorch runtime rather than the model, whose fp32 weights are ~48 MB.

Verdict for a laptop

Yes, comfortably. Any machine that can install PyTorch and RDKit runs small at roughly a fifth of a second per structure with no GPU and under 0.5 GB of memory. Extrapolating to a mid-range x86 laptop at perhaps 2x slower single-core speed puts it near half a second per image -- an extrapolation, not a measurement.

Accuracy is a separate question and is not established by any of the above; see reports/REPORT.md for rates measured at n=3000.

One labelling note, since the two sets of numbers come from different files: the latency above was measured on molmini_{small,base}_best.pt, while the accuracy in reports/REPORT.md and the existing reports/latency_*.json come from the _last checkpoints at step 240,000. The two are different weight files. It does not affect the latency figures -- decode cost is set by the architecture and the sequence length, not by which weights are loaded -- but the accuracy on this page's checkpoints has not been measured, and nothing here should be read as claiming it.

4. Running in a browser: built and measured

/demo/ is the recognizer itself, running client-side. There is no server doing the recognition -- Cloudflare serves static files, the model executes in the visitor's browser, and no image is uploaded. That is what makes it free: the Workers CPU budget of section 1 never applies, because no Worker runs.

Native execution was never a path on a phone -- there is no PyTorch and no RDKit on iOS or Android -- so "phone" means "browser", and so does the desktop demo.

4.1 The export

The decode loop cannot be one graph: it is up to 160 data-dependent steps, and _reorder_caches in src/pic2smiles/infer/decode.py reorders KV caches with a Python loop that has no ONNX equivalent. So scripts/export_onnx.py splits the model the way transformers.js does:

graph in out int8 size
encoder.onnx image the cross-attention K/V for every decoder layer 8.5 MB
decoder_step.onnx one token, rotary slice, cross K/V, self K/V logits, updated self K/V 6.1 MB

Cross-attention K/V are computed in the encoder graph rather than the step graph, because they never change during a decode: leaving that projection inside would pay ~66 MFLOP per layer on every one of up to 160 steps instead of once. The rotary slice for the current position is passed in rather than sliced from a table inside the graph, which keeps the step graph static.

Two things in the training-time modules do not trace: CrossAttention fills its cache lazily inside forward, and the RoPE offset is a Python int. Both are re-expressed in the export module rather than edited in place, so the trained model's benchmarked inference path is untouched. That duplication is the risk, and scripts/check_onnx.py is what contains it.

Beam search is gone. Client-side decoding is greedy only, so this page is slightly weaker than pic2smiles predict --beam-size 5 on the same checkpoint.

4.2 Does the exported model still work?

molmini_small_best.pt against 300 images strided across the clean, varied and degraded tiers of chembl_test_linux, all three paths fed the identical preprocessed tensor:

build token sequences identical to PyTorch exact match median ms/image (native ORT)
PyTorch reference -- 0.883 167 (PyTorch, from section 3)
ONNX fp32 300/300 0.883 143
ONNX int8 286/300 0.897 65

The float export is exact: 300 out of 300 greedy decodes reproduce PyTorch token for token. A separate 48-image sample found one divergence, on a degraded image of a 16-carbon chain, where the two numerically different orderings amplify apart; encoder outputs agree to a relative 1e-5 to 4e-4 across a spread of images, so this is float noise on a near-tied input rather than a wrong graph.

int8 quantization costs nothing measurable: 286 of 300 sequences unchanged, and exact match moves +1.3 points, which at n=300 is well inside sampling noise (one standard error is about 1.9 points). It is not evidence that quantization helps; it is evidence that the cost is below what 300 images can resolve. int8 is therefore what ships, at 15 MB instead of 50 MB and 2.2x faster.

Reproduce with scripts/check_onnx.py; the two summaries are committed as reports/onnx_parity_small_fp32.json and reports/onnx_parity_small_int8.json.

4.3 What it costs in a browser

Measured on the deployed site, cross-origin isolated, four WASM threads, on the same M3 as section 3:

stage cost
a typical 50-token molecule 0.25--3.1 s
decoder, isolated microbenchmark ~13 ms per token
encoder, isolated microbenchmark 0.6--1.3 s
first visit download ~15 MB model + 13.5 MB WASM runtime

That range is embarrassingly wide and the width is the measurement, not the model: the same 52-token image decoded in 251 ms on one run and 2.8 s on another. All of this was driven through an embedded browser pane that throttles a tab it does not consider foreground, and the fast readings cluster when it is. Take 0.25--0.5 s as the figure for a real desktop tab and treat the rest as instrumentation noise -- but that is an inference, and the honest reported range is the one in the table. No phone was measured at all; a phone will be slower than any number above.

The fastest reading is worth noting: 251 ms in WASM against 167 ms for native PyTorch on the same machine (section 3). Being within 1.5x of native is not what a 160-step autoregressive decode in a browser usually costs.

The shape is worth noting because it inverts the native result: natively the decoder dominates and the encoder is cheap, while in WASM the convolutional encoder is the larger half. The demo reports its own per-image time in the page, which is the honest way for a visitor to find out what their own device does.

4.4 Preprocessing is the remaining unmeasured risk

The browser reproduces image_to_tensor (src/pic2smiles/data/dataset.py): greyscale by ITU-R 601-2 luma, padded to a square, inverted so ink is near 1.

The padding is not cosmetic and the first deployed version got it wrong. It stretched instead, which is a no-op on the square images in the frozen evaluation sets -- so all three shipped samples still matched their references and nothing looked broken -- but wrong on the case the demo actually exists for: a screenshot someone drops in. Stretching a wide picture to a square changes every bond angle and length, and the model answers confidently and wrongly rather than visibly failing. pad_to_square on master fixes this for the Python paths (issue #4); the browser now mirrors it.

The resize filter is a separate gap and it is now measured, at n=1. A canvas drawImage is not Pillow's BILINEAR, and the accuracy table above deliberately holds preprocessing constant to isolate the export. On square images near the model's native scale the two agree: the three demo samples decode identically through the browser and through the Python path, and all three match their reference SMILES. Under a heavy reduction they do not: a 2:1 test image built by padding clean_000000 onto a double-width canvas decoded to three different molecules through the browser, through the Python path, and in the reference -- none of them right.

That last result is the important one, and it is not really about the filter. Padding preserves geometry but halves the drawing's scale, and the model was trained at one scale. Cropping close to the structure is what recovers it, which is exactly what --screenshot does in the Python path before padding (src/pic2smiles/imaging.py: trim margins, flatten transparency, invert dark-mode captures). The demo implements none of that -- it flattens transparency onto white and pads, and that is all. A non-square drop is therefore a known-weak path, and the page now says so on screen rather than returning a confident answer silently.

Three images is a smoke test, not a rate, and one wide image is an anecdote.

RDKit is not shipped. The page shows the decoder's raw output rather than a canonicalized structure, because an RDKit WASM build would roughly double the download for something the model does not need in order to answer.