perf(geotiff)!: block-aligned LRU header cache; lazy tile metadata#529
Merged
Conversation
Supersedes the earlier read-ahead cache design. Uses chunkd's existing SourceChunk + SourceCache (64 KiB blocks, 8 MiB LRU) instead of a custom sequential cache; drops the eager TileOffsets/TileByteCounts prefetch in favor of cogeotiff's lazy per-entry reads through the block cache; disables cogeotiff's GDAL leader-bytes path so the header cache stays free of image-data bytes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cogeotiff lazily fetches individual entries from these arrays via the header source on first access. With a block-aligned header cache (next commit), adjacent per-tile lookups hit the same 64 KiB block. The eager bulk fetch downloaded tens of MB on huge COGs (e.g. Vermont) before any tile could render, all of which was wasted work when the initial view was at an overview level that didn't use the primary image's arrays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ays metadata-only cogeotiff auto-detects GDAL ghost option BLOCK_LEADER=SIZE_AS_UINT4 at Tiff.create() time. When set, TiffImage.getTileSize fetches 4 bytes near the tile data instead of reading TileByteCounts. The intent is that the fetch's chunk also contains the tile, but tiles are often larger than the chunk size, so the optimization pollutes the header cache with image-data chunks and evicts metadata. cogeotiff core only reads tiff.options here; nulling it after creation is safe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-call prefetch tuning with a fixed-block cache matching geotiff.js's BlockedSource. cogeotiff's lazy per-entry reads now hit a shared 64 KiB block when adjacent (the typical case for tile-offset arrays). LRU eviction keeps memory bounded at 8 MiB by default. Breaking: drops the `prefetch` option on `GeoTIFF.fromUrl`. `prefetch` remains available on `GeoTIFF.open` for direct callers that need to control cogeotiff's defaultReadSize. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kylebarron
commented
May 12, 2026
|
|
||
| The original design (the sequential exponential read-ahead cache, then a frozen-after-open variant) tried to optimize *steady-state* tile rendering by bulk-loading `TileOffsets` / `TileByteCounts` arrays for each IFD. That moved the cost to *open time*. On a real 200 GB Vermont COG, that's tens of MB downloaded before any tile renders — even though the initial view is at an overview level whose primary-image arrays are never used. | ||
|
|
||
| geotiff.js takes the opposite approach. Each `fromUrl` call fetches just 1024 bytes (header + first IFD pointer). `getImage(i)` reads only that IFD's entries; tile-array values are wrapped in a `DeferredArray` that holds only their file offset + count. Per-tile reads fetch a single 4–8 byte entry through `BlockedSource`, a fixed-block LRU that coalesces adjacent entries into one block. The block cache lives inside the source layer; cogeotiff's lazy per-entry reads benefit from it automatically. |
Member
Author
There was a problem hiding this comment.
We should fetch an initial prefetch of a full block size, not 1024 bytes
kylebarron
commented
May 12, 2026
|
|
||
| ### Why disable cogeotiff's leader-bytes path? | ||
|
|
||
| cogeotiff auto-detects the GDAL ghost option `BLOCK_LEADER=SIZE_AS_UINT4` at `Tiff.create()` time. If present, `TiffImage.getTileSize()` skips the `TileByteCounts` lookup and instead fetches 4 bytes just before the tile data. The comment in cogeotiff explains the intent: *"This fetch will generally load in the bytes needed for the image too provided the image size is less than the size of a chunk."* But that assumption breaks for tiles larger than the block size (very common — many COG tiles are 256×256×3 bytes ≈ 200 KB, well above 64 KiB). When it breaks, the result is: |
Member
Author
There was a problem hiding this comment.
How does it know where the tile data is though? Does it fetch the offset separately?
kylebarron
commented
May 12, 2026
kylebarron
commented
May 12, 2026
…plicit one-block prefetch Addresses PR review feedback on #529: - Rename the `source` parameter on the vendored getTile/getBytes helpers to `dataSource`. Functionally unchanged — every caller already passes `self.dataSource` — but the new name makes it impossible to confuse with the header source that cogeotiff uses internally for the TileOffsets/TileByteCounts lookups. - Expand the doc comments on those helpers to explain the header-vs-data split explicitly. - Pass `prefetch: chunkSize` from `GeoTIFF.fromUrl` to `GeoTIFF.open`, so the very first cogeotiff read is exactly one block. SourceChunk would pad it anyway, but being explicit keeps the intent local. - Update the spec to clarify how per-tile offset/bytecount lookups work and note that we explicitly fetch a full block on the first read (cogeotiff's DefaultReadSize is 16 KiB). - Add a TODO referencing the upstream issue (to be filed) tracking a cleaner opt-out for cogeotiff's GDAL leader-bytes path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 tasks
Adds `debug?: boolean` (off by default) to `GeoTIFF.open` and `GeoTIFF.fromUrl`. When enabled, the tile-fetch path logs each `dataSource.fetch` call to the console with a `data`/`mask` label, offset, and length. Useful for diagnosing per-request behavior against the browser network panel — e.g. surfacing the tiny mask-tile requests that motivated the option in the first place. Threaded through `HasTiffReference` so both `GeoTIFF` and `Overview` paths log. A future change can coalesce adjacent (data, mask) tile pairs into a single range request; until then, this option is the easiest way to observe the current behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kylebarron
commented
May 12, 2026
In the typical fromUrl path, prefetch was just coupled to chunkSize via SourceChunk padding — small requests get padded up to one block, large ones fetch multiple blocks. The option added no behavior over what the chunking middleware already provides, and exposed a knob that callers almost never need to tune. Direct GeoTIFF.open callers who want a specific initial fetch size can compose a SourceChunk of the desired block size into their headerSource; the option is the right tool for that job, not a separate dial on open. cogeotiff's default DefaultReadSize (16 KiB) is now used for the very first read; SourceChunk pads it to chunkSize transparently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kylebarron
commented
May 12, 2026
Overview doesn't expose debug — only the primary GeoTIFF does. Drop the required-property constraint so Overview satisfies the interface without its own getter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ew accessor Addresses PR review: - Class field debug -> _debug, marked @internal. The user-facing option on GeoTIFF.open/fromUrl stays named 'debug'. - Remove Overview.debug getter — overview tile fetches don't need to log; the primary GeoTIFF's _debug is the only opt-in surface. - HasTiffReference._debug? is optional so Overview (without it) still satisfies the interface. - Drop the explicit defaultReadSize parameter by switching from Tiff.create to 'new Tiff(...).init({ signal })', letting the constructor default to Tiff.DefaultReadSize. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This provides a huge latency improvement for rendering large COGs.
Before
61MB of header fetching!
Screen.Recording.2026-05-12.at.3.27.01.PM.mov
After:
Roughly 3 seconds from a cold cache, and much less metadata. (4x more tiles are being fetched in this view than in the Before, because #513 increased tile count by 4x)
Screen.Recording.2026-05-12.at.3.22.28.PM.mov
Change list
debug?param toGeoTIFF.openfor logging out debug info about requests madeCloses #528, Closes #501, Closes #294
Summary
Reworks COG header loading after live testing showed PR #509's approach (eager bulk-prefetch of TileOffsets/TileByteCounts at open) downloaded tens of MB before any tile could render on huge COGs.
This branch goes in the opposite direction — lazy everything, with a fixed-block LRU cache to amortize per-entry reads. Matches geotiff.js's
BlockedSourcearchitecture.TileOffsets/TileByteCountsprefetch. cogeotiff already supports lazy per-entry reads; the block cache (below) makes them cheap.GeoTIFF.open(tiff.options = undefined). That optimization assumes tiles fit in one chunk and is harmful when they don't — it pollutes the header cache with image-data bytes.GeoTIFF.fromUrluses 64 KiBSourceChunk+ 8 MiBSourceCache(matches geotiff.js's defaults). LRU-ish eviction keeps memory bounded.fromUrldrops theprefetchoption (still onGeoTIFF.openfor direct callers).For the Vermont 200 GB COG with 61 MB header: previous PR downloaded ~60 MB upfront; this design opens with ~3 small reads and lazy-loads per-IFD metadata as tiles are actually requested.
Closes #500. Supersedes #509 (which should be closed).
Spec:
dev-docs/specs/2026-05-12-cog-block-cache-design.mdTest plan
pnpm --filter @developmentseed/geotiff typecheckcleanintegration-rasteriofixture failures unrelated to this branch)block-cache.test.tsasserts: every underlying fetch is 64 KiB-aligned;tiff.optionsis undefined post-open;getTileSize(0)triggers at most 2 chunk fetchesfromurl.test.ts(issue SourceError: Request outside of bounds #524 regression) still passes🤖 Generated with Claude Code