perf(xml): share the decoded image between applications, not the Geometry - #2024
perf(xml): share the decoded image between applications, not the Geometry#2024jcschaff wants to merge 2 commits into
Conversation
…er application A BioModel stores a full copy of its geometry -- image included -- inside EVERY <SimulationContext>: Xmlproducer:1416 writes it there and XmlReader:6024 parses each copy independently. A model with 11 spatial applications on one geometry therefore paid for that geometry 11 times on every read. That is what killed two prod api pods (#2021). The request was GET /api/v0/biomodel/101963252/simulation/98916046 from the PetalBot crawler; the model has 15 simulation contexts and the log shows 11 image-size warnings, one per geometry parse, before the heap ran out. XmlReader now digests each <Geometry> element and reuses the Geometry it already parsed for an identical one. The cache is a field on XmlReader, and XmlHelper constructs one XmlReader per document, so it lives exactly as long as one parse and is never shared across documents or threads. Measured with XmlGeometrySharingBenchmark, 11 applications over one 7872x7872 image (61,968,384 pixels -- the pixel count from the incident): peak retained time sharing OFF 790.9 MB 661.4 MB 2626 ms sharing ON 162.0 MB 60.3 MB 303 ms 4.9x 11.0x 8.7x Retained falls by exactly the application count, which is the expected shape. Note the benchmark's 2D two-subvolume image is much cheaper than the real 3D model -- the profiler in #2023 measured ~1.4 GB peak for ONE parse of a 62 MP 3D geometry -- so 790 MB is a lower bound on what prod was actually doing against a 1000 MB heap. Only byte-identical elements share. Nothing here tries to decide that two DIFFERENT geometries are equivalent; that is a much harder question and not one a parser should answer. The digest streams the element through SHA-256 rather than building a String first: the element carries the image as hex text, so for this model that String would be tens of MB, and allocating it to save allocations would be self-defeating. If the digest cannot be computed the element is simply parsed unshared, so a failure there costs performance and never correctness. On sharing a mutable object: a SimulationContext and its MathDescription already share one Geometry instance today, so intra-document aliasing is established practice rather than a new idea, and Geometry holds no back-reference to an owner. The client's editing paths replace a geometry (SimulationContext.setGeometry) rather than mutating it in place. Even so this changes object identity, so vcell.xml.shareIdenticalGeometries=false restores the old behaviour -- an escape hatch, not a tuning knob. Tests: identical elements share; different geometries are NOT conflated (the important one -- a key that stopped discriminating would be far worse than the memory problem); the shared geometry keeps its image, subvolumes and surfaces, checked on the SECOND application because that is the one served from the cache; and the property restores the old behaviour. That last test doubles as the negative control -- it asserts distinct instances with sharing off, which is exactly the pre-change behaviour. vcell-core Fast: 579 run, 0 failures, 8 errors -- the MathOverrideRoundTripTest and VCellDataTest errors docs/BUILDING.md documents for a worktree without the Poetry environments. Refs #2021, #2023 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kr8SbzXtwW3gMVUgMfDDt
…etry Jim's objection to the first commit was correct and is the reason for this one: if five applications share one Geometry object, editing one edits all of them, which is not what anyone expects. That risk was flagged but not resolved. Resolving it turns out to cost nothing. Measured, 11 applications over one 7872x7872 image (61,968,384 px -- the pixel count from #2021): peak retained time Geometry objects VCImage objects share nothing 787.1 MB 661.4 MB 2689ms 11 11 share images only 162.1 MB 60.4 MB 287ms 11 1 share whole geometries 162.1 MB 60.3 MB 273ms 1 1 Sharing images alone captures the ENTIRE win. Sharing the Geometry on top of it buys nothing measurable and carries the aliasing risk, so it is now off by default and opt-in via vcell.xml.shareIdenticalGeometries, for read-only consumers that parse a document to serialise it or to generate math and never edit it. Sharing a VCImage is safe in a way sharing a Geometry is not: its compressed pixels are final, VCPixelClass is declared Immutable and has no setters, and everything editable -- subvolumes, their names, extent, origin, surfaces -- lives on the per-application GeometrySpec and stays private. The test demonstrates that rather than asserting it structurally: it renames a subvolume in one application and checks the other is unchanged. The measurement also corrects what the incident was. 661 MB retained over 11 parses is 11 x 62 MB of pixels, not 11 surface rebuilds -- and it matches the ~370 ms spacing of the prod warnings, which is hex-decode-plus-inflate time, not the ~1.1 s a 62 MP RegionImage takes. Reading the code confirms why: XmlReader:2086 passes the geometry into getGeometrySurfaceDescription, which MUTATES it (setGeometricRegions, setVolumeSampleSize, setFilterCutoffFrequency); only the return value is discarded, into a variable named 'dummy'. So getGeometricRegions() is non-null afterwards and precomputeAll(...,false,false) skips updateAll() entirely. A saved model reuses its stored surfaces and never rebuilds them. docs/geometry-memory-notes.md, added by #2023, said the opposite; it is corrected on that branch, not here, since the file does not exist on this one. Likely side benefit, from code reading and NOT verified against the database: saveBioModel keys memoryToDatabaseHash by the image object, and VCImage does not override equals or hashCode. Before this change, the XML round-trip inside the save produced N distinct image objects for one image, so a NEW image (key == null) fell into the insert branch N times and was written as N rows with mangled unique names. One shared object collapses that to one insert. Worth someone checking prod for duplicate image rows. Tests (6): identical images decode once; each application keeps its own Geometry, proven by renaming a subvolume; the shared image keeps its content, checked on the SECOND application because that is the one served from the cache; different images are NOT conflated; the escape hatch works; geometry sharing stays off unless asked for. MathGen_IT: 1045 tests, 0 failures, 0 errors -- 705 math-generation comparisons over real stored VCML. vcell-core Fast: 581 run, 0 failures, 8 errors -- the environmental Poetry ones. Refs #2021, #2023 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kr8SbzXtwW3gMVUgMfDDt
It does not, for any saved model. XmlReader:2086 passes the geometry into getGeometrySurfaceDescription, which MUTATES it -- setVolumeSampleSize, setFilterCutoffFrequency, setGeometricRegions -- and only the RETURN value is discarded, into a variable named 'dummy'. The name is what misled me. Because getGeometricRegions() is non-null afterwards, precomputeAll(factory,false,false) skips updateAll() entirely and the stored surfaces are reused. So opportunity 2 was largely already true, and the phase-5 figure (1.4 GB peak), while a correct measurement of new RegionImage(...), is NOT a cost an XML read of a stored model pays. This also re-explains the #2021 incident: the eleven parses cost eleven image decodes and eleven retained copies of the pixels, not eleven surface rebuilds. That matches the ~370 ms spacing of the prod warnings -- hex-decode-plus-inflate time, not the ~1.1 s a 62 MP RegionImage takes -- and it is why sharing the decoded VCImage alone recovers the entire win in #2024. Refs #2021, #2024 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kr8SbzXtwW3gMVUgMfDDt
|
You're right, and I'd flagged that risk without resolving it. Resolving it turns out to cost nothing — pushed Measured all three, 11 applications over one 7872×7872 image (61,968,384 px — the incident's pixel count):
Sharing images alone captures the entire win. Sharing the Geometry on top buys nothing measurable and carries exactly the aliasing you described, so it's now off by default and opt-in via Sharing a The measurement also corrected what the incident was. 661 MB over 11 parses is 11 × 62 MB of pixels, not 11 surface rebuilds — matching the ~370 ms warning spacing (hex-decode + inflate), not the ~1.1 s a 62 MP RegionImage takes. Reading the code confirms why: Possible side benefit, code-reading only, not verified against the database:
|
|
Converted to draft. Superseded for now by the planning document in #2025, where this appears as option §5.1 — the recommended first step, and the only one with the full measured win already demonstrated (peak 787→162 MB, retained 661→60 MB, 8.7× faster, |
#2026 (8a6e9f7) and #2027 (620ccd5) landed on master 2026-08-24. Admin-merged because master requires a review and none was available, which bypasses the merge queue, so regression.yml was triggered manually against master. Everything else in this document is still open: #2022, #2023 and #2024 remain drafts and none of the decisions in section 6 have been made. Refs #2021, #2026, #2027 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kr8SbzXtwW3gMVUgMfDDt
|
Closing. The premise this was built on no longer holds, and two of the numbers in the description It is not needed for the memory problemThis was designed when the incident looked like 11 × 62 MB of unavoidable retention. #2026 and Each parsed geometry still holds its The measured win was overstatedThe benchmark above used 11 applications over one identical image — synthetic, identical by
The design objection standsThe digest matches neither identity that means anything:
And sharing instances needs a client-side split on write, which this has no mechanism for. Even What replaces itAn explicit list of geometries on the BioModel, edited directly — sharing as a modelled, The one genuinely useful thing here — the duplicate-insert fix — deserves its own small PR rather Superseded by #2025 §5.2. Not deferred — the reasoning is recorded so nobody re-derives it. |
…tion Records why #2024 was closed rather than deferred, so nobody re-derives it. It is not needed. It was designed when the incident looked like 11 x 62 MB of unavoidable retention; 5.3b removed that premise. With the pixels held softly the retained floor for the same document drops from ~680 MB to ~11 MB, without deduplicating anything. The win was also overstated, and section 4.6 now records that as one of my own errors. The 11x figure came from a synthetic document built with eleven IDENTICAL copies -- the benchmark assumed the thing it was meant to test. Measured across 23 corpus models carrying 103 image elements (new section 3.10): today 103 decodes digest of the <Image> element 66 (36%) by database key 66 (36%) by pixel payload 52 (50%) biomodel_27192717 shows why: 9 image elements, 8 distinct names, 8 distinct keys, and only 2 distinct pixel payloads. The duplicate-insert bug had already renamed every copy and given each its own row, and the name and version sit INSIDE the digested element -- so the bug defeats its own detection. And the design was wrong regardless: the digest matched neither declared identity (KeyValue, which the save controller effectively uses) nor content identity, and sharing instances needs a client-side split on write that it had no mechanism for. 5.2 is rewritten from a VCML format change to giving a BioModel an explicit list of geometries, edited directly -- Jim's framing, and a better one. Modelling the sharing answers every objection that sank 5.1 at the source: declared identity for the recursive, non-atomic incremental save controller to key on, a visible home for copy-on-write, and it matches what the database already stores. VCML is the layer that discards the relationship by inlining. New 5.10 salvages the one useful part of #2024 as its own item: VCImage overrides neither equals nor hashCode while saveBioModel keys a hashtable by it, so a new image is inserted once per application. Includes the finding that SQLCreateAllTables.writeScript(POSTGRES, ...) already emits the whole schema, so a testcontainer can build the real thing rather than a stub -- there is currently no test of the document save path at all. Sequencing reordered accordingly: 5.9 (free) -> 5.10 (stop creating duplicates) -> 5.8 (clean up the existing ones) -> 5.4 -> 5.2. Refs #2021, #2024, #2025 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kr8SbzXtwW3gMVUgMfDDt
Implements the largest opportunity identified in #2023: parse each distinct geometry once per
document instead of once per application.
The problem
A BioModel stores a full copy of its geometry — image included — inside every
<SimulationContext>.Xmlproducer.java:1416writes it there;XmlReader.java:6024parses eachcopy independently. A model with 11 spatial applications on one geometry paid for that geometry 11
times on every read.
That is what killed two prod api pods (#2021):
GET /api/v0/biomodel/101963252/simulation/98916046from the PetalBot crawler. The model has 15 simulation contexts, and the log shows 11 image-size
warnings — one per geometry parse — before the heap ran out.
What changed
XmlReaderdigests each<Geometry>element and reuses theGeometryit already parsed for anidentical one. The cache is a field on
XmlReader, andXmlHelperconstructs oneXmlReaderperdocument, so it lives exactly as long as one parse and is never shared across documents or threads.
Measured
XmlGeometrySharingBenchmark(included), 11 applications over one 7872×7872 image —61,968,384 pixels, the pixel count from the incident:
Retained falls by exactly the application count, which is the shape you'd expect if the fix does
what it claims.
790 MB is a lower bound on what prod was doing. The benchmark's 2D two-subvolume image is far
cheaper than the real 3D model — the profiler in #2023 measured ~1.4 GB peak for ONE parse of a
62 MP 3D geometry. Against a 1000 MB heap.
Scope of the sharing
Only byte-identical elements share. Nothing here tries to decide that two different
geometries are equivalent — that is a much harder question and not one a parser should be
answering.
The digest streams the element through SHA-256 rather than building a
Stringfirst: the elementcarries the image as hex text, so for this model that
Stringwould be tens of MB, and allocatingit to avoid allocations would be self-defeating. If the digest can't be computed the element is
simply parsed unshared — a failure there costs performance, never correctness.
On sharing a mutable object — the part worth your attention
This changes object identity, so it deserves a straight answer rather than a reassurance. What I
checked:
SimulationContextand itsMathDescriptionalready share oneGeometryinstance today(
ParticleMathMapping:456among others), so intra-document aliasing is established practice, nota new idea.
Geometryholds no back-reference to an owner — its fields are version, name, description,GeometrySpec, unit system,GeometrySurfaceDescription.SimulationContext.setGeometry, e.g.GeometryViewer:197,ClientRequestManager:259) rather than mutating one in place.The residual risk is a client path that mutates a geometry in place — renaming a subvolume, say —
where the change would now be visible to sibling applications. I did not find one that applies to a
simulation context's geometry, but I can't prove absence across the whole client. Hence
vcell.xml.shareIdenticalGeometries=falserestores the old behaviour: an escape hatch, not atuning knob.
If you'd rather this were narrower, the conservative variant is to share only the immutable heavy
payload (
VCImage,RegionImage,SurfaceCollection) and still build aGeometrySpecperapplication. It's more code and gives up part of the win. Your call — this is your architecture.
Tests
XmlReaderGeometrySharingTest, 4 cases:would be far worse than the memory problem being solved.
application, because that is the one served from the cache
That last test doubles as the automated negative control: it asserts distinct instances with
sharing off, which is exactly the pre-change behaviour. The suite therefore proves both directions
without needing a manual revert.
Regression
MathGen_IT: 1045 tests, 0 failures, 0 errors (705MathGenCompareTest+ 340MathOverrideApplyTest, 13 min). These parse real stored VCML biomodels and compare generatedmath — the strongest available signal that sharing a
Geometrychanges nothing semantically.vcell-coreFast: 579 run, 0 failures, 8 errors — theMathOverrideRoundTripTest/VCellDataTesterrorsdocs/BUILDING.mddocuments for a worktree without the Poetryenvironments.
Not included
The write side. Emitting geometries once at BioModel level and referencing them by key would cut
VCML size ~11× for these models too, but it is a format change with old-client implications and
belongs in its own discussion.
Refs #2021, #2023
🤖 Generated with Claude Code
https://claude.ai/code/session_018kr8SbzXtwW3gMVUgMfDDt