From 081b34c7c47de22b36706467a4a9d642eab92c31 Mon Sep 17 00:00:00 2001 From: snkmcb Date: Thu, 20 Aug 2026 21:43:32 +0900 Subject: [PATCH 1/4] Stop small scattered reads from becoming small scattered requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3, and the first release that changes the recorded numbers on purpose. libs/usd-asset-cache sits between the resolver and a backend as an AssetReader that wraps an AssetReader, so nothing above it knows it is there and nothing below it changes: block alignment, coalescing, single-flight, and eviction, over a CacheKey that carries the validator from the first commit. What it moves, against the table v0.2.0 recorded as the definition of success (docs/reference/BASELINE.md, "What the next release has to move"): header and index read 18 requests -> 3 parallel readers 152 requests -> 25 bounded query amplification 1.000000 -> 4.543210, and bytesOverFetched now non-zero and honest about it full sequential read 33 requests, amplification 1.000000 -- unchanged, which is the row that had to not regress The fourth line is the one worth stating twice. A cache that improves the first three and quietly damages the worst case has not improved anything, and the bypass threshold is what keeps it: a read above 1 MiB goes straight to the backend rather than through a store it would evict itself out of. **The constants are measured, and the measurement is recorded rather than implied.** tests/cache-tuning sweeps four access patterns across five block sizes and four coalescing gaps -- sixty-six runs, each against a fresh store so no row is warmed by the row above it -- and docs/reference/BLOCK_POLICY.md is the record. 64 KiB blocks and a gap of one block are chosen from request and byte counts alone, because those are exact on every machine and a wall clock on loopback is a fact about the runner. Two of the five constants were not measured and say so in the table: the request ceiling is a safety bound and the budget is a residency policy. A number in a table under a heading about measurement, which was not measured, is the failure that file exists to prevent. The premise the harness cannot measure is stated outright rather than assumed quietly: on any link this project targets, one round trip costs more than one block of bytes. That is CACHE.md §2 as an assumption, and it is what makes fewer requests and more bytes the better trade. It gets its own measurement in v0.5.0, when a consumer puts real distance between the reader and the origin. Until then the direction of the trade is assumed and only its magnitude is measured. The cache enters the shared boundary suite as a row rather than as a suite, for the third time: boundary_cached_local, the cached backend over the local one, which is the configuration where the oracle can still say what every byte should be. A cache that returns a byte from the wrong block fails there against the same naive oracle usdAssetLocal has been checked against since v0.1.0. Cache counters join the metrics that already existed, so the block cache reports hits, misses, over-fetch, and requests saved by single-flight in the same dump every other counter appears in. Co-Authored-By: Claude Opus 5 --- docs/reference/BASELINE.md | 199 +++++--- docs/reference/BLOCK_POLICY.md | 200 ++++++++ libs/usd-asset-cache/CMakeLists.txt | 107 ++++ .../cmake/usdAssetCacheConfig.cmake.in | 8 + .../include/usdAssetCache/BlockCache.h | 182 +++++++ .../include/usdAssetCache/CacheKey.h | 92 ++++ .../include/usdAssetCache/CacheOptions.h | 91 ++++ .../include/usdAssetCache/CachedAssetReader.h | 143 ++++++ libs/usd-asset-cache/openstrata.library.yaml | 20 + libs/usd-asset-cache/src/BlockCache.cpp | 417 +++++++++++++++ libs/usd-asset-cache/src/BlockPlan.cpp | 95 ++++ libs/usd-asset-cache/src/BlockPlan.h | 96 ++++ libs/usd-asset-cache/src/CacheKey.cpp | 61 +++ libs/usd-asset-cache/src/CacheOptions.cpp | 72 +++ .../usd-asset-cache/src/CachedAssetReader.cpp | 437 ++++++++++++++++ libs/usd-asset-cache/tests/CMakeLists.txt | 32 ++ libs/usd-asset-cache/tests/Check.h | 55 ++ libs/usd-asset-cache/tests/FakeReader.h | 199 ++++++++ libs/usd-asset-cache/tests/test_cache.cpp | 481 ++++++++++++++++++ libs/usd-asset-cache/tests/test_plan.cpp | 269 ++++++++++ .../tests/test_singleflight.cpp | 269 ++++++++++ .../include/usdAssetHttp/HttpAssetReader.h | 5 + libs/usd-asset-http/src/HttpAssetReader.cpp | 2 + .../usd-asset-io/include/usdAssetIo/Metrics.h | 65 +++ libs/usd-asset-io/src/Metrics.cpp | 96 ++++ .../include/usdAssetLocal/LocalAssetReader.h | 8 + libs/usd-asset-local/src/LocalAssetReader.cpp | 2 + plugins/http-resolver/CMakeLists.txt | 11 +- plugins/http-resolver/src/Configuration.cpp | 101 ++++ plugins/http-resolver/src/Configuration.h | 35 +- plugins/http-resolver/src/HttpResolver.cpp | 49 +- plugins/http-resolver/src/HttpResolver.h | 9 + plugins/http-resolver/tests/CMakeLists.txt | 3 +- .../tests/test_configuration.cpp | 67 ++- plugins/http-resolver/tests/test_stage.cpp | 50 +- tests/CMakeLists.txt | 8 + tests/baseline/CMakeLists.txt | 6 +- tests/baseline/Report.cpp | 47 +- tests/baseline/Report.h | 7 + tests/baseline/baseline_main.cpp | 392 +++++++++++--- tests/boundary/CMakeLists.txt | 10 + .../backends/boundary_cached_local_main.cpp | 134 +++++ tests/boundary/src/Suite.cpp | 43 +- tests/cache-tuning/CMakeLists.txt | 51 ++ tests/cache-tuning/Check.h | 61 +++ tests/cache-tuning/tuning_main.cpp | 439 ++++++++++++++++ 46 files changed, 5061 insertions(+), 165 deletions(-) create mode 100644 docs/reference/BLOCK_POLICY.md create mode 100644 libs/usd-asset-cache/CMakeLists.txt create mode 100644 libs/usd-asset-cache/cmake/usdAssetCacheConfig.cmake.in create mode 100644 libs/usd-asset-cache/include/usdAssetCache/BlockCache.h create mode 100644 libs/usd-asset-cache/include/usdAssetCache/CacheKey.h create mode 100644 libs/usd-asset-cache/include/usdAssetCache/CacheOptions.h create mode 100644 libs/usd-asset-cache/include/usdAssetCache/CachedAssetReader.h create mode 100644 libs/usd-asset-cache/openstrata.library.yaml create mode 100644 libs/usd-asset-cache/src/BlockCache.cpp create mode 100644 libs/usd-asset-cache/src/BlockPlan.cpp create mode 100644 libs/usd-asset-cache/src/BlockPlan.h create mode 100644 libs/usd-asset-cache/src/CacheKey.cpp create mode 100644 libs/usd-asset-cache/src/CacheOptions.cpp create mode 100644 libs/usd-asset-cache/src/CachedAssetReader.cpp create mode 100644 libs/usd-asset-cache/tests/CMakeLists.txt create mode 100644 libs/usd-asset-cache/tests/Check.h create mode 100644 libs/usd-asset-cache/tests/FakeReader.h create mode 100644 libs/usd-asset-cache/tests/test_cache.cpp create mode 100644 libs/usd-asset-cache/tests/test_plan.cpp create mode 100644 libs/usd-asset-cache/tests/test_singleflight.cpp create mode 100644 tests/boundary/backends/boundary_cached_local_main.cpp create mode 100644 tests/cache-tuning/CMakeLists.txt create mode 100644 tests/cache-tuning/Check.h create mode 100644 tests/cache-tuning/tuning_main.cpp diff --git a/docs/reference/BASELINE.md b/docs/reference/BASELINE.md index 3421779..d094d32 100644 --- a/docs/reference/BASELINE.md +++ b/docs/reference/BASELINE.md @@ -5,7 +5,7 @@ This file holds the current record for the five scenarios in today, not what it intends to measure later, which is why it lives beside [CAPABILITY_MATRIX.md](CAPABILITY_MATRIX.md). -Last recorded: 2026-08-19, against `main`; unchanged at `v0.2.0`. +Last recorded: 2026-08-20, against `main`, for `v0.3.0`. A release record copies this table at its tag and never rewrites it; this file is rewritten whenever I/O behavior changes, which is what @@ -13,16 +13,19 @@ is rewritten whenever I/O behavior changes, which is what the record is history and this is the present, and a release that finds them disagreeing has found the regression the gate exists for. -Until this file existed, this project made no performance claim. `v0.2.0` is the -first release that could produce one, because it is the first release in which -bytes cross a network. +`v0.3.0` is the first release to change these numbers on purpose. Every scenario +is therefore measured twice — once against the transport alone and once with the +block cache over it — because METRICS.md §6 asks a release that changes I/O +behavior to record *the counter values before and after*, and a table carrying +only the after would leave the next gate comparing a run against a document. +Why the cache's constants are the ones they are is a separate record: +[BLOCK_POLICY.md](BLOCK_POLICY.md). ## What is measured, and by what `tests/baseline` is the harness. It stands up the loopback fixture server, serves -one large synthetic asset, and runs the five scenarios against it through the -HTTP backend with the shipped transport defaults — the deadlines, the redirect -bound, and the attempt count a caller gets when it passes nothing, because a +one large synthetic asset, and runs the five scenarios against it — twice each — +with the shipped transport defaults and the shipped cache defaults, because a baseline measured with a configuration that does not ship is a baseline about something else. @@ -38,18 +41,21 @@ remembers to invoke: ctest --test-dir build/core -R usdAssetHttp_io_baseline ``` -Gate 6 is a regression gate. A cache that over-fetches, a retry nobody asked -for, or a redirect that starts being followed is a byte count that moves, and -every functional test in this repository passes straight through it — the -boundary suite compares bytes against an oracle, and all of those bytes would -still be right. +Gate 6 is a regression gate. A cache that over-fetches beyond its block size, a +retry nobody asked for, or a redirect that starts being followed is a byte count +that moves, and every functional test in this repository passes straight through +it — the boundary suite compares bytes against an oracle, and all of those bytes +would still be right. ## What is gated and what is reported | Quantity | Treatment | Why | | --- | --- | --- | -| Bytes requested, bytes transferred, request counts, retries, redirects | Asserted exactly | With no cache, a read of *n* bytes is one request that moves exactly *n* bytes. Anything else is over-fetch, a retry, or a redirect, and each of those is a defect until a release says otherwise | -| `amplification`, `selectivity`, and the other derived ratios | Recorded | They are the byte counts divided by the fixture size, and a gate on them would move when the fixture did | +| Uncached rows: bytes requested, bytes transferred, request counts, retries, redirects | Asserted exactly | With no cache, a read of *n* bytes is one request that moves exactly *n* bytes. Anything else is over-fetch, a retry, or a redirect, and each of those is a defect until a release says otherwise | +| Cached rows: bytes transferred and request count | Asserted against the server's log, exactly | A cache makes the expected transfer a function of the block policy, and asserting the backend's counter against a number the harness computed from the same policy would be asserting the policy against itself | +| Cached rows: fewer requests than the row above | Asserted | This is the release's claim. It is checked against the uncached run of the same scenario in the same process, not against a number copied from a previous release | +| The full sequential read, cached against uncached | Asserted identical | Not "no worse by a margin". Every read in it is above the bypass threshold and never reaches the store, so any difference at all means the bypass stopped applying | +| `amplification`, `selectivity`, and the other derived ratios | Recorded | They are byte counts divided by the fixture size, and a gate on them would move when the fixture did | | Latency and wall clock | Recorded | Loopback has no bandwidth-delay product. These are numbers about this process on this machine, and a lane that failed on them would fail for reasons that are not this repository's | Every request count is asserted twice: once against the backend's own counter, @@ -75,72 +81,118 @@ Fixture: a synthetic asset of 134217728 bytes (128.0 MiB) at `/baseline/asset.bi Layout: a 4096-byte header at offset 0, a 65536-byte index in the tail, body between them. -Measured with the shipped transport defaults, on Windows AMD64, MSVC 19.34.31937.0, Release. +Measured with the shipped transport defaults and the shipped cache defaults -- 64 KiB blocks, a gap of one block, a 1 MiB bypass threshold, a 128 MiB budget (see [BLOCK_POLICY.md](BLOCK_POLICY.md)) -- on Windows AMD64, MSVC 19.34.31937.0, Release. | Scenario | requests | metadata | retries | redirects | bytes requested | bytes transferred | amplification | selectivity | wall ms | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| metadata-only open | 1 | 1 | 0 | 0 | 0 | 0 | — | 0.000000 | 1.4 | -| header and index read | 18 | 1 | 0 | 0 | 69632 | 69632 | 1.000000 | 0.000519 | 1.4 | +| metadata-only open | 1 | 1 | 0 | 0 | 0 | 0 | — | 0.000000 | 2.0 | +| metadata-only open (cached) | 1 | 1 | 0 | 0 | 0 | 0 | — | 0.000000 | 0.9 | +| header and index read | 18 | 1 | 0 | 0 | 69632 | 69632 | 1.000000 | 0.000519 | 1.5 | +| header and index read (cached) | 3 | 1 | 0 | 0 | 69632 | 131072 | 1.882353 | 0.000977 | 0.6 | | bounded spatial query | 19 | 1 | 0 | 0 | 331776 | 331776 | 1.000000 | 0.002472 | 1.5 | -| full sequential read | 33 | 1 | 0 | 0 | 134217728 | 134217728 | 1.000000 | 1.000000 | 131.0 | -| parallel readers | 152 | 8 | 0 | 0 | 2654208 | 2654208 | 1.000000 | 0.019775 | 3.5 | +| bounded spatial query (cached) | 18 | 1 | 0 | 0 | 331776 | 1507328 | 4.543210 | 0.011230 | 2.2 | +| full sequential read | 33 | 1 | 0 | 0 | 134217728 | 134217728 | 1.000000 | 1.000000 | 132.4 | +| full sequential read (cached) | 33 | 1 | 0 | 0 | 134217728 | 134217728 | 1.000000 | 1.000000 | 148.6 | +| parallel readers | 152 | 8 | 0 | 0 | 2654208 | 2654208 | 1.000000 | 0.019775 | 3.3 | +| parallel readers (cached) | 25 | 8 | 0 | 0 | 2654208 | 1507328 | 0.567901 | 0.011230 | 3.0 | + + +The cached parallel row is the one number in this record that is not identical +from run to run: it lands at 25 or 26 requests depending on which reader wins +each claim, because a reader that arrives while a block is in flight waits where +a reader arriving a microsecond later finds it resident. The harness therefore +asserts that it is *below* the uncached row rather than equal to a constant, and +this record states which run it came from rather than implying it is fixed. + +Cache counters, for the rows that have them. Every one is zero on an uncached row, and those rows are omitted. + +| Scenario | blockHits | blockMisses | partialHits | savedByCoalescing | savedBySingleFlight | bytesFromCache | bytesOverFetched | evictions | peakResidentBytes | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| metadata-only open (cached) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| header and index read (cached) | 15 | 2 | 0 | 0 | 0 | 61440 | 122880 | 0 | 131072 | +| bounded spatial query (cached) | 1 | 23 | 0 | 6 | 0 | 16384 | 1191936 | 0 | 1507328 | +| full sequential read (cached) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| parallel readers (cached) | 16 | 23 | 0 | 6 | 153 | 2338816 | 1191936 | 0 | 1507328 | Latency, in microseconds. Quantiles are bucket upper bounds, not exact order statistics (METRICS.md §4), and the request and read columns are p50 / p90 / p99 / max. | Scenario | open | request | read | | --- | ---: | ---: | ---: | -| metadata-only open | 1350 | 632 / 632 / 632 / 632 | — | -| header and index read | 327 | 63 / 127 / 322 / 322 | 63 / 88 / 88 / 88 | -| bounded spatial query | 257 | 63 / 127 / 253 / 253 | 63 / 84 / 84 / 84 | -| full sequential read | 264 | 1455 / 1455 / 1455 / 1455 | 1458 / 1458 / 1458 / 1458 | -| parallel readers | 854 | 127 / 255 / 840 / 840 | 127 / 249 / 249 / 249 | - -Every cache counter in METRICS.md §2.2 is zero, and `bytesFromCache` with it, because no cache exists in this release -- not because none hit. `v0.3.0` is where these rows are expected to move, and the request counts above are what it has to move. +| metadata-only open | 1979 | 1145 / 1145 / 1145 / 1145 | — | +| metadata-only open (cached) | 808 | 799 / 799 / 799 / 799 | — | +| header and index read | 444 | 63 / 255 / 437 / 437 | 63 / 127 / 143 / 143 | +| header and index read (cached) | 257 | 127 / 253 / 253 / 253 | 0 / 127 / 140 / 140 | +| bounded spatial query | 245 | 63 / 127 / 241 / 241 | 63 / 68 / 68 / 68 | +| bounded spatial query (cached) | 229 | 63 / 127 / 225 / 225 | 127 / 142 / 142 / 142 | +| full sequential read | 233 | 1309 / 1309 / 1309 / 1309 | 1312 / 1312 / 1312 / 1312 | +| full sequential read (cached) | 446 | 1023 / 2047 / 16627 / 16627 | 1023 / 2047 / 16630 / 16630 | +| parallel readers | 639 | 127 / 255 / 629 / 629 | 127 / 255 / 255 / 268 | +| parallel readers (cached) | 479 | 127 / 473 / 473 / 473 | 127 / 178 / 178 / 178 | + +The cache counters in METRICS.md §2.2 are populated: the runs below served 2416640 bytes from a block store and over-fetched 2506752 to do it. Both halves belong in the record. A design that reported only the first would be selling something, which is what METRICS.md §2.2 calls `bytesOverFetched` the honest counter for. | Scenario | What it exercises | Notes | | --- | --- | --- | | metadata-only open | `openLatency`, `metadataRequestCount` — the cost of merely resolving | One `HEAD`. No content byte crosses the transport, and the reader is bound to a revision before any read is issued | -| header and index read | The clustered small-read pattern the block cache exists for | One 4 KiB header read and 16 adjacent 4 KiB index reads. Every one of them is its own request today, which is the number `v0.3.0` exists to collapse | +| metadata-only open (cached) | `openLatency`, `metadataRequestCount` — the cost of merely resolving | One `HEAD`, unchanged. Binding a store costs no request, which is why this row is the one row the cache does not move | +| header and index read | The clustered small-read pattern the block cache exists for | One 4 KiB header read and 16 adjacent 4 KiB index reads, each its own request | +| header and index read (cached) | The clustered small-read pattern the block cache exists for | The same seventeen reads, collapsed onto 2 block fetches. The bytes the alignment moved beyond the reads are `bytesOverFetched`, and the reads that never reached the transport are `blockHits` | | bounded spatial query | `selectivity` — the headline claim | A header, a tail index, and 16 scattered 16 KiB chunks: 331776 bytes moved to answer a query against an asset of 134217728 | -| full sequential read | The worst case; must not be worse than a plain download | 32 reads of 4 MiB against one plain `GET` of the whole asset over the fixture server's own raw client: identical content bytes, 33 requests against 1, 131.0 ms against 120.0 ms. The comparator reads 4 KiB at a time, so the times are recorded and not gated | -| parallel readers | `requestsSavedBySingleFlight`, contention | 8 readers running the bounded query at once, each with its own revision binding. Every request is issued 8 times, because nothing is shared between readers yet; that is the figure `requestsSavedBySingleFlight` has to move in `v0.3.0` | +| bounded spatial query (cached) | `selectivity` — the headline claim | The same query, with every read expanded to whole blocks: 1507328 bytes moved against an asset of 134217728. `selectivity` is worse than the uncached row on purpose -- that is what alignment costs, and `bytesOverFetched` is the counter for it | +| full sequential read | The worst case; must not be worse than a plain download | 32 reads of 4 MiB against one plain `GET` of the whole asset over the fixture server's own raw client: identical content bytes, 33 requests against 1, 132.4 ms against 127.9 ms. The comparator reads 4 KiB at a time, so the times are recorded and not gated | +| full sequential read (cached) | The worst case; must not be worse than a plain download | 32 reads of 4 MiB against one plain `GET` of the whole asset over the fixture server's own raw client: identical content bytes, 33 requests against 1, 148.6 ms against 129.1 ms. Every read bypassed the cache, so this row is the uncached row and is asserted to be | +| parallel readers | `requestsSavedBySingleFlight`, contention | 8 readers running the bounded query at once, each with its own revision binding. Every request is issued 8 times, because nothing is shared between readers | +| parallel readers (cached) | `requestsSavedBySingleFlight`, contention | 8 readers running the bounded query at once, each with its own revision binding and all of them sharing one store. What they no longer share is the traffic: 25 requests against 152. `requestsSavedBySingleFlight` is 153 and `blockHits` is 16: those count blocks a reader did not have to fetch, not requests, so they do not subtract to the difference above and are not meant to | ## What the numbers say -**The headline is `selectivity` on the bounded query: 0.0025.** A query that -read a header, an index, and sixteen scattered chunks moved 324 KiB of a 128 MiB -asset — a quarter of one percent of it — and every byte it moved was a byte the -caller asked for. That is the sentence §1 of METRICS.md says the architecture is -made of, in the form §14 of the [design policy](../design/DESIGN_POLICY.md) -requires it to be made in: a counter on a named fixture rather than a claim. - -**`amplification` is exactly 1.000000 in every scenario that moved a byte**, and -that is the more interesting number, because it is the one that can only get -worse. There is no cache, so a read of *n* bytes is one request for exactly *n* -bytes: no block alignment, no read-ahead, no coalescing window, and nothing -over-fetched. `v0.3.0` will trade that number for a smaller request count, and -this row is what the trade is measured against. - -**The request counts are the cost of having no cache, stated plainly.** Sixteen -adjacent 4 KiB index reads are sixteen requests; eight readers doing identical -work do it eight times over. Neither is a defect — every read is a request in -this release, deliberately, so that the request pattern is visible before it is -optimized — but both are the numbers `v0.3.0` exists to move, and neither could -have been argued about before it was counted. - -**The full sequential read does not lose to a plain download.** Thirty-three -requests against one moved the same content bytes in a comparable time on -loopback, which is what row 4 of METRICS.md §6 asks: a range-based reader that -reads an entire asset must not lose badly to `curl`. The per-request overhead is -visible in the request count and not in the byte count, which is where it should -be. +**The clustered read is the release, and it is a factor of six.** Seventeen +reads of a header and an index cost eighteen requests in `v0.2.0` and cost three +here — one `HEAD` and two block fetches. Fifteen of the seventeen reads never +reached the transport at all. The price is 122880 bytes of alignment against +69632 bytes asked for, stated in `bytesOverFetched` rather than left to be +inferred, and it is the whole of what the row cost. + +**Eight readers of one asset now move what one reader moves.** The parallel row +went from 152 requests to 25, and — the number worth stopping on — from 2654208 +bytes to 1507328, which is *exactly the transfer of the single cached bounded +query above it*. Eight readers of one revision moved one reader's worth of +bytes. That is what the cache key buys: the eight have eight independent +revision bindings and one identity, so seven of them found the blocks resident +or waited on the flight that was already in the air. +`requestsSavedBySingleFlight` is 153, and it counts blocks rather than requests, +so it is not the arithmetic difference of the two request counts and is not +meant to be. + +**`selectivity` got worse, on purpose, and is still the headline.** The bounded +query moved 0.0025 of the asset before and moves 0.0112 now. Alignment converts +request count into transferred bytes — §1 of [CACHE.md](../architecture/CACHE.md) +says so before any of this was built — and one percent of a 128 MiB asset to +answer a query against it is still the sentence the architecture is made of. The +request count barely moved on that row, 19 to 18, because sixteen chunks 8 MiB +apart are sixteen requests whatever the block size; that pattern is not what a +block cache is for, and the record shows it not being helped rather than +implying it was. + +**The worst case did not move at all.** The full sequential read is 33 requests +and 134217728 bytes with the cache and without it, and the harness asserts the +two rows are *identical* rather than merely close. `BASELINE.md` said in +`v0.2.0` that a release which improved the first three rows and quietly damaged +the fourth had not improved anything. The bypass rule in CACHE.md §3 is what +keeps it, and this row is where it is checked. + +**`bytesOverFetched` is 2506752 across the whole run.** It belongs in the +record. A design that reported only what the cache saved would be selling +something, and the honest form of "the clustered read went from 18 requests to +3" is "for 61440 bytes nobody asked for". **Nothing here is a network measurement.** Loopback has no round-trip time worth -the name, so the latency columns describe this process rather than a CDN. What -loopback does measure exactly is how many bytes and how many requests a pattern -costs, and that is the whole of what gate 6 is about. A measurement over real -distance arrives with the first consumer integration in `v0.5.0`, against a -fixture of at least a gigabyte; see +the name, so the latency columns describe this process rather than a CDN, and +the trade this release makes — bytes for round trips — is one loopback cannot +price. What loopback does measure exactly is how many bytes and how many +requests a pattern costs, and that is the whole of what gate 6 is about. A +measurement over real distance arrives with the first consumer integration in +`v0.5.0`, against a fixture of at least a gigabyte; see [consumer integration](../roadmap/consumer-integration.md). ## Reproducing it @@ -161,26 +213,21 @@ report states the size it used, so no run of it can be mistaken for another. The byte counts are the same on every platform and in every configuration; the times are not, and a run reproduced on other hardware is expected to agree on -the first and disagree on the second. That is not an aspiration: three -consecutive runs on the machine above produced byte-identical counter tables and -a different latency table each time, which is the property that makes the -counters a gate and the durations a note. The sanitizer lanes run the same scenarios +the first and disagree on the second. The sanitizer lanes run the same scenarios against an 8 MiB fixture — 128 MiB of instrumented `memcpy` is a lane that times out rather than a lane that measures — and they are there for the counter assertions under a data race, not for the numbers. ## What the next release has to move -`v0.3.0` is the first release that will change these numbers on purpose, and the -direction each one moves is the release's own definition of success: +`v0.4.0` adds persistence, and it changes what a hit is worth rather than what a +block is. The rows it is measured against are these: -| Row | Now | `v0.3.0` | +| Row | Now | `v0.4.0` | | --- | --- | --- | -| Header and index read, requests | 18 | Fewer: sixteen adjacent 4 KiB reads inside one 64 KiB region are one or two block fetches | -| Bounded query, `amplification` | 1.000000 | Above 1.0, by the block size — and `bytesOverFetched` becomes non-zero, which is the honest counter for it | -| Parallel readers, requests | 152 | Fewer, by `requestsSavedBySingleFlight`, which is 0 here because nothing is shared between readers yet | -| Full sequential read | 33 requests, `amplification` 1.000000 | Must not regress. A cache that turns the worst case into a worse case has the wrong policy | - -A release that improves the first three and quietly damages the fourth has not -improved anything, which is why all five scenarios are recorded together and why -the fourth is in the list at all. +| Every scenario, on a second open of the same asset | The whole cost again: a new process starts cold | Cheaper, for a `Stable` identity only. A `Weak` or `Unavailable` one must still start cold, and a row showing otherwise is the stale-data generator CACHE.md §8 refuses | +| `metadata-only open` | 1 request | Unchanged. A persistent cache that skipped the `HEAD` would be reusing an identity it had not revalidated | +| Full sequential read | 33 requests, `amplification` 1.000000 | Must not regress, again. The rule does not get weaker because the cache got bigger | + +The scenario this file still cannot record is the one that would price the trade +it made. `v0.5.0` is where that arrives. diff --git a/docs/reference/BLOCK_POLICY.md b/docs/reference/BLOCK_POLICY.md new file mode 100644 index 0000000..f018e2c --- /dev/null +++ b/docs/reference/BLOCK_POLICY.md @@ -0,0 +1,200 @@ +# Recorded block policy + +This file holds the measurement that chose the cache's constants, and the +reasoning from it. §5 of the [design policy](../design/DESIGN_POLICY.md) says +cache behavior is measured before it is tuned, and §4 of +[CACHE.md](../architecture/CACHE.md) says the coalescing numbers are recorded +with the measurement that produced them, because *a tuned constant without a +recorded measurement is a guess with a decimal point*. This is that record. + +Last recorded: 2026-08-20, against `main`, for `v0.3.0`. + +It sits beside [BASELINE.md](BASELINE.md) and answers a different question. The +baseline records what the shipped configuration costs; this records why the +shipped configuration is that one and not another. A change to either constant +rewrites this file, and a release that changes I/O behavior rewrites both. + +## What produced the numbers + +`tests/cache-tuning` is the harness. It stands up the loopback fixture server, +serves one synthetic asset of 134217728 bytes with a strong `ETag`, and runs +four access patterns through `usdAssetCache` over `usdAssetHttp` at five block +sizes and four coalescing gaps — sixty-six runs, each against a fresh store, so +that no row is warmed by the row above it. + +```sh +ctest --test-dir build/core -R usdAssetCache_block_policy +./build/core/tests/cache-tuning/usdAssetCache_tuning +``` + +Every byte of the fixture is a hash of its own offset and every read is +verified, so a configuration that returns the wrong bytes fails the run rather +than contributing a fast row. That verification is the part of the harness that +is a test: the cache runs over a real socket at five block sizes, and a block +boundary that is wrong at one of them fails the lane. + +Every request count is asserted twice, once against the backend's counter and +once against the number of requests the fixture server logged answering — the +same independent witness [BASELINE.md](BASELINE.md) keeps, and for the same +reason: a request issued outside the metrics sink costs a round trip and counts +nothing, and a sweep watching only the sink would choose a block size from +numbers that were wrong in the same direction everywhere. + +## What is chosen from what + +**The defaults are chosen from the request counts and the byte counts, and from +nothing else.** Those are exact and identical on every machine. The wall-clock +column is a fact about loopback on the runner that drew the job, and choosing a +block size from it would be choosing it on a link with no round-trip time — +which is precisely the cost the block cache exists to trade bytes against. + +That leaves one premise the harness cannot measure and this file therefore +states outright: + +> On any link this project targets, one round trip costs more than one block of +> bytes. + +That is §2 of CACHE.md as an assumption rather than a result. It is why a +configuration with fewer requests and more bytes is preferred to one with more +requests and fewer bytes, and it is the sentence that gets its own measurement +in `v0.5.0`, when the first consumer integration puts real distance between the +reader and the origin. Until then the honest statement is that the *direction* +of the trade is assumed and its *magnitude* is measured. + +## The chosen defaults + +| Constant | Value | Chosen from | +| --- | ---: | --- | +| `blockSize` | 65536 | Measured. Six times fewer requests on the clustered pattern, at a bounded cost of 64 KiB per miss | +| `coalesceGapBlocks` | 1 | Measured. The whole of the observed benefit at small block sizes, and nothing beyond it | +| `maxRequestBytes` | 8388608 | Not measured — a safety bound. No merged run in the sweep comes near it | +| `budgetBytes` | 134217728 | Not measured — a residency policy. See below | +| `bypassThresholdBytes` | 1048576 | Measured, as a non-regression: it is what keeps the full sequential read at the uncached request count | + +The last two are labelled as what they are. A number in a table under a heading +about measurement, which was not measured, is the failure this file exists to +prevent. + +## The sweep + +Fixture: 134217728 bytes at `/tuning/asset.bin` on the loopback fixture server, +ephemeral port, `Behavior::Normal`, strong `ETag`. Layout: a 4096-byte header at +offset 0, a 65536-byte index in the tail, body between them — the same layout +the recorded baseline uses, so a row here can be read against a row there. + +Windows AMD64, MSVC 19.34.31937.0, Release. `requests` includes the one metadata +request every open costs. + +### Header and index read + +One 4 KiB header read, then sixteen adjacent 4 KiB index reads. This is the +clustered small-read pattern §2 of CACHE.md is written about. + +| block | gap | requests | bytes moved | amplification | over-fetch | +| ---: | ---: | ---: | ---: | ---: | ---: | +| none | — | 18 | 69632 | 1.000000 | 0 | +| 4 KiB | 0–4 | 18 | 69632 | 1.000000 | 0 | +| 16 KiB | 0–4 | 6 | 81920 | 1.176471 | 61440 | +| **64 KiB** | **0–4** | **3** | **131072** | **1.882353** | **122880** | +| 256 KiB | 0–4 | 3 | 524288 | 7.529412 | 516096 | +| 1 MiB | 0–4 | 3 | 2097152 | 30.117647 | 2088960 | + +### Bounded spatial query + +A header, a tail index, and sixteen scattered 16 KiB chunks. The pattern +`selectivity` is claimed on, and the one that punishes a large block. + +| block | gap | requests | bytes moved | amplification | over-fetch | +| ---: | ---: | ---: | ---: | ---: | ---: | +| none | — | 19 | 331776 | 1.000000 | 0 | +| 4 KiB | 0–4 | 19 | 393216 | 1.185185 | 61440 | +| 16 KiB | 0–4 | 19 | 589824 | 1.777778 | 270336 | +| **64 KiB** | **0–4** | **18** | **1507328** | **4.543210** | **1191936** | +| 256 KiB | 0–4 | 18 | 5242880 | 15.802469 | 4927488 | +| 1 MiB | 0–4 | 18 | 20971520 | 63.209877 | 20656128 | + +### Interleaved index re-read + +Every other 4 KiB piece of the index, and then the whole index region in one +read. The only pattern in the sweep in which the coalescing gap can bind at all, +and it is here for that reason alone; see below. + +| block | gap | requests | bytes moved | over-fetch | +| ---: | ---: | ---: | ---: | ---: | +| none | — | 10 | 98304 | 0 | +| 4 KiB | 0 | 17 | 65536 | 0 | +| **4 KiB** | **1–4** | **10** | **94208** | **0** | +| 16 KiB | 0–4 | 5 | 65536 | 49152 | +| 64 KiB | 0–4 | 2 | 65536 | 61440 | +| 256 KiB | 0–4 | 2 | 262144 | 258048 | +| 1 MiB | 0–4 | 2 | 1048576 | 1044480 | + +### Full sequential read + +Thirty-two reads of 4 MiB over the whole asset. The worst case, and the row that +has to not move. + +| block | gap | requests | bytes moved | amplification | over-fetch | +| ---: | ---: | ---: | ---: | ---: | ---: | +| none | — | 33 | 134217728 | 1.000000 | 0 | +| 4 KiB … 1 MiB | 1 | 33 | 134217728 | 1.000000 | 0 | + +Identical at every block size, because every read in it is larger than +`bypassThresholdBytes` and never reaches the store. That is the policy working, +and it is checked here rather than argued: a cache that turned the worst case +into a worse case would show up in this table as a request count or a byte count +that moved. + +## What the numbers say + +**Block size buys request count and is paid for in bytes, and the exchange rate +gets worse fast.** From 4 KiB to 64 KiB, the clustered pattern goes from 18 +requests to 3 — six times fewer — for 61440 extra bytes. From 64 KiB to 1 MiB it +buys nothing at all on that pattern, 3 requests either way, and costs a further +1966080 bytes. The knee is at 64 KiB and it is not close. + +**The scattered pattern is where a large block is punished, and it agrees.** The +bounded query's request count barely moves across the whole sweep — 19 down to +18 — because sixteen chunks 8 MiB apart are sixteen requests whatever the block +size. All a larger block does there is multiply the bytes: at 64 KiB the query +moves 1507328 bytes, and at 1 MiB it moves 20971520 to answer the same question. +Both tables point at the same value from opposite directions, which is the +strongest thing this sweep produces. + +**`selectivity` gets worse, on purpose, and stays the headline.** The bounded +query moved 0.0025 of the asset in `v0.2.0` and moves 0.0112 at the chosen block +size. That is the trade §1 of CACHE.md describes — alignment converts request +count into transferred bytes — and it is still one percent of a 128 MiB asset to +answer a query against it. + +**The coalescing gap is real, and it does not bind at the chosen block size.** +This is the finding worth stating plainly rather than tidying away. On the +interleaved re-read at 4 KiB blocks, a gap of one block takes 17 requests down to +10 for 28672 extra bytes, which is exactly the trade §4 of CACHE.md predicts. +At 16 KiB and above the whole index region fits in one or two blocks, there is +no gap left to merge across, and gaps of 0, 1, 2 and 4 produce identical rows +everywhere in the sweep. So `coalesceGapBlocks` is 1 because that is the value +that captures the entire measured benefit where the benefit exists, and because +2 and 4 were measured and bought nothing anywhere. At the shipped block size the +constant currently does no work, and a reader of this table should know that +rather than infer that it does. + +**Two of the five constants were not measured and are labelled.** +`maxRequestBytes` is a bound on the pathological case: no run in this sweep +produces a merged request within two orders of magnitude of it, so there was +nothing to measure, and its job is to stop one enormous request defeating +cancellation rather than to make a pattern faster. `budgetBytes` is a residency +policy rather than an I/O constant — 128 MiB is a ceiling a DCC process can +afford and roughly two thousand blocks at the chosen size — and measuring it +would take a working set that outlives one query, which is `v0.5.0`'s consumer +fixture and not this harness. + +## What the next release has to move + +`v0.4.0` adds persistence, which changes what a hit is worth and nothing about +what a block is. The constants here are expected to survive it. + +`v0.5.0` is what tests the premise. The first consumer integration puts real +distance between the reader and the origin, and it is the first run in which a +round trip costs what this file assumes it costs. If 64 KiB is the wrong number, +that is where it will show, and this file is what the new measurement replaces. diff --git a/libs/usd-asset-cache/CMakeLists.txt b/libs/usd-asset-cache/CMakeLists.txt new file mode 100644 index 0000000..4c08d81 --- /dev/null +++ b/libs/usd-asset-cache/CMakeLists.txt @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# usdAssetCache -- the block cache, as a decorator over AssetReader. +# +# It links usdasset::io and nothing else. Not a backend, not a transport, not +# OpenUSD: the cache holds a reader it did not construct, and if it knew what +# HTTP was the local backend would stop being a usable oracle for the cached +# path (WORKSPACE.md section 2, invariant 5). + +cmake_minimum_required(VERSION 3.23) + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/../../VERSION") + file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/../../VERSION" _usd_asset_cache_version + LIMIT_COUNT 1) + string(STRIP "${_usd_asset_cache_version}" _usd_asset_cache_version) +else() + set(_usd_asset_cache_version "0.3.0") +endif() + +project(usdAssetCache + VERSION ${_usd_asset_cache_version} + DESCRIPTION "Aligned block cache, coalescing, and single-flight over AssetReader" + LANGUAGES CXX) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT DEFINED USDASSETCACHE_BUILD_TESTS) + if(DEFINED USD_HTTP_RESOLVER_BUILD_TESTS) + set(_usd_asset_cache_tests_default "${USD_HTTP_RESOLVER_BUILD_TESTS}") + else() + set(_usd_asset_cache_tests_default "${PROJECT_IS_TOP_LEVEL}") + endif() + option(USDASSETCACHE_BUILD_TESTS "Build usdAssetCache tests" + ${_usd_asset_cache_tests_default}) +endif() + +# Standalone builds (`ost library build libs/usd-asset-cache`) resolve the +# dependency; in-tree builds already have the target from the root's ordered +# library list. +if(NOT TARGET usdasset::io) + find_package(usdAssetIo CONFIG REQUIRED) +endif() + +add_library(usdAssetCache STATIC + src/BlockCache.cpp + src/BlockPlan.cpp + src/CacheKey.cpp + src/CacheOptions.cpp + src/CachedAssetReader.cpp) +add_library(usdasset::cache ALIAS usdAssetCache) +set_target_properties(usdAssetCache PROPERTIES + EXPORT_NAME cache + POSITION_INDEPENDENT_CODE ON) + +target_compile_features(usdAssetCache PUBLIC cxx_std_17) +target_include_directories(usdAssetCache + PUBLIC + "$" + "$" + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src") +target_link_libraries(usdAssetCache PUBLIC usdasset::io) + +find_package(Threads REQUIRED) +target_link_libraries(usdAssetCache PUBLIC Threads::Threads) + +if(MSVC) + target_compile_options(usdAssetCache PRIVATE /utf-8 /W4) +else() + target_compile_options(usdAssetCache PRIVATE -Wall -Wextra -Wpedantic) +endif() + +include(GNUInstallDirs) +install(TARGETS usdAssetCache + EXPORT usdAssetCacheTargets + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + INCLUDES DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") +install(DIRECTORY include/ DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") + +include(CMakePackageConfigHelpers) +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/usdAssetCacheConfig.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/usdAssetCacheConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/usdAssetCache") +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/usdAssetCacheConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) +install(EXPORT usdAssetCacheTargets + FILE usdAssetCacheTargets.cmake + NAMESPACE usdasset:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/usdAssetCache") +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/usdAssetCacheConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/usdAssetCacheConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/usdAssetCache") + +if(USDASSETCACHE_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/libs/usd-asset-cache/cmake/usdAssetCacheConfig.cmake.in b/libs/usd-asset-cache/cmake/usdAssetCacheConfig.cmake.in new file mode 100644 index 0000000..ebe0b85 --- /dev/null +++ b/libs/usd-asset-cache/cmake/usdAssetCacheConfig.cmake.in @@ -0,0 +1,8 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(usdAssetIo) + +include("${CMAKE_CURRENT_LIST_DIR}/usdAssetCacheTargets.cmake") + +check_required_components(usdAssetCache) diff --git a/libs/usd-asset-cache/include/usdAssetCache/BlockCache.h b/libs/usd-asset-cache/include/usdAssetCache/BlockCache.h new file mode 100644 index 0000000..1fdcbfb --- /dev/null +++ b/libs/usd-asset-cache/include/usdAssetCache/BlockCache.h @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The block store: residency, eviction under a budget, and single-flight. +// +// This is the half of the cache that holds bytes. The half that decides which +// bytes to ask for is CachedAssetReader; the two are separated because the +// arithmetic is worth testing without a reader and the residency is worth +// testing without a transport. +// +// The store is shared between readers on purpose. Eight Hydra threads opening +// one asset are eight readers over one revision, and a store that were private +// to each of them would issue every request eight times, move eight times the +// bytes, and report a metrics table that is off by a factor of eight +// (CACHE.md §5). Sharing is admitted by identity, and identity includes the +// validator, so it is never a URL match alone (CACHE.md §6, CacheKey.h). +// +// Locking is per block, striped: there is no lock in this file that a read of +// an unrelated block can be made to wait on, because a global lock over a +// network cache serializes the entire stage and §7 of the design policy +// forbids it. +// +// Normative contract: docs/architecture/CACHE.md §5, §6, §7. + +#ifndef USDASSETCACHE_BLOCKCACHE_H +#define USDASSETCACHE_BLOCKCACHE_H + +#include +#include +#include +#include +#include + +#include "usdAssetIo/Validator.h" +#include "usdAssetCache/CacheKey.h" +#include "usdAssetCache/CacheOptions.h" + +namespace usdasset { +namespace cache { + +/// One cached block. Immutable once published, which is what lets a reader copy +/// out of it after it has left the map: eviction drops the store's reference, +/// and the bytes live exactly as long as the last reader looking at them. +using BlockPtr = std::shared_ptr>; + +class BlockCache { +public: + class Binding; + + struct Stats { + std::uint64_t residentBytes = 0; + std::uint64_t blockCount = 0; + std::uint64_t pendingCount = 0; + std::uint64_t evictions = 0; + std::uint64_t peakResidentBytes = 0; + std::uint64_t identityCount = 0; + std::uint32_t shardCount = 0; + std::uint64_t shardBudgetBytes = 0; + }; + + explicit BlockCache(const CacheOptions& options); + ~BlockCache(); + + BlockCache(const BlockCache&) = delete; + BlockCache& operator=(const BlockCache&) = delete; + + /// The process-wide store. The budget is process-wide and shared across + /// assets, per CACHE.md §7, so there is one of these and readers bind into + /// it rather than each carrying their own. + static BlockCache& Process(); + + /// Rebuilds the process store with `options`. + /// + /// For a host that resolves the budget from its environment before opening + /// anything (CONFIGURATION.md §2). Discards everything resident, so it is + /// called once, at resolver construction, and never with readers open -- + /// a binding that outlived its store would be reading freed memory, so the + /// call is refused while any binding is alive and reports that it was. + static bool ConfigureProcess(const CacheOptions& options); + + const CacheOptions& Options() const noexcept; + Stats Snapshot() const; + + /// Bytes resident right now, as one relaxed load. + /// + /// `Snapshot` locks every stripe, which is fine for a test and wrong for + /// the read path: a counter that locks the whole store on every read is + /// exactly the instrumentation METRICS.md section 4 forbids. This is the + /// number a reader records its high-water mark from, and it is approximate + /// under concurrent publishes -- which a high-water mark can afford to be. + std::uint64_t ResidentBytes() const noexcept; + + /// Drops every block and every interned identity. Tests only: there is no + /// production reason to throw away a cache that is correct by construction. + void ClearForTesting(); + + /// Binds a reader to an identity. + /// + /// `validator` is read for exactly two things and never parsed: its value + /// becomes part of the key, and its strength decides whether the entries + /// stored under it may be shared with another reader or are private to this + /// one (CacheKey.h, `IsShareable`). + std::shared_ptr Bind(const std::string& resolvedIdentifier, + const Validator& validator, + std::uint64_t blockSize); + +private: + class Impl; + std::unique_ptr _impl; +}; + +/// One reader's handle on the store. +/// +/// Holds the interned identity, so a lookup costs two integers rather than two +/// string comparisons, and owns the private entries of a reader whose validator +/// is not strong enough to share: those are dropped when this handle dies. +class BlockCache::Binding { +public: + ~Binding(); + + Binding(const Binding&) = delete; + Binding& operator=(const Binding&) = delete; + + enum class Acquisition { + /// The block was resident. `block` holds it. + Hit, + /// It was absent, and this caller is now the one obliged to fetch it. + /// Exactly one caller is ever told this for a given block, which is + /// what single-flight *is*. + Owned, + /// It was absent and another caller is already fetching it. `Await`. + Busy, + }; + + struct AcquireResult { + Acquisition outcome = Acquisition::Owned; + BlockPtr block; + }; + + AcquireResult Acquire(std::uint64_t blockIndex); + + /// Waits for the owner of a `Busy` block to publish it. + /// + /// Returns null when the owner failed or abandoned the block rather than + /// publishing it. A null is not an error to report: the fetch that failed + /// was somebody else's, against somebody else's transport, and reporting it + /// here would fail one reader for another's network. The caller acquires + /// again and does the work itself. + BlockPtr Await(std::uint64_t blockIndex); + + /// Publishes bytes for a block this binding owns, and evicts under the + /// budget to make room for it. Returns the number of blocks evicted. + /// + /// A publish for a block this binding no longer owns -- because the store + /// was cleared underneath it -- stores nothing and returns zero. It is not + /// an error: the bytes are still handed to the caller that fetched them. + std::uint64_t Publish(std::uint64_t blockIndex, + const unsigned char* bytes, + std::size_t length); + + /// Gives up ownership without publishing, and wakes everyone waiting. Every + /// `Owned` acquisition ends in exactly one `Publish` or one `Abandon`; a + /// fetch that failed and did neither would leave every later reader of that + /// block waiting on a fetch that is not happening. + void Abandon(std::uint64_t blockIndex); + + const AssetIdentity& Identity() const noexcept; + + /// True when this binding's entries are private to it and are dropped when + /// it closes -- which is what a weak or absent validator buys. + bool IsPrivate() const noexcept; + +private: + friend class BlockCache; + class Impl; + explicit Binding(std::unique_ptr impl); + std::unique_ptr _impl; +}; + +} // namespace cache +} // namespace usdasset + +#endif // USDASSETCACHE_BLOCKCACHE_H diff --git a/libs/usd-asset-cache/include/usdAssetCache/CacheKey.h b/libs/usd-asset-cache/include/usdAssetCache/CacheKey.h new file mode 100644 index 0000000..6f1cc7e --- /dev/null +++ b/libs/usd-asset-cache/include/usdAssetCache/CacheKey.h @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Cache identity. +// +// CacheKey = resolvedIdentifier + validator + blockSize + blockIndex +// +// The rule the key exists to enforce is one line of CACHE.md §6, and it is the +// whole point of the module: +// +// equal identifiers never imply equal content +// +// A URL match alone is never a hit. Two revisions published at one URL are two +// cache identities, and an entry from revision A must never serve a read of +// revision B. +// +// The validator is an opaque byte string here and nothing more. This layer +// never parses it, never compares it to an ETag, and never infers recency from +// it; it reads exactly one other field of the validator, `strength`, and +// exactly once -- to decide whether an entry may be shared with a reader that +// is not the one that stored it. Every other validator question belongs to the +// backend (ASSET_READER.md §7.1). +// +// Normative contract: docs/architecture/CACHE.md §6. + +#ifndef USDASSETCACHE_CACHEKEY_H +#define USDASSETCACHE_CACHEKEY_H + +#include +#include +#include + +#include "usdAssetIo/Validator.h" + +namespace usdasset { +namespace cache { + +/// The part of the key that is constant for one reader: everything except the +/// block index. +/// +/// Split out because it is what the store interns. A key carrying two strings +/// per cached block would spend more memory on identity than on bytes for a +/// small block size, and would hash two strings on every lookup; interning +/// turns the per-block key into two integers and leaves this struct as the +/// thing the contract is stated over. +struct AssetIdentity { + /// The normalized absolute URI after redirects, per RESOLVER.md. + std::string resolvedIdentifier; + + /// `Validator::value`, treated here as bytes. Empty is legal and means the + /// backend captured nothing usable; see `IsShareable`. + std::string validator; + + /// Part of the identity, not a parameter beside it: blocks stored under one + /// block size cannot answer a lookup made under another. + std::uint64_t blockSize = 0; +}; + +bool operator==(const AssetIdentity& lhs, const AssetIdentity& rhs) noexcept; +bool operator!=(const AssetIdentity& lhs, const AssetIdentity& rhs) noexcept; + +std::size_t HashAssetIdentity(const AssetIdentity& identity) noexcept; + +/// The key itself, as CACHE.md §6 states it. +struct CacheKey { + AssetIdentity identity; + std::uint64_t blockIndex = 0; +}; + +bool operator==(const CacheKey& lhs, const CacheKey& rhs) noexcept; +bool operator!=(const CacheKey& lhs, const CacheKey& rhs) noexcept; + +std::size_t HashCacheKey(const CacheKey& key) noexcept; + +/// Whether an entry stored under this validator may be served to a reader that +/// is not the one that stored it. +/// +/// Only a strong validator admits it. The argument is the one CACHE.md §8 makes +/// about persistence, applied one level earlier: a weak validator cannot prove +/// two responses are byte-identical -- that is what weak means -- so two readers +/// that opened the same URL and got the same weak token may be holding two +/// different files, and serving one's blocks to the other composes exactly the +/// byte sequence §2.1 of ASSET_READER.md exists to prevent. +/// +/// Within one reader the binding carries the guarantee whatever the strength +/// is, which is why a weak or absent validator still caches -- privately, for +/// that reader's lifetime, and dropped when it closes. +bool IsShareable(const Validator& validator) noexcept; + +} // namespace cache +} // namespace usdasset + +#endif // USDASSETCACHE_CACHEKEY_H diff --git a/libs/usd-asset-cache/include/usdAssetCache/CacheOptions.h b/libs/usd-asset-cache/include/usdAssetCache/CacheOptions.h new file mode 100644 index 0000000..b930ba4 --- /dev/null +++ b/libs/usd-asset-cache/include/usdAssetCache/CacheOptions.h @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The cache's policy constants, in one struct. +// +// Every value here is a measured constant rather than a guessed one, per §5 of +// the design policy: "a tuned constant without a recorded measurement is a +// guess with a decimal point". The measurement that produced the defaults is +// tests/cache-tuning, and it is recorded in docs/reference/BLOCK_POLICY.md. +// +// They are parameters rather than compile-time constants for the reason the +// transport bounds are: a test has to be able to make a budget overflow in +// kilobytes instead of provisioning a gigabyte, and the environment surface in +// CONFIGURATION.md §2 has to have something to set. +// +// Normative contract: docs/architecture/CACHE.md. + +#ifndef USDASSETCACHE_CACHEOPTIONS_H +#define USDASSETCACHE_CACHEOPTIONS_H + +#include + +namespace usdasset { +namespace cache { + +/// The smallest and largest block this cache will use, whatever it is asked +/// for. Below the floor the per-block bookkeeping costs more than the block; +/// above the ceiling one miss transfers more than most assets are worth. +inline constexpr std::uint64_t kMinBlockSize = 4096; +inline constexpr std::uint64_t kMaxBlockSize = 64ull * 1024 * 1024; + +/// Defaults. See BLOCK_POLICY.md for the runs these came from. +inline constexpr std::uint64_t kDefaultBlockSize = 64ull * 1024; +inline constexpr std::uint64_t kDefaultBudgetBytes = 128ull * 1024 * 1024; +inline constexpr std::uint32_t kDefaultCoalesceGapBlocks = 1; +inline constexpr std::uint64_t kDefaultMaxRequestBytes = 8ull * 1024 * 1024; +inline constexpr std::uint64_t kDefaultBypassThresholdBytes = 1ull * 1024 * 1024; + +struct CacheOptions { + /// A power of two. Fixed per reader for its lifetime, because it is part + /// of the cache key: a reader that changed it mid-flight would be looking + /// up blocks that were stored under a different arithmetic. + std::uint64_t blockSize = kDefaultBlockSize; + + /// The resident ceiling, process-wide and shared across assets, so that one + /// enormous asset cannot starve the rest of the stage (CACHE.md §7). + std::uint64_t budgetBytes = kDefaultBudgetBytes; + + /// The largest run of blocks this reader will fetch *through* in order to + /// merge the fetches on either side of it. Zero merges nothing. + /// + /// The trade is stated in CACHE.md §4: transferring the gap costs less than + /// a second round trip, up to some width, and past that width it is just + /// bytes nobody asked for. + std::uint32_t coalesceGapBlocks = kDefaultCoalesceGapBlocks; + + /// The ceiling on one merged request. A merge is never taken past this, + /// because one enormous request defeats cancellation and stalls every other + /// read on the connection. + std::uint64_t maxRequestBytes = kDefaultMaxRequestBytes; + + /// A read at least this large bypasses the cache entirely: it is served + /// straight from the reader underneath, and nothing it moved is stored. + /// + /// Not an optimization -- a correctness-of-policy rule. A streaming pass + /// over a large asset would otherwise evict the whole working set to store + /// bytes that will never be read twice, and the read that follows it would + /// pay for the privilege (CACHE.md §3). + std::uint64_t bypassThresholdBytes = kDefaultBypassThresholdBytes; + + /// The same options with every field made legal: `blockSize` rounded down + /// to a power of two inside [kMinBlockSize, kMaxBlockSize], and the rest + /// clamped so that a merged request can always hold at least one block and + /// the budget can always hold at least one. + /// + /// Clamps rather than fails. A caller reaches this with values from an + /// environment variable, and CONFIGURATION.md §2 already fixes what a bad + /// value costs -- a diagnostic at the point it is read, and the default in + /// force -- so a second failure mode here would only make the first + /// unreachable. + CacheOptions Normalized() const noexcept; + + /// True when `blockSize` is a power of two within the bounds and every + /// other field is consistent with it. `Normalized()` is idempotent, which + /// is what this predicate is for in a test. + bool IsNormalized() const noexcept; +}; + +} // namespace cache +} // namespace usdasset + +#endif // USDASSETCACHE_CACHEOPTIONS_H diff --git a/libs/usd-asset-cache/include/usdAssetCache/CachedAssetReader.h b/libs/usd-asset-cache/include/usdAssetCache/CachedAssetReader.h new file mode 100644 index 0000000..e5334a7 --- /dev/null +++ b/libs/usd-asset-cache/include/usdAssetCache/CachedAssetReader.h @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The block cache, as a decorator over `AssetReader`. +// +// It holds a reader it did not construct and knows no transport concept: no +// URL parsing, no header, no status code, no client library. That is not +// tidiness, it is what keeps the local backend a usable oracle for the cached +// path -- the boundary suite runs against `local` and against `cache over +// local` and compares the two, and it could not if this file knew what HTTP +// was (WORKSPACE.md section 2, invariant 5). +// +// What it does: +// +// read (offset, size) +// -> resolve against the asset size, exactly once, in usdAssetIo +// -> expand to whole blocks +// -> serve what is resident, fetch what is not, wait for what someone else +// is already fetching +// -> merge the fetches, bounded by a gap and by a length +// +// What it deliberately does not do: revalidate. A reader is bound to one +// revision for its lifetime (ASSET_READER.md section 2.1), and the blocks this +// cache holds for it were captured under that binding, so serving them is +// serving the bound revision. `AssetChanged` is reported by the reader +// underneath, on the reads that reach it; a hit reaches nothing and observes +// nothing, and the contract's wording is exactly that -- "a reader that +// observes a changed validator fails subsequent reads". +// +// Normative contract: docs/architecture/CACHE.md. + +#ifndef USDASSETCACHE_CACHEDASSETREADER_H +#define USDASSETCACHE_CACHEDASSETREADER_H + +#include +#include +#include + +#include "usdAssetIo/AssetReader.h" +#include "usdAssetIo/Metrics.h" +#include "usdAssetCache/BlockCache.h" +#include "usdAssetCache/CacheOptions.h" + +namespace usdasset { +namespace cache { + +/// A reader that answers from blocks, over a reader that answers from a +/// transport. +/// +/// Thread-safe, and with no lock of its own: everything mutable is either a +/// relaxed counter or lives in the store, whose locks are per block. +class CachedAssetReader final : public AssetReader { +public: + ~CachedAssetReader() override; + + /// The decorated reader's metadata, verbatim. A cache that restated an + /// asset's size or validator would be a second place for them to be wrong. + const AssetMetadata& Metadata() const override; + + /// Reads up to `size` bytes at `offset`, with the semantics every backend + /// implements -- the same EOF boundary, the same overflow rule, the same + /// refusal to return a hole. The shared boundary suite runs against this + /// reader unchanged, which is the only statement of equivalence worth + /// making. + /// + /// A read of at least `CacheOptions::bypassThresholdBytes` goes straight to + /// the reader underneath and stores nothing. + ReadResult Read(std::uint64_t offset, void* dst, std::size_t size) override; + + /// The resolved options this reader is using -- normalized, so they are + /// what is in force rather than what was asked for. + const CacheOptions& Options() const noexcept; + + /// The whole stack's counters: this decorator's cache counters and caller + /// side accounting, composed with the transport counters of the reader + /// underneath. + /// + /// This, and not `Metrics()`, is what a test or a baseline harness asserts + /// on: `Metrics()` is half of the stack, and `amplification` computed from + /// half of a stack is a ratio between two layers. + MetricsSnapshot SnapshotMetrics() const; + + /// This decorator's own counter set. It is the one that folds into the + /// process aggregate, and it absorbs the inner reader's transport counters + /// when this reader closes. + const ReaderMetrics& Metrics() const noexcept; + + /// The store binding, for a test that wants to see identity sharing rather + /// than infer it from a request count. + const BlockCache::Binding& Binding() const noexcept; + +private: + class Impl; + explicit CachedAssetReader(std::unique_ptr impl); + + friend struct CachedReaderFactory; + + std::unique_ptr _impl; +}; + +/// The result of decorating a reader, typed to the concrete reader. +struct CachedOpenResult { + std::unique_ptr reader; ///< Null exactly when `status` fails. + Status status; +}; + +/// Wraps `inner` in a block cache. +/// +/// `innerMetrics` is the counter set of the reader being wrapped, and passing +/// it is what makes the stack report one set of numbers instead of two. The +/// caller supplies it because only the caller knows the concrete backend: this +/// module may not name one, and `AssetReader` deliberately carries no metrics +/// accessor. Null is legal and means the stack reports the cache's counters +/// only, with the transport's folding separately. +/// +/// `store` is the block store to bind into. Null takes the process store, which +/// is the normal case: the budget is process-wide and shared across assets +/// (CACHE.md section 7), so a store per reader would not be one budget. +/// +/// Fails with `InvalidArgument` when `inner` is null, and passes through the +/// reader unchanged -- undecorated -- when its metadata says it cannot serve +/// random access. Caching a reader that cannot seek would store the one block +/// it managed to read and miss forever after. +CachedOpenResult Wrap(std::unique_ptr inner, + ReaderMetrics* innerMetrics, + const CacheOptions& options, + BlockCache* store); + +/// The same wrap, in the shape every backend's open returns, so that a caller +/// that has an `OpenResult` can decorate it in one line and hand the result +/// wherever an `AssetReader` goes. +/// +/// A failed open passes through untouched: there is no reader to decorate, and +/// replacing the backend's status with one of this module's would erase the +/// only useful thing the result carries. +OpenResult WrapAsset(OpenResult inner, + ReaderMetrics* innerMetrics, + const CacheOptions& options, + BlockCache* store); + +} // namespace cache +} // namespace usdasset + +#endif // USDASSETCACHE_CACHEDASSETREADER_H diff --git a/libs/usd-asset-cache/openstrata.library.yaml b/libs/usd-asset-cache/openstrata.library.yaml new file mode 100644 index 0000000..f09fc16 --- /dev/null +++ b/libs/usd-asset-cache/openstrata.library.yaml @@ -0,0 +1,20 @@ +# OpenStrata plain-library descriptor. +# +# usdAssetCache is a plain static CMake library over usdAssetIo, with no +# OpenUSD dependency and no transport dependency of any kind +# (docs/architecture/WORKSPACE.md section 1). +schema: openstrata.library/v1alpha1 +library: + id: usdAssetCache + version: 0.3.0 +requires: + libraries: + # The only edge the cache is allowed. Not a backend -- the cache is a + # decorator and holds a reader it did not construct; if it knew about HTTP + # it would grow HTTP policy, and the local backend would stop being a usable + # oracle for the cached path. + - id: usdAssetIo + version: ">=0.1,<0.2" +cmake: + package: usdAssetCache + target: usdasset::cache diff --git a/libs/usd-asset-cache/src/BlockCache.cpp b/libs/usd-asset-cache/src/BlockCache.cpp new file mode 100644 index 0000000..6a1c51d --- /dev/null +++ b/libs/usd-asset-cache/src/BlockCache.cpp @@ -0,0 +1,417 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "usdAssetCache/BlockCache.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace usdasset { +namespace cache { + +namespace { + +/// The interned form of a key: two integers, because that is what a lookup on +/// the read path can afford. The string form is `CacheKey`, and the mapping +/// between them is the identity table below. +struct ShardKey { + std::uint64_t identityId = 0; + std::uint64_t blockIndex = 0; +}; + +bool operator==(const ShardKey& lhs, const ShardKey& rhs) noexcept { + return lhs.identityId == rhs.identityId && lhs.blockIndex == rhs.blockIndex; +} + +std::uint64_t Mix(std::uint64_t value) noexcept { + // SplitMix64's finalizer. Used to choose a stripe, so that the blocks of + // one asset land in different stripes and two threads reading two blocks of + // one asset do not queue behind each other on one mutex. + value += 0x9E3779B97F4A7C15ull; + value = (value ^ (value >> 30)) * 0xBF58476D1CE4E5B9ull; + value = (value ^ (value >> 27)) * 0x94D049BB133111EBull; + return value ^ (value >> 31); +} + +struct ShardKeyHash { + std::size_t operator()(const ShardKey& key) const noexcept { + return static_cast(Mix(key.identityId ^ Mix(key.blockIndex))); + } +}; + +struct IdentityHash { + std::size_t operator()(const AssetIdentity& identity) const noexcept { + return HashAssetIdentity(identity); + } +}; + +struct Entry { + BlockPtr block; ///< Null exactly while `pending`. + bool pending = false; + std::uint64_t bytes = 0; + std::list::iterator lruIt{}; + bool inLru = false; ///< A pending entry is not evictable. +}; + +/// One stripe of the store. Everything that needs a lock is in here, and there +/// is no lock outside it on the read path -- which is the whole of what "no +/// global lock" means in section 7 of the design policy. +struct Shard { + mutable std::mutex mutex; + std::condition_variable published; + std::unordered_map entries; + std::list lru; ///< Front is most recently used. + std::uint64_t residentBytes = 0; + std::uint64_t evictions = 0; + std::uint64_t peakResidentBytes = 0; +}; + +std::uint32_t ChooseShardCount(std::uint64_t budgetBytes, std::uint64_t blockSize) noexcept { + // Enough stripes that unrelated reads rarely collide, but never so many + // that a stripe's share of the budget cannot hold a working set. Below + // eight blocks per stripe the eviction order stops resembling LRU and + // starts resembling a coin toss, so the count falls back toward one -- and + // at one stripe the eviction order is exact, which is what a test that + // fills a small budget wants. + const std::uint64_t desired = blockSize > 0 ? budgetBytes / (blockSize * 8) : 1; + std::uint32_t count = 1; + while (count < 64 && static_cast(count) * 2 <= desired) { + count *= 2; + } + return count; +} + +} // namespace + +// --- BlockCache::Impl -------------------------------------------------------- + +class BlockCache::Impl { +public: + explicit Impl(const CacheOptions& requested) { Reset(requested); } + + void Reset(const CacheOptions& requested) { + options = requested.Normalized(); + const std::uint32_t count = ChooseShardCount(options.budgetBytes, options.blockSize); + shards.clear(); + shards.reserve(count); + for (std::uint32_t i = 0; i < count; ++i) { + shards.emplace_back(new Shard()); + } + shardMask = count - 1; + shardBudget = (std::max)(options.budgetBytes / count, options.blockSize); + residentTotal.store(0, std::memory_order_relaxed); + const std::lock_guard lock(identityMutex); + identities.clear(); + } + + Shard& ShardFor(const ShardKey& key) noexcept { + return *shards[ShardKeyHash()(key) & shardMask]; + } + + CacheOptions options; + std::vector> shards; + std::size_t shardMask = 0; + std::uint64_t shardBudget = 0; + + /// The stripes' resident totals, summed, maintained relaxed so that the + /// read path can read it without taking a lock. + std::atomic residentTotal{0}; + + mutable std::mutex identityMutex; + std::unordered_map identities; + std::uint64_t nextIdentityId = 1; + std::uint64_t liveBindings = 0; +}; + +namespace { + +/// Drops least-recently-used blocks until the stripe is inside its budget. +/// +/// Called with the stripe locked. Eviction is invisible to correctness: an +/// evicted block is re-fetched, and it is never served stale and never served +/// zero-filled (CACHE.md section 7). The last resident block is never evicted, +/// so a budget smaller than one block degrades to a one-block cache rather than +/// to a cache that stores every block and immediately drops it again. +std::uint64_t EvictLocked(Shard& shard, std::uint64_t budget, std::atomic& total) { + std::uint64_t evicted = 0; + while (shard.residentBytes > budget && shard.lru.size() > 1) { + const ShardKey victim = shard.lru.back(); + shard.lru.pop_back(); + auto it = shard.entries.find(victim); + if (it != shard.entries.end()) { + shard.residentBytes -= it->second.bytes; + total.fetch_sub(it->second.bytes, std::memory_order_relaxed); + shard.entries.erase(it); + } + ++shard.evictions; + ++evicted; + } + return evicted; +} + +void TouchLocked(Shard& shard, Entry& entry) { + if (!entry.inLru) { + return; + } + shard.lru.splice(shard.lru.begin(), shard.lru, entry.lruIt); + entry.lruIt = shard.lru.begin(); +} + +} // namespace + +// --- BlockCache::Binding::Impl ---------------------------------------------- + +class BlockCache::Binding::Impl { +public: + BlockCache::Impl* store = nullptr; + AssetIdentity identity; + std::uint64_t identityId = 0; + bool isPrivate = false; +}; + +// --- BlockCache -------------------------------------------------------------- + +BlockCache::BlockCache(const CacheOptions& options) : _impl(new Impl(options)) {} + +BlockCache::~BlockCache() = default; + +BlockCache& BlockCache::Process() { + // Constructed on first use with the shipped defaults. A host that wants + // other numbers calls ConfigureProcess before it opens anything. + static BlockCache instance{CacheOptions()}; + return instance; +} + +bool BlockCache::ConfigureProcess(const CacheOptions& options) { + BlockCache& store = Process(); + { + const std::lock_guard lock(store._impl->identityMutex); + if (store._impl->liveBindings != 0) { + // Refused rather than applied. Reconfiguring rebuilds the stripes, + // and a binding that held a stripe index across that rebuild would + // be reading a container that no longer exists. + return false; + } + } + store._impl->Reset(options); + return true; +} + +const CacheOptions& BlockCache::Options() const noexcept { return _impl->options; } + +std::uint64_t BlockCache::ResidentBytes() const noexcept { + return _impl->residentTotal.load(std::memory_order_relaxed); +} + +BlockCache::Stats BlockCache::Snapshot() const { + Stats stats; + stats.shardCount = static_cast(_impl->shards.size()); + stats.shardBudgetBytes = _impl->shardBudget; + for (const auto& shard : _impl->shards) { + const std::lock_guard lock(shard->mutex); + stats.residentBytes += shard->residentBytes; + stats.evictions += shard->evictions; + // Summed rather than maximized: each stripe holds a disjoint set of + // blocks, so the sum of the stripe peaks is the closest thing to a + // store-wide high-water mark that costs no lock to maintain. It is an + // upper bound, and it is reported as one. + stats.peakResidentBytes += shard->peakResidentBytes; + for (const auto& entry : shard->entries) { + if (entry.second.pending) { + ++stats.pendingCount; + } else { + ++stats.blockCount; + } + } + } + const std::lock_guard lock(_impl->identityMutex); + stats.identityCount = _impl->identities.size(); + return stats; +} + +void BlockCache::ClearForTesting() { + for (const auto& shard : _impl->shards) { + const std::lock_guard lock(shard->mutex); + shard->entries.clear(); + shard->lru.clear(); + shard->residentBytes = 0; + shard->evictions = 0; + shard->peakResidentBytes = 0; + shard->published.notify_all(); + } + _impl->residentTotal.store(0, std::memory_order_relaxed); + const std::lock_guard lock(_impl->identityMutex); + _impl->identities.clear(); +} + +std::shared_ptr BlockCache::Bind(const std::string& resolvedIdentifier, + const Validator& validator, + std::uint64_t blockSize) { + std::unique_ptr impl(new Binding::Impl()); + impl->store = _impl.get(); + impl->identity.resolvedIdentifier = resolvedIdentifier; + impl->identity.validator = validator.value; + impl->identity.blockSize = blockSize; + impl->isPrivate = !IsShareable(validator); + + { + const std::lock_guard lock(_impl->identityMutex); + ++_impl->liveBindings; + if (impl->isPrivate) { + // A private identity is never interned, so nothing can look it up + // and no second reader can collide with it -- which is exactly the + // property a weak or absent validator buys (CacheKey.h). + impl->identityId = _impl->nextIdentityId++; + } else { + auto found = _impl->identities.find(impl->identity); + if (found != _impl->identities.end()) { + impl->identityId = found->second; + } else { + impl->identityId = _impl->nextIdentityId++; + _impl->identities.emplace(impl->identity, impl->identityId); + } + } + } + + return std::shared_ptr(new Binding(std::move(impl))); +} + +// --- BlockCache::Binding ----------------------------------------------------- + +BlockCache::Binding::Binding(std::unique_ptr impl) : _impl(std::move(impl)) {} + +BlockCache::Binding::~Binding() { + BlockCache::Impl& store = *_impl->store; + if (_impl->isPrivate) { + // Private entries die with the reader that made them. Nothing else can + // reach them, so leaving them resident would spend the budget on bytes + // that are unreachable by construction. + const std::uint64_t id = _impl->identityId; + for (const auto& shard : store.shards) { + const std::lock_guard lock(shard->mutex); + for (auto it = shard->entries.begin(); it != shard->entries.end();) { + if (it->first.identityId != id) { + ++it; + continue; + } + if (it->second.inLru) { + shard->lru.erase(it->second.lruIt); + shard->residentBytes -= it->second.bytes; + store.residentTotal.fetch_sub(it->second.bytes, + std::memory_order_relaxed); + } + it = shard->entries.erase(it); + } + shard->published.notify_all(); + } + } + const std::lock_guard lock(store.identityMutex); + --store.liveBindings; +} + +const AssetIdentity& BlockCache::Binding::Identity() const noexcept { + return _impl->identity; +} + +bool BlockCache::Binding::IsPrivate() const noexcept { return _impl->isPrivate; } + +BlockCache::Binding::AcquireResult BlockCache::Binding::Acquire(std::uint64_t blockIndex) { + const ShardKey key{_impl->identityId, blockIndex}; + Shard& shard = _impl->store->ShardFor(key); + + AcquireResult result; + const std::lock_guard lock(shard.mutex); + auto found = shard.entries.find(key); + if (found == shard.entries.end()) { + Entry entry; + entry.pending = true; + shard.entries.emplace(key, entry); + result.outcome = Acquisition::Owned; + return result; + } + if (found->second.pending) { + result.outcome = Acquisition::Busy; + return result; + } + TouchLocked(shard, found->second); + result.outcome = Acquisition::Hit; + result.block = found->second.block; + return result; +} + +BlockPtr BlockCache::Binding::Await(std::uint64_t blockIndex) { + const ShardKey key{_impl->identityId, blockIndex}; + Shard& shard = _impl->store->ShardFor(key); + + std::unique_lock lock(shard.mutex); + for (;;) { + auto found = shard.entries.find(key); + if (found == shard.entries.end()) { + // The owner abandoned it, or the store was cleared. Not an error + // here; the caller acquires again and fetches it itself. + return BlockPtr(); + } + if (!found->second.pending) { + TouchLocked(shard, found->second); + return found->second.block; + } + shard.published.wait(lock); + } +} + +std::uint64_t BlockCache::Binding::Publish(std::uint64_t blockIndex, + const unsigned char* bytes, + std::size_t length) { + // Built outside the stripe lock. Copying a block while holding the mutex + // that every other block in the stripe waits on is the one allocation on + // this path that would be worth complaining about. + BlockPtr block(new std::vector(bytes, bytes + length)); + + const ShardKey key{_impl->identityId, blockIndex}; + Shard& shard = _impl->store->ShardFor(key); + + std::uint64_t evicted = 0; + { + const std::lock_guard lock(shard.mutex); + auto found = shard.entries.find(key); + if (found == shard.entries.end() || !found->second.pending) { + // Ownership was taken away underneath us -- ClearForTesting, or a + // private binding closing. The bytes still go to the caller that + // fetched them; only the store forgets them. + shard.published.notify_all(); + return 0; + } + Entry& entry = found->second; + entry.block = block; + entry.pending = false; + entry.bytes = length; + shard.lru.push_front(key); + entry.lruIt = shard.lru.begin(); + entry.inLru = true; + shard.residentBytes += length; + _impl->store->residentTotal.fetch_add(length, std::memory_order_relaxed); + shard.peakResidentBytes = (std::max)(shard.peakResidentBytes, shard.residentBytes); + evicted = EvictLocked(shard, _impl->store->shardBudget, _impl->store->residentTotal); + shard.published.notify_all(); + } + return evicted; +} + +void BlockCache::Binding::Abandon(std::uint64_t blockIndex) { + const ShardKey key{_impl->identityId, blockIndex}; + Shard& shard = _impl->store->ShardFor(key); + + const std::lock_guard lock(shard.mutex); + auto found = shard.entries.find(key); + if (found != shard.entries.end() && found->second.pending) { + shard.entries.erase(found); + } + shard.published.notify_all(); +} + +} // namespace cache +} // namespace usdasset diff --git a/libs/usd-asset-cache/src/BlockPlan.cpp b/libs/usd-asset-cache/src/BlockPlan.cpp new file mode 100644 index 0000000..db0f79a --- /dev/null +++ b/libs/usd-asset-cache/src/BlockPlan.cpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "BlockPlan.h" + +#include + +namespace usdasset { +namespace cache { +namespace detail { + +BlockSpan CoveringBlocks(std::uint64_t offset, + std::uint64_t length, + std::uint64_t blockSize) noexcept { + BlockSpan span; + span.first = offset / blockSize; + // The last byte's block, not the block after the end. Computing it from + // `offset + length` and rounding up would put a read that ends exactly on a + // boundary into a block it never touches, and that block would be fetched, + // stored, and counted as over-fetch that never happened. + span.last = (offset + length - 1) / blockSize; + return span; +} + +BlockExtent ExtentOf(std::uint64_t blockIndex, + std::uint64_t blockSize, + std::uint64_t assetSize) noexcept { + BlockExtent extent; + extent.offset = blockIndex * blockSize; + if (extent.offset >= assetSize) { + extent.length = 0; + return extent; + } + const std::uint64_t remaining = assetSize - extent.offset; + extent.length = (std::min)(blockSize, remaining); + return extent; +} + +std::vector PlanRuns(const std::vector& blocks, + std::uint64_t blockSize, + std::uint64_t assetSize, + std::uint32_t coalesceGapBlocks, + std::uint64_t maxRequestBytes) { + std::vector runs; + if (blocks.empty()) { + return runs; + } + runs.reserve(blocks.size()); + + const auto close = [&](std::uint64_t first, std::uint64_t last) { + const BlockExtent begin = ExtentOf(first, blockSize, assetSize); + const BlockExtent end = ExtentOf(last, blockSize, assetSize); + FetchRun run; + run.firstBlock = first; + run.blockCount = last - first + 1; + run.offset = begin.offset; + run.length = (end.offset + end.length) - begin.offset; + runs.push_back(run); + }; + + std::uint64_t first = blocks.front(); + std::uint64_t last = first; + + for (std::size_t i = 1; i < blocks.size(); ++i) { + const std::uint64_t next = blocks[i]; + const std::uint64_t gap = next - last - 1; + const BlockExtent begin = ExtentOf(first, blockSize, assetSize); + const BlockExtent end = ExtentOf(next, blockSize, assetSize); + const std::uint64_t merged = (end.offset + end.length) - begin.offset; + + if (gap <= coalesceGapBlocks && merged <= maxRequestBytes) { + last = next; + continue; + } + close(first, last); + first = next; + last = next; + } + close(first, last); + return runs; +} + +std::uint64_t OverFetchedBytes(const FetchRun& run, + std::uint64_t wantedOffset, + std::uint64_t wantedLength) noexcept { + const std::uint64_t runEnd = run.offset + run.length; + const std::uint64_t wantedEnd = wantedOffset + wantedLength; + const std::uint64_t overlapBegin = (std::max)(run.offset, wantedOffset); + const std::uint64_t overlapEnd = (std::min)(runEnd, wantedEnd); + const std::uint64_t overlap = overlapEnd > overlapBegin ? overlapEnd - overlapBegin : 0; + return run.length - overlap; +} + +} // namespace detail +} // namespace cache +} // namespace usdasset diff --git a/libs/usd-asset-cache/src/BlockPlan.h b/libs/usd-asset-cache/src/BlockPlan.h new file mode 100644 index 0000000..17d3eaf --- /dev/null +++ b/libs/usd-asset-cache/src/BlockPlan.h @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Block alignment and coalescing, as pure arithmetic. +// +// Internal to the module and deliberately not installed: it is the part of the +// cache that has no state, no lock, and no reader, and it is separated for the +// reason ResolveReadRange is separated in usdAssetIo -- this is where the +// off-by-one lives, and it should be checkable without provisioning an asset. +// +// Normative contract: docs/architecture/CACHE.md §3, §4. + +#ifndef USDASSETCACHE_BLOCKPLAN_H +#define USDASSETCACHE_BLOCKPLAN_H + +#include +#include + +namespace usdasset { +namespace cache { +namespace detail { + +/// The inclusive range of blocks a resolved read touches. +struct BlockSpan { + std::uint64_t first = 0; + std::uint64_t last = 0; + + std::uint64_t Count() const noexcept { return last - first + 1; } +}; + +/// Expands a resolved read to whole blocks. +/// +/// `length` must be non-zero and `offset + length` must already have been +/// resolved against the asset size by ResolveReadRange: this function does no +/// EOF reasoning and no overflow checking, because doing either here would be +/// a second copy of the arithmetic the whole project keeps in one place. +BlockSpan CoveringBlocks(std::uint64_t offset, + std::uint64_t length, + std::uint64_t blockSize) noexcept; + +/// The byte extent of one block, clamped to the asset. +/// +/// The final block of an asset is short and is stored at its true length. A +/// cache that padded it would answer a read past EOF with zeros, which is a +/// silent corruption that looks exactly like valid data (CACHE.md §3). +struct BlockExtent { + std::uint64_t offset = 0; + std::uint64_t length = 0; +}; + +BlockExtent ExtentOf(std::uint64_t blockIndex, + std::uint64_t blockSize, + std::uint64_t assetSize) noexcept; + +/// One merged fetch: a contiguous byte range covering `blockCount` blocks +/// starting at `firstBlock`, some of which the caller may not want. +struct FetchRun { + std::uint64_t firstBlock = 0; + std::uint64_t blockCount = 0; + std::uint64_t offset = 0; + std::uint64_t length = 0; +}; + +/// Merges the blocks a caller must fetch into as few requests as the policy +/// allows. +/// +/// `blocks` is ascending and without duplicates. Two blocks separated by no +/// more than `coalesceGapBlocks` blocks the caller does *not* need are merged +/// into one request that fetches the gap too, because transferring the gap +/// costs less than a second round trip; a merge is never taken past +/// `maxRequestBytes`, because one enormous request defeats cancellation and +/// stalls every other read on the connection (CACHE.md §4). +/// +/// The bound is never applied so tightly that a single block cannot be +/// fetched: a run of one block is emitted whatever its length, since splitting +/// a block would store a partial one and the store has no way to say so. +std::vector PlanRuns(const std::vector& blocks, + std::uint64_t blockSize, + std::uint64_t assetSize, + std::uint32_t coalesceGapBlocks, + std::uint64_t maxRequestBytes); + +/// The bytes of `run` that fall outside `[wantedOffset, wantedOffset + wantedLength)`. +/// +/// This is `bytesOverFetched` for one fetch: the cost of block alignment and of +/// merging across a gap, charged where it is incurred. METRICS.md §2.2 calls it +/// the honest counter, and it is charged at fetch time and never refunded when +/// a later read hits those bytes -- that refund is what `cacheHitRatio` is. +std::uint64_t OverFetchedBytes(const FetchRun& run, + std::uint64_t wantedOffset, + std::uint64_t wantedLength) noexcept; + +} // namespace detail +} // namespace cache +} // namespace usdasset + +#endif // USDASSETCACHE_BLOCKPLAN_H diff --git a/libs/usd-asset-cache/src/CacheKey.cpp b/libs/usd-asset-cache/src/CacheKey.cpp new file mode 100644 index 0000000..62707be --- /dev/null +++ b/libs/usd-asset-cache/src/CacheKey.cpp @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "usdAssetCache/CacheKey.h" + +#include + +namespace usdasset { +namespace cache { + +namespace { + +/// The 64-bit mixing constant of FNV-1a, used only to combine two hashes. The +/// key is never persisted and never leaves the process, so this needs to spread +/// bits and nothing else; it is not a checksum and must never become one. +void HashCombine(std::size_t& seed, std::size_t value) noexcept { + seed ^= value + 0x9E3779B97F4A7C15ull + (seed << 6) + (seed >> 2); +} + +} // namespace + +bool operator==(const AssetIdentity& lhs, const AssetIdentity& rhs) noexcept { + return lhs.blockSize == rhs.blockSize && + lhs.resolvedIdentifier == rhs.resolvedIdentifier && + lhs.validator == rhs.validator; +} + +bool operator!=(const AssetIdentity& lhs, const AssetIdentity& rhs) noexcept { + return !(lhs == rhs); +} + +std::size_t HashAssetIdentity(const AssetIdentity& identity) noexcept { + std::size_t seed = std::hash()(identity.resolvedIdentifier); + // The validator is hashed as bytes, like every other use of it at this + // layer. Nothing here knows whether it is an ETag, a date, or a synthesized + // triple, and the moment it did this module would have become an HTTP + // cache (CACHE.md §6). + HashCombine(seed, std::hash()(identity.validator)); + HashCombine(seed, std::hash()(identity.blockSize)); + return seed; +} + +bool operator==(const CacheKey& lhs, const CacheKey& rhs) noexcept { + return lhs.blockIndex == rhs.blockIndex && lhs.identity == rhs.identity; +} + +bool operator!=(const CacheKey& lhs, const CacheKey& rhs) noexcept { + return !(lhs == rhs); +} + +std::size_t HashCacheKey(const CacheKey& key) noexcept { + std::size_t seed = HashAssetIdentity(key.identity); + HashCombine(seed, std::hash()(key.blockIndex)); + return seed; +} + +bool IsShareable(const Validator& validator) noexcept { + return validator.IsUsable() && validator.strength == ValidatorStrength::Strong; +} + +} // namespace cache +} // namespace usdasset diff --git a/libs/usd-asset-cache/src/CacheOptions.cpp b/libs/usd-asset-cache/src/CacheOptions.cpp new file mode 100644 index 0000000..0ae1ab4 --- /dev/null +++ b/libs/usd-asset-cache/src/CacheOptions.cpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "usdAssetCache/CacheOptions.h" + +#include + +namespace usdasset { +namespace cache { + +namespace { + +/// The largest power of two not greater than `value`, inside the block bounds. +/// +/// Rounds *down* rather than to the nearest. A caller that asked for 100000 +/// bytes gets 65536 and not 131072: the risk this cache is exposed to is +/// over-fetch, and rounding a request for a block size upward would silently +/// double the bytes moved by every miss. +std::uint64_t RoundDownToPowerOfTwo(std::uint64_t value) noexcept { + if (value < kMinBlockSize) { + return kMinBlockSize; + } + if (value >= kMaxBlockSize) { + return kMaxBlockSize; + } + std::uint64_t power = kMinBlockSize; + while ((power << 1) <= value) { + power <<= 1; + } + return power; +} + +} // namespace + +CacheOptions CacheOptions::Normalized() const noexcept { + CacheOptions normalized = *this; + + normalized.blockSize = + RoundDownToPowerOfTwo(blockSize == 0 ? kDefaultBlockSize : blockSize); + + // Every other bound is expressed in blocks somewhere, so each one has to be + // able to hold at least one. A budget that cannot hold a block, or a merged + // request that cannot carry one, does not mean "cache nothing" -- it means + // "fetch a block and immediately drop it", which is the worst of both. + normalized.maxRequestBytes = (std::max)(normalized.maxRequestBytes, normalized.blockSize); + normalized.budgetBytes = (std::max)(normalized.budgetBytes, normalized.blockSize); + normalized.bypassThresholdBytes = + (std::max)(normalized.bypassThresholdBytes, normalized.blockSize); + + // A gap wide enough that merging across it could never fit under the + // request ceiling is not a policy, it is a number that never applies. Cap + // it where it stops meaning anything, so that a reader of the resolved + // options sees the gap that is actually in force. + const std::uint64_t blocksPerRequest = normalized.maxRequestBytes / normalized.blockSize; + const std::uint64_t gapCeiling = blocksPerRequest > 0 ? blocksPerRequest - 1 : 0; + if (normalized.coalesceGapBlocks > gapCeiling) { + normalized.coalesceGapBlocks = static_cast( + (std::min)(gapCeiling, static_cast(0xFFFFFFFFu))); + } + + return normalized; +} + +bool CacheOptions::IsNormalized() const noexcept { + const CacheOptions normalized = Normalized(); + return normalized.blockSize == blockSize && normalized.budgetBytes == budgetBytes && + normalized.coalesceGapBlocks == coalesceGapBlocks && + normalized.maxRequestBytes == maxRequestBytes && + normalized.bypassThresholdBytes == bypassThresholdBytes; +} + +} // namespace cache +} // namespace usdasset diff --git a/libs/usd-asset-cache/src/CachedAssetReader.cpp b/libs/usd-asset-cache/src/CachedAssetReader.cpp new file mode 100644 index 0000000..51ae29a --- /dev/null +++ b/libs/usd-asset-cache/src/CachedAssetReader.cpp @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "usdAssetCache/CachedAssetReader.h" + +#include +#include +#include +#include +#include + +#include "usdAssetIo/RangeMath.h" +#include "BlockPlan.h" + +namespace usdasset { +namespace cache { + +namespace { + +using detail::BlockExtent; +using detail::BlockSpan; +using detail::ExtentOf; +using detail::FetchRun; +using detail::OverFetchedBytes; +using detail::PlanRuns; + +/// How many times a read will go round the acquire / fetch / wait loop before +/// it stops cooperating and fetches what is left itself. +/// +/// A bound rather than a spin. A block this reader waited for can come back +/// unpublished -- the owner's fetch failed, or its binding closed -- and the +/// honest response is to acquire it again and do the work. Twice is enough to +/// absorb that; a third time means something is churning, and serving the read +/// directly is better than joining the churn. +constexpr int kMaxCooperativePasses = 3; + +/// Copies the part of one block that the caller's range covers. +/// +/// Returns the number of bytes copied, which is zero for a block that turned +/// out not to overlap -- a case the arithmetic above should make impossible, +/// and which is handled rather than asserted because the cost of being wrong +/// here is a buffer overrun. +std::size_t CopyOverlap(unsigned char* dst, + std::uint64_t rangeOffset, + std::uint64_t rangeLength, + std::uint64_t blockOffset, + const unsigned char* blockBytes, + std::uint64_t blockLength) { + const std::uint64_t rangeEnd = rangeOffset + rangeLength; + const std::uint64_t blockEnd = blockOffset + blockLength; + const std::uint64_t begin = (std::max)(rangeOffset, blockOffset); + const std::uint64_t end = (std::min)(rangeEnd, blockEnd); + if (end <= begin) { + return 0; + } + const std::size_t length = static_cast(end - begin); + std::memcpy(dst + (begin - rangeOffset), blockBytes + (begin - blockOffset), length); + return length; +} + +} // namespace + +// --- Impl -------------------------------------------------------------------- + +class CachedAssetReader::Impl { +public: + Impl(std::unique_ptr reader, + ReaderMetrics* readerMetrics, + const CacheOptions& requested, + BlockCache& blockStore) + : inner(std::move(reader)), + innerMetrics(readerMetrics), + options(requested.Normalized()), + store(blockStore), + metrics(inner->Metadata().resolvedIdentifier) { + metrics.SetAssetSize(inner->Metadata().size); + if (innerMetrics != nullptr) { + // One counter set per stack. The inner reader's own fold would + // otherwise report the cache's expanded asks as a second reader's + // caller-side demand. + innerMetrics->DetachFromRegistry(); + } + binding = store.Bind(inner->Metadata().resolvedIdentifier, + inner->Metadata().validator, options.blockSize); + } + + ~Impl() { + // While the inner reader is still alive, and before `metrics` is + // destroyed and folds. Both halves of that sentence are why this is a + // destructor body and not a member initializer order comment. + if (innerMetrics != nullptr) { + metrics.AbsorbTransport(*innerMetrics); + } + } + + ReadResult ReadCached(const ReadRange& range, unsigned char* dst); + + /// Reads part of one block straight from the transport, without claiming it + /// in the store. The fallback for a block this reader could neither own nor + /// wait for; it asks for exactly the bytes the caller wants, so it cannot + /// over-fetch and has nothing to publish. + Status ReadDirect(std::uint64_t offset, unsigned char* dst, std::size_t length); + + std::unique_ptr inner; + ReaderMetrics* innerMetrics = nullptr; + CacheOptions options; + BlockCache& store; + ReaderMetrics metrics; + std::shared_ptr binding; +}; + +Status CachedAssetReader::Impl::ReadDirect(std::uint64_t offset, + unsigned char* dst, + std::size_t length) { + const ReadResult result = inner->Read(offset, dst, length); + if (!result.status.IsOk()) { + return result.status; + } + if (result.bytesRead != length) { + // Below EOF by construction: the extent was clamped to the asset size + // captured at open. A transfer that stopped short of it is the short + // read the contract forbids turning into a hole. + return ShortReadStatus(offset, length, result.bytesRead, + inner->Metadata().resolvedIdentifier); + } + return Status::Ok(); +} + +ReadResult CachedAssetReader::Impl::ReadCached(const ReadRange& range, unsigned char* dst) { + const std::uint64_t assetSize = inner->Metadata().size; + const std::uint64_t blockSize = options.blockSize; + const BlockSpan span = detail::CoveringBlocks(range.offset, range.length, blockSize); + const std::size_t blockCount = static_cast(span.Count()); + + std::vector resolved(blockCount, false); + std::vector transfer; + + std::uint64_t residentHits = 0; + std::uint64_t served = 0; + + for (int pass = 0; pass < kMaxCooperativePasses; ++pass) { + std::vector owned; + std::vector busy; + + for (std::size_t i = 0; i < blockCount; ++i) { + if (resolved[i]) { + continue; + } + const std::uint64_t blockIndex = span.first + i; + const BlockExtent extent = ExtentOf(blockIndex, blockSize, assetSize); + BlockCache::Binding::AcquireResult acquired = binding->Acquire(blockIndex); + + if (acquired.outcome == BlockCache::Binding::Acquisition::Hit) { + if (acquired.block && acquired.block->size() == extent.length) { + const std::size_t copied = + CopyOverlap(dst, range.offset, range.length, extent.offset, + acquired.block->data(), extent.length); + metrics.AddBytesFromCache(copied); + served += copied; + ++residentHits; + resolved[i] = true; + continue; + } + // A resident block whose length disagrees with the asset size + // this reader was opened against. Nothing can make that block + // correct for this reader, so it is not used and not trusted -- + // the bytes are fetched instead. The ownerships this pass has + // already taken are handed back first, because the direct read + // below can fail and return, and a block left pending is a + // block every later reader waits on for a fetch that is not + // happening. The next pass acquires them again. + for (const std::uint64_t abandoned : owned) { + binding->Abandon(abandoned); + } + owned.clear(); + + const std::uint64_t begin = (std::max)(range.offset, extent.offset); + const std::uint64_t end = + (std::min)(range.offset + range.length, extent.offset + extent.length); + const Status direct = + ReadDirect(begin, dst + (begin - range.offset), + static_cast(end - begin)); + if (!direct.IsOk()) { + return ReadResult{0, direct}; + } + metrics.AddBlockMiss(); + served += (end - begin); + resolved[i] = true; + continue; + } + if (acquired.outcome == BlockCache::Binding::Acquisition::Owned) { + owned.push_back(blockIndex); + } else { + busy.push_back(i); + } + } + + if (owned.empty() && busy.empty()) { + break; + } + + // --- fetch what this reader owns ------------------------------------ + if (!owned.empty()) { + const std::vector runs = PlanRuns(owned, blockSize, assetSize, + options.coalesceGapBlocks, + options.maxRequestBytes); + // Every owned block would have been its own request without the + // merge. What the merge saved is the difference, and it is counted + // here rather than inferred from a request total that a hit also + // moves. + metrics.AddRequestsSavedByCoalescing(owned.size() - runs.size()); + + std::size_t nextOwned = 0; + for (const FetchRun& run : runs) { + transfer.assign(static_cast(run.length), 0); + const ReadResult fetched = + inner->Read(run.offset, transfer.data(), + static_cast(run.length)); + + if (!fetched.status.IsOk() || fetched.bytesRead != run.length) { + // Give every block this read still owns back to the store + // before returning. A block left pending is a block every + // later reader waits on for a fetch that is not happening. + for (std::size_t k = nextOwned; k < owned.size(); ++k) { + binding->Abandon(owned[k]); + } + if (!fetched.status.IsOk()) { + return ReadResult{0, fetched.status}; + } + return ReadResult{0, ShortReadStatus( + run.offset, + static_cast(run.length), + fetched.bytesRead, + inner->Metadata().resolvedIdentifier)}; + } + + metrics.AddBytesOverFetched( + OverFetchedBytes(run, range.offset, range.length)); + + const std::uint64_t runLast = run.firstBlock + run.blockCount - 1; + while (nextOwned < owned.size() && owned[nextOwned] <= runLast) { + const std::uint64_t blockIndex = owned[nextOwned]; + const BlockExtent extent = ExtentOf(blockIndex, blockSize, assetSize); + const unsigned char* bytes = + transfer.data() + (extent.offset - run.offset); + + metrics.AddEviction(binding->Publish( + blockIndex, bytes, static_cast(extent.length))); + metrics.AddBlockMiss(); + + served += CopyOverlap(dst, range.offset, range.length, extent.offset, + bytes, extent.length); + resolved[static_cast(blockIndex - span.first)] = true; + ++nextOwned; + } + } + } + + // --- wait for what somebody else owns ------------------------------- + for (const std::size_t i : busy) { + const std::uint64_t blockIndex = span.first + i; + const BlockExtent extent = ExtentOf(blockIndex, blockSize, assetSize); + const BlockPtr block = binding->Await(blockIndex); + if (!block || block->size() != extent.length) { + // The owner failed or abandoned it. Not this reader's failure; + // the next pass acquires it again and fetches it. + continue; + } + const std::size_t copied = CopyOverlap(dst, range.offset, range.length, + extent.offset, block->data(), + extent.length); + // Served without this reader issuing a request, which is what + // single-flight buys and what the counter is for. + metrics.AddBytesFromCache(copied); + metrics.AddRequestsSavedBySingleFlight(); + served += copied; + resolved[i] = true; + } + } + + // --- anything the loop could not settle cooperatively -------------------- + for (std::size_t i = 0; i < blockCount; ++i) { + if (resolved[i]) { + continue; + } + const BlockExtent extent = ExtentOf(span.first + i, blockSize, assetSize); + const std::uint64_t begin = (std::max)(range.offset, extent.offset); + const std::uint64_t end = + (std::min)(range.offset + range.length, extent.offset + extent.length); + const Status direct = ReadDirect(begin, dst + (begin - range.offset), + static_cast(end - begin)); + if (!direct.IsOk()) { + return ReadResult{0, direct}; + } + metrics.AddBlockMiss(); + served += (end - begin); + resolved[i] = true; + } + + if (residentHits == static_cast(blockCount)) { + metrics.AddBlockHit(); + } else if (residentHits > 0) { + metrics.AddPartialHit(); + } + // One relaxed load, not a snapshot: `Snapshot` locks every stripe, and a + // counter that locks the whole store on every read is the instrumentation + // METRICS.md section 4 forbids. + metrics.ObserveResidentBytes(store.ResidentBytes()); + + if (served != range.length) { + // Unreachable by the arithmetic above, and checked anyway: the failure + // it would otherwise be is a buffer with a hole in it reported as a + // complete read, which is the one outcome this project treats as worse + // than an error. + return ReadResult{0, ShortReadStatus(range.offset, + static_cast(range.length), + static_cast(served), + inner->Metadata().resolvedIdentifier)}; + } + return ReadResult{static_cast(range.length), Status::Ok()}; +} + +// --- CachedAssetReader ------------------------------------------------------- + +CachedAssetReader::CachedAssetReader(std::unique_ptr impl) : _impl(std::move(impl)) {} + +CachedAssetReader::~CachedAssetReader() = default; + +const AssetMetadata& CachedAssetReader::Metadata() const { return _impl->inner->Metadata(); } + +const CacheOptions& CachedAssetReader::Options() const noexcept { return _impl->options; } + +const ReaderMetrics& CachedAssetReader::Metrics() const noexcept { return _impl->metrics; } + +const BlockCache::Binding& CachedAssetReader::Binding() const noexcept { + return *_impl->binding; +} + +MetricsSnapshot CachedAssetReader::SnapshotMetrics() const { + MetricsSnapshot snapshot = _impl->metrics.Snapshot(); + if (_impl->innerMetrics != nullptr) { + snapshot.AbsorbTransport(_impl->innerMetrics->Snapshot()); + } + return snapshot; +} + +ReadResult CachedAssetReader::Read(std::uint64_t offset, void* dst, std::size_t size) { + ScopedLatency readTimer(_impl->metrics.ReadLatency()); + _impl->metrics.AddBytesRequested(size); + + const ReadRange range = ResolveReadRange(offset, size, _impl->inner->Metadata().size); + if (range.outcome == ReadRangeOutcome::Overflow) { + return ReadResult{0, OverflowStatus(offset, size)}; + } + // Before the empty check, for the reason the local backend gives: a null + // buffer with a non-zero length is a caller bug wherever the offset lands. + if (size > 0 && dst == nullptr) { + return ReadResult{0, Status::Error(StatusCode::InvalidArgument, + "read destination buffer is null") + .WithRange(offset, size)}; + } + if (range.outcome == ReadRangeOutcome::Empty) { + return ReadResult{0, Status::Ok()}; + } + + if (range.length >= _impl->options.bypassThresholdBytes) { + // A streaming pass. Served straight through and stored nowhere: keeping + // it would evict the working set to hold bytes that will not be read + // twice, and the read after it would pay for the privilege. + return _impl->inner->Read(offset, dst, size); + } + + return _impl->ReadCached(range, static_cast(dst)); +} + +// --- Wrap -------------------------------------------------------------------- + +struct CachedReaderFactory { + /// Builds the whole reader rather than taking an `Impl` as a parameter: + /// the private nested type stays inside the one scope that is a friend, + /// which is also how the backends' factories are shaped. + static std::unique_ptr Make(std::unique_ptr inner, + ReaderMetrics* innerMetrics, + const CacheOptions& options, + BlockCache& store) { + std::unique_ptr impl(new CachedAssetReader::Impl( + std::move(inner), innerMetrics, options, store)); + return std::unique_ptr(new CachedAssetReader(std::move(impl))); + } +}; + +CachedOpenResult Wrap(std::unique_ptr inner, + ReaderMetrics* innerMetrics, + const CacheOptions& options, + BlockCache* store) { + CachedOpenResult result; + if (!inner) { + result.status = Status::Error(StatusCode::InvalidArgument, + "the cache was given no reader to decorate"); + return result; + } + + BlockCache& blockStore = store != nullptr ? *store : BlockCache::Process(); + result.reader = + CachedReaderFactory::Make(std::move(inner), innerMetrics, options, blockStore); + return result; +} + +OpenResult WrapAsset(OpenResult inner, + ReaderMetrics* innerMetrics, + const CacheOptions& options, + BlockCache* store) { + OpenResult result; + if (!inner.reader) { + // Nothing to decorate. The backend's status is the useful thing the + // result carries, and replacing it with one of this module's would + // erase it. + result.status = std::move(inner.status); + return result; + } + if (!inner.reader->Metadata().supportsRandomAccess) { + // A reader that cannot seek would store the one block it managed to + // read and miss forever after. Passed through undecorated rather than + // failed: the reader works, and a cache is an optimization. + result.reader = std::move(inner.reader); + result.status = std::move(inner.status); + return result; + } + + CachedOpenResult wrapped = + Wrap(std::move(inner.reader), innerMetrics, options, store); + result.reader = std::move(wrapped.reader); + result.status = std::move(wrapped.status); + return result; +} + +} // namespace cache +} // namespace usdasset diff --git a/libs/usd-asset-cache/tests/CMakeLists.txt b/libs/usd-asset-cache/tests/CMakeLists.txt new file mode 100644 index 0000000..44d5b5e --- /dev/null +++ b/libs/usd-asset-cache/tests/CMakeLists.txt @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Module tests only. +# +# The read semantics live in the shared boundary suite under tests/boundary, +# where the cache is entered as a row over the local backend and runs the same +# cases unchanged. What is here is what the suite cannot ask: how many requests +# a pattern cost, how many bytes were moved that nobody asked for, and whether +# eight threads missing one block issue one request or eight. +# +# test_plan reaches into src/. The block arithmetic is internal -- putting it in +# an installed header would make it part of the module's surface -- and it is +# where the off-by-one lives, so it is tested directly rather than through a +# reader. + +set(_usd_asset_cache_tests + plan + cache + singleflight) + +foreach(_test ${_usd_asset_cache_tests}) + add_executable(usdAssetCache_test_${_test} test_${_test}.cpp) + target_link_libraries(usdAssetCache_test_${_test} PRIVATE usdasset::cache) + target_include_directories(usdAssetCache_test_${_test} + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_CURRENT_SOURCE_DIR}/../src") + if(MSVC) + target_compile_options(usdAssetCache_test_${_test} PRIVATE /utf-8) + endif() + add_test(NAME usdAssetCache_${_test} COMMAND usdAssetCache_test_${_test}) +endforeach() diff --git a/libs/usd-asset-cache/tests/Check.h b/libs/usd-asset-cache/tests/Check.h new file mode 100644 index 0000000..2972f7e --- /dev/null +++ b/libs/usd-asset-cache/tests/Check.h @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// A twenty-line check macro, deliberately not a test framework. +// +// This repository takes exactly one third-party dependency -- the HTTP client +// chosen in v0.2.0 on license, footprint, and Wasm viability. A test framework +// pulled in ahead of it would be a second one, acquired without that argument +// ever being made. `CHECK` reports every failure rather than aborting on the +// first, which is what makes a boundary table readable when several rows break +// at once. + +#ifndef USDASSETCACHE_TESTS_CHECK_H +#define USDASSETCACHE_TESTS_CHECK_H + +#include + +namespace usdassettest { + +inline int& FailureCount() { + static int failures = 0; + return failures; +} + +inline int Report(const char* suite) { + const int failures = FailureCount(); + if (failures == 0) { + std::printf("%s: ok\n", suite); + return 0; + } + std::printf("%s: %d failure(s)\n", suite, failures); + return 1; +} + +} // namespace usdassettest + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); \ + ++::usdassettest::FailureCount(); \ + } \ + } while (false) + +#define CHECK_EQ(actual, expected) \ + do { \ + const auto _actual = (actual); \ + const auto _expected = (expected); \ + if (!(_actual == _expected)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s == %s\n", __FILE__, __LINE__, \ + #actual, #expected); \ + ++::usdassettest::FailureCount(); \ + } \ + } while (false) + +#endif // USDASSETCACHE_TESTS_CHECK_H diff --git a/libs/usd-asset-cache/tests/FakeReader.h b/libs/usd-asset-cache/tests/FakeReader.h new file mode 100644 index 0000000..0cc23d3 --- /dev/null +++ b/libs/usd-asset-cache/tests/FakeReader.h @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// An in-memory reader to decorate, and a log of what the cache asked it for. +// +// The cache's whole job is to change how many requests reach the reader +// underneath and how many bytes they move. Asserting that against a real +// backend would be asserting the backend as well; against this it is one +// question with one answer. It implements the same read semantics through the +// same `ResolveReadRange`, so a case that passes here is a case about the +// cache. + +#ifndef USDASSETCACHE_TESTS_FAKEREADER_H +#define USDASSETCACHE_TESTS_FAKEREADER_H + +#include +#include +#include +#include +#include +#include + +#include "usdAssetIo/AssetReader.h" +#include "usdAssetIo/Metrics.h" +#include "usdAssetIo/RangeMath.h" + +namespace usdassetcachetest { + +/// Every byte a function of its own offset, so a read that landed at the wrong +/// offset cannot compare equal to what was asked for. +inline unsigned char ContentByte(std::uint64_t offset) { + std::uint64_t mixed = offset * 0x9E3779B97F4A7C15ull; + mixed ^= mixed >> 29; + return static_cast(mixed & 0xFF); +} + +inline std::vector MakeContent(std::uint64_t size) { + std::vector content(static_cast(size)); + for (std::uint64_t i = 0; i < size; ++i) { + content[static_cast(i)] = ContentByte(i); + } + return content; +} + +/// A latch a test can hold every reader inside `Read` on, so that N threads are +/// provably concurrent at the moment single-flight has to work. +class Gate { +public: + void Arrive() { + std::unique_lock lock(_mutex); + ++_arrived; + _changed.notify_all(); + _changed.wait(lock, [this] { return _open; }); + } + + void WaitForArrivals(int count) { + std::unique_lock lock(_mutex); + _changed.wait(lock, [this, count] { return _arrived >= count; }); + } + + void Open() { + const std::lock_guard lock(_mutex); + _open = true; + _changed.notify_all(); + } + + int Arrived() const { + const std::lock_guard lock(_mutex); + return _arrived; + } + +private: + mutable std::mutex _mutex; + std::condition_variable _changed; + int _arrived = 0; + bool _open = false; +}; + +class FakeReader final : public usdasset::AssetReader { +public: + struct Call { + std::uint64_t offset = 0; + std::size_t size = 0; + }; + + FakeReader(std::string identifier, + std::vector content, + usdasset::Validator validator) + : _content(std::move(content)), _metrics(identifier) { + _metadata.resolvedIdentifier = std::move(identifier); + _metadata.size = _content.size(); + _metadata.supportsRandomAccess = true; + _metadata.validator = std::move(validator); + _metadata.stability = usdasset::ClassifyStability(_metadata.validator); + _metrics.SetAssetSize(_metadata.size); + } + + const usdasset::AssetMetadata& Metadata() const override { return _metadata; } + + usdasset::ReadResult Read(std::uint64_t offset, void* dst, std::size_t size) override { + _metrics.AddBytesRequested(size); + const usdasset::ReadRange range = + usdasset::ResolveReadRange(offset, size, _metadata.size); + if (range.outcome == usdasset::ReadRangeOutcome::Overflow) { + return usdasset::ReadResult{0, usdasset::OverflowStatus(offset, size)}; + } + if (range.outcome == usdasset::ReadRangeOutcome::Empty) { + return usdasset::ReadResult{0, usdasset::Status::Ok()}; + } + + { + const std::lock_guard lock(_mutex); + _calls.push_back(Call{range.offset, range.length}); + } + if (_gate) { + _gate->Arrive(); + } + + usdasset::Status failure; + { + const std::lock_guard lock(_mutex); + if (_failuresRemaining > 0) { + --_failuresRemaining; + failure = _failure; + } + } + if (!failure.IsOk()) { + _metrics.AddRequest(); + return usdasset::ReadResult{0, failure}; + } + + _metrics.AddRequest(); + _metrics.AddBytesTransferred(range.length); + std::memcpy(dst, _content.data() + range.offset, range.length); + return usdasset::ReadResult{range.length, usdasset::Status::Ok()}; + } + + usdasset::ReaderMetrics& Metrics() noexcept { return _metrics; } + + std::size_t CallCount() const { + const std::lock_guard lock(_mutex); + return _calls.size(); + } + + std::vector Calls() const { + const std::lock_guard lock(_mutex); + return _calls; + } + + std::uint64_t BytesRead() const { + const std::lock_guard lock(_mutex); + std::uint64_t total = 0; + for (const Call& call : _calls) { + total += call.size; + } + return total; + } + + void SetGate(Gate* gate) { _gate = gate; } + + /// Makes the next `count` reads fail with `status`, delivering nothing. + void FailNext(int count, usdasset::Status status) { + const std::lock_guard lock(_mutex); + _failuresRemaining = count; + _failure = std::move(status); + } + +private: + std::vector _content; + usdasset::AssetMetadata _metadata; + usdasset::ReaderMetrics _metrics; + + mutable std::mutex _mutex; + std::vector _calls; + int _failuresRemaining = 0; + usdasset::Status _failure; + Gate* _gate = nullptr; +}; + +inline usdasset::Validator StrongValidator(std::string value) { + usdasset::Validator validator; + validator.value = std::move(value); + validator.kind = usdasset::ValidatorKind::EntityTag; + validator.strength = usdasset::ValidatorStrength::Strong; + return validator; +} + +inline usdasset::Validator WeakValidator(std::string value) { + usdasset::Validator validator; + validator.value = std::move(value); + validator.kind = usdasset::ValidatorKind::EntityTag; + validator.strength = usdasset::ValidatorStrength::Weak; + return validator; +} + +inline usdasset::Validator NoValidator() { return usdasset::Validator(); } + +} // namespace usdassetcachetest + +#endif // USDASSETCACHE_TESTS_FAKEREADER_H diff --git a/libs/usd-asset-cache/tests/test_cache.cpp b/libs/usd-asset-cache/tests/test_cache.cpp new file mode 100644 index 0000000..4664c0c --- /dev/null +++ b/libs/usd-asset-cache/tests/test_cache.cpp @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// What the cache does to the reader underneath it. +// +// Every case here is a statement about requests and bytes, because that is the +// only thing the cache changes: correctness of the bytes themselves is the +// shared boundary suite's question, and the suite runs against `cache over +// local` unchanged. What cannot be asked there is "how many requests did that +// cost", and that is what this file is. + +#include +#include +#include +#include + +#include "usdAssetCache/BlockCache.h" +#include "usdAssetCache/CacheOptions.h" +#include "usdAssetCache/CachedAssetReader.h" + +#include "Check.h" +#include "FakeReader.h" + +using namespace usdasset; +using namespace usdasset::cache; +using usdassetcachetest::ContentByte; +using usdassetcachetest::FakeReader; +using usdassetcachetest::MakeContent; + +namespace { + +constexpr std::uint64_t kBlock = 4096; + +CacheOptions TestOptions() { + CacheOptions options; + options.blockSize = kBlock; + options.budgetBytes = 64 * kBlock; + options.coalesceGapBlocks = 1; + options.maxRequestBytes = 16 * kBlock; + options.bypassThresholdBytes = 8 * kBlock; + return options.Normalized(); +} + +/// A cached reader over a fake one, with the fake still reachable so that a +/// test can ask what the cache actually requested. +struct Stack { + FakeReader* inner = nullptr; + std::unique_ptr reader; +}; + +Stack MakeStack(BlockCache& store, + const CacheOptions& options, + const std::string& identifier, + std::uint64_t size, + const Validator& validator) { + std::unique_ptr fake( + new FakeReader(identifier, MakeContent(size), validator)); + Stack stack; + stack.inner = fake.get(); + ReaderMetrics* innerMetrics = &fake->Metrics(); + CachedOpenResult opened = + Wrap(std::unique_ptr(fake.release()), innerMetrics, options, &store); + stack.reader = std::move(opened.reader); + return stack; +} + +bool ContentMatches(const std::vector& buffer, + std::uint64_t offset, + std::size_t length) { + for (std::size_t i = 0; i < length; ++i) { + if (buffer[i] != ContentByte(offset + i)) { + return false; + } + } + return true; +} + +/// Reads and checks the bytes, returning the result so a case can assert on it. +ReadResult ReadChecked(AssetReader& reader, + std::uint64_t offset, + std::size_t length, + const char* what) { + std::vector buffer(length + 8, 0xEE); + const ReadResult result = reader.Read(offset, buffer.data(), length); + if (result.status.IsOk() && !ContentMatches(buffer, offset, result.bytesRead)) { + std::fprintf(stderr, "FAIL %s: wrong bytes at offset %llu\n", what, + static_cast(offset)); + ++::usdassettest::FailureCount(); + } + for (std::size_t i = 0; i < 8; ++i) { + if (buffer[length + i] != 0xEE) { + std::fprintf(stderr, "FAIL %s: wrote past the buffer it was given\n", what); + ++::usdassettest::FailureCount(); + break; + } + } + return result; +} + +void ClusteredSmallReadsBecomeOneRequest() { + // The headline of the release, in one case: sixteen adjacent 256-byte reads + // inside one block are one fetch, not sixteen. + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://clustered", kBlock * 8, + usdassetcachetest::StrongValidator("etag-1")); + + for (int i = 0; i < 16; ++i) { + const ReadResult result = + ReadChecked(*stack.reader, static_cast(i) * 256, 256, + "clustered read"); + CHECK(result.status.IsOk()); + CHECK_EQ(result.bytesRead, std::size_t{256}); + } + + CHECK_EQ(stack.inner->CallCount(), std::size_t{1}); + CHECK_EQ(stack.inner->BytesRead(), kBlock); + + const MetricsSnapshot snapshot = stack.reader->SnapshotMetrics(); + CHECK_EQ(snapshot.bytesRequested, std::uint64_t{16 * 256}); + CHECK_EQ(snapshot.bytesTransferred, kBlock); + CHECK_EQ(snapshot.requestCount, std::uint64_t{1}); + CHECK_EQ(snapshot.blockMisses, std::uint64_t{1}); + // Fifteen of the sixteen reads were answered without touching the reader. + CHECK_EQ(snapshot.blockHits, std::uint64_t{15}); + CHECK_EQ(snapshot.bytesFromCache, std::uint64_t{15 * 256}); + // The two counters that look contradictory and are not, which is why this + // case asserts both. `bytesOverFetched` is charged when the block is + // fetched -- 4096 moved for a 256-byte read -- and it is never refunded + // when the other fifteen reads consume the rest. `amplification` is the + // whole read pattern against the whole transfer, and here it lands at + // exactly 1.0 because the caller did eventually ask for every byte the + // block alignment moved. The refund lives in `cacheHitRatio`. + CHECK_EQ(snapshot.bytesOverFetched, kBlock - 256); + CHECK_EQ(snapshot.Amplification(), 1.0); + CHECK_EQ(snapshot.CacheHitRatio(), 15.0 / 16.0); +} + +void ARereadOfTheSameRangeIssuesNoRequest() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://reread", kBlock * 4, + usdassetcachetest::StrongValidator("etag-1")); + + ReadChecked(*stack.reader, kBlock, 100, "first"); + const std::size_t afterFirst = stack.inner->CallCount(); + ReadChecked(*stack.reader, kBlock, 100, "second"); + CHECK_EQ(stack.inner->CallCount(), afterFirst); + + const MetricsSnapshot snapshot = stack.reader->SnapshotMetrics(); + CHECK_EQ(snapshot.blockHits, std::uint64_t{1}); + CHECK_EQ(snapshot.CacheHitRatio(), 0.5); +} + +void AReadSpanningACachedAndAnUncachedBlockIsAPartialHit() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://partial", kBlock * 4, + usdassetcachetest::StrongValidator("etag-1")); + + ReadChecked(*stack.reader, 0, 16, "warm block 0"); + ReadChecked(*stack.reader, kBlock - 8, 16, "straddle"); + + const MetricsSnapshot snapshot = stack.reader->SnapshotMetrics(); + CHECK_EQ(snapshot.partialHits, std::uint64_t{1}); + CHECK_EQ(snapshot.blockMisses, std::uint64_t{2}); +} + +void AGapIsMergedIntoOneRequestAndCounted() { + CacheOptions options = TestOptions(); + options.coalesceGapBlocks = 1; + options = options.Normalized(); + + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://gap", kBlock * 16, + usdassetcachetest::StrongValidator("etag-1")); + + // Block 1 is made resident first, so the read that follows wants blocks 0 + // and 2 and has a one-block gap between them -- which is the shape of the + // example in CACHE.md section 4, and the only way a gap can arise: every + // block of a contiguous read is wanted unless something already holds it. + ReadChecked(*stack.reader, kBlock, 16, "warm the gap block"); + CHECK_EQ(stack.inner->CallCount(), std::size_t{1}); + + std::vector buffer(kBlock * 2 + 16, 0); + const ReadResult result = stack.reader->Read(0, buffer.data(), kBlock * 2 + 16); + CHECK(result.status.IsOk()); + CHECK_EQ(result.bytesRead, static_cast(kBlock * 2 + 16)); + + // One request for blocks 0..2, which fetches block 1 again although it was + // already resident. That is the trade the policy names: transferring the + // gap costs less than a second round trip. + CHECK_EQ(stack.inner->CallCount(), std::size_t{2}); + const std::vector calls = stack.inner->Calls(); + CHECK_EQ(calls[1].offset, std::uint64_t{0}); + CHECK_EQ(calls[1].size, static_cast(kBlock * 3)); + + const MetricsSnapshot snapshot = stack.reader->SnapshotMetrics(); + CHECK_EQ(snapshot.blockMisses, std::uint64_t{3}); + CHECK_EQ(snapshot.partialHits, std::uint64_t{1}); + // Two owned blocks that would have been two requests became one. + CHECK_EQ(snapshot.requestsSavedByCoalescing, std::uint64_t{1}); +} + +void ALargeReadBypassesTheCacheAndStoresNothing() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://bypass", kBlock * 64, + usdassetcachetest::StrongValidator("etag-1")); + + const std::size_t size = static_cast(options.bypassThresholdBytes); + const ReadResult result = ReadChecked(*stack.reader, 0, size, "bypass"); + CHECK(result.status.IsOk()); + CHECK_EQ(result.bytesRead, size); + + // Exactly what was asked for, from one request, and nothing resident. + CHECK_EQ(stack.inner->CallCount(), std::size_t{1}); + CHECK_EQ(stack.inner->BytesRead(), static_cast(size)); + CHECK_EQ(store.Snapshot().blockCount, std::uint64_t{0}); + + const MetricsSnapshot snapshot = stack.reader->SnapshotMetrics(); + CHECK_EQ(snapshot.bytesOverFetched, std::uint64_t{0}); + CHECK_EQ(snapshot.Amplification(), 1.0); +} + +void TheFinalShortBlockIsServedAtItsTrueLength() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + const std::uint64_t assetSize = kBlock * 2 + 17; + Stack stack = MakeStack(store, options, "test://short-tail", assetSize, + usdassetcachetest::StrongValidator("etag-1")); + + // Straddling EOF: the remainder, and Ok. Never seventeen bytes and 4079 + // zeros, which is what a padded final block would produce. + const ReadResult straddle = ReadChecked(*stack.reader, kBlock * 2, 4096, "straddle EOF"); + CHECK(straddle.status.IsOk()); + CHECK_EQ(straddle.bytesRead, std::size_t{17}); + + // And again, from cache this time, with the same answer. + const ReadResult again = ReadChecked(*stack.reader, kBlock * 2, 4096, "straddle again"); + CHECK(again.status.IsOk()); + CHECK_EQ(again.bytesRead, std::size_t{17}); + CHECK_EQ(stack.inner->CallCount(), std::size_t{1}); + + // At EOF: an absence, not an error, and no request. + unsigned char scratch[8]; + const ReadResult atEnd = stack.reader->Read(assetSize, scratch, sizeof(scratch)); + CHECK(atEnd.status.IsOk()); + CHECK_EQ(atEnd.bytesRead, std::size_t{0}); + CHECK_EQ(stack.inner->CallCount(), std::size_t{1}); +} + +void EvictionIsInvisibleToCorrectness() { + CacheOptions options = TestOptions(); + options.budgetBytes = 4 * kBlock; // one stripe, four blocks + options = options.Normalized(); + + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://evict", kBlock * 64, + usdassetcachetest::StrongValidator("etag-1")); + + for (int i = 0; i < 16; ++i) { + const ReadResult result = ReadChecked( + *stack.reader, static_cast(i) * kBlock, 64, "walk"); + CHECK(result.status.IsOk()); + } + CHECK(store.Snapshot().evictions > 0); + CHECK(store.Snapshot().residentBytes <= options.budgetBytes); + + // The block that was evicted first is re-fetched rather than served stale + // or zero-filled, and the bytes are still right. + const std::size_t before = stack.inner->CallCount(); + const ReadResult again = ReadChecked(*stack.reader, 0, 64, "re-read evicted"); + CHECK(again.status.IsOk()); + CHECK(stack.inner->CallCount() > before); + + const MetricsSnapshot snapshot = stack.reader->SnapshotMetrics(); + CHECK(snapshot.evictions > 0); + CHECK(snapshot.peakResidentBytes > 0); +} + +void TwoReadersOfOneRevisionShareBlocks() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + + Stack first = MakeStack(store, options, "test://shared", kBlock * 8, + usdassetcachetest::StrongValidator("etag-1")); + ReadChecked(*first.reader, 0, 64, "first reader"); + CHECK_EQ(first.inner->CallCount(), std::size_t{1}); + + Stack second = MakeStack(store, options, "test://shared", kBlock * 8, + usdassetcachetest::StrongValidator("etag-1")); + const ReadResult result = ReadChecked(*second.reader, 0, 64, "second reader"); + CHECK(result.status.IsOk()); + // The second reader moved no bytes at all. This is the figure the parallel + // readers row of the baseline exists to move. + CHECK_EQ(second.inner->CallCount(), std::size_t{0}); + CHECK_EQ(second.reader->SnapshotMetrics().bytesFromCache, std::uint64_t{64}); + CHECK_EQ(store.Snapshot().identityCount, std::uint64_t{1}); +} + +void AnEntryFromOneRevisionNeverServesAnother() { + // The rule the key exists for. Same URL, different validator, and the + // second reader is not allowed to see the first one's bytes. + const CacheOptions options = TestOptions(); + BlockCache store(options); + + Stack revisionA = MakeStack(store, options, "test://published", kBlock * 4, + usdassetcachetest::StrongValidator("etag-A")); + ReadChecked(*revisionA.reader, 0, 64, "revision A"); + CHECK_EQ(revisionA.inner->CallCount(), std::size_t{1}); + + Stack revisionB = MakeStack(store, options, "test://published", kBlock * 4, + usdassetcachetest::StrongValidator("etag-B")); + ReadChecked(*revisionB.reader, 0, 64, "revision B"); + CHECK_EQ(revisionB.inner->CallCount(), std::size_t{1}); + CHECK_EQ(store.Snapshot().identityCount, std::uint64_t{2}); +} + +void AWeakValidatorCachesPrivatelyAndDropsOnClose() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + + { + Stack weak = MakeStack(store, options, "test://weak", kBlock * 4, + usdassetcachetest::WeakValidator("W/etag")); + CHECK(weak.reader->Binding().IsPrivate()); + ReadChecked(*weak.reader, 0, 64, "weak first"); + ReadChecked(*weak.reader, 0, 64, "weak second"); + // It still caches within the reader's own lifetime: the binding in + // ASSET_READER.md section 2.1 carries the guarantee there. + CHECK_EQ(weak.inner->CallCount(), std::size_t{1}); + + Stack other = MakeStack(store, options, "test://weak", kBlock * 4, + usdassetcachetest::WeakValidator("W/etag")); + ReadChecked(*other.reader, 0, 64, "weak other reader"); + // But it is not shared: a weak validator cannot prove the two readers + // are looking at the same bytes. + CHECK_EQ(other.inner->CallCount(), std::size_t{1}); + } + + // And nothing survives the readers that made it. + CHECK_EQ(store.Snapshot().blockCount, std::uint64_t{0}); + CHECK_EQ(store.Snapshot().residentBytes, std::uint64_t{0}); + CHECK_EQ(store.ResidentBytes(), std::uint64_t{0}); +} + +void NoValidatorCachesPrivatelyToo() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack none = MakeStack(store, options, "test://novalidator", kBlock * 4, + usdassetcachetest::NoValidator()); + CHECK(none.reader->Binding().IsPrivate()); + CHECK_EQ(none.reader->Metadata().stability, IdentityStability::Unavailable); + ReadChecked(*none.reader, 0, 64, "no validator"); + ReadChecked(*none.reader, 0, 64, "no validator again"); + CHECK_EQ(none.inner->CallCount(), std::size_t{1}); +} + +void AFailedFetchLeavesNothingPendingAndIsReported() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://failure", kBlock * 4, + usdassetcachetest::StrongValidator("etag-1")); + + stack.inner->FailNext(1, Status::Error(StatusCode::NetworkError, "connection reset")); + + std::vector buffer(64, 0); + const ReadResult failed = stack.reader->Read(0, buffer.data(), 64); + CHECK_EQ(failed.status.code, StatusCode::NetworkError); + CHECK_EQ(failed.bytesRead, std::size_t{0}); + + // Nothing is left pending: a block that stayed claimed would make every + // later reader wait for a fetch that is not happening. + CHECK_EQ(store.Snapshot().pendingCount, std::uint64_t{0}); + + const ReadResult recovered = ReadChecked(*stack.reader, 0, 64, "after failure"); + CHECK(recovered.status.IsOk()); + CHECK_EQ(recovered.bytesRead, std::size_t{64}); +} + +void CountersAreTheStacksAndNotOneLayers() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://counters", kBlock * 4, + usdassetcachetest::StrongValidator("etag-1")); + ReadChecked(*stack.reader, 0, 100, "counters"); + + const MetricsSnapshot snapshot = stack.reader->SnapshotMetrics(); + // The caller's ask, from the cache; the transport's move, from the reader + // underneath. A snapshot with one and not the other is a ratio between two + // layers rather than a measurement of a stack. + CHECK_EQ(snapshot.bytesRequested, std::uint64_t{100}); + CHECK_EQ(snapshot.bytesTransferred, kBlock); + CHECK_EQ(snapshot.requestCount, std::uint64_t{1}); + CHECK_EQ(snapshot.assetSize, kBlock * 4); + CHECK(stack.inner->Metrics().IsDetached()); + CHECK(snapshot.identifier.find("test://counters") != std::string::npos); +} + +void AnOverflowingRequestIsRefusedBeforeAnythingIsFetched() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://overflow", kBlock * 4, + usdassetcachetest::StrongValidator("etag-1")); + + unsigned char scratch[16]; + const ReadResult result = stack.reader->Read(0xFFFFFFFFFFFFFFF0ull, scratch, 32); + CHECK_EQ(result.status.code, StatusCode::InvalidArgument); + CHECK_EQ(result.bytesRead, std::size_t{0}); + CHECK_EQ(stack.inner->CallCount(), std::size_t{0}); +} + +void AZeroLengthReadIssuesNoRequest() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://zero", kBlock * 4, + usdassetcachetest::StrongValidator("etag-1")); + + const ReadResult result = stack.reader->Read(0, nullptr, 0); + CHECK(result.status.IsOk()); + CHECK_EQ(result.bytesRead, std::size_t{0}); + CHECK_EQ(stack.inner->CallCount(), std::size_t{0}); +} + +void AStoreCannotBeReconfiguredUnderneathALiveBinding() { + // The process store, deliberately: this is the one call that rebuilds the + // stripes, and a binding that held a stripe index across the rebuild would + // be reading a container that no longer exists. + CacheOptions options = TestOptions(); + CHECK(BlockCache::ConfigureProcess(options)); + + std::unique_ptr fake(new FakeReader( + "test://process", MakeContent(kBlock), usdassetcachetest::StrongValidator("e"))); + ReaderMetrics* metrics = &fake->Metrics(); + CachedOpenResult opened = + Wrap(std::unique_ptr(fake.release()), metrics, options, nullptr); + CHECK(opened.reader != nullptr); + CHECK(!BlockCache::ConfigureProcess(options)); + opened.reader.reset(); + CHECK(BlockCache::ConfigureProcess(options)); + BlockCache::Process().ClearForTesting(); +} + +void WrappingNothingFailsRatherThanCrashing() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + CachedOpenResult empty = Wrap(nullptr, nullptr, options, &store); + CHECK(empty.reader == nullptr); + CHECK_EQ(empty.status.code, StatusCode::InvalidArgument); + + OpenResult failedOpen; + failedOpen.status = Status::Error(StatusCode::NotFound, "no such asset"); + OpenResult passedThrough = WrapAsset(std::move(failedOpen), nullptr, options, &store); + CHECK(passedThrough.reader == nullptr); + // The backend's status survives; replacing it would erase the only useful + // thing the result carries. + CHECK_EQ(passedThrough.status.code, StatusCode::NotFound); +} + +} // namespace + +int main() { + ClusteredSmallReadsBecomeOneRequest(); + ARereadOfTheSameRangeIssuesNoRequest(); + AReadSpanningACachedAndAnUncachedBlockIsAPartialHit(); + AGapIsMergedIntoOneRequestAndCounted(); + ALargeReadBypassesTheCacheAndStoresNothing(); + TheFinalShortBlockIsServedAtItsTrueLength(); + EvictionIsInvisibleToCorrectness(); + TwoReadersOfOneRevisionShareBlocks(); + AnEntryFromOneRevisionNeverServesAnother(); + AWeakValidatorCachesPrivatelyAndDropsOnClose(); + NoValidatorCachesPrivatelyToo(); + AFailedFetchLeavesNothingPendingAndIsReported(); + CountersAreTheStacksAndNotOneLayers(); + AnOverflowingRequestIsRefusedBeforeAnythingIsFetched(); + AZeroLengthReadIssuesNoRequest(); + AStoreCannotBeReconfiguredUnderneathALiveBinding(); + WrappingNothingFailsRatherThanCrashing(); + return usdassettest::Report("usdAssetCache_cache"); +} diff --git a/libs/usd-asset-cache/tests/test_plan.cpp b/libs/usd-asset-cache/tests/test_plan.cpp new file mode 100644 index 0000000..c6698df --- /dev/null +++ b/libs/usd-asset-cache/tests/test_plan.cpp @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The cache's arithmetic, with no reader, no store, and no thread: block +// alignment, coalescing, the over-fetch charge, option normalization, and key +// identity. +// +// Separated from the reader tests for the reason ResolveReadRange is separated +// from the backends: this is where the off-by-one lives, and it should be +// checkable without provisioning an asset. + +#include +#include + +#include "usdAssetCache/CacheKey.h" +#include "usdAssetCache/CacheOptions.h" + +#include "BlockPlan.h" +#include "Check.h" + +using namespace usdasset; +using namespace usdasset::cache; +using namespace usdasset::cache::detail; + +namespace { + +constexpr std::uint64_t kBlock = 4096; + +void AReadInsideOneBlockCoversOneBlock() { + const BlockSpan span = CoveringBlocks(10, 20, kBlock); + CHECK_EQ(span.first, std::uint64_t{0}); + CHECK_EQ(span.last, std::uint64_t{0}); + CHECK_EQ(span.Count(), std::uint64_t{1}); +} + +void AReadEndingExactlyOnABoundaryDoesNotTouchTheNextBlock() { + // The case the naive `(offset + length) / blockSize` gets wrong. A read of + // [0, 4096) ends where block 1 begins and touches none of it; rounding up + // would fetch block 1, store it, and charge the caller for it. + const BlockSpan span = CoveringBlocks(0, kBlock, kBlock); + CHECK_EQ(span.first, std::uint64_t{0}); + CHECK_EQ(span.last, std::uint64_t{0}); +} + +void AReadStartingExactlyOnABoundaryStartsAtThatBlock() { + const BlockSpan span = CoveringBlocks(kBlock, 1, kBlock); + CHECK_EQ(span.first, std::uint64_t{1}); + CHECK_EQ(span.last, std::uint64_t{1}); +} + +void AReadStraddlingABoundaryCoversBoth() { + const BlockSpan span = CoveringBlocks(kBlock - 1, 2, kBlock); + CHECK_EQ(span.first, std::uint64_t{0}); + CHECK_EQ(span.last, std::uint64_t{1}); +} + +void TheFinalBlockIsShortAndIsNotPadded() { + // A cache that padded it would answer a read past EOF with zeros, which is + // a silent corruption that looks exactly like valid data. + const std::uint64_t assetSize = kBlock * 2 + 17; + const BlockExtent extent = ExtentOf(2, kBlock, assetSize); + CHECK_EQ(extent.offset, kBlock * 2); + CHECK_EQ(extent.length, std::uint64_t{17}); + + const BlockExtent whole = ExtentOf(1, kBlock, assetSize); + CHECK_EQ(whole.length, kBlock); + + const BlockExtent past = ExtentOf(9, kBlock, assetSize); + CHECK_EQ(past.length, std::uint64_t{0}); +} + +void AdjacentBlocksBecomeOneRequest() { + const std::vector blocks{4, 5, 6}; + const std::vector runs = PlanRuns(blocks, kBlock, kBlock * 100, 0, kBlock * 64); + CHECK_EQ(runs.size(), std::size_t{1}); + CHECK_EQ(runs[0].firstBlock, std::uint64_t{4}); + CHECK_EQ(runs[0].blockCount, std::uint64_t{3}); + CHECK_EQ(runs[0].offset, kBlock * 4); + CHECK_EQ(runs[0].length, kBlock * 3); +} + +void AGapWiderThanThePolicyIsNotMerged() { + const std::vector blocks{5, 6, 8}; + const std::vector split = PlanRuns(blocks, kBlock, kBlock * 100, 0, kBlock * 64); + CHECK_EQ(split.size(), std::size_t{2}); + CHECK_EQ(split[0].blockCount, std::uint64_t{2}); + CHECK_EQ(split[1].firstBlock, std::uint64_t{8}); +} + +void AGapInsideThePolicyIsMergedAndFetchesTheGap() { + // CACHE.md section 4, verbatim: blocks 5, 6 and 8 with a gap of one become + // one request for blocks 5..8, which fetches block 7 unnecessarily. + const std::vector blocks{5, 6, 8}; + const std::vector runs = PlanRuns(blocks, kBlock, kBlock * 100, 1, kBlock * 64); + CHECK_EQ(runs.size(), std::size_t{1}); + CHECK_EQ(runs[0].firstBlock, std::uint64_t{5}); + CHECK_EQ(runs[0].blockCount, std::uint64_t{4}); + CHECK_EQ(runs[0].length, kBlock * 4); +} + +void AMergeIsNeverTakenPastTheRequestCeiling() { + const std::vector blocks{0, 1, 2, 3}; + const std::vector runs = PlanRuns(blocks, kBlock, kBlock * 100, 4, kBlock * 2); + CHECK_EQ(runs.size(), std::size_t{2}); + CHECK_EQ(runs[0].blockCount, std::uint64_t{2}); + CHECK_EQ(runs[1].blockCount, std::uint64_t{2}); +} + +void ASingleBlockIsAlwaysEmittedWhateverTheCeiling() { + const std::vector blocks{7}; + const std::vector runs = PlanRuns(blocks, kBlock, kBlock * 100, 4, 1); + CHECK_EQ(runs.size(), std::size_t{1}); + CHECK_EQ(runs[0].length, kBlock); +} + +void ARunAtTheEndOfTheAssetStopsAtTheEnd() { + const std::uint64_t assetSize = kBlock * 3 + 100; + const std::vector blocks{2, 3}; + const std::vector runs = PlanRuns(blocks, kBlock, assetSize, 0, kBlock * 64); + CHECK_EQ(runs.size(), std::size_t{1}); + CHECK_EQ(runs[0].length, kBlock + 100); + CHECK_EQ(runs[0].offset + runs[0].length, assetSize); +} + +void OverFetchIsTheBytesNobodyAskedFor() { + FetchRun run; + run.offset = 0; + run.length = kBlock * 4; + + // A caller that wanted 100 bytes in the middle paid for four blocks. + CHECK_EQ(OverFetchedBytes(run, kBlock, 100), kBlock * 4 - 100); + // A caller that wanted all of it paid for nothing extra. + CHECK_EQ(OverFetchedBytes(run, 0, kBlock * 4), std::uint64_t{0}); + // A run that overlaps nothing the caller wanted is over-fetch end to end. + CHECK_EQ(OverFetchedBytes(run, kBlock * 10, kBlock), kBlock * 4); +} + +void OptionsRoundTheBlockSizeDownToAPowerOfTwo() { + CacheOptions options; + options.blockSize = 100000; + const CacheOptions normalized = options.Normalized(); + CHECK_EQ(normalized.blockSize, std::uint64_t{65536}); + // Down, not to the nearest. The risk this cache is exposed to is + // over-fetch, and rounding up would double the bytes every miss moves. + CHECK(normalized.blockSize < 100000); +} + +void OptionsClampToSomethingUsable() { + CacheOptions options; + options.blockSize = 1; + options.budgetBytes = 1; + options.maxRequestBytes = 1; + options.bypassThresholdBytes = 1; + options.coalesceGapBlocks = 1000; + + const CacheOptions normalized = options.Normalized(); + CHECK_EQ(normalized.blockSize, kMinBlockSize); + CHECK(normalized.budgetBytes >= normalized.blockSize); + CHECK(normalized.maxRequestBytes >= normalized.blockSize); + CHECK(normalized.bypassThresholdBytes >= normalized.blockSize); + // One block per request leaves no room to merge across anything. + CHECK_EQ(normalized.coalesceGapBlocks, std::uint32_t{0}); +} + +void NormalizationIsIdempotent() { + CacheOptions options; + options.blockSize = 3 * 4096; + options.budgetBytes = 7; + options.coalesceGapBlocks = 99; + const CacheOptions once = options.Normalized(); + CHECK(once.IsNormalized()); + const CacheOptions twice = once.Normalized(); + CHECK_EQ(twice.blockSize, once.blockSize); + CHECK_EQ(twice.budgetBytes, once.budgetBytes); + CHECK_EQ(twice.coalesceGapBlocks, once.coalesceGapBlocks); + CHECK(CacheOptions().IsNormalized()); +} + +void EqualIdentifiersNeverImplyEqualContent() { + // The one line CACHE.md section 6 is made of. Two revisions published at + // one URL are two cache identities. + AssetIdentity a; + a.resolvedIdentifier = "https://example.org/asset.usd"; + a.validator = "etag-A"; + a.blockSize = 65536; + + AssetIdentity b = a; + b.validator = "etag-B"; + + CHECK(a != b); + CHECK(HashAssetIdentity(a) != HashAssetIdentity(b)); + + AssetIdentity same = a; + CHECK(a == same); + CHECK_EQ(HashAssetIdentity(a), HashAssetIdentity(same)); +} + +void TheBlockSizeIsPartOfTheIdentity() { + AssetIdentity a; + a.resolvedIdentifier = "https://example.org/asset.usd"; + a.validator = "etag"; + a.blockSize = 65536; + + AssetIdentity b = a; + b.blockSize = 4096; + CHECK(a != b); +} + +void KeysDifferByBlockIndex() { + CacheKey first; + first.identity.resolvedIdentifier = "https://example.org/a"; + first.identity.validator = "etag"; + first.identity.blockSize = 4096; + first.blockIndex = 3; + + CacheKey second = first; + second.blockIndex = 4; + + CHECK(first != second); + CHECK(first == first); + CHECK(HashCacheKey(first) != HashCacheKey(second)); +} + +void OnlyAStrongValidatorAdmitsSharing() { + Validator strong; + strong.value = "abc"; + strong.kind = ValidatorKind::EntityTag; + strong.strength = ValidatorStrength::Strong; + CHECK(IsShareable(strong)); + + Validator weak = strong; + weak.strength = ValidatorStrength::Weak; + CHECK(!IsShareable(weak)); + + Validator none; + CHECK(!IsShareable(none)); + + // A strength without a kind is a producer bug, and resolving it toward the + // weaker answer is the only safe direction -- the same rule + // ClassifyStability applies. + Validator strengthWithoutKind; + strengthWithoutKind.value = "abc"; + strengthWithoutKind.strength = ValidatorStrength::Strong; + CHECK(!IsShareable(strengthWithoutKind)); +} + +} // namespace + +int main() { + AReadInsideOneBlockCoversOneBlock(); + AReadEndingExactlyOnABoundaryDoesNotTouchTheNextBlock(); + AReadStartingExactlyOnABoundaryStartsAtThatBlock(); + AReadStraddlingABoundaryCoversBoth(); + TheFinalBlockIsShortAndIsNotPadded(); + AdjacentBlocksBecomeOneRequest(); + AGapWiderThanThePolicyIsNotMerged(); + AGapInsideThePolicyIsMergedAndFetchesTheGap(); + AMergeIsNeverTakenPastTheRequestCeiling(); + ASingleBlockIsAlwaysEmittedWhateverTheCeiling(); + ARunAtTheEndOfTheAssetStopsAtTheEnd(); + OverFetchIsTheBytesNobodyAskedFor(); + OptionsRoundTheBlockSizeDownToAPowerOfTwo(); + OptionsClampToSomethingUsable(); + NormalizationIsIdempotent(); + EqualIdentifiersNeverImplyEqualContent(); + TheBlockSizeIsPartOfTheIdentity(); + KeysDifferByBlockIndex(); + OnlyAStrongValidatorAdmitsSharing(); + return usdassettest::Report("usdAssetCache_plan"); +} diff --git a/libs/usd-asset-cache/tests/test_singleflight.cpp b/libs/usd-asset-cache/tests/test_singleflight.cpp new file mode 100644 index 0000000..c97413d --- /dev/null +++ b/libs/usd-asset-cache/tests/test_singleflight.cpp @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Single-flight, and the concurrency around it. +// +// This is the file ThreadSanitizer exists for in this module. Section 5 of +// CACHE.md calls single-flight correctness-adjacent rather than an +// optimization, for two reasons that are both checked here: N Hydra threads +// opening one asset otherwise produce N identical requests, N times the bytes, +// and a metrics report off by a factor of N; and it is where a naive +// implementation deadlocks. +// +// Asserting concurrency properties in prose asserts nothing +// (BOUNDARY_SUITE.md section 5), so every case here races real threads and the +// lane that means anything is `core-tsan`. + +#include +#include +#include +#include +#include +#include + +#include "usdAssetCache/BlockCache.h" +#include "usdAssetCache/CacheOptions.h" +#include "usdAssetCache/CachedAssetReader.h" + +#include "Check.h" +#include "FakeReader.h" + +using namespace usdasset; +using namespace usdasset::cache; +using usdassetcachetest::ContentByte; +using usdassetcachetest::FakeReader; +using usdassetcachetest::Gate; +using usdassetcachetest::MakeContent; + +namespace { + +constexpr std::uint64_t kBlock = 4096; +constexpr int kThreads = 8; + +CacheOptions TestOptions() { + CacheOptions options; + options.blockSize = kBlock; + options.budgetBytes = 256 * kBlock; + options.coalesceGapBlocks = 1; + options.maxRequestBytes = 16 * kBlock; + options.bypassThresholdBytes = 8 * kBlock; + return options.Normalized(); +} + +struct Stack { + FakeReader* inner = nullptr; + std::unique_ptr reader; +}; + +Stack MakeStack(BlockCache& store, + const CacheOptions& options, + const std::string& identifier, + std::uint64_t size, + const Validator& validator) { + std::unique_ptr fake( + new FakeReader(identifier, MakeContent(size), validator)); + Stack stack; + stack.inner = fake.get(); + ReaderMetrics* metrics = &fake->Metrics(); + CachedOpenResult opened = + Wrap(std::unique_ptr(fake.release()), metrics, options, &store); + stack.reader = std::move(opened.reader); + return stack; +} + +bool BytesAreRight(const std::vector& buffer, + std::uint64_t offset, + std::size_t length) { + for (std::size_t i = 0; i < length; ++i) { + if (buffer[i] != ContentByte(offset + i)) { + return false; + } + } + return true; +} + +void ManyThreadsMissingOneBlockIssueOneRequest() { + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://oneflight", kBlock * 8, + usdassetcachetest::StrongValidator("etag-1")); + + // The owner is held inside the transport, so every other thread that gets + // as far as the store finds the block claimed and has to wait. Without the + // latch the case would still be a race worth running; with it, the moment + // single-flight has to work is guaranteed to happen. + Gate gate; + stack.inner->SetGate(&gate); + + std::atomic wrongBytes{0}; + std::atomic failures{0}; + std::vector threads; + threads.reserve(kThreads); + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&] { + std::vector buffer(512, 0); + const ReadResult result = stack.reader->Read(64, buffer.data(), 512); + if (!result.status.IsOk() || result.bytesRead != 512) { + ++failures; + } else if (!BytesAreRight(buffer, 64, 512)) { + ++wrongBytes; + } + }); + } + + gate.WaitForArrivals(1); + gate.Open(); + for (std::thread& thread : threads) { + thread.join(); + } + + CHECK_EQ(failures.load(), 0); + CHECK_EQ(wrongBytes.load(), 0); + // The whole point: eight threads, one request, one block moved. + CHECK_EQ(stack.inner->CallCount(), std::size_t{1}); + CHECK_EQ(stack.inner->BytesRead(), kBlock); + + const MetricsSnapshot snapshot = stack.reader->SnapshotMetrics(); + CHECK_EQ(snapshot.bytesTransferred, kBlock); + CHECK_EQ(snapshot.requestCount, std::uint64_t{1}); + CHECK(snapshot.requestsSavedBySingleFlight + snapshot.blockHits >= 1); +} + +void ManyReadersOfOneRevisionIssueOneRequestBetweenThem() { + // Not threads on one reader -- separate readers, each with its own + // transport, as eight parallel opens of one asset actually are. They share + // because the identity says they may, and for no other reason. + const CacheOptions options = TestOptions(); + BlockCache store(options); + + std::vector stacks; + stacks.reserve(kThreads); + for (int i = 0; i < kThreads; ++i) { + stacks.push_back(MakeStack(store, options, "test://parallel", kBlock * 8, + usdassetcachetest::StrongValidator("etag-1"))); + } + + std::atomic failures{0}; + std::vector threads; + threads.reserve(kThreads); + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&, t] { + std::vector buffer(256, 0); + const ReadResult result = stacks[t].reader->Read(0, buffer.data(), 256); + if (!result.status.IsOk() || !BytesAreRight(buffer, 0, 256)) { + ++failures; + } + }); + } + + // No latch here, and none is needed: whichever reader wins the claim, the + // others either wait on it or find the block resident, and both outcomes + // are one request. Two requests would mean the identity did not match. + for (std::thread& thread : threads) { + thread.join(); + } + + CHECK_EQ(failures.load(), 0); + std::size_t totalCalls = 0; + for (const Stack& stack : stacks) { + totalCalls += stack.inner->CallCount(); + } + CHECK_EQ(totalCalls, std::size_t{1}); +} + +void OverlappingScatteredReadsAgreeOnEveryByte() { + // The ThreadSanitizer case: threads on one reader over overlapping ranges, + // crossing block boundaries, with eviction running underneath them because + // the budget is smaller than the asset. + CacheOptions options = TestOptions(); + options.budgetBytes = 8 * kBlock; + options = options.Normalized(); + + BlockCache store(options); + const std::uint64_t assetSize = kBlock * 64 + 123; + Stack stack = MakeStack(store, options, "test://scatter", assetSize, + usdassetcachetest::StrongValidator("etag-1")); + + std::atomic wrong{0}; + std::vector threads; + threads.reserve(kThreads); + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&, t] { + std::uint64_t seed = 0x1234567ull + static_cast(t); + std::vector buffer(9000, 0); + for (int i = 0; i < 400; ++i) { + seed = seed * 6364136223846793005ull + 1442695040888963407ull; + const std::uint64_t offset = (seed >> 17) % (assetSize + 64); + const std::size_t size = static_cast((seed >> 5) % 8192) + 1; + const ReadResult result = stack.reader->Read(offset, buffer.data(), size); + if (!result.status.IsOk()) { + ++wrong; + continue; + } + const std::uint64_t expected = + offset >= assetSize ? 0 + : (std::min)(static_cast(size), + assetSize - offset); + if (result.bytesRead != expected || + !BytesAreRight(buffer, offset, result.bytesRead)) { + ++wrong; + } + } + }); + } + for (std::thread& thread : threads) { + thread.join(); + } + CHECK_EQ(wrong.load(), 0); + CHECK(store.Snapshot().residentBytes <= options.budgetBytes); + CHECK_EQ(store.Snapshot().pendingCount, std::uint64_t{0}); +} + +void AWaiterRecoversWhenTheOwnersFetchFails() { + // A block claimed by a fetch that then fails must not fail every reader + // that was waiting on it: the failure was somebody else's, against + // somebody else's transport. They acquire again and do the work. + const CacheOptions options = TestOptions(); + BlockCache store(options); + Stack stack = MakeStack(store, options, "test://ownerfails", kBlock * 8, + usdassetcachetest::StrongValidator("etag-1")); + + Gate gate; + stack.inner->SetGate(&gate); + stack.inner->FailNext(1, Status::Error(StatusCode::NetworkError, "reset")); + + std::atomic failures{0}; + std::atomic wrong{0}; + std::vector threads; + threads.reserve(kThreads); + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&] { + std::vector buffer(128, 0); + const ReadResult result = stack.reader->Read(0, buffer.data(), 128); + if (!result.status.IsOk()) { + ++failures; + } else if (!BytesAreRight(buffer, 0, 128)) { + ++wrong; + } + }); + } + gate.WaitForArrivals(1); + gate.Open(); + for (std::thread& thread : threads) { + thread.join(); + } + + // Exactly one read saw the transport failure -- the one that issued it. + CHECK_EQ(failures.load(), 1); + CHECK_EQ(wrong.load(), 0); + CHECK_EQ(store.Snapshot().pendingCount, std::uint64_t{0}); +} + +} // namespace + +int main() { + ManyThreadsMissingOneBlockIssueOneRequest(); + ManyReadersOfOneRevisionIssueOneRequestBetweenThem(); + OverlappingScatteredReadsAgreeOnEveryByte(); + AWaiterRecoversWhenTheOwnersFetchFails(); + return usdassettest::Report("usdAssetCache_singleflight"); +} diff --git a/libs/usd-asset-http/include/usdAssetHttp/HttpAssetReader.h b/libs/usd-asset-http/include/usdAssetHttp/HttpAssetReader.h index 4132d29..f498661 100644 --- a/libs/usd-asset-http/include/usdAssetHttp/HttpAssetReader.h +++ b/libs/usd-asset-http/include/usdAssetHttp/HttpAssetReader.h @@ -114,6 +114,11 @@ class HttpAssetReader final : public AssetReader { /// interface this project keeps deliberately narrow. const ReaderMetrics& Metrics() const noexcept; + /// The same counters, writable, for a decorator that composes this reader + /// into a stack and folds the whole stack once. See the local backend's + /// note and `ReaderMetrics::AbsorbTransport`. + ReaderMetrics& Metrics() noexcept; + private: class Impl; explicit HttpAssetReader(std::unique_ptr impl); diff --git a/libs/usd-asset-http/src/HttpAssetReader.cpp b/libs/usd-asset-http/src/HttpAssetReader.cpp index c3075b9..ac678dd 100644 --- a/libs/usd-asset-http/src/HttpAssetReader.cpp +++ b/libs/usd-asset-http/src/HttpAssetReader.cpp @@ -430,6 +430,8 @@ const AssetMetadata& HttpAssetReader::Metadata() const { return _impl->metadata; const ReaderMetrics& HttpAssetReader::Metrics() const noexcept { return _impl->metrics; } +ReaderMetrics& HttpAssetReader::Metrics() noexcept { return _impl->metrics; } + ReadResult HttpAssetReader::Read(std::uint64_t offset, void* dst, std::size_t size) { ScopedLatency readTimer(_impl->metrics.ReadLatency()); _impl->metrics.AddBytesRequested(size); diff --git a/libs/usd-asset-io/include/usdAssetIo/Metrics.h b/libs/usd-asset-io/include/usdAssetIo/Metrics.h index c86aff6..97204cc 100644 --- a/libs/usd-asset-io/include/usdAssetIo/Metrics.h +++ b/libs/usd-asset-io/include/usdAssetIo/Metrics.h @@ -109,6 +109,19 @@ struct MetricsSnapshot { /// moved to answer a query. double Selectivity() const noexcept; + /// Takes the transport-side counters of an inner reader in a decorated + /// stack, leaving this snapshot's own caller-side counters alone. + /// + /// A decorated stack has one counter set, and it is the outermost reader's, + /// because the two ends of the stack disagree about what `bytesRequested` + /// means: to the cache it is what the caller asked for, and to the reader + /// underneath it is what the cache asked for expanded to whole blocks. + /// Summing them would make `amplification` a ratio over a denominator that + /// is two different measurements added together. So the outer set keeps + /// what the caller asked for and the cache served, and takes from the inner + /// set only what crossed the transport. + void AbsorbTransport(const MetricsSnapshot& inner); + /// Sums counters. Latency merges only `count`, `sum`, and `max`: quantiles /// cannot be recovered from two summaries, so they are zeroed rather than /// averaged into a number that looks like a measurement. The aggregate in @@ -149,6 +162,31 @@ class ReaderMetrics { void AddRetry(std::uint64_t count = 1) noexcept; void AddRedirect(std::uint64_t count = 1) noexcept; + // The cache counters of METRICS.md §2.2. Declared here rather than on a + // cache-owned structure because a counter set that is per reader for the + // transport and per decorator for the cache cannot be summed: the process + // aggregate and the top-assets table both fold one `ReaderMetrics`, and a + // cache hit that landed somewhere else would be invisible in exactly the + // report it is evidence for. + // + // A backend never calls these; `libs/usd-asset-cache` does, on the reader + // metrics of the reader it decorates. + void AddBlockHit(std::uint64_t count = 1) noexcept; + void AddBlockMiss(std::uint64_t count = 1) noexcept; + void AddPartialHit(std::uint64_t count = 1) noexcept; + void AddRequestsSavedByCoalescing(std::uint64_t count = 1) noexcept; + void AddRequestsSavedBySingleFlight(std::uint64_t count = 1) noexcept; + void AddBytesOverFetched(std::uint64_t bytes) noexcept; + void AddEviction(std::uint64_t count = 1) noexcept; + + /// Raises the resident high-water mark to `bytes` if it is higher. + /// + /// A high-water mark is a maximum and not a sum, which is why it is + /// observed rather than added: two readers sharing one block store did not + /// make the store twice as large, and `MetricsSnapshot::Add` takes the + /// maximum of this field for the same reason. + void ObserveResidentBytes(std::uint64_t bytes) noexcept; + LatencyHistogram& OpenLatency() noexcept { return _openLatency; } LatencyHistogram& RequestLatency() noexcept { return _requestLatency; } LatencyHistogram& ReadLatency() noexcept { return _readLatency; } @@ -159,6 +197,22 @@ class ReaderMetrics { const std::string& Identifier() const noexcept { return _identifier; } + /// Absorbs an inner reader's transport counters into this set, histograms + /// included, so that the decorator that owns this set folds the whole + /// stack into the process aggregate exactly once. + /// + /// The inner set is detached first; see `DetachFromRegistry`. + void AbsorbTransport(const ReaderMetrics& inner) noexcept; + + /// Stops this counter set folding into the process aggregate when it is + /// destroyed. + /// + /// For the inner reader of a decorated stack, whose counters are folded by + /// the decorator above it. A set that both is absorbed and folds itself + /// would count every transferred byte twice. + void DetachFromRegistry() noexcept; + bool IsDetached() const noexcept; + /// Readable while the reader lives. MetricsSnapshot Snapshot() const; @@ -174,6 +228,17 @@ class ReaderMetrics { std::atomic _retryCount{0}; std::atomic _redirectCount{0}; + std::atomic _blockHits{0}; + std::atomic _blockMisses{0}; + std::atomic _partialHits{0}; + std::atomic _requestsSavedByCoalescing{0}; + std::atomic _requestsSavedBySingleFlight{0}; + std::atomic _bytesOverFetched{0}; + std::atomic _evictions{0}; + std::atomic _peakResidentBytes{0}; + + std::atomic _detached{false}; + LatencyHistogram _openLatency; LatencyHistogram _requestLatency; LatencyHistogram _readLatency; diff --git a/libs/usd-asset-io/src/Metrics.cpp b/libs/usd-asset-io/src/Metrics.cpp index 6cff5e9..8e0d79f 100644 --- a/libs/usd-asset-io/src/Metrics.cpp +++ b/libs/usd-asset-io/src/Metrics.cpp @@ -184,6 +184,25 @@ double MetricsSnapshot::Selectivity() const noexcept { return Ratio(bytesTransferred, assetSize); } +void MetricsSnapshot::AbsorbTransport(const MetricsSnapshot& inner) { + // The asset is one asset however many readers are stacked over it, so its + // size is taken and not summed. + assetSize = (std::max)(assetSize, inner.assetSize); + + bytesTransferred += inner.bytesTransferred; + requestCount += inner.requestCount; + metadataRequestCount += inner.metadataRequestCount; + retryCount += inner.retryCount; + redirectCount += inner.redirectCount; + + // Not taken: bytesRequested, bytesFromCache, and every cache counter. Those + // belong to the layer that faces the caller, and the inner reader's + // bytesRequested is the expanded ask this cache made of it -- already + // visible, exactly once, as bytesTransferred. + openLatency = inner.openLatency; + requestLatency = inner.requestLatency; +} + void MetricsSnapshot::Add(const MetricsSnapshot& other) { assetSize += other.assetSize; bytesRequested += other.bytesRequested; @@ -229,6 +248,9 @@ ReaderMetrics::ReaderMetrics(std::string identifier) } ReaderMetrics::~ReaderMetrics() { + if (_detached.load(kRelaxed)) { + return; + } MetricsRegistry::Instance().Fold(*this); } @@ -265,6 +287,59 @@ void ReaderMetrics::AddRedirect(std::uint64_t count) noexcept { AddRelaxed(_redirectCount, count); } +void ReaderMetrics::AddBlockHit(std::uint64_t count) noexcept { + AddRelaxed(_blockHits, count); +} + +void ReaderMetrics::AddBlockMiss(std::uint64_t count) noexcept { + AddRelaxed(_blockMisses, count); +} + +void ReaderMetrics::AddPartialHit(std::uint64_t count) noexcept { + AddRelaxed(_partialHits, count); +} + +void ReaderMetrics::AddRequestsSavedByCoalescing(std::uint64_t count) noexcept { + AddRelaxed(_requestsSavedByCoalescing, count); +} + +void ReaderMetrics::AddRequestsSavedBySingleFlight(std::uint64_t count) noexcept { + AddRelaxed(_requestsSavedBySingleFlight, count); +} + +void ReaderMetrics::AddBytesOverFetched(std::uint64_t bytes) noexcept { + AddRelaxed(_bytesOverFetched, bytes); +} + +void ReaderMetrics::AddEviction(std::uint64_t count) noexcept { + AddRelaxed(_evictions, count); +} + +void ReaderMetrics::ObserveResidentBytes(std::uint64_t bytes) noexcept { + MaxRelaxed(_peakResidentBytes, bytes); +} + +void ReaderMetrics::AbsorbTransport(const ReaderMetrics& inner) noexcept { + MaxRelaxed(_assetSize, inner._assetSize.load(kRelaxed)); + AddRelaxed(_bytesTransferred, inner._bytesTransferred.load(kRelaxed)); + // Deliberately not through AddMetadataRequest, which also bumps the request + // total: the inner reader already counted its metadata requests there, and + // routing them through that helper would count each one twice. + AddRelaxed(_requestCount, inner._requestCount.load(kRelaxed)); + AddRelaxed(_metadataRequestCount, inner._metadataRequestCount.load(kRelaxed)); + AddRelaxed(_retryCount, inner._retryCount.load(kRelaxed)); + AddRelaxed(_redirectCount, inner._redirectCount.load(kRelaxed)); + + // Folded bucket by bucket rather than merged as summaries, which is the + // only way the quantiles survive. + _openLatency.Fold(inner._openLatency); + _requestLatency.Fold(inner._requestLatency); +} + +void ReaderMetrics::DetachFromRegistry() noexcept { _detached.store(true, kRelaxed); } + +bool ReaderMetrics::IsDetached() const noexcept { return _detached.load(kRelaxed); } + MetricsSnapshot ReaderMetrics::Snapshot() const { MetricsSnapshot snapshot; snapshot.identifier = _identifier; @@ -276,6 +351,14 @@ MetricsSnapshot ReaderMetrics::Snapshot() const { snapshot.metadataRequestCount = _metadataRequestCount.load(kRelaxed); snapshot.retryCount = _retryCount.load(kRelaxed); snapshot.redirectCount = _redirectCount.load(kRelaxed); + snapshot.blockHits = _blockHits.load(kRelaxed); + snapshot.blockMisses = _blockMisses.load(kRelaxed); + snapshot.partialHits = _partialHits.load(kRelaxed); + snapshot.requestsSavedByCoalescing = _requestsSavedByCoalescing.load(kRelaxed); + snapshot.requestsSavedBySingleFlight = _requestsSavedBySingleFlight.load(kRelaxed); + snapshot.bytesOverFetched = _bytesOverFetched.load(kRelaxed); + snapshot.evictions = _evictions.load(kRelaxed); + snapshot.peakResidentBytes = _peakResidentBytes.load(kRelaxed); snapshot.openLatency = _openLatency.Snapshot(); snapshot.requestLatency = _requestLatency.Snapshot(); snapshot.readLatency = _readLatency.Snapshot(); @@ -406,8 +489,21 @@ void MetricsRegistry::Dump(std::ostream& out) const { << " metadataRequestCount " << aggregate.metadataRequestCount << "\n" << " retryCount " << aggregate.retryCount << "\n" << " redirectCount " << aggregate.redirectCount << "\n" + // The cache block. Printed unconditionally, including the zeroes a + // release with no cache produces: a dump that hid them would make + // "no cache ran" and "the cache never hit" the same output. + << " blockHits " << aggregate.blockHits << "\n" + << " blockMisses " << aggregate.blockMisses << "\n" + << " partialHits " << aggregate.partialHits << "\n" + << " savedByCoalescing " << aggregate.requestsSavedByCoalescing << "\n" + << " savedBySingleFlight " << aggregate.requestsSavedBySingleFlight << "\n" + << " bytesOverFetched " << aggregate.bytesOverFetched << "\n" + << " evictions " << aggregate.evictions << "\n" + << " peakResidentBytes " << aggregate.peakResidentBytes << "\n" << " amplification " << aggregate.Amplification() << "\n" << " selectivity " << aggregate.Selectivity() << "\n" + << " cacheHitRatio " << aggregate.CacheHitRatio() << "\n" + << " overFetchRatio " << aggregate.OverFetchRatio() << "\n" << " openLatency us p50 " << aggregate.openLatency.p50 << " p90 " << aggregate.openLatency.p90 << " p99 " << aggregate.openLatency.p99 << " max " << aggregate.openLatency.max << "\n" diff --git a/libs/usd-asset-local/include/usdAssetLocal/LocalAssetReader.h b/libs/usd-asset-local/include/usdAssetLocal/LocalAssetReader.h index 548abce..08f8535 100644 --- a/libs/usd-asset-local/include/usdAssetLocal/LocalAssetReader.h +++ b/libs/usd-asset-local/include/usdAssetLocal/LocalAssetReader.h @@ -56,6 +56,14 @@ class LocalAssetReader final : public AssetReader { /// deliberately narrow. const ReaderMetrics& Metrics() const noexcept; + /// The same counters, writable. + /// + /// Exists for one caller: a decorator that composes this reader into a + /// stack has to be able to detach these counters from the process + /// aggregate and absorb them, or the stack folds the transport's bytes + /// twice (Metrics.h, `AbsorbTransport`). Nothing else writes them. + ReaderMetrics& Metrics() noexcept; + private: class Impl; explicit LocalAssetReader(std::unique_ptr impl); diff --git a/libs/usd-asset-local/src/LocalAssetReader.cpp b/libs/usd-asset-local/src/LocalAssetReader.cpp index 5908155..379b730 100644 --- a/libs/usd-asset-local/src/LocalAssetReader.cpp +++ b/libs/usd-asset-local/src/LocalAssetReader.cpp @@ -50,6 +50,8 @@ const ReaderMetrics& LocalAssetReader::Metrics() const noexcept { return _impl->metrics; } +ReaderMetrics& LocalAssetReader::Metrics() noexcept { return _impl->metrics; } + ReadResult LocalAssetReader::Read(std::uint64_t offset, void* dst, std::size_t size) { ScopedLatency readTimer(_impl->metrics.ReadLatency()); _impl->metrics.AddBytesRequested(size); diff --git a/plugins/http-resolver/CMakeLists.txt b/plugins/http-resolver/CMakeLists.txt index e1b4359..525d45c 100644 --- a/plugins/http-resolver/CMakeLists.txt +++ b/plugins/http-resolver/CMakeLists.txt @@ -61,6 +61,12 @@ if(NOT TARGET usdasset::http) find_package(usdAssetHttp CONFIG REQUIRED) endif() +# And the cache, resolved the same way. It is not a backend and knows no +# transport: it decorates the reader the backend returns. +if(NOT TARGET usdasset::cache) + find_package(usdAssetCache CONFIG REQUIRED) +endif() + set(PLUGIN_NAME HttpResolver) add_library(${PLUGIN_NAME} SHARED @@ -75,7 +81,10 @@ target_compile_features(${PLUGIN_NAME} PRIVATE cxx_std_17) target_include_directories(${PLUGIN_NAME} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src" ${PXR_INCLUDE_DIRS}) -target_link_libraries(${PLUGIN_NAME} PRIVATE usdasset::http) +# The cache joins the link line in v0.3.0. WORKSPACE.md section 2 has admitted +# the edge since the workspace contract was written; this is the release in +# which the bundle takes it. +target_link_libraries(${PLUGIN_NAME} PRIVATE usdasset::http usdasset::cache) if(MSVC) target_compile_options(${PLUGIN_NAME} PRIVATE /utf-8 /W4) diff --git a/plugins/http-resolver/src/Configuration.cpp b/plugins/http-resolver/src/Configuration.cpp index 3798c44..a59945a 100644 --- a/plugins/http-resolver/src/Configuration.cpp +++ b/plugins/http-resolver/src/Configuration.cpp @@ -2,6 +2,7 @@ #include "Configuration.h" +#include #include #include #include @@ -15,6 +16,11 @@ constexpr const char* kTotalTimeout = "USD_HTTP_RESOLVER_TOTAL_TIMEOUT_MS"; constexpr const char* kMaxRetries = "USD_HTTP_RESOLVER_MAX_RETRIES"; constexpr const char* kMaxRedirects = "USD_HTTP_RESOLVER_MAX_REDIRECTS"; +constexpr const char* kBlockSize = "USD_HTTP_RESOLVER_BLOCK_SIZE"; +constexpr const char* kCacheBudget = "USD_HTTP_RESOLVER_CACHE_BUDGET"; +constexpr const char* kCoalesceGap = "USD_HTTP_RESOLVER_COALESCE_GAP"; +constexpr const char* kMaxRequestBytes = "USD_HTTP_RESOLVER_MAX_REQUEST_BYTES"; + /// Parses a non-negative integer with no leading sign, no whitespace, and no /// trailing text. /// @@ -80,8 +86,82 @@ void ReadInto(const EnvironmentLookup& lookup, const char* name, long long min, *target = static_cast(value); } +/// The 64-bit form of `ReadInto`, for the variables that are byte counts. +/// +/// A block size and a budget do not fit in the `int` the transport bounds are, +/// and a budget that silently wrapped at two gigabytes would be a cache that +/// held nothing on the machines large enough to want one. +void ReadBytesInto(const EnvironmentLookup& lookup, const char* name, + long long min, long long max, std::uint64_t* target, + std::vector* problemsOut) { + std::string text; + if (!lookup(name, &text)) return; + + long long value = 0; + std::string reason; + if (!ParseCount(text, min, max, &value, &reason)) { + if (problemsOut) { + problemsOut->push_back({name, text, reason}); + } + return; + } + *target = static_cast(value); +} + } // namespace +usdasset::cache::CacheOptions CacheOptionsFrom( + const EnvironmentLookup& lookup, + std::vector* problemsOut) { + usdasset::cache::CacheOptions options; + + // The bounds are the module's own, so that a value this function accepts is + // a value the cache can use. Below the floor the per-block bookkeeping costs + // more than the block; above the ceiling one miss transfers more than most + // assets are worth. + ReadBytesInto(lookup, kBlockSize, + static_cast(usdasset::cache::kMinBlockSize), + static_cast(usdasset::cache::kMaxBlockSize), + &options.blockSize, problemsOut); + + // A budget below one block is refused rather than clamped: it means the + // caller wanted no cache, and there is no variable for that, so saying so is + // better than quietly giving them a one-block one. + ReadBytesInto(lookup, kCacheBudget, + static_cast(usdasset::cache::kMinBlockSize), + 64LL * 1024 * 1024 * 1024, &options.budgetBytes, problemsOut); + + std::uint64_t gap = options.coalesceGapBlocks; + ReadBytesInto(lookup, kCoalesceGap, 0, 1024, &gap, problemsOut); + options.coalesceGapBlocks = static_cast(gap); + + ReadBytesInto(lookup, kMaxRequestBytes, + static_cast(usdasset::cache::kMinBlockSize), + 4LL * 1024 * 1024 * 1024, &options.maxRequestBytes, + problemsOut); + + // CONFIGURATION.md §2 says the block size is "rounded to a power of two", + // and rounding is an adjustment the operator did not ask for. Reported, so + // that a deployment that set 100000 and got 65536 can find out from a log + // rather than from a byte count. + const usdasset::cache::CacheOptions normalized = options.Normalized(); + if (problemsOut != nullptr && normalized.blockSize != options.blockSize) { + problemsOut->push_back({kBlockSize, std::to_string(options.blockSize), + "rounded down to the power of two " + + std::to_string(normalized.blockSize)}); + } + if (problemsOut != nullptr && + normalized.coalesceGapBlocks != options.coalesceGapBlocks) { + problemsOut->push_back( + {kCoalesceGap, std::to_string(options.coalesceGapBlocks), + "capped at " + std::to_string(normalized.coalesceGapBlocks) + + ", the widest gap that can fit under " + "USD_HTTP_RESOLVER_MAX_REQUEST_BYTES"}); + } + + return options; +} + usdasset::http::HttpOptions OptionsFrom( const EnvironmentLookup& lookup, std::vector* problemsOut) { @@ -124,8 +204,29 @@ usdasset::http::HttpOptions OptionsFromEnvironment( return OptionsFrom(lookup, problemsOut); } +ResolverConfiguration ConfigurationFrom( + const EnvironmentLookup& lookup, + std::vector* problemsOut) { + ResolverConfiguration configuration; + configuration.transport = OptionsFrom(lookup, problemsOut); + configuration.cache = CacheOptionsFrom(lookup, problemsOut); + return configuration; +} + +ResolverConfiguration ConfigurationFromEnvironment( + std::vector* problemsOut) { + const EnvironmentLookup lookup = [](const char* name, std::string* valueOut) { + const char* value = ReadEnvironment(name); + if (value == nullptr) return false; + valueOut->assign(value); + return true; + }; + return ConfigurationFrom(lookup, problemsOut); +} + const std::vector& ConfiguredVariables() { static const std::vector variables = { + kBlockSize, kCacheBudget, kCoalesceGap, kMaxRequestBytes, kConnectTimeout, kReadTimeout, kTotalTimeout, kMaxRetries, kMaxRedirects}; return variables; diff --git a/plugins/http-resolver/src/Configuration.h b/plugins/http-resolver/src/Configuration.h index 3da5aa5..75f716b 100644 --- a/plugins/http-resolver/src/Configuration.h +++ b/plugins/http-resolver/src/Configuration.h @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // -// The environment-variable configuration surface, which CONFIGURATION.md §2 -// schedules for `v0.2.0`: the five transport bounds, and nothing else. The -// cache variables arrive with the cache they configure, and the +// The environment-variable configuration surface of CONFIGURATION.md §2: the +// five transport bounds, which arrived in `v0.2.0`, and the four cache +// variables, which arrive here in `v0.3.0` with the cache they configure. The // `ArResolverContext` form arrives in `v0.6.0`. // // Parsing is separated from reading the environment, and from reporting, on @@ -21,6 +21,7 @@ #include #include +#include "usdAssetCache/CacheOptions.h" #include "usdAssetHttp/HttpAssetReader.h" namespace usdhttpresolver { @@ -55,6 +56,34 @@ usdasset::http::HttpOptions OptionsFrom( usdasset::http::HttpOptions OptionsFromEnvironment( std::vector* problemsOut); +/// The cache policy `lookup` describes, starting from the shipped defaults. +/// +/// The values the defaults are is a measured question and its answer is +/// docs/reference/BLOCK_POLICY.md; what this function does is let a deployment +/// override them, and refuse to do so silently when it asks for something that +/// is not a number. +/// +/// The returned options are *not* normalized here. Normalization rounds and +/// clamps, and a value that had to be rounded is worth a diagnostic rather than +/// a silent adjustment -- so the rounding is reported as a problem and the +/// caller normalizes when it applies them. +usdasset::cache::CacheOptions CacheOptionsFrom( + const EnvironmentLookup& lookup, + std::vector* problemsOut); + +/// Everything one resolver is configured by, read in one pass. +struct ResolverConfiguration { + usdasset::http::HttpOptions transport; + usdasset::cache::CacheOptions cache; +}; + +ResolverConfiguration ConfigurationFrom( + const EnvironmentLookup& lookup, + std::vector* problemsOut); + +ResolverConfiguration ConfigurationFromEnvironment( + std::vector* problemsOut); + /// The variables this version reads, in the order CONFIGURATION.md lists them. /// Exposed so a test asserts the set rather than restating it. const std::vector& ConfiguredVariables(); diff --git a/plugins/http-resolver/src/HttpResolver.cpp b/plugins/http-resolver/src/HttpResolver.cpp index 9befa1b..639d69e 100644 --- a/plugins/http-resolver/src/HttpResolver.cpp +++ b/plugins/http-resolver/src/HttpResolver.cpp @@ -15,6 +15,9 @@ #include "Identifier.h" #include "Report.h" #include "ResolvedAsset.h" + +#include "usdAssetCache/BlockCache.h" +#include "usdAssetCache/CachedAssetReader.h" #include "usdAssetIo/Diagnostics.h" PXR_NAMESPACE_OPEN_SCOPE @@ -38,7 +41,25 @@ usdasset::Status UnsupportedWrite() { HttpResolver::HttpResolver() { std::vector problems; - _options = usdhttpresolver::OptionsFromEnvironment(&problems); + const usdhttpresolver::ResolverConfiguration configuration = + usdhttpresolver::ConfigurationFromEnvironment(&problems); + _options = configuration.transport; + _cacheOptions = configuration.cache.Normalized(); + + // The budget belongs to the process store rather than to this resolver, so + // it is applied where it lives. Refused only when something is already bound + // into that store, which for a resolver constructed by `Plug` before any + // stage opens cannot happen -- and if it somehow does, the store keeps the + // budget it has and says so rather than being rebuilt underneath a live + // reader. + if (!usdasset::cache::BlockCache::ConfigureProcess(_cacheOptions)) { + problems.push_back( + {"USD_HTTP_RESOLVER_CACHE_BUDGET", + std::to_string(_cacheOptions.budgetBytes), + "the process block store was already in use; its budget and block " + "size were left as they were"}); + } + for (const usdhttpresolver::ConfigurationProblem& problem : problems) { // At first use, per CONFIGURATION.md §2, which for a process-global // surface is when the resolver is constructed. A typo that silently @@ -132,9 +153,29 @@ std::shared_ptr HttpResolver::_OpenAsset( } // Captured before the reader is moved from, and valid for as long as the - // reader is: it is a member of the reader's own implementation. - const usdasset::ReaderMetrics* const metrics = &reader->Metrics(); - return std::make_shared(std::move(reader), metrics); + // reader is: it is a member of the reader's own implementation, and the + // decorator below owns the reader for the whole life of the asset. + // + // The *transport's* counter set, deliberately, and not the decorated + // stack's. What this pointer is for is `HTTP101`, a retry that succeeded + // and cost the latency somebody is investigating, and a retry is a + // transport event: the cache neither issues one nor sees one. + usdasset::ReaderMetrics* const metrics = &reader->Metrics(); + + // The block cache goes on here rather than in `_Resolve`, because + // `_Resolve` only has to establish that the asset exists and this is where + // bytes start being asked for. `WrapAsset` binds into the process store by + // identity -- the resolved identifier and the validator the reader captured + // at open -- so two `ArAsset`s over one revision share blocks, and two over + // two revisions never do (CACHE.md section 6). + usdasset::cache::CachedOpenResult cached = usdasset::cache::Wrap( + std::unique_ptr(reader.release()), metrics, + _cacheOptions, nullptr); + if (!cached.reader) { + usdhttpresolver::Report(cached.status, identifier); + return nullptr; + } + return std::make_shared(std::move(cached.reader), metrics); } std::shared_ptr HttpResolver::_OpenAssetForWrite( diff --git a/plugins/http-resolver/src/HttpResolver.h b/plugins/http-resolver/src/HttpResolver.h index 828b15f..8080183 100644 --- a/plugins/http-resolver/src/HttpResolver.h +++ b/plugins/http-resolver/src/HttpResolver.h @@ -27,6 +27,7 @@ #include "pxr/usd/ar/resolvedPath.h" #include "pxr/usd/ar/resolver.h" +#include "usdAssetCache/CacheOptions.h" #include "usdAssetHttp/HttpAssetReader.h" PXR_NAMESPACE_OPEN_SCOPE @@ -138,6 +139,14 @@ class HttpResolver final : public ArResolver { usdasset::http::HttpOptions _options; + /// The block policy every asset this resolver opens is decorated with. + /// + /// Resolved once, at construction, from the environment. The blocks + /// themselves live in the process-wide store rather than here, because the + /// budget is process-wide and shared across assets (CACHE.md section 7) and + /// a store per resolver would not be one budget. + usdasset::cache::CacheOptions _cacheOptions; + mutable std::mutex _tableMutex; mutable std::unordered_map> _table; mutable std::deque _order; ///< Insertion order, for eviction. diff --git a/plugins/http-resolver/tests/CMakeLists.txt b/plugins/http-resolver/tests/CMakeLists.txt index d0ef336..71cf254 100644 --- a/plugins/http-resolver/tests/CMakeLists.txt +++ b/plugins/http-resolver/tests/CMakeLists.txt @@ -40,7 +40,8 @@ http_resolver_offline_test(identifier Identifier) # `Identifier.cpp` needs nothing at all; the other two need the contracts they # are expressed in. Neither needs a transport, and neither opens a socket. -target_link_libraries(httpResolver_test_configuration PRIVATE usdasset::http) +target_link_libraries(httpResolver_test_configuration + PRIVATE usdasset::http usdasset::cache) target_link_libraries(httpResolver_test_diagnostics PRIVATE usdasset::io) # The end-to-end test needs the fixture corpus, which exists only in a build diff --git a/plugins/http-resolver/tests/test_configuration.cpp b/plugins/http-resolver/tests/test_configuration.cpp index 3805a0c..9ccd243 100644 --- a/plugins/http-resolver/tests/test_configuration.cpp +++ b/plugins/http-resolver/tests/test_configuration.cpp @@ -127,16 +127,78 @@ void TestIndependence() { void TestVariableSet() { const std::vector& variables = usdhttpresolver::ConfiguredVariables(); - CHECK_EQ(variables.size(), std::size_t{5}); + // Five transport bounds from `v0.2.0` and four cache variables from + // `v0.3.0`, which is the whole of CONFIGURATION.md §2 except the metrics + // dump -- that one is read by usdAssetIo and not by this resolver. + CHECK_EQ(variables.size(), std::size_t{9}); for (const char* name : variables) { CHECK(std::string(name).rfind("USD_HTTP_RESOLVER_", 0) == 0); std::vector problems; - OptionsFrom(From({{name, "not a number"}}), &problems); + usdhttpresolver::ConfigurationFrom(From({{name, "not a number"}}), + &problems); // Every variable this version claims to read is actually read. CHECK_EQ(problems.size(), std::size_t{1}); } } +/// The cache variables, one at a time, and the two adjustments that are +/// reported rather than made silently. +void TestCacheVariables() { + std::vector problems; + const usdasset::cache::CacheOptions defaults = + usdhttpresolver::CacheOptionsFrom(From({}), &problems); + CHECK_EQ(problems.size(), std::size_t{0}); + CHECK_EQ(defaults.blockSize, usdasset::cache::kDefaultBlockSize); + CHECK_EQ(defaults.budgetBytes, usdasset::cache::kDefaultBudgetBytes); + + problems.clear(); + const usdasset::cache::CacheOptions set = usdhttpresolver::CacheOptionsFrom( + From({{"USD_HTTP_RESOLVER_BLOCK_SIZE", "16384"}, + {"USD_HTTP_RESOLVER_CACHE_BUDGET", "1048576"}, + {"USD_HTTP_RESOLVER_COALESCE_GAP", "0"}, + {"USD_HTTP_RESOLVER_MAX_REQUEST_BYTES", "65536"}}), + &problems); + CHECK_EQ(problems.size(), std::size_t{0}); + CHECK_EQ(set.blockSize, std::uint64_t{16384}); + CHECK_EQ(set.budgetBytes, std::uint64_t{1048576}); + CHECK_EQ(set.coalesceGapBlocks, std::uint32_t{0}); + CHECK_EQ(set.maxRequestBytes, std::uint64_t{65536}); + + // A block size that is not a power of two is rounded down, and the rounding + // is a diagnostic: an operator who set 100000 and got 65536 should learn it + // from a log rather than from a byte count. + problems.clear(); + const usdasset::cache::CacheOptions rounded = usdhttpresolver::CacheOptionsFrom( + From({{"USD_HTTP_RESOLVER_BLOCK_SIZE", "100000"}}), &problems); + CHECK_EQ(problems.size(), std::size_t{1}); + CHECK_EQ(rounded.Normalized().blockSize, std::uint64_t{65536}); + + // Below the floor and above the ceiling are refused, not clamped. + problems.clear(); + usdhttpresolver::CacheOptionsFrom( + From({{"USD_HTTP_RESOLVER_BLOCK_SIZE", "512"}}), &problems); + CHECK_EQ(problems.size(), std::size_t{1}); + + problems.clear(); + usdhttpresolver::CacheOptionsFrom( + From({{"USD_HTTP_RESOLVER_CACHE_BUDGET", "12"}}), &problems); + CHECK_EQ(problems.size(), std::size_t{1}); + + // And one bad cache value does not discard the transport configuration, or + // the other three cache values. + problems.clear(); + const usdhttpresolver::ResolverConfiguration mixed = + usdhttpresolver::ConfigurationFrom( + From({{"USD_HTTP_RESOLVER_BLOCK_SIZE", "nonsense"}, + {"USD_HTTP_RESOLVER_CACHE_BUDGET", "1048576"}, + {"USD_HTTP_RESOLVER_MAX_REDIRECTS", "1"}}), + &problems); + CHECK_EQ(problems.size(), std::size_t{1}); + CHECK_EQ(mixed.cache.blockSize, usdasset::cache::kDefaultBlockSize); + CHECK_EQ(mixed.cache.budgetBytes, std::uint64_t{1048576}); + CHECK_EQ(mixed.transport.maxRedirects, 1); +} + } // namespace int main() { @@ -145,5 +207,6 @@ int main() { TestRejectedValues(); TestIndependence(); TestVariableSet(); + TestCacheVariables(); return usdassettest::Report("httpResolver configuration"); } diff --git a/plugins/http-resolver/tests/test_stage.cpp b/plugins/http-resolver/tests/test_stage.cpp index a94d76e..619f644 100644 --- a/plugins/http-resolver/tests/test_stage.cpp +++ b/plugins/http-resolver/tests/test_stage.cpp @@ -137,6 +137,23 @@ void TestStageOpens() { } /// §4: the `ArAsset` surface, and the reason this project exists -- a window +/// `bytes=first-last`, as the fixture server logged it. +/// +/// The test parses the header itself rather than asking the backend what it +/// sent, for the reason the baseline harness does: the server's log is the +/// independent witness, and a request issued outside the metrics sink counts +/// nothing there and still costs a round trip. +bool ParseByteRange(const std::string& header, std::uint64_t* first, + std::uint64_t* last) { + const std::size_t equals = header.find('='); + if (equals == std::string::npos) return false; + const std::size_t dash = header.find('-', equals + 1); + if (dash == std::string::npos) return false; + *first = std::strtoull(header.c_str() + equals + 1, nullptr, 10); + *last = std::strtoull(header.c_str() + dash + 1, nullptr, 10); + return *last >= *first; +} + /// out of an asset costs the window. void TestRangeRead() { const std::size_t size = 1u << 20; // 1 MiB @@ -182,15 +199,42 @@ void TestRangeRead() { CHECK(mark.IsClean()); mark.Clear(); - // What was actually asked of the network. A `Range` header on the read, - // and no request that fetched the whole megabyte. + // What was actually asked of the network. A `Range` header on every read, a + // request that covers the window, and nothing that fetched the whole + // megabyte. + // + // The covering request is no longer the window itself. `v0.3.0` puts a + // block cache under this asset, so the window is expanded to the block that + // holds it, and CACHE.md section 3 trades the exact-bytes property away on + // purpose. What the release still claims -- and what this checks -- is that + // a 4 KiB window out of a megabyte costs a block and not the megabyte. The + // bound is stated here rather than imported from the cache's constants: a + // test that knew the block size would agree with the cache by construction, + // and the question being asked is whether the cache ran away. + const std::uint64_t windowEnd = offset + count; bool sawWindow = false; + std::uint64_t movedByGets = 0; for (const usdassetfixture::RequestRecord& record : g_server->Log()) { if (record.method != "GET") continue; CHECK(!record.range.empty()); - if (record.range == "bytes=500000-504095") sawWindow = true; + std::uint64_t first = 0; + std::uint64_t last = 0; + if (!ParseByteRange(record.range, &first, &last)) { + std::fprintf(stderr, "FAIL %s:%d: unparseable Range: %s\n", + __FILE__, __LINE__, record.range.c_str()); + ++::usdassettest::FailureCount(); + continue; + } + movedByGets += last - first + 1; + if (first <= offset && last + 1 >= windowEnd) sawWindow = true; + // No single request took a quarter of the asset. + CHECK(last - first + 1 <= size / 4); } CHECK(sawWindow); + // And neither did all of them together. Three reads of a megabyte-sized + // asset moved a fraction of it, which is the sentence the whole project is + // made of. + CHECK(movedByGets <= size / 4); } /// §2.3: one metadata request per identifier, reused by the open that follows. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2201f5c..1b820fd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,6 +20,14 @@ if(TARGET usdasset::http AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/corpus/CMakeLis add_subdirectory(corpus) endif() +# The block-policy sweep, guarded on both of the things it measures a stack of. +# It chooses the cache's constants, and it chooses them over round trips, which +# is why it needs a server and could not have been run against a local file. +if(TARGET usdasset::http AND TARGET usdasset::cache + AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cache-tuning/CMakeLists.txt") + add_subdirectory(cache-tuning) +endif() + # The recorded I/O baseline, guarded the same way and for the same reason: the # five scenarios in METRICS.md §6 measure a transport, and there is nothing to # measure until one is present. diff --git a/tests/baseline/CMakeLists.txt b/tests/baseline/CMakeLists.txt index a1a855f..58ac3ae 100644 --- a/tests/baseline/CMakeLists.txt +++ b/tests/baseline/CMakeLists.txt @@ -21,8 +21,12 @@ add_executable(usdAssetHttp_baseline Report.cpp ../fixture-server/tests/RawClient.cpp) +# The cache joins the link line in v0.3.0: every scenario is measured with and +# without it, because METRICS.md section 6 asks a release that changes I/O +# behavior for the counter values before and after, and this is the release that +# changes them on purpose. target_link_libraries(usdAssetHttp_baseline - PRIVATE usdasset::http usdasset::fixtureserver) + PRIVATE usdasset::http usdasset::cache usdasset::fixtureserver) target_include_directories(usdAssetHttp_baseline PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/../fixture-server/tests" diff --git a/tests/baseline/Report.cpp b/tests/baseline/Report.cpp index b8a4ed1..5e3b9a3 100644 --- a/tests/baseline/Report.cpp +++ b/tests/baseline/Report.cpp @@ -90,6 +90,40 @@ std::string FormatBaseline(const RunContext& context, out += " |\n"; } + // The cache counters, for the rows that have any. Emitted as their own + // table rather than as eight more columns on the one above: the counter set + // METRICS.md §2.2 defines is about a different mechanism from the one §2.1 + // defines, and a nineteen-column table is a table nobody reads. Cached rows + // whose counters are all zero are still printed -- the full sequential read + // is exactly that, and its zeroes are the bypass rule working. + bool anyCachedRow = false; + for (const ScenarioRecord& record : records) anyCachedRow |= record.cached; + if (anyCachedRow) { + out += "\n\nCache counters, for the rows that have them." + " Every one is zero on an uncached row, and those rows are" + " omitted.\n\n"; + out += "| Scenario | blockHits | blockMisses | partialHits |" + " savedByCoalescing | savedBySingleFlight | bytesFromCache |" + " bytesOverFetched | evictions | peakResidentBytes |\n"; + out += "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |" + " ---: |\n"; + for (const ScenarioRecord& record : records) { + if (!record.cached) continue; + const usdasset::MetricsSnapshot& m = record.metrics; + out += "| " + record.name; + out += " | " + Unsigned(m.blockHits); + out += " | " + Unsigned(m.blockMisses); + out += " | " + Unsigned(m.partialHits); + out += " | " + Unsigned(m.requestsSavedByCoalescing); + out += " | " + Unsigned(m.requestsSavedBySingleFlight); + out += " | " + Unsigned(m.bytesFromCache); + out += " | " + Unsigned(m.bytesOverFetched); + out += " | " + Unsigned(m.evictions); + out += " | " + Unsigned(m.peakResidentBytes); + out += " |\n"; + } + } + out += "\nLatency, in microseconds. Quantiles are bucket upper bounds, not" " exact order statistics (METRICS.md §4), and the request and read" " columns are p50 / p90 / p99 / max.\n\n"; @@ -130,11 +164,14 @@ std::string FormatBaseline(const RunContext& context, " -- not because none hit. `v0.3.0` is where these rows are expected" " to move, and the request counts above are what it has to move.\n"; } else { - out += "\nThe cache counters in METRICS.md §2.2 are no longer all zero: " - "this run served " + Unsigned(cached) + - " bytes from a cache and over-fetched " + Unsigned(overFetched) + - ". A release whose cache moved is a release that records a new" - " baseline rather than inheriting this one.\n"; + out += "\nThe cache counters in METRICS.md §2.2 are populated: the runs" + " below served " + Unsigned(cached) + + " bytes from a block store and over-fetched " + + Unsigned(overFetched) + + " to do it. Both halves belong in the record. A design that" + " reported only the first would be selling something, which is" + " what METRICS.md §2.2 calls `bytesOverFetched` the honest counter" + " for.\n"; } out += "\n| Scenario | What it exercises | Notes |\n"; diff --git a/tests/baseline/Report.h b/tests/baseline/Report.h index 77711b9..2309662 100644 --- a/tests/baseline/Report.h +++ b/tests/baseline/Report.h @@ -42,6 +42,13 @@ struct ScenarioRecord { std::string exercises; ///< The METRICS.md §6 column, in that table's words. usdasset::MetricsSnapshot metrics; + /// Whether the block cache was in the stack. + /// + /// Carried as a field rather than read off the end of `name`, so that the + /// cache-counter table below is selected by what was measured rather than + /// by how it was spelled. + bool cached = false; + /// Wall clock for the whole scenario, open included. Reported and never /// asserted: it is a loopback number on whatever runner drew the job, and a /// gate on it would fail for reasons that are not this repository's. diff --git a/tests/baseline/baseline_main.cpp b/tests/baseline/baseline_main.cpp index e436285..3c8eae9 100644 --- a/tests/baseline/baseline_main.cpp +++ b/tests/baseline/baseline_main.cpp @@ -40,6 +40,9 @@ #include #include +#include "usdAssetCache/BlockCache.h" +#include "usdAssetCache/CacheOptions.h" +#include "usdAssetCache/CachedAssetReader.h" #include "usdAssetHttp/HttpAssetReader.h" #include "usdAssetIo/Metrics.h" #include "usdassetfixture/Corpus.h" @@ -55,8 +58,12 @@ using usdasset::AssetReader; using usdasset::MetricsRegistry; using usdasset::MetricsSnapshot; using usdasset::ReadResult; +using usdasset::cache::BlockCache; +using usdasset::cache::CacheOptions; +using usdasset::cache::CachedAssetReader; using usdasset::http::HttpAssetReader; using usdasset::http::HttpOpenResult; +using usdassetfixture::RequestRecord; using usdassetbaseline::RunContext; using usdassetbaseline::ScenarioRecord; using usdassetfixture::AssetSpec; @@ -288,6 +295,116 @@ std::unique_ptr OpenOrReport(const Fixture& fixture) { return std::move(opened.reader); } +/// One reader, with or without the cache over it. +/// +/// Every scenario is measured both ways, because METRICS.md section 6 asks a +/// release that changes I/O behavior to record "the counter values before and +/// after" -- and `v0.3.0` is the release that changes them on purpose. A record +/// carrying only the after would leave gate 6 comparing this release's table +/// against a document rather than against a run. +struct Stack { + std::unique_ptr store; ///< Null when uncached, or when shared. + std::unique_ptr http; + std::unique_ptr cached; + AssetReader* reader = nullptr; + + MetricsSnapshot Snapshot() const { + return cached ? cached->SnapshotMetrics() : http->Metrics().Snapshot(); + } +}; + +/// The cache's shipped defaults, for the reason the transport's are used: a +/// baseline measured with a block size no caller gets is a baseline about a +/// configuration that does not ship. What chose them is +/// docs/reference/BLOCK_POLICY.md. +CacheOptions BaselineCacheOptions() { return CacheOptions().Normalized(); } + +/// Opens into `stack`, sharing `store` when one is given -- which is what the +/// parallel-readers scenario needs and what every other scenario must not have. +bool OpenStack(const Fixture& fixture, bool cached, BlockCache* shared, Stack* stack) { + std::unique_ptr http = OpenOrReport(fixture); + if (!http) return false; + + if (!cached) { + stack->http = std::move(http); + stack->reader = stack->http.get(); + return true; + } + + BlockCache* store = shared; + if (store == nullptr) { + // A store per scenario, so that no scenario is warmed by the one above + // it. The process store would make this table a function of the order + // the rows happen to be written in. + stack->store.reset(new BlockCache(BaselineCacheOptions())); + store = stack->store.get(); + } + + usdasset::ReaderMetrics* innerMetrics = &http->Metrics(); + usdasset::cache::CachedOpenResult wrapped = usdasset::cache::Wrap( + std::unique_ptr(http.release()), innerMetrics, + BaselineCacheOptions(), store); + if (!wrapped.reader) { + std::fprintf(stderr, "FAIL: wrap: %s\n", + usdasset::ToString(wrapped.status).c_str()); + ++usdassettest::FailureCount(); + return false; + } + stack->cached = std::move(wrapped.reader); + stack->reader = stack->cached.get(); + return true; +} + +/// The content bytes the server was asked for, read off its own log. +/// +/// The independent witness for a cached run. With no cache, "n bytes requested +/// is n bytes transferred" was itself the check; with one, the expected +/// transfer is a function of the block policy, and asserting the backend's +/// counter against a number this file computed from the same policy would be +/// asserting the policy against itself. The server logged the ranges it +/// answered, and that is a different measurement of the same fact. +std::uint64_t RangeBytesFromLog(const std::vector& log) { + std::uint64_t total = 0; + for (const RequestRecord& record : log) { + if (record.range.empty()) continue; // The HEAD an open costs. + const std::size_t equals = record.range.find('='); + if (equals == std::string::npos) continue; + const std::size_t dash = record.range.find('-', equals + 1); + if (dash == std::string::npos) continue; + const std::uint64_t first = + std::strtoull(record.range.c_str() + equals + 1, nullptr, 10); + const std::uint64_t last = + std::strtoull(record.range.c_str() + dash + 1, nullptr, 10); + if (last < first) continue; + total += last - first + 1; + } + return total; +} + +/// The counter assertions the cached runs share. +/// +/// Deliberately not the uncached ones with a tolerance added. With a cache the +/// rule "a read of n bytes moves exactly n bytes" is gone by design, so what is +/// asserted here is what is still exactly true: the caller's ask, the metadata +/// cost, that nothing retried or redirected, and that the backend and the +/// server agree about both the request count and the byte count. The rest of +/// the release's claim -- fewer requests than the uncached run -- is asserted +/// per scenario against that scenario's own uncached row, because that +/// comparison is what the claim is made of. +void CheckCachedShape(const MetricsSnapshot& metrics, + const std::vector& log, + std::uint64_t serverRequests, + std::uint64_t expectedBytes, + std::uint64_t readers) { + CHECK_EQ(metrics.bytesRequested, expectedBytes); + CHECK_EQ(metrics.metadataRequestCount, readers); + CHECK_EQ(metrics.retryCount, std::uint64_t{0}); + CHECK_EQ(metrics.redirectCount, std::uint64_t{0}); + CHECK_EQ(serverRequests, metrics.requestCount); + CHECK_EQ(metrics.bytesTransferred, RangeBytesFromLog(log)); + CHECK(metrics.bytesFromCache <= metrics.bytesRequested); +} + /// Every counter in METRICS.md §2.2, not the two a scenario happens to think /// about. /// @@ -346,25 +463,58 @@ std::uint64_t ServerRequests(const Server& server) { } // --- the scenarios ----------------------------------------------------------- +// +// Each one runs twice: once against the transport alone, and once with the +// block cache over it. The uncached run keeps the assertions `v0.2.0` recorded +// -- a read of n bytes is one request that moves exactly n bytes -- and is +// still the regression gate for the transport. The cached run asserts what is +// still exactly true with a cache in the stack, and then asserts the release's +// actual claim against the uncached run beside it: fewer requests, and a worst +// case that did not move. + +/// The name a row carries in the record. The suffix is part of the name rather +/// than a column, so that a release record's table can be pasted and read +/// without a legend. +std::string RowName(const char* base, bool cached) { + return cached ? std::string(base) + " (cached)" : std::string(base); +} /// METRICS.md §6, row 1: the cost of merely resolving. -ScenarioRecord MetadataOnlyOpen(const Fixture& fixture, Server& server) { +ScenarioRecord MetadataOnlyOpen(const Fixture& fixture, + Server& server, + bool cached, + const MetricsSnapshot* uncached) { ScenarioRecord record; - record.name = "metadata-only open"; + record.name = RowName("metadata-only open", cached); + record.cached = cached; record.exercises = "`openLatency`, `metadataRequestCount` — the cost of merely resolving"; server.ClearLog(); const Clock::time_point started = Clock::now(); - std::unique_ptr reader = OpenOrReport(fixture); + Stack stack; + if (!OpenStack(fixture, cached, nullptr, &stack)) { + record.wallMs = ElapsedMs(started); + return record; + } record.wallMs = ElapsedMs(started); - if (!reader) return record; - - record.metrics = reader->Metrics().Snapshot(); + record.metrics = stack.Snapshot(); CHECK_EQ(record.metrics.assetSize, fixture.size); - CHECK(reader->Metadata().supportsRandomAccess); - CheckUncachedShape(record.metrics, ServerRequests(server), 0, 0, 1); + CHECK(stack.reader->Metadata().supportsRandomAccess); + if (cached) { + CheckCachedShape(record.metrics, server.Log(), ServerRequests(server), 0, 1); + // A cache changes what a read costs and must not change what an open + // costs. Wrapping a reader issues no request of its own, and a stack + // that bound its store by asking the server something would show up + // here as a second request. + CHECK_EQ(record.metrics.requestCount, std::uint64_t{1}); + if (uncached != nullptr) { + CHECK_EQ(record.metrics.requestCount, uncached->requestCount); + } + } else { + CheckUncachedShape(record.metrics, ServerRequests(server), 0, 0, 1); + } // The one request, named by the server rather than by the counter that // classified it. "No content byte crosses the transport" is a property of @@ -375,80 +525,119 @@ ScenarioRecord MetadataOnlyOpen(const Fixture& fixture, Server& server) { CHECK(log[0].method == "HEAD"); CHECK(log[0].range.empty()); } - // The one request an open costs is the metadata request, and it moves no - // content. A backend that read a byte to discover a size would show up here - // and nowhere else. CHECK_EQ(record.metrics.openLatency.count, std::uint64_t{1}); record.note = - "One `HEAD`. No content byte crosses the transport, and the reader is " - "bound to a revision before any read is issued"; + cached ? "One `HEAD`, unchanged. Binding a store costs no request, which " + "is why this row is the one row the cache does not move" + : "One `HEAD`. No content byte crosses the transport, and the " + "reader is bound to a revision before any read is issued"; return record; } /// METRICS.md §6, row 2: the clustered small-read pattern the block cache exists /// for. -ScenarioRecord HeaderAndIndexRead(const Fixture& fixture, Server& server) { +ScenarioRecord HeaderAndIndexRead(const Fixture& fixture, + Server& server, + bool cached, + const MetricsSnapshot* uncached) { ScenarioRecord record; - record.name = "header and index read"; + record.name = RowName("header and index read", cached); + record.cached = cached; record.exercises = "The clustered small-read pattern the block cache exists for"; server.ClearLog(); const Clock::time_point started = Clock::now(); - std::unique_ptr reader = OpenOrReport(fixture); - if (!reader) { + Stack stack; + if (!OpenStack(fixture, cached, nullptr, &stack)) { record.wallMs = ElapsedMs(started); return record; } std::vector buffer; - bool ok = ReadChecked(*reader, 0, kHeaderBytes, &buffer); + bool ok = ReadChecked(*stack.reader, 0, kHeaderBytes, &buffer); for (int i = 0; ok && i < kIndexReads; ++i) { const std::uint64_t offset = fixture.size - kIndexBytes + kIndexReadBytes * static_cast(i); - ok = ReadChecked(*reader, offset, kIndexReadBytes, &buffer); + ok = ReadChecked(*stack.reader, offset, kIndexReadBytes, &buffer); } record.wallMs = ElapsedMs(started); - record.metrics = reader->Metrics().Snapshot(); + record.metrics = stack.Snapshot(); if (!ok) return record; + if (cached) { + CheckCachedShape(record.metrics, server.Log(), ServerRequests(server), + kHeaderBytes + kIndexBytes, 1); + // The release's claim, on the pattern it is made about. + if (uncached != nullptr) { + CHECK(record.metrics.requestCount < uncached->requestCount); + } + CHECK(record.metrics.blockHits > 0); + record.note = + "The same seventeen reads, collapsed onto " + + std::to_string(record.metrics.requestCount - 1) + + " block fetches. The bytes the alignment moved beyond the reads are " + "`bytesOverFetched`, and the reads that never reached the transport " + "are `blockHits`"; + return record; + } + CheckUncachedShape(record.metrics, ServerRequests(server), kHeaderBytes + kIndexBytes, 1 + static_cast(kIndexReads), 1); - record.note = "One 4 KiB header read and " + std::to_string(kIndexReads) + - " adjacent 4 KiB index reads. Every one of them is its own " - "request today, which is the number `v0.3.0` exists to collapse"; + " adjacent 4 KiB index reads, each its own request"; return record; } /// METRICS.md §6, row 3: `selectivity`, the headline claim. -ScenarioRecord BoundedSpatialQuery(const Fixture& fixture, Server& server) { +ScenarioRecord BoundedSpatialQuery(const Fixture& fixture, + Server& server, + bool cached, + const MetricsSnapshot* uncached) { ScenarioRecord record; - record.name = "bounded spatial query"; + record.name = RowName("bounded spatial query", cached); + record.cached = cached; record.exercises = "`selectivity` — the headline claim"; server.ClearLog(); const Clock::time_point started = Clock::now(); - std::unique_ptr reader = OpenOrReport(fixture); - if (!reader) { + Stack stack; + if (!OpenStack(fixture, cached, nullptr, &stack)) { record.wallMs = ElapsedMs(started); return record; } - const bool ok = ReadBoundedQuery(*reader, fixture); + const bool ok = ReadBoundedQuery(*stack.reader, fixture); record.wallMs = ElapsedMs(started); - record.metrics = reader->Metrics().Snapshot(); + record.metrics = stack.Snapshot(); if (!ok) return record; + if (cached) { + CheckCachedShape(record.metrics, server.Log(), ServerRequests(server), + kBoundedQueryBytes, 1); + if (uncached != nullptr) { + CHECK(record.metrics.requestCount <= uncached->requestCount); + } + // The bound this row is gated on now that alignment is allowed to move + // more than was asked for: one block per read, and no more. A coalescing + // window that widened, or a read-ahead nobody asked for, is a byte count + // above this. + const std::uint64_t blockSize = BaselineCacheOptions().blockSize; + CHECK(record.metrics.bytesTransferred <= + kBoundedQueryBytes + blockSize * (kBoundedQueryReads + 1)); + record.note = + "The same query, with every read expanded to whole blocks: " + + std::to_string(record.metrics.bytesTransferred) + + " bytes moved against an asset of " + std::to_string(fixture.size) + + ". `selectivity` is worse than the uncached row on purpose -- that is " + "what alignment costs, and `bytesOverFetched` is the counter for it"; + return record; + } + CheckUncachedShape(record.metrics, ServerRequests(server), kBoundedQueryBytes, kBoundedQueryReads, 1); - - // The amplification gate, and the reason this file exists. An absolute byte - // budget rather than a ratio: the ratio moves with the fixture size, and a - // gate that moved with it would stop catching the thing it is for. CHECK(record.metrics.bytesTransferred <= kBoundedQueryBytes); - record.note = "A header, a tail index, and " + std::to_string(kChunkReads) + " scattered 16 KiB chunks: " + std::to_string(kBoundedQueryBytes) + " bytes moved to answer a query against an asset of " + @@ -465,18 +654,25 @@ ScenarioRecord BoundedSpatialQuery(const Fixture& fixture, Server& server) { /// comparison mean anything, and it is already in this repository for that /// reason. What it is not is a performance-matched client: it reads 4 KiB at a /// time, so its wall clock flatters the backend and is recorded rather than -/// gated. Its *byte* count flatters nobody, and that is what is gated: a ranged -/// full read must put no more content on the wire than a plain download of the -/// same asset. -ScenarioRecord FullSequentialRead(const Fixture& fixture, Server& server) { +/// gated. Its *byte* count flatters nobody, and that is what is gated. +/// +/// This is the row a cache is most able to damage, and the one `BASELINE.md` +/// says must not regress: a cache that turns the worst case into a worse case +/// has the wrong policy. The bypass rule in CACHE.md §3 is what keeps it, and +/// the cached run is where that is checked rather than asserted. +ScenarioRecord FullSequentialRead(const Fixture& fixture, + Server& server, + bool cached, + const MetricsSnapshot* uncached) { ScenarioRecord record; - record.name = "full sequential read"; + record.name = RowName("full sequential read", cached); + record.cached = cached; record.exercises = "The worst case; must not be worse than a plain download"; server.ClearLog(); const Clock::time_point started = Clock::now(); - std::unique_ptr reader = OpenOrReport(fixture); - if (!reader) { + Stack stack; + if (!OpenStack(fixture, cached, nullptr, &stack)) { record.wallMs = ElapsedMs(started); return record; } @@ -488,18 +684,33 @@ ScenarioRecord FullSequentialRead(const Fixture& fixture, Server& server) { while (ok && offset < fixture.size) { const std::uint64_t size = std::min(kSequentialChunkBytes, fixture.size - offset); - ok = ReadChecked(*reader, offset, size, &buffer); + ok = ReadChecked(*stack.reader, offset, size, &buffer); offset += size; ++reads; } record.wallMs = ElapsedMs(started); - record.metrics = reader->Metrics().Snapshot(); + record.metrics = stack.Snapshot(); if (!ok) return record; // Read before the comparator issues its own request, which the backend did // not make. - CheckUncachedShape(record.metrics, ServerRequests(server), fixture.size, reads, - 1); + if (cached) { + CheckCachedShape(record.metrics, server.Log(), ServerRequests(server), + fixture.size, 1); + if (uncached != nullptr) { + // Not "no worse by some margin" -- identical. Every read here is + // larger than `bypassThresholdBytes` and never reaches the store, so + // a request count or a byte count that differs from the uncached row + // at all means the bypass stopped applying. + CHECK_EQ(record.metrics.requestCount, uncached->requestCount); + CHECK_EQ(record.metrics.bytesTransferred, uncached->bytesTransferred); + } + CHECK_EQ(record.metrics.bytesOverFetched, std::uint64_t{0}); + CHECK_EQ(record.metrics.blockMisses, std::uint64_t{0}); + } else { + CheckUncachedShape(record.metrics, ServerRequests(server), fixture.size, + reads, 1); + } // The plain download, over a client that is not the one under test. const Clock::time_point downloadStarted = Clock::now(); @@ -509,10 +720,6 @@ ScenarioRecord FullSequentialRead(const Fixture& fixture, Server& server) { CHECK_EQ(plain.status, 200); CHECK_EQ(static_cast(plain.body.size()), fixture.size); - // The gate: ranged reading moved no more content than downloading the whole - // asset in one request did. Headers are outside both sides of it -- the - // counter is a content counter -- and the request count is where the - // per-request overhead is visible instead. CHECK(record.metrics.bytesTransferred <= static_cast(plain.body.size())); @@ -521,21 +728,28 @@ ScenarioRecord FullSequentialRead(const Fixture& fixture, Server& server) { "%llu reads of %llu MiB against one plain `GET` of the whole " "asset over the fixture server's own raw client: identical " "content bytes, %llu requests against 1, %.1f ms against " - "%.1f ms. The comparator reads 4 KiB at a time, so the times " - "are recorded and not gated", + "%.1f ms.%s", static_cast(reads), static_cast(kSequentialChunkBytes / (1024 * 1024)), static_cast(record.metrics.requestCount), - record.wallMs, downloadMs); + record.wallMs, downloadMs, + cached ? " Every read bypassed the cache, so this row is the " + "uncached row and is asserted to be" + : " The comparator reads 4 KiB at a time, so the times " + "are recorded and not gated"); record.note = note; return record; } /// METRICS.md §6, row 5: parallel readers on one asset. -ScenarioRecord ParallelReaders(const Fixture& fixture, Server& server) { +ScenarioRecord ParallelReaders(const Fixture& fixture, + Server& server, + bool cached, + const MetricsSnapshot* uncached) { ScenarioRecord record; - record.name = "parallel readers"; + record.name = RowName("parallel readers", cached); + record.cached = cached; record.exercises = "`requestsSavedBySingleFlight`, contention"; server.ClearLog(); @@ -546,14 +760,21 @@ ScenarioRecord ParallelReaders(const Fixture& fixture, Server& server) { // first, so nothing an earlier scenario folded lands in this row. MetricsRegistry::Instance().ResetForTesting(); + // One store between the eight of them, which is the whole question this row + // asks. A store per reader would make eight readers of one revision eight + // times the traffic, which is the number `v0.2.0` recorded and this release + // exists to move. + std::unique_ptr shared; + if (cached) shared.reset(new BlockCache(BaselineCacheOptions())); + const Clock::time_point started = Clock::now(); std::vector threads; threads.reserve(kParallelReaders); for (int i = 0; i < kParallelReaders; ++i) { - threads.emplace_back([&fixture] { - std::unique_ptr reader = OpenOrReport(fixture); - if (!reader) return; - ReadBoundedQuery(*reader, fixture); + threads.emplace_back([&fixture, cached, &shared] { + Stack stack; + if (!OpenStack(fixture, cached, shared.get(), &stack)) return; + ReadBoundedQuery(*stack.reader, fixture); }); } for (std::thread& thread : threads) thread.join(); @@ -565,16 +786,42 @@ ScenarioRecord ParallelReaders(const Fixture& fixture, Server& server) { // `selectivity` is about it. record.metrics.assetSize = fixture.size; + if (cached) { + CheckCachedShape(record.metrics, server.Log(), ServerRequests(server), + kBoundedQueryBytes * kParallelReaders, kParallelReaders); + if (uncached != nullptr) { + CHECK(record.metrics.requestCount < uncached->requestCount); + } + // The counter the row is named after. Zero here would mean eight readers + // each fetched their own copy and the store's identity did not match -- + // which is the failure this release's cache key exists to prevent. + CHECK(record.metrics.requestsSavedBySingleFlight + + record.metrics.blockHits > + 0); + record.note = + std::to_string(kParallelReaders) + + " readers running the bounded query at once, each with its own " + "revision binding and all of them sharing one store. What they no " + "longer share is the traffic: " + + std::to_string(record.metrics.requestCount) + " requests against " + + (uncached != nullptr ? std::to_string(uncached->requestCount) + : std::string("the uncached row")) + + ". `requestsSavedBySingleFlight` is " + + std::to_string(record.metrics.requestsSavedBySingleFlight) + + " and `blockHits` is " + std::to_string(record.metrics.blockHits) + + ": those count blocks a reader did not have to fetch, not requests, " + "so they do not subtract to the difference above and are not meant to"; + return record; + } + CheckUncachedShape(record.metrics, ServerRequests(server), kBoundedQueryBytes * kParallelReaders, kBoundedQueryReads * kParallelReaders, kParallelReaders); - record.note = std::to_string(kParallelReaders) + " readers running the bounded query at once, each with its own " "revision binding. Every request is issued " + std::to_string(kParallelReaders) + - " times, because nothing is shared between readers yet; that is " - "the figure `requestsSavedBySingleFlight` has to move in `v0.3.0`"; + " times, because nothing is shared between readers"; return record; } @@ -651,12 +898,25 @@ int main(int argc, char** argv) { fixture.url = server->Url(kAssetPath); fixture.size = assetBytes; + // Uncached first, then the same scenario cached, so that each pair sits + // together in the table and the cached run has the uncached row to assert + // against. That ordering is the record's whole shape: METRICS.md section 6 + // asks a release that changes I/O behavior for the values before and after, + // and this release changes them on purpose. std::vector records; - records.push_back(MetadataOnlyOpen(fixture, *server)); - records.push_back(HeaderAndIndexRead(fixture, *server)); - records.push_back(BoundedSpatialQuery(fixture, *server)); - records.push_back(FullSequentialRead(fixture, *server)); - records.push_back(ParallelReaders(fixture, *server)); + // Reserved so that the pointer each cached run is given into the row above + // it cannot be invalidated by the push that follows. + records.reserve(10); + records.push_back(MetadataOnlyOpen(fixture, *server, false, nullptr)); + records.push_back(MetadataOnlyOpen(fixture, *server, true, &records[0].metrics)); + records.push_back(HeaderAndIndexRead(fixture, *server, false, nullptr)); + records.push_back(HeaderAndIndexRead(fixture, *server, true, &records[2].metrics)); + records.push_back(BoundedSpatialQuery(fixture, *server, false, nullptr)); + records.push_back(BoundedSpatialQuery(fixture, *server, true, &records[4].metrics)); + records.push_back(FullSequentialRead(fixture, *server, false, nullptr)); + records.push_back(FullSequentialRead(fixture, *server, true, &records[6].metrics)); + records.push_back(ParallelReaders(fixture, *server, false, nullptr)); + records.push_back(ParallelReaders(fixture, *server, true, &records[8].metrics)); RunContext context; context.assetBytes = assetBytes; diff --git a/tests/boundary/CMakeLists.txt b/tests/boundary/CMakeLists.txt index 13d5675..1ad5707 100644 --- a/tests/boundary/CMakeLists.txt +++ b/tests/boundary/CMakeLists.txt @@ -56,6 +56,16 @@ if(TARGET usdasset::local) backends/boundary_local_main.cpp usdasset::local) endif() +# The cache's row: the same local backend with a decorator over it, which is +# what makes "byte-for-byte equivalence with the uncached path over the full +# suite" an assertion rather than a claim. Not a fourth backend -- the cache is +# not a transport -- and it links no transport, which is the property that keeps +# the local backend a usable oracle for the cached path. +if(TARGET usdasset::cache AND TARGET usdasset::local) + usd_http_resolver_add_boundary_backend(cached_local + backends/boundary_cached_local_main.cpp usdasset::cache usdasset::local) +endif() + # The line v0.2.0 said it would be. The HTTP row additionally links the fixture # server, which is the one reverse edge WORKSPACE.md §2 admits for it: a remote # backend has to arrange for its bytes to exist somewhere a transport can reach, diff --git a/tests/boundary/backends/boundary_cached_local_main.cpp b/tests/boundary/backends/boundary_cached_local_main.cpp new file mode 100644 index 0000000..628ce95 --- /dev/null +++ b/tests/boundary/backends/boundary_cached_local_main.cpp @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The block cache's row in the shared boundary suite, over the local backend. +// +// The cache is not a transport, so this row is not a fourth backend: it is the +// same local backend with a decorator on top, and entering it here is what +// makes "byte-for-byte equivalence with the uncached path over the full suite" +// an assertion rather than a claim. Every case runs unchanged, and the oracle +// is the same independent naive reader the `local` row is compared against -- +// so a block boundary that is off by one, a short final block that got padded, +// or an EOF answer the expansion moved shows up as a byte mismatch against a +// file, not as an argument about caching. +// +// It is the local backend underneath rather than the HTTP one deliberately. A +// cached row over a socket would be measuring two things at once, and the cache +// knows no transport concept -- if this row needed one, the decorator would +// have acquired knowledge WORKSPACE.md invariant 5 forbids it. + +#include +#include +#include + +#include "usdAssetCache/BlockCache.h" +#include "usdAssetCache/CacheOptions.h" +#include "usdAssetCache/CachedAssetReader.h" +#include "usdAssetLocal/LocalAssetReader.h" +#include "usdAssetLocal/Testing.h" +#include "usdassetboundary/Backend.h" +#include "usdassetboundary/Fixture.h" +#include "usdassetboundary/Suite.h" + +namespace { + +/// Deliberately smaller than the shipped default, and smaller than the suite's +/// nominal alignment. +/// +/// The suite's interesting offsets are powers of two around 65536, and a block +/// size of 65536 would put every one of them exactly on a block boundary -- +/// which is the one case block arithmetic never gets wrong. At 4096 the suite's +/// offsets land inside blocks, across blocks, and on boundaries, and the tail +/// of every fixture is a short final block. +const std::uint64_t kRowBlockSize = 4096; + +usdasset::cache::CacheOptions RowOptions() { + usdasset::cache::CacheOptions options; + options.blockSize = kRowBlockSize; + // Small enough that eviction runs during the suite rather than sitting + // unexercised: the property cases walk assets larger than this, so blocks + // are dropped and re-fetched while the oracle comparison is watching. + options.budgetBytes = 64 * kRowBlockSize; + options.coalesceGapBlocks = 1; + options.maxRequestBytes = 32 * kRowBlockSize; + // Above the largest whole-asset read the suite issues, so the cached path + // is the path under test rather than the bypass. + options.bypassThresholdBytes = 4 * usdassetboundary::kNominalBlockSize; + return options.Normalized(); +} + +/// The store this row uses. Its own rather than the process one, so that the +/// budget above is the budget in force. +usdasset::cache::BlockCache& RowStore() { + static usdasset::cache::BlockCache store{RowOptions()}; + return store; +} + +usdasset::OpenResult Decorate(usdasset::local::LocalOpenResult local) { + usdasset::OpenResult result; + if (!local.reader) { + result.status = std::move(local.status); + return result; + } + // The inner reader's counters, so the stack reports one set of numbers. + usdasset::ReaderMetrics* innerMetrics = &local.reader->Metrics(); + usdasset::cache::CachedOpenResult cached = usdasset::cache::Wrap( + std::unique_ptr(local.reader.release()), innerMetrics, + RowOptions(), &RowStore()); + result.reader = std::move(cached.reader); + result.status = cached.reader ? std::move(local.status) : std::move(cached.status); + return result; +} + +/// The same injected fault the `local` row uses: half of the first request, and +/// then nothing. Through the cache it is a fetch of a whole block that stops +/// below the end of the asset, which is the same condition the contract names. +usdasset::local::testing::ReadFault MakeShortReadFault() { + auto delivered = std::make_shared(false); + return [delivered](std::uint64_t, std::size_t size) -> std::size_t { + if (*delivered) { + return 0; + } + *delivered = true; + return size / 2; + }; +} + +usdassetboundary::BackendUnderTest MakeCachedLocalBackend() { + usdassetboundary::BackendUnderTest backend; + backend.name = "cached-local"; + + backend.open = [](const std::string& identifier) { + return Decorate(usdasset::local::Open(identifier)); + }; + + backend.provision = [](const usdassetboundary::FixtureRequest& request) { + usdassetboundary::ProvisionedAsset asset; + asset.identifier = request.oraclePath; + if (request.behavior == usdassetboundary::FixtureBehavior::ShortReadBelowEof) { + const std::string path = request.oraclePath; + asset.open = [path] { + return Decorate(usdasset::local::testing::OpenWithReadFault( + path, MakeShortReadFault())); + }; + } + return asset; + }; + + // Unchanged from the row underneath: a decorator cannot add a cancellation + // channel a file descriptor does not have. + backend.admitsCancellation = false; + + backend.simulatesRevisionChange = true; + backend.republish = [](const std::string& identifier, + const std::vector& content) { + return usdassetboundary::RepublishFile(identifier, content); + }; + + return backend; +} + +} // namespace + +int main(int argc, char** argv) { + return usdassetboundary::RunBoundarySuite(MakeCachedLocalBackend(), argc, argv); +} diff --git a/tests/boundary/src/Suite.cpp b/tests/boundary/src/Suite.cpp index 953e763..706dac4 100644 --- a/tests/boundary/src/Suite.cpp +++ b/tests/boundary/src/Suite.cpp @@ -2,6 +2,7 @@ #include "usdassetboundary/Suite.h" +#include #include #include #include @@ -294,8 +295,11 @@ void RunRevisionChangeCase(const BackendUnderTest& backend, return; } + // The read that has to observe the change: an offset this reader has not + // read before, so nothing it already holds can answer it and the request + // reaches the transport. std::vector after(size + kGuardBytes, kGuardFill); - const ReadResult second = opened.reader->Read(0, after.data(), size); + const ReadResult second = opened.reader->Read(kNominalBlockSize, after.data(), size); if (second.status.code != StatusCode::AssetChanged) { reporter.Fail(caseName, std::string("status ") + @@ -305,11 +309,44 @@ void RunRevisionChangeCase(const BackendUnderTest& backend, } reporter.Pass(); + // A range this reader already read, read again. Two answers are correct and + // one is not. + // + // A backend that reaches its transport observes the change and reports + // `AssetChanged`. A reader with a block cache over it answers from bytes it + // captured *under this binding*, observes nothing, and hands back the + // revision it is bound to -- which is the guarantee, not an exception to + // it: the contract's wording is "a reader that observes a changed validator + // fails subsequent reads", and CACHE.md section 6 admits in-memory caching + // for the reader's lifetime for exactly this reason. What neither is + // allowed to do is return the new revision's bytes, and that is what this + // checks. A byte comparison, because a status comparison cannot see it. + std::vector reread(size + kGuardBytes, kGuardFill); + const ReadResult repeat = opened.reader->Read(0, reread.data(), size); + if (repeat.status.code == StatusCode::Ok) { + if (repeat.bytesRead != first.bytesRead || + !std::equal(reread.begin(), reread.begin() + repeat.bytesRead, + before.begin())) { + reporter.Fail(caseName + " (a repeated read serves the bound revision)", + "the bytes changed underneath a reader that reported Ok"); + return; + } + } else if (repeat.status.code != StatusCode::AssetChanged) { + reporter.Fail(caseName + " (a repeated read)", + std::string("status ") + + usdasset::StatusCodeName(repeat.status.code) + + ", expected AssetChanged or the bound revision's bytes"); + return; + } + reporter.Pass(); + // And it stays changed. A reader that recovers on the next call has // rebound to the new revision, which is the failure the code exists to - // prevent rather than a transient it survived. + // prevent rather than a transient it survived. Another offset this reader + // has not read, for the reason the first one was. std::vector third(size + kGuardBytes, kGuardFill); - const ReadResult retry = opened.reader->Read(kNominalBlockSize, third.data(), size); + const ReadResult retry = + opened.reader->Read(kNominalBlockSize + size, third.data(), size); if (retry.status.code != StatusCode::AssetChanged) { reporter.Fail(caseName + " (does not rebind)", std::string("a later read returned ") + diff --git a/tests/cache-tuning/CMakeLists.txt b/tests/cache-tuning/CMakeLists.txt new file mode 100644 index 0000000..ae94c39 --- /dev/null +++ b/tests/cache-tuning/CMakeLists.txt @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# The block-policy measurement: a sweep of block size against coalescing gap +# over the access patterns METRICS.md section 6 names. +# +# This is the fifth legal reverse edge in WORKSPACE.md section 2 -- a test +# outside libs/ that links both a backend and the fixture server -- and it is +# here for a reason the other four share and one they do not. The shared reason: +# a module's tests must not depend on anything outside libs/, or +# `ost library build libs/usd-asset-cache`, which builds the module alone, stops +# working. The reason of its own: the constants this measures are about round +# trips, and a sweep over a local file would be a sweep over a cost that does +# not exist there. +# +# It links the cache and the HTTP backend at once, which nothing else does. That +# is not the cache learning what a transport is -- it holds an AssetReader and +# nothing else -- it is a measurement of a stack, made where the stack is. + +add_executable(usdAssetCache_tuning tuning_main.cpp) + +target_link_libraries(usdAssetCache_tuning + PRIVATE usdasset::cache usdasset::http usdasset::fixtureserver) +target_include_directories(usdAssetCache_tuning PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}") + +if(MSVC) + target_compile_options(usdAssetCache_tuning PRIVATE /utf-8 /W4) + target_compile_definitions(usdAssetCache_tuning PRIVATE _CRT_SECURE_NO_WARNINGS) +else() + target_compile_options(usdAssetCache_tuning PRIVATE -Wall -Wextra -Wpedantic) +endif() + +if(COMMAND usd_http_resolver_stage_runtime_dependencies) + usd_http_resolver_stage_runtime_dependencies(usdAssetCache_tuning) +endif() + +# Registered as a test rather than kept as a tool, for the reason the baseline +# is: what it asserts is that every configuration in the sweep returns the right +# bytes, which is the cache running over a real socket at five block sizes and +# four gaps. The table it prints is the measurement, and it is recorded in +# docs/reference/BLOCK_POLICY.md. +add_test(NAME usdAssetCache_block_policy COMMAND usdAssetCache_tuning) +set_tests_properties(usdAssetCache_block_policy PROPERTIES TIMEOUT 900) + +# A sanitizer lane runs the sweep for its assertions and not for its numbers, +# and 128 MiB of instrumented memcpy over loopback forty times is a lane that +# times out rather than a lane that measures. +if(USD_HTTP_RESOLVER_SANITIZER) + set_tests_properties(usdAssetCache_block_policy PROPERTIES + ENVIRONMENT "USD_ASSET_TUNING_ASSET_BYTES=8388608") +endif() diff --git a/tests/cache-tuning/Check.h b/tests/cache-tuning/Check.h new file mode 100644 index 0000000..0807f82 --- /dev/null +++ b/tests/cache-tuning/Check.h @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// A twenty-line check macro, deliberately not a test framework. Same reasoning +// as every other Check.h in this tree: this repository takes exactly one +// third-party dependency, chosen on stated criteria in ADR-0003, and a test +// framework beside it would be a second one acquired without an argument. +// +// One divergence from the others, and it is the reason this copy is not a copy: +// the counter is atomic. The parallel-readers scenario checks from eight +// threads, and a plain `int` incremented from eight threads is a data race +// however unlikely a failure is -- and the day one happened, ThreadSanitizer +// would report the race rather than the failure, which buries the finding under +// its own symptom. + +#ifndef USDASSETHTTP_BASELINE_CHECK_H +#define USDASSETHTTP_BASELINE_CHECK_H + +#include +#include + +namespace usdassettest { + +inline std::atomic& FailureCount() { + static std::atomic failures{0}; + return failures; +} + +inline int Report(const char* suite) { + const int failures = FailureCount().load(); + if (failures == 0) { + std::printf("%s: ok\n", suite); + return 0; + } + std::printf("%s: %d failure(s)\n", suite, failures); + return 1; +} + +} // namespace usdassettest + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); \ + ++::usdassettest::FailureCount(); \ + } \ + } while (false) + +#define CHECK_EQ(actual, expected) \ + do { \ + const auto _actual = (actual); \ + const auto _expected = (expected); \ + if (!(_actual == _expected)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s == %s (%llu vs %llu)\n", \ + __FILE__, __LINE__, #actual, #expected, \ + static_cast(_actual), \ + static_cast(_expected)); \ + ++::usdassettest::FailureCount(); \ + } \ + } while (false) + +#endif // USDASSETHTTP_BASELINE_CHECK_H diff --git a/tests/cache-tuning/tuning_main.cpp b/tests/cache-tuning/tuning_main.cpp new file mode 100644 index 0000000..9e95681 --- /dev/null +++ b/tests/cache-tuning/tuning_main.cpp @@ -0,0 +1,439 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The measurement that chooses the block policy. +// +// Section 5 of the design policy says cache behavior is measured before it is +// tuned, and CACHE.md section 4 says both coalescing numbers are recorded with +// the measurement that produced them, because "a tuned constant without a +// recorded measurement is a guess with a decimal point". This is that +// measurement: a sweep of block size against coalescing gap over the access +// patterns METRICS.md section 6 names, against a real socket, with every byte +// verified. +// +// It is a sweep and not a benchmark, and the difference matters. What it +// reports -- requests, bytes moved, amplification -- are counts, exact and the +// same on every machine. What it also reports, wall clock, is a fact about +// loopback on the runner that drew the job, and no default is chosen from it: +// loopback has no round-trip time worth the name, and the whole argument for +// merging small reads is about a round-trip time this harness cannot produce. +// So the numbers that choose the defaults are the request counts and the byte +// counts, and the reasoning from them is written down beside the table in +// docs/reference/BLOCK_POLICY.md. +// +// What it asserts, rather than reports, is that every configuration in the +// sweep returns the right bytes. That is the part of this file that is a test: +// the cache runs over a real transport at five block sizes and four gaps, and a +// block boundary that is wrong at one of them fails the lane. +// +// Normative contracts: +// docs/architecture/CACHE.md sections 3 and 4, the model and the policy +// docs/architecture/METRICS.md section 6, the access patterns +// docs/design/DESIGN_POLICY.md section 5, measured before tuned + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "usdAssetCache/BlockCache.h" +#include "usdAssetCache/CacheOptions.h" +#include "usdAssetCache/CachedAssetReader.h" +#include "usdAssetHttp/HttpAssetReader.h" +#include "usdAssetIo/Metrics.h" +#include "usdassetfixture/Server.h" + +#include "Check.h" + +namespace { + +using usdasset::AssetReader; +using usdasset::MetricsSnapshot; +using usdasset::ReadResult; +using usdasset::cache::BlockCache; +using usdasset::cache::CacheOptions; +using usdasset::cache::CachedAssetReader; +using usdasset::http::HttpOpenResult; +using usdassetfixture::AssetSpec; +using usdassetfixture::Behavior; +using usdassetfixture::Server; + +using Clock = std::chrono::steady_clock; + +/// The same layout the recorded baseline uses, so the two tables are about one +/// fixture and a row here can be read against a row there. +constexpr std::uint64_t kDefaultAssetBytes = 128ull * 1024 * 1024; +constexpr std::uint64_t kMinAssetBytes = 4ull * 1024 * 1024; +constexpr std::uint64_t kHeaderBytes = 4 * 1024; +constexpr std::uint64_t kIndexBytes = 64 * 1024; +constexpr std::uint64_t kIndexReadBytes = 4 * 1024; +constexpr std::uint64_t kChunkBytes = 16 * 1024; +constexpr int kChunkReads = 16; +constexpr std::uint64_t kSequentialChunkBytes = 4ull * 1024 * 1024; + +const char kAssetPath[] = "/tuning/asset.bin"; + +unsigned char ByteAt(std::uint64_t offset) noexcept { + std::uint64_t x = offset + 0x9E3779B97F4A7C15ull; + x ^= x >> 30; + x *= 0xBF58476D1CE4E5B9ull; + x ^= x >> 27; + x *= 0x94D049BB133111EBull; + x ^= x >> 31; + return static_cast(x & 0xffull); +} + +std::vector MakeContent(std::uint64_t size) { + std::vector content(static_cast(size)); + for (std::uint64_t i = 0; i < size; ++i) { + content[static_cast(i)] = ByteAt(i); + } + return content; +} + +bool ReadChecked(AssetReader& reader, + std::uint64_t offset, + std::uint64_t size, + std::vector* buffer) { + buffer->assign(static_cast(size), 0); + const ReadResult result = + reader.Read(offset, buffer->data(), static_cast(size)); + if (!result.status.IsOk() || result.bytesRead != size) { + std::fprintf(stderr, "FAIL: read at %llu+%llu: %s (%llu bytes)\n", + static_cast(offset), + static_cast(size), + usdasset::ToString(result.status).c_str(), + static_cast(result.bytesRead)); + ++usdassettest::FailureCount(); + return false; + } + for (std::uint64_t i = 0; i < size; ++i) { + if ((*buffer)[static_cast(i)] == ByteAt(offset + i)) continue; + std::fprintf(stderr, + "FAIL: read at %llu+%llu: byte %llu is not the byte that " + "belongs at that offset\n", + static_cast(offset), + static_cast(size), + static_cast(i)); + ++usdassettest::FailureCount(); + return false; + } + return true; +} + +struct Fixture { + std::string url; + std::uint64_t size = 0; +}; + +std::uint64_t ChunkOffset(const Fixture& fixture, int index) { + const std::uint64_t body = fixture.size - kHeaderBytes - kIndexBytes; + const std::uint64_t stride = (body - kChunkBytes) / kChunkReads; + return kHeaderBytes + stride * static_cast(index); +} + +/// The access patterns, named the way METRICS.md section 6 names them. +enum class Pattern { + HeaderAndIndex, + BoundedQuery, + /// The one pattern here that METRICS.md section 6 does not name, and the + /// only one that can measure the coalescing gap at all. + /// + /// A gap exists when a read wants blocks that straddle blocks something + /// already holds, and none of the three patterns above ever produces one: + /// every block of a contiguous read is wanted unless a previous read left + /// one resident, and those patterns never revisit a region at a wider + /// granularity. A format that reads an index in pieces and then re-reads + /// the region does, so this reads every other piece of the index and then + /// reads the whole of it. Without that row the gap constant would be + /// recorded against a sweep in which it provably could not matter, which is + /// a measurement of nothing presented as a measurement. + InterleavedIndex, + FullSequential, +}; + +const char* PatternName(Pattern pattern) { + switch (pattern) { + case Pattern::HeaderAndIndex: return "header and index"; + case Pattern::BoundedQuery: return "bounded query"; + case Pattern::InterleavedIndex: return "interleaved index re-read"; + case Pattern::FullSequential: return "full sequential"; + } + return "unknown"; +} + +bool RunPattern(AssetReader& reader, const Fixture& fixture, Pattern pattern) { + std::vector buffer; + switch (pattern) { + case Pattern::HeaderAndIndex: { + if (!ReadChecked(reader, 0, kHeaderBytes, &buffer)) return false; + const int reads = static_cast(kIndexBytes / kIndexReadBytes); + for (int i = 0; i < reads; ++i) { + const std::uint64_t offset = + fixture.size - kIndexBytes + + kIndexReadBytes * static_cast(i); + if (!ReadChecked(reader, offset, kIndexReadBytes, &buffer)) return false; + } + return true; + } + case Pattern::BoundedQuery: { + if (!ReadChecked(reader, 0, kHeaderBytes, &buffer)) return false; + if (!ReadChecked(reader, fixture.size - kIndexBytes, kIndexBytes, &buffer)) { + return false; + } + for (int i = 0; i < kChunkReads; ++i) { + if (!ReadChecked(reader, ChunkOffset(fixture, i), kChunkBytes, &buffer)) { + return false; + } + } + return true; + } + case Pattern::InterleavedIndex: { + const std::uint64_t base = fixture.size - kIndexBytes; + const int reads = static_cast(kIndexBytes / kIndexReadBytes); + for (int i = 0; i < reads; i += 2) { + const std::uint64_t offset = + base + kIndexReadBytes * static_cast(i); + if (!ReadChecked(reader, offset, kIndexReadBytes, &buffer)) return false; + } + // And now the whole region, which wants what it already holds and + // what it does not, alternating. + return ReadChecked(reader, base, kIndexBytes, &buffer); + } + case Pattern::FullSequential: { + std::uint64_t offset = 0; + while (offset < fixture.size) { + const std::uint64_t size = + (std::min)(kSequentialChunkBytes, fixture.size - offset); + if (!ReadChecked(reader, offset, size, &buffer)) return false; + offset += size; + } + return true; + } + } + return false; +} + +struct Row { + Pattern pattern = Pattern::HeaderAndIndex; + std::uint64_t blockSize = 0; + std::uint32_t gap = 0; + bool cached = false; + MetricsSnapshot metrics; + std::uint64_t serverRequests = 0; + double wallMs = 0.0; +}; + +/// One run: a fresh reader, a fresh store, and a server log cleared beforehand. +/// +/// A fresh store for every row is the point. A sweep that let one configuration +/// warm the next would be measuring the order the rows happen to be in. +Row Measure(const Fixture& fixture, + Server& server, + Pattern pattern, + const CacheOptions& options, + bool cached) { + Row row; + row.pattern = pattern; + row.blockSize = options.blockSize; + row.gap = options.coalesceGapBlocks; + row.cached = cached; + + server.ClearLog(); + const Clock::time_point started = Clock::now(); + + HttpOpenResult opened = usdasset::http::Open(fixture.url); + if (!opened.reader) { + std::fprintf(stderr, "FAIL: open: %s\n", + usdasset::ToString(opened.status).c_str()); + ++usdassettest::FailureCount(); + return row; + } + + if (!cached) { + RunPattern(*opened.reader, fixture, pattern); + row.wallMs = + std::chrono::duration(Clock::now() - started).count(); + row.metrics = opened.reader->Metrics().Snapshot(); + row.serverRequests = static_cast(server.RequestCount()); + return row; + } + + BlockCache store(options); + usdasset::ReaderMetrics* innerMetrics = &opened.reader->Metrics(); + usdasset::cache::CachedOpenResult wrapped = usdasset::cache::Wrap( + std::unique_ptr(opened.reader.release()), innerMetrics, options, + &store); + if (!wrapped.reader) { + std::fprintf(stderr, "FAIL: wrap: %s\n", + usdasset::ToString(wrapped.status).c_str()); + ++usdassettest::FailureCount(); + return row; + } + + RunPattern(*wrapped.reader, fixture, pattern); + row.wallMs = + std::chrono::duration(Clock::now() - started).count(); + row.metrics = wrapped.reader->SnapshotMetrics(); + row.serverRequests = static_cast(server.RequestCount()); + + // The independent witness, the same one the recorded baseline keeps: the + // backend's account of what it did, against the server's account of what it + // answered. A request issued outside the metrics sink costs a round trip and + // counts nothing, and a sweep watching only the sink would choose a block + // size from numbers that were wrong in the same direction everywhere. + CHECK_EQ(row.serverRequests, row.metrics.requestCount); + return row; +} + +std::string HumanBytes(std::uint64_t bytes) { + char text[64]; + if (bytes >= 1024 * 1024) { + std::snprintf(text, sizeof(text), "%llu MiB", + static_cast(bytes / (1024 * 1024))); + } else { + std::snprintf(text, sizeof(text), "%llu KiB", + static_cast(bytes / 1024)); + } + return text; +} + +void PrintTable(const std::vector& rows, std::uint64_t assetBytes) { + std::printf("\n### Sweep against a %llu-byte fixture on loopback\n\n", + static_cast(assetBytes)); + std::printf("| Pattern | block | gap | requests | bytes moved | amplification |"); + std::printf(" over-fetch | wall ms |\n"); + std::printf("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n"); + for (const Row& row : rows) { + if (!row.cached) { + std::printf("| %s | none | — | %llu | %llu | %.6f | %llu | %.1f |\n", + PatternName(row.pattern), + static_cast(row.metrics.requestCount), + static_cast(row.metrics.bytesTransferred), + row.metrics.Amplification(), + static_cast(row.metrics.bytesOverFetched), + row.wallMs); + continue; + } + std::printf("| %s | %s | %u | %llu | %llu | %.6f | %llu | %.1f |\n", + PatternName(row.pattern), HumanBytes(row.blockSize).c_str(), + row.gap, + static_cast(row.metrics.requestCount), + static_cast(row.metrics.bytesTransferred), + row.metrics.Amplification(), + static_cast(row.metrics.bytesOverFetched), + row.wallMs); + } +} + +bool ParseDigits(const char* text, std::uint64_t* out) { + if (text == nullptr || text[0] == 0) return false; + for (const char* c = text; *c != 0; ++c) { + if (*c < '0' || *c > '9') return false; + } + *out = std::strtoull(text, nullptr, 10); + return true; +} + +} // namespace + +int main() { + std::uint64_t assetBytes = kDefaultAssetBytes; + const char* raw = std::getenv("USD_ASSET_TUNING_ASSET_BYTES"); + if (raw != nullptr && raw[0] != 0) { + if (!ParseDigits(raw, &assetBytes) || assetBytes < kMinAssetBytes) { + std::fprintf(stderr, + "FAIL: USD_ASSET_TUNING_ASSET_BYTES is not a usable byte " + "count: %s\n", + raw); + return 1; + } + } + + std::string error; + std::unique_ptr server = Server::Start(&error); + if (!server) { + std::fprintf(stderr, "FAIL: the fixture server could not bind loopback: %s\n", + error.c_str()); + return 1; + } + + { + AssetSpec spec; + spec.path = kAssetPath; + try { + spec.content = MakeContent(assetBytes); + } catch (const std::exception& failure) { + std::fprintf(stderr, "FAIL: could not allocate the %llu-byte fixture: %s\n", + static_cast(assetBytes), failure.what()); + server->Stop(); + return 1; + } + spec.behavior = Behavior::Normal; + // Strong, because sharing between readers turns on it and a sweep run + // with a weak one would be measuring the private-cache path. + spec.etag = "\"tuning-rev-1\""; + spec.lastModified = "Thu, 20 Aug 2026 09:00:00 GMT"; + spec.revisedContent.assign(1, 0); + server->Serve(spec); + } + + Fixture fixture; + fixture.url = server->Url(kAssetPath); + fixture.size = assetBytes; + + const std::vector blockSizes{4ull * 1024, 16ull * 1024, + 64ull * 1024, 256ull * 1024, + 1024ull * 1024}; + const std::vector gaps{0, 1, 2, 4}; + + std::vector rows; + + // The two clustered patterns, swept. These are the ones the block cache + // exists for and the ones the defaults are chosen from. + for (const Pattern pattern : {Pattern::HeaderAndIndex, Pattern::BoundedQuery, + Pattern::InterleavedIndex}) { + CacheOptions uncached; + rows.push_back(Measure(fixture, *server, pattern, uncached, false)); + for (const std::uint64_t blockSize : blockSizes) { + for (const std::uint32_t gap : gaps) { + CacheOptions options; + options.blockSize = blockSize; + options.coalesceGapBlocks = gap; + options.budgetBytes = 128ull * 1024 * 1024; + options.maxRequestBytes = 8ull * 1024 * 1024; + options.bypassThresholdBytes = 1024ull * 1024; + rows.push_back( + Measure(fixture, *server, pattern, options.Normalized(), true)); + } + } + } + + // The worst case, swept over block size only. The gap cannot matter here: + // every read is a streaming read above the bypass threshold, which is the + // policy this row exists to check has not quietly stopped applying. + { + CacheOptions uncached; + rows.push_back(Measure(fixture, *server, Pattern::FullSequential, uncached, + false)); + for (const std::uint64_t blockSize : blockSizes) { + CacheOptions options; + options.blockSize = blockSize; + options.coalesceGapBlocks = 1; + options.budgetBytes = 128ull * 1024 * 1024; + options.maxRequestBytes = 8ull * 1024 * 1024; + options.bypassThresholdBytes = 1024ull * 1024; + rows.push_back(Measure(fixture, *server, Pattern::FullSequential, + options.Normalized(), true)); + } + } + + PrintTable(rows, assetBytes); + server->Stop(); + return usdassettest::Report("usdAssetCache/block-policy-sweep"); +} From 2b0bb331fd348c6600389fc08fdd177130c3fa5c Mon Sep 17 00:00:00 2001 From: snkmcb Date: Thu, 20 Aug 2026 22:14:53 +0900 Subject: [PATCH 2/4] Charge the gap, keep the ownership, and take the lock for the whole rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight findings from the review of this branch. Two are defects that a test cannot reach, three are counters that were wrong, and three are a comment or a bound saying something the code does not. **The store's rebuild raced its own admission check.** `ConfigureProcess` took `identityMutex`, read `liveBindings == 0`, released it, and *then* called `Reset`, which clears and rebuilds `shards` with no lock held. A `Bind` that had already passed the same check could hand back a binding whose stripe index pointed into the vector the rebuild was about to free. The check and the rebuild are one critical section now; `Reset` split into a locking wrapper and a `ResetLocked` the configure path calls with the lock already held. What makes it sufficient is the binding destructor's ordering, which was already right: `liveBindings` drops as its last act, after the binding has finished touching every stripe, so zero under this lock means nobody is inside `shards`. **An exception left blocks pending for the life of the process.** `Abandon` documents the invariant -- every `Owned` acquisition ends in exactly one `Publish` or one `Abandon` -- and the release paths kept it across every `return` and across no `throw`. Both allocations on that path can throw: the transfer buffer is sized by the run and may be `maxRequestBytes`, and `Publish` copies the block. A `bad_alloc` out of either left every unpublished block of that read pending forever, and `Await` has no deadline, so the next reader of one of those blocks waits forever too -- a hang, in a process that never saw an error. Ownership is a destructor now. `Publish` allocates before it touches the store, so a throw from it leaves the block still owned and the guard still correct. **`bytesOverFetched` did not count the gap, which is most of what it exists to count.** It was charged as the bytes of a run falling outside the caller's byte range. Coalescing across a block that is already resident re-transfers that block, and those bytes sit *inside* the range -- so the counter called them wanted and charged nothing, while the wire moved them and the caller read that block from the store. It is charged against what the caller took out of each transfer now, which counts both halves: the alignment slack outside the range and the gap inside it. The recorded numbers move with it, in exactly one row of one table, and that row is the one the coalescing constant is chosen from. BLOCK_POLICY.md's interleaved re-read at 4 KiB blocks recorded a gap of one block as costing 0 bytes; it costs 28672. The prose two sections below it already said 28672 -- it was worked out by hand -- so the file has been disagreeing with itself since it was written, and the generated half was the wrong half. The trade the constant is chosen on is unchanged and now legible: seven fewer round trips for 28 KiB. **A read served entirely by single-flight was invisible.** Blocks obtained through `Await` bumped `bytesFromCache` and `requestsSavedBySingleFlight` and neither of the two counters that classify a read, so such a read landed in none of `blockHits`, `blockMisses`, or `partialHits`. They count as what they are -- blocks this reader did not fetch -- which is what `blockMisses` deliberately excludes them from. BASELINE.md's parallel row moves with it: `blockHits` 16 to 127, and `requestsSavedBySingleFlight` 153 to 156, which is that row's documented run-to-run variance rather than the fix. **The gap ceiling was off by one.** Merging across G blocks puts G + 2 in the request, so a ceiling of N blocks admits a gap of N - 2, not N - 1. With a 4 KiB block and a 16 KiB ceiling the normalizer resolved the gap to 3, and a gap of 3 needs 20 KiB -- a resolved option `PlanRuns` can never take, which is the exact thing the cap exists to prevent. The shipped defaults are unaffected: 128 blocks per request against a gap of 1. **Two clamps were silent and one comment described a bound that is not there.** The budget's floor is `kMinBlockSize`, not one block of the configured size, so a large block size and a small budget passed the parse and were then raised by the normalizer with nothing reported -- unlike the block size and the gap, which both report. The budget and the request ceiling report now, and the comment says what the floor is. **The resolver's comment named the guarded entry point and the code called the unguarded one.** `WrapAsset` checks `supportsRandomAccess` and passes such a reader through undecorated; `Wrap` cannot, because it returns a `CachedAssetReader` and has nothing to pass through -- and its own doc comment claimed the check anyway, which is a third statement of the same confusion. The resolver calls `WrapAsset`, and `Wrap`'s doc says what it does. Latent rather than live: ADR-0002 makes range support a hard error at open, so every reader reaching that line has it, which is how long a missing guard stays invisible. **And `ParseByteRange` was pasted into the middle of `TestRangeRead`'s doc comment**, leaving §4's sentence attached to the parser and "out of an asset costs the window." dangling above the test. Reunited. Verified: 29/29 on Windows with the bundle, and 25/25 under ASan+UBSan and 25/25 under TSan in WSL under GCC 15.2 -- the lanes that are the only real check on the first two findings. The over-fetch fix is pinned by an assertion in the gap test that the old accounting fails, and the recorded tables were regenerated and diffed rather than edited by hand. Co-Authored-By: Claude Opus 5 --- docs/reference/BASELINE.md | 20 +-- docs/reference/BLOCK_POLICY.md | 7 +- .../include/usdAssetCache/CachedAssetReader.h | 11 +- libs/usd-asset-cache/src/BlockCache.cpp | 37 ++++-- libs/usd-asset-cache/src/BlockPlan.cpp | 14 +- libs/usd-asset-cache/src/BlockPlan.h | 15 ++- libs/usd-asset-cache/src/CacheOptions.cpp | 9 +- .../usd-asset-cache/src/CachedAssetReader.cpp | 120 ++++++++++++++---- libs/usd-asset-cache/tests/test_cache.cpp | 15 +++ libs/usd-asset-cache/tests/test_plan.cpp | 21 +-- plugins/http-resolver/src/Configuration.cpp | 23 +++- plugins/http-resolver/src/HttpResolver.cpp | 18 ++- plugins/http-resolver/tests/test_stage.cpp | 5 +- 13 files changed, 234 insertions(+), 81 deletions(-) diff --git a/docs/reference/BASELINE.md b/docs/reference/BASELINE.md index d094d32..91c38f9 100644 --- a/docs/reference/BASELINE.md +++ b/docs/reference/BASELINE.md @@ -97,12 +97,14 @@ Measured with the shipped transport defaults and the shipped cache defaults -- 6 | parallel readers (cached) | 25 | 8 | 0 | 0 | 2654208 | 1507328 | 0.567901 | 0.011230 | 3.0 | -The cached parallel row is the one number in this record that is not identical -from run to run: it lands at 25 or 26 requests depending on which reader wins -each claim, because a reader that arrives while a block is in flight waits where -a reader arriving a microsecond later finds it resident. The harness therefore -asserts that it is *below* the uncached row rather than equal to a constant, and -this record states which run it came from rather than implying it is fixed. +The cached parallel row is the one row in this record that is not identical from +run to run: it lands at 25 or 26 requests depending on which reader wins each +claim, because a reader that arrives while a block is in flight waits where a +reader arriving a microsecond later finds it resident. `blockHits` and +`requestsSavedBySingleFlight` move with it, and for the same reason. The harness +therefore asserts that the request count is *below* the uncached row rather than +equal to a constant, and this record states which run it came from rather than +implying it is fixed. Cache counters, for the rows that have them. Every one is zero on an uncached row, and those rows are omitted. @@ -112,7 +114,7 @@ Cache counters, for the rows that have them. Every one is zero on an uncached ro | header and index read (cached) | 15 | 2 | 0 | 0 | 0 | 61440 | 122880 | 0 | 131072 | | bounded spatial query (cached) | 1 | 23 | 0 | 6 | 0 | 16384 | 1191936 | 0 | 1507328 | | full sequential read (cached) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | -| parallel readers (cached) | 16 | 23 | 0 | 6 | 153 | 2338816 | 1191936 | 0 | 1507328 | +| parallel readers (cached) | 127 | 23 | 0 | 6 | 156 | 2338816 | 1191936 | 0 | 1507328 | Latency, in microseconds. Quantiles are bucket upper bounds, not exact order statistics (METRICS.md §4), and the request and read columns are p50 / p90 / p99 / max. @@ -142,7 +144,7 @@ The cache counters in METRICS.md §2.2 are populated: the runs below served 2416 | full sequential read | The worst case; must not be worse than a plain download | 32 reads of 4 MiB against one plain `GET` of the whole asset over the fixture server's own raw client: identical content bytes, 33 requests against 1, 132.4 ms against 127.9 ms. The comparator reads 4 KiB at a time, so the times are recorded and not gated | | full sequential read (cached) | The worst case; must not be worse than a plain download | 32 reads of 4 MiB against one plain `GET` of the whole asset over the fixture server's own raw client: identical content bytes, 33 requests against 1, 148.6 ms against 129.1 ms. Every read bypassed the cache, so this row is the uncached row and is asserted to be | | parallel readers | `requestsSavedBySingleFlight`, contention | 8 readers running the bounded query at once, each with its own revision binding. Every request is issued 8 times, because nothing is shared between readers | -| parallel readers (cached) | `requestsSavedBySingleFlight`, contention | 8 readers running the bounded query at once, each with its own revision binding and all of them sharing one store. What they no longer share is the traffic: 25 requests against 152. `requestsSavedBySingleFlight` is 153 and `blockHits` is 16: those count blocks a reader did not have to fetch, not requests, so they do not subtract to the difference above and are not meant to | +| parallel readers (cached) | `requestsSavedBySingleFlight`, contention | 8 readers running the bounded query at once, each with its own revision binding and all of them sharing one store. What they no longer share is the traffic: 25 requests against 152. `requestsSavedBySingleFlight` is 156 and `blockHits` is 127: those count blocks a reader did not have to fetch, not requests, so they do not subtract to the difference above and are not meant to | ## What the numbers say @@ -160,7 +162,7 @@ query above it*. Eight readers of one revision moved one reader's worth of bytes. That is what the cache key buys: the eight have eight independent revision bindings and one identity, so seven of them found the blocks resident or waited on the flight that was already in the air. -`requestsSavedBySingleFlight` is 153, and it counts blocks rather than requests, +`requestsSavedBySingleFlight` is 156, and it counts blocks rather than requests, so it is not the arithmetic difference of the two request counts and is not meant to be. diff --git a/docs/reference/BLOCK_POLICY.md b/docs/reference/BLOCK_POLICY.md index f018e2c..8efa932 100644 --- a/docs/reference/BLOCK_POLICY.md +++ b/docs/reference/BLOCK_POLICY.md @@ -119,11 +119,16 @@ Every other 4 KiB piece of the index, and then the whole index region in one read. The only pattern in the sweep in which the coalescing gap can bind at all, and it is here for that reason alone; see below. +It is also the only pattern whose over-fetch comes from the *gap* rather than +from alignment: the merged request re-transfers blocks that were already +resident, and the caller reads those from the store while the wire moves them +again. That is a real cost and it is charged here. + | block | gap | requests | bytes moved | over-fetch | | ---: | ---: | ---: | ---: | ---: | | none | — | 10 | 98304 | 0 | | 4 KiB | 0 | 17 | 65536 | 0 | -| **4 KiB** | **1–4** | **10** | **94208** | **0** | +| **4 KiB** | **1–4** | **10** | **94208** | **28672** | | 16 KiB | 0–4 | 5 | 65536 | 49152 | | 64 KiB | 0–4 | 2 | 65536 | 61440 | | 256 KiB | 0–4 | 2 | 262144 | 258048 | diff --git a/libs/usd-asset-cache/include/usdAssetCache/CachedAssetReader.h b/libs/usd-asset-cache/include/usdAssetCache/CachedAssetReader.h index e5334a7..29681a5 100644 --- a/libs/usd-asset-cache/include/usdAssetCache/CachedAssetReader.h +++ b/libs/usd-asset-cache/include/usdAssetCache/CachedAssetReader.h @@ -116,10 +116,13 @@ struct CachedOpenResult { /// is the normal case: the budget is process-wide and shared across assets /// (CACHE.md section 7), so a store per reader would not be one budget. /// -/// Fails with `InvalidArgument` when `inner` is null, and passes through the -/// reader unchanged -- undecorated -- when its metadata says it cannot serve -/// random access. Caching a reader that cannot seek would store the one block -/// it managed to read and miss forever after. +/// Fails with `InvalidArgument` when `inner` is null. +/// +/// It does **not** check `supportsRandomAccess`, and cannot: it returns a +/// `CachedAssetReader`, so it has no way to hand back an undecorated reader. +/// Caching a reader that cannot seek would store the one block it managed to +/// read and miss forever after, so a caller that cannot guarantee random access +/// wants `WrapAsset` below, which can pass one through. CachedOpenResult Wrap(std::unique_ptr inner, ReaderMetrics* innerMetrics, const CacheOptions& options, diff --git a/libs/usd-asset-cache/src/BlockCache.cpp b/libs/usd-asset-cache/src/BlockCache.cpp index 6a1c51d..c617834 100644 --- a/libs/usd-asset-cache/src/BlockCache.cpp +++ b/libs/usd-asset-cache/src/BlockCache.cpp @@ -94,6 +94,19 @@ class BlockCache::Impl { explicit Impl(const CacheOptions& requested) { Reset(requested); } void Reset(const CacheOptions& requested) { + const std::lock_guard lock(identityMutex); + ResetLocked(requested); + } + + /// Rebuilds the stripes. `identityMutex` must be held. + /// + /// The lock covers the rebuild and not merely the identity map, because + /// `shards` is what the rebuild replaces and `liveBindings` is the only + /// thing that says nobody is reading it. A caller that checked + /// `liveBindings == 0`, released the lock, and then rebuilt would be racing + /// a `Bind` that had already passed the same check -- and the binding it + /// returned would hold a stripe index into a vector that had been freed. + void ResetLocked(const CacheOptions& requested) { options = requested.Normalized(); const std::uint32_t count = ChooseShardCount(options.budgetBytes, options.blockSize); shards.clear(); @@ -104,7 +117,6 @@ class BlockCache::Impl { shardMask = count - 1; shardBudget = (std::max)(options.budgetBytes / count, options.blockSize); residentTotal.store(0, std::memory_order_relaxed); - const std::lock_guard lock(identityMutex); identities.clear(); } @@ -188,16 +200,21 @@ BlockCache& BlockCache::Process() { bool BlockCache::ConfigureProcess(const CacheOptions& options) { BlockCache& store = Process(); - { - const std::lock_guard lock(store._impl->identityMutex); - if (store._impl->liveBindings != 0) { - // Refused rather than applied. Reconfiguring rebuilds the stripes, - // and a binding that held a stripe index across that rebuild would - // be reading a container that no longer exists. - return false; - } + // The check and the rebuild happen under one lock. Splitting them was the + // defect: `liveBindings == 0` is only true for as long as the lock is held, + // so releasing it before the rebuild let a `Bind` that had already been + // admitted hand back a binding whose stripe index pointed into the vector + // this call was about to free. A binding's destructor decrements + // `liveBindings` as its last act, after it has finished touching every + // stripe, so zero under this lock means no reader is inside `shards`. + const std::lock_guard lock(store._impl->identityMutex); + if (store._impl->liveBindings != 0) { + // Refused rather than applied. Reconfiguring rebuilds the stripes, and + // a binding that held a stripe index across that rebuild would be + // reading a container that no longer exists. + return false; } - store._impl->Reset(options); + store._impl->ResetLocked(options); return true; } diff --git a/libs/usd-asset-cache/src/BlockPlan.cpp b/libs/usd-asset-cache/src/BlockPlan.cpp index db0f79a..dd78bb6 100644 --- a/libs/usd-asset-cache/src/BlockPlan.cpp +++ b/libs/usd-asset-cache/src/BlockPlan.cpp @@ -79,15 +79,11 @@ std::vector PlanRuns(const std::vector& blocks, return runs; } -std::uint64_t OverFetchedBytes(const FetchRun& run, - std::uint64_t wantedOffset, - std::uint64_t wantedLength) noexcept { - const std::uint64_t runEnd = run.offset + run.length; - const std::uint64_t wantedEnd = wantedOffset + wantedLength; - const std::uint64_t overlapBegin = (std::max)(run.offset, wantedOffset); - const std::uint64_t overlapEnd = (std::min)(runEnd, wantedEnd); - const std::uint64_t overlap = overlapEnd > overlapBegin ? overlapEnd - overlapBegin : 0; - return run.length - overlap; +std::uint64_t OverFetchedBytes(const FetchRun& run, std::uint64_t takenBytes) noexcept { + // Clamped rather than trusted. `takenBytes` is a sum accumulated by the + // caller, and a counter that underflowed to 18 exabytes would be worse than + // one that reported zero. + return takenBytes < run.length ? run.length - takenBytes : 0; } } // namespace detail diff --git a/libs/usd-asset-cache/src/BlockPlan.h b/libs/usd-asset-cache/src/BlockPlan.h index 17d3eaf..9b9c9b1 100644 --- a/libs/usd-asset-cache/src/BlockPlan.h +++ b/libs/usd-asset-cache/src/BlockPlan.h @@ -79,15 +79,22 @@ std::vector PlanRuns(const std::vector& blocks, std::uint32_t coalesceGapBlocks, std::uint64_t maxRequestBytes); -/// The bytes of `run` that fall outside `[wantedOffset, wantedOffset + wantedLength)`. +/// The bytes of `run` the caller did not take out of it. /// /// This is `bytesOverFetched` for one fetch: the cost of block alignment and of /// merging across a gap, charged where it is incurred. METRICS.md §2.2 calls it /// the honest counter, and it is charged at fetch time and never refunded when /// a later read hits those bytes -- that refund is what `cacheHitRatio` is. -std::uint64_t OverFetchedBytes(const FetchRun& run, - std::uint64_t wantedOffset, - std::uint64_t wantedLength) noexcept; +/// +/// `takenBytes` is what the caller actually copied out of this transfer, and it +/// is the parameter rather than the caller's byte range because the two are not +/// the same number. Merging across a gap re-fetches a block that was already +/// resident; those bytes lie *inside* the caller's range, so charging by range +/// would call them wanted and count nothing -- while the wire moved them and +/// the caller read that block from the store. Charging by what was taken counts +/// both halves of the cost: the alignment slack outside the range, and the gap +/// inside it. +std::uint64_t OverFetchedBytes(const FetchRun& run, std::uint64_t takenBytes) noexcept; } // namespace detail } // namespace cache diff --git a/libs/usd-asset-cache/src/CacheOptions.cpp b/libs/usd-asset-cache/src/CacheOptions.cpp index 0ae1ab4..0a646ff 100644 --- a/libs/usd-asset-cache/src/CacheOptions.cpp +++ b/libs/usd-asset-cache/src/CacheOptions.cpp @@ -50,8 +50,15 @@ CacheOptions CacheOptions::Normalized() const noexcept { // request ceiling is not a policy, it is a number that never applies. Cap // it where it stops meaning anything, so that a reader of the resolved // options sees the gap that is actually in force. + // Two, not one. Merging across a gap of G blocks puts G + 2 blocks in the + // request -- the block before the gap, the gap, and the block after it -- + // so the widest gap a request ceiling of N blocks can carry is N - 2. At + // N - 1 the normalizer advertised a gap `PlanRuns` can never take: with a + // 4 KiB block and a 16 KiB ceiling it resolved to 3, and a gap of 3 needs + // 20 KiB. A resolved option that never applies is the thing this cap exists + // to prevent. const std::uint64_t blocksPerRequest = normalized.maxRequestBytes / normalized.blockSize; - const std::uint64_t gapCeiling = blocksPerRequest > 0 ? blocksPerRequest - 1 : 0; + const std::uint64_t gapCeiling = blocksPerRequest >= 2 ? blocksPerRequest - 2 : 0; if (normalized.coalesceGapBlocks > gapCeiling) { normalized.coalesceGapBlocks = static_cast( (std::min)(gapCeiling, static_cast(0xFFFFFFFFu))); diff --git a/libs/usd-asset-cache/src/CachedAssetReader.cpp b/libs/usd-asset-cache/src/CachedAssetReader.cpp index 51ae29a..fd33d2e 100644 --- a/libs/usd-asset-cache/src/CachedAssetReader.cpp +++ b/libs/usd-asset-cache/src/CachedAssetReader.cpp @@ -57,6 +57,59 @@ std::size_t CopyOverlap(unsigned char* dst, return length; } +/// The blocks one read owns and has not published yet. +/// +/// `BlockCache::Binding::Abandon` documents the invariant this exists to keep: +/// every `Owned` acquisition ends in exactly one `Publish` or one `Abandon`, +/// because a block left pending is one that every later reader of it waits on +/// for a fetch that is not happening -- and `Await` has no deadline, so "waits" +/// means forever. +/// +/// Hand-written release paths kept that invariant across every `return` and +/// across no `throw`. Both of the allocations on this path can throw: the +/// transfer buffer is sized by the run and may be `maxRequestBytes`, and +/// `Publish` copies the block. A `bad_alloc` out of either used to leave the +/// remaining blocks pending for the life of the process. So ownership is held +/// by a destructor now, which runs either way. +class OwnedBlocks { +public: + explicit OwnedBlocks(BlockCache::Binding& binding) noexcept : _binding(&binding) {} + ~OwnedBlocks() { AbandonAll(); } + + OwnedBlocks(const OwnedBlocks&) = delete; + OwnedBlocks& operator=(const OwnedBlocks&) = delete; + + void Add(std::uint64_t blockIndex) { _blocks.push_back(blockIndex); } + + std::size_t Size() const noexcept { return _blocks.size(); } + bool Empty() const noexcept { return _blocks.empty(); } + const std::vector& Blocks() const noexcept { return _blocks; } + + /// The next block still owned, in fetch order. + std::uint64_t Next() const noexcept { return _blocks[_settled]; } + bool AllSettled() const noexcept { return _settled >= _blocks.size(); } + + /// Records that `Publish` took the next block. `Publish` allocates before it + /// touches the store, so a throw from it leaves the block still owned and + /// the destructor still correct. + void MarkPublished() noexcept { ++_settled; } + + /// Hands back every block still owned. Idempotent, so the destructor + /// running after an explicit call costs nothing. + void AbandonAll() noexcept { + while (_settled < _blocks.size()) { + _binding->Abandon(_blocks[_settled++]); + } + _blocks.clear(); + _settled = 0; + } + +private: + BlockCache::Binding* _binding; + std::vector _blocks; + std::size_t _settled = 0; +}; + } // namespace // --- Impl -------------------------------------------------------------------- @@ -134,11 +187,14 @@ ReadResult CachedAssetReader::Impl::ReadCached(const ReadRange& range, unsigned std::vector resolved(blockCount, false); std::vector transfer; - std::uint64_t residentHits = 0; + /// Blocks this read got without fetching them: resident on arrival, or + /// published by whoever owned them while this read waited. Both are bytes + /// that came out of the store, which is what classifies the read below. + std::uint64_t cacheServed = 0; std::uint64_t served = 0; for (int pass = 0; pass < kMaxCooperativePasses; ++pass) { - std::vector owned; + OwnedBlocks owned(*binding); std::vector busy; for (std::size_t i = 0; i < blockCount; ++i) { @@ -156,7 +212,7 @@ ReadResult CachedAssetReader::Impl::ReadCached(const ReadRange& range, unsigned acquired.block->data(), extent.length); metrics.AddBytesFromCache(copied); served += copied; - ++residentHits; + ++cacheServed; resolved[i] = true; continue; } @@ -168,10 +224,7 @@ ReadResult CachedAssetReader::Impl::ReadCached(const ReadRange& range, unsigned // below can fail and return, and a block left pending is a // block every later reader waits on for a fetch that is not // happening. The next pass acquires them again. - for (const std::uint64_t abandoned : owned) { - binding->Abandon(abandoned); - } - owned.clear(); + owned.AbandonAll(); const std::uint64_t begin = (std::max)(range.offset, extent.offset); const std::uint64_t end = @@ -188,28 +241,27 @@ ReadResult CachedAssetReader::Impl::ReadCached(const ReadRange& range, unsigned continue; } if (acquired.outcome == BlockCache::Binding::Acquisition::Owned) { - owned.push_back(blockIndex); + owned.Add(blockIndex); } else { busy.push_back(i); } } - if (owned.empty() && busy.empty()) { + if (owned.Empty() && busy.empty()) { break; } // --- fetch what this reader owns ------------------------------------ - if (!owned.empty()) { - const std::vector runs = PlanRuns(owned, blockSize, assetSize, + if (!owned.Empty()) { + const std::vector runs = PlanRuns(owned.Blocks(), blockSize, assetSize, options.coalesceGapBlocks, options.maxRequestBytes); // Every owned block would have been its own request without the // merge. What the merge saved is the difference, and it is counted // here rather than inferred from a request total that a hit also // moves. - metrics.AddRequestsSavedByCoalescing(owned.size() - runs.size()); + metrics.AddRequestsSavedByCoalescing(owned.Size() - runs.size()); - std::size_t nextOwned = 0; for (const FetchRun& run : runs) { transfer.assign(static_cast(run.length), 0); const ReadResult fetched = @@ -220,9 +272,7 @@ ReadResult CachedAssetReader::Impl::ReadCached(const ReadRange& range, unsigned // Give every block this read still owns back to the store // before returning. A block left pending is a block every // later reader waits on for a fetch that is not happening. - for (std::size_t k = nextOwned; k < owned.size(); ++k) { - binding->Abandon(owned[k]); - } + owned.AbandonAll(); if (!fetched.status.IsOk()) { return ReadResult{0, fetched.status}; } @@ -233,25 +283,35 @@ ReadResult CachedAssetReader::Impl::ReadCached(const ReadRange& range, unsigned inner->Metadata().resolvedIdentifier)}; } - metrics.AddBytesOverFetched( - OverFetchedBytes(run, range.offset, range.length)); - const std::uint64_t runLast = run.firstBlock + run.blockCount - 1; - while (nextOwned < owned.size() && owned[nextOwned] <= runLast) { - const std::uint64_t blockIndex = owned[nextOwned]; + std::uint64_t takenFromRun = 0; + while (!owned.AllSettled() && owned.Next() <= runLast) { + const std::uint64_t blockIndex = owned.Next(); const BlockExtent extent = ExtentOf(blockIndex, blockSize, assetSize); const unsigned char* bytes = transfer.data() + (extent.offset - run.offset); metrics.AddEviction(binding->Publish( blockIndex, bytes, static_cast(extent.length))); + owned.MarkPublished(); metrics.AddBlockMiss(); - served += CopyOverlap(dst, range.offset, range.length, extent.offset, - bytes, extent.length); + const std::size_t copied = + CopyOverlap(dst, range.offset, range.length, extent.offset, + bytes, extent.length); + takenFromRun += copied; + served += copied; resolved[static_cast(blockIndex - span.first)] = true; - ++nextOwned; } + + // Charged against what the caller took *out of this transfer*, + // not against the caller's byte range. The two differ exactly + // where coalescing merged across a block that was already + // resident: those bytes sit inside the range, so a range-based + // charge called them wanted, and the wire moved them anyway + // while the caller read that block from the store. They are the + // cost of the merge, which is the thing this counter is for. + metrics.AddBytesOverFetched(OverFetchedBytes(run, takenFromRun)); } } @@ -272,6 +332,14 @@ ReadResult CachedAssetReader::Impl::ReadCached(const ReadRange& range, unsigned // single-flight buys and what the counter is for. metrics.AddBytesFromCache(copied); metrics.AddRequestsSavedBySingleFlight(); + // And counted as a block this read did not fetch, which it had not + // been. `blockMisses` counts blocks *this* reader pulled over the + // transport and this is not one; the classification below then saw + // neither a hit nor a miss, so a read served entirely by + // single-flight landed in none of `blockHits`, `blockMisses`, or + // `partialHits` -- invisible in the three counters METRICS.md §2.2 + // defines the cache by. + ++cacheServed; served += copied; resolved[i] = true; } @@ -296,9 +364,9 @@ ReadResult CachedAssetReader::Impl::ReadCached(const ReadRange& range, unsigned resolved[i] = true; } - if (residentHits == static_cast(blockCount)) { + if (cacheServed == static_cast(blockCount)) { metrics.AddBlockHit(); - } else if (residentHits > 0) { + } else if (cacheServed > 0) { metrics.AddPartialHit(); } // One relaxed load, not a snapshot: `Snapshot` locks every stripe, and a diff --git a/libs/usd-asset-cache/tests/test_cache.cpp b/libs/usd-asset-cache/tests/test_cache.cpp index 4664c0c..17b20f9 100644 --- a/libs/usd-asset-cache/tests/test_cache.cpp +++ b/libs/usd-asset-cache/tests/test_cache.cpp @@ -199,6 +199,21 @@ void AGapIsMergedIntoOneRequestAndCounted() { CHECK_EQ(snapshot.partialHits, std::uint64_t{1}); // Two owned blocks that would have been two requests became one. CHECK_EQ(snapshot.requestsSavedByCoalescing, std::uint64_t{1}); + + // The honest counter, over both reads, and the case that says why it is + // charged against what the caller took out of each transfer rather than + // against the caller's byte range: + // + // warm-up one block moved, 16 bytes taken kBlock - 16 + // merged three blocks moved; block 0 and 16 bytes of + // block 2 taken, and block 1 moved again while + // the caller read it from the store 2 * kBlock - 16 + // + // Charged by byte range instead, block 1 would have counted as nothing at + // all -- it sits inside the range the caller asked for -- and the merge + // would have reported kBlock - 16, hiding the whole cost of the gap in the + // one counter METRICS.md section 2.2 calls honest. + CHECK_EQ(snapshot.bytesOverFetched, kBlock * 3 - 32); } void ALargeReadBypassesTheCacheAndStoresNothing() { diff --git a/libs/usd-asset-cache/tests/test_plan.cpp b/libs/usd-asset-cache/tests/test_plan.cpp index c6698df..386e0ff 100644 --- a/libs/usd-asset-cache/tests/test_plan.cpp +++ b/libs/usd-asset-cache/tests/test_plan.cpp @@ -121,17 +121,22 @@ void ARunAtTheEndOfTheAssetStopsAtTheEnd() { CHECK_EQ(runs[0].offset + runs[0].length, assetSize); } -void OverFetchIsTheBytesNobodyAskedFor() { +void OverFetchIsTheBytesTheCallerDidNotTake() { FetchRun run; run.offset = 0; run.length = kBlock * 4; - // A caller that wanted 100 bytes in the middle paid for four blocks. - CHECK_EQ(OverFetchedBytes(run, kBlock, 100), kBlock * 4 - 100); - // A caller that wanted all of it paid for nothing extra. - CHECK_EQ(OverFetchedBytes(run, 0, kBlock * 4), std::uint64_t{0}); - // A run that overlaps nothing the caller wanted is over-fetch end to end. - CHECK_EQ(OverFetchedBytes(run, kBlock * 10, kBlock), kBlock * 4); + // A caller that took 100 bytes out of the middle paid for four blocks. + CHECK_EQ(OverFetchedBytes(run, 100), kBlock * 4 - 100); + // A caller that took all of it paid for nothing extra. + CHECK_EQ(OverFetchedBytes(run, kBlock * 4), std::uint64_t{0}); + // A run nothing was taken out of is over-fetch end to end. This is also the + // shape of a merge across a resident gap, whose bytes the caller reads from + // the store rather than out of the transfer that moved them. + CHECK_EQ(OverFetchedBytes(run, 0), kBlock * 4); + // Never underflows. `takenBytes` is a sum the caller accumulates, and a + // counter that wrapped to 18 exabytes would be worse than one reporting 0. + CHECK_EQ(OverFetchedBytes(run, kBlock * 8), std::uint64_t{0}); } void OptionsRoundTheBlockSizeDownToAPowerOfTwo() { @@ -257,7 +262,7 @@ int main() { AMergeIsNeverTakenPastTheRequestCeiling(); ASingleBlockIsAlwaysEmittedWhateverTheCeiling(); ARunAtTheEndOfTheAssetStopsAtTheEnd(); - OverFetchIsTheBytesNobodyAskedFor(); + OverFetchIsTheBytesTheCallerDidNotTake(); OptionsRoundTheBlockSizeDownToAPowerOfTwo(); OptionsClampToSomethingUsable(); NormalizationIsIdempotent(); diff --git a/plugins/http-resolver/src/Configuration.cpp b/plugins/http-resolver/src/Configuration.cpp index a59945a..b6905d7 100644 --- a/plugins/http-resolver/src/Configuration.cpp +++ b/plugins/http-resolver/src/Configuration.cpp @@ -124,9 +124,12 @@ usdasset::cache::CacheOptions CacheOptionsFrom( static_cast(usdasset::cache::kMaxBlockSize), &options.blockSize, problemsOut); - // A budget below one block is refused rather than clamped: it means the - // caller wanted no cache, and there is no variable for that, so saying so is - // better than quietly giving them a one-block one. + // The floor here is the smallest block this module will ever use, not one + // block of the size *this* configuration asked for -- the two differ + // whenever the block size is raised, and the normalizer then lifts the + // budget to one block. That lift is reported below rather than applied + // quietly, on the same principle as the rounding: an operator who set a + // number and got another one should learn it from a log. ReadBytesInto(lookup, kCacheBudget, static_cast(usdasset::cache::kMinBlockSize), 64LL * 1024 * 1024 * 1024, &options.budgetBytes, problemsOut); @@ -158,6 +161,20 @@ usdasset::cache::CacheOptions CacheOptionsFrom( ", the widest gap that can fit under " "USD_HTTP_RESOLVER_MAX_REQUEST_BYTES"}); } + if (problemsOut != nullptr && normalized.budgetBytes != options.budgetBytes) { + problemsOut->push_back( + {kCacheBudget, std::to_string(options.budgetBytes), + "raised to " + std::to_string(normalized.budgetBytes) + + ", one block: a budget that cannot hold a block does not " + "cache nothing, it fetches a block and drops it"}); + } + if (problemsOut != nullptr && normalized.maxRequestBytes != options.maxRequestBytes) { + problemsOut->push_back( + {kMaxRequestBytes, std::to_string(options.maxRequestBytes), + "raised to " + std::to_string(normalized.maxRequestBytes) + + ", one block: a merged request that cannot carry a block " + "cannot carry the block it was merging"}); + } return options; } diff --git a/plugins/http-resolver/src/HttpResolver.cpp b/plugins/http-resolver/src/HttpResolver.cpp index 639d69e..bba53aa 100644 --- a/plugins/http-resolver/src/HttpResolver.cpp +++ b/plugins/http-resolver/src/HttpResolver.cpp @@ -164,13 +164,23 @@ std::shared_ptr HttpResolver::_OpenAsset( // The block cache goes on here rather than in `_Resolve`, because // `_Resolve` only has to establish that the asset exists and this is where - // bytes start being asked for. `WrapAsset` binds into the process store by + // bytes start being asked for. The wrap binds into the process store by // identity -- the resolved identifier and the validator the reader captured // at open -- so two `ArAsset`s over one revision share blocks, and two over // two revisions never do (CACHE.md section 6). - usdasset::cache::CachedOpenResult cached = usdasset::cache::Wrap( - std::unique_ptr(reader.release()), metrics, - _cacheOptions, nullptr); + // + // `WrapAsset` and not `Wrap`, which is what this comment used to say while + // the line below said otherwise. The difference is the `supportsRandomAccess` + // guard: `Wrap` returns a `CachedAssetReader` and therefore cannot decline + // to decorate, and a reader that cannot seek would store the one block it + // managed to read and miss forever after. ADR-0002 makes range support a + // hard error at open, so every reader that reaches this line supports it and + // the guard has never fired -- which is exactly how long a missing guard + // stays invisible. + usdasset::OpenResult opened; + opened.reader = std::unique_ptr(reader.release()); + usdasset::OpenResult cached = usdasset::cache::WrapAsset( + std::move(opened), metrics, _cacheOptions, nullptr); if (!cached.reader) { usdhttpresolver::Report(cached.status, identifier); return nullptr; diff --git a/plugins/http-resolver/tests/test_stage.cpp b/plugins/http-resolver/tests/test_stage.cpp index 619f644..94c8b72 100644 --- a/plugins/http-resolver/tests/test_stage.cpp +++ b/plugins/http-resolver/tests/test_stage.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -136,8 +137,7 @@ void TestStageOpens() { mark.Clear(); } -/// §4: the `ArAsset` surface, and the reason this project exists -- a window -/// `bytes=first-last`, as the fixture server logged it. +/// Parses a `Range` header's `bytes=first-last`, as the fixture server logged it. /// /// The test parses the header itself rather than asking the backend what it /// sent, for the reason the baseline harness does: the server's log is the @@ -154,6 +154,7 @@ bool ParseByteRange(const std::string& header, std::uint64_t* first, return *last >= *first; } +/// §4: the `ArAsset` surface, and the reason this project exists -- a window /// out of an asset costs the window. void TestRangeRead() { const std::size_t size = 1u << 20; // 1 MiB From 06e75094ee2c076f384b8f7986d10ca35abf8bf7 Mon Sep 17 00:00:00 2001 From: snkmcb Date: Fri, 21 Aug 2026 12:08:40 +0900 Subject: [PATCH 3/4] Write the cache into the contracts it was built against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code for the block cache landed in the two commits before this one; what was left is every document that had said the cache was planned, and one that turned out to be asserting something narrower than the contract it is the executable form of. That one is worth naming. The boundary suite's mid-read revision case asserted `AssetChanged` on a re-read of a range the reader had already read. A block cache answers that read from bytes it captured under the same binding, observes nothing, and returns the revision the reader is bound to -- which is the guarantee rather than an exception to it: ASSET_READER.md §2.1 already says *observes*, and CACHE.md §6 already admits in-memory caching for the reader's lifetime. The case now asserts `AssetChanged` at an offset the reader has not read, and for a repeated range asserts either `AssetChanged` or byte-for-byte what the first read returned, never the new revision. It is a strengthening: the byte comparison is new, and the old case would have passed a backend that rebound *and* reported `AssetChanged`. The rest is bookkeeping that the invariants require: - WORKSPACE.md gains the fifth reverse edge onto the fixture server, for tests/cache-tuning, and says why that one needs a server for a reason of its own -- the constants it measures are about round trips, and a sweep over a local file would be a sweep over a cost that does not exist there. - CONFIGURATION.md fills in the four cache defaults with the measured values and states the two rules the code follows: a block size that is not a power of two is rounded down and the rounding is reported, and a value outside the bounds is refused rather than clamped. - libs/usd-asset-cache/README.md, which invariant 10 requires and which states what the module refuses to own -- no transport, no revalidation, no validator interpretation, no read-ahead. - METRICS.md records the rule the decorator forced: a decorated stack has one counter set, because the two ends disagree about what `bytesRequested` means. - CAPABILITY_MATRIX.md, the roadmap, the design policy's assessment, the changelog, and the CI comment that counted three libraries. Both lanes are green on this tree: 25 of 25 under core-msvc, 29 of 29 in the plugin lane including httpResolver_stage, and 25 of 25 under each of core-asan and core-tsan on GCC 15.2. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 99 +++++++++++- README.md | 26 +++- docs/README.md | 25 +-- docs/architecture/ASSET_READER.md | 10 ++ docs/architecture/CACHE.md | 57 ++++++- docs/architecture/METRICS.md | 19 ++- docs/architecture/WORKSPACE.md | 62 ++++++-- docs/contributing/BOUNDARY_SUITE.md | 26 +++- docs/design/DESIGN_POLICY.md | 19 ++- docs/reference/CAPABILITY_MATRIX.md | 53 ++++--- docs/reference/CONFIGURATION.md | 35 ++++- docs/roadmap/README.md | 4 + docs/roadmap/implementation-status.md | 72 +++++++-- libs/usd-asset-cache/README.md | 211 ++++++++++++++++++++++++++ openstrata.ci.yaml | 4 +- tests/README.md | 45 +++++- 16 files changed, 680 insertions(+), 87 deletions(-) create mode 100644 libs/usd-asset-cache/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ca2d310..aa8ac91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,102 @@ one. ## Unreleased -Nothing yet. `v0.3.0`'s scope is the block cache, and its definition of success -is the table in [BASELINE.md](docs/reference/BASELINE.md) § *What the next -release has to move*. +`v0.3.0`'s scope, the block cache, is implemented and unreleased. Its definition +of success was the table in [BASELINE.md](docs/reference/BASELINE.md) *What the +next release has to move*, and all four rows moved the way that table asked: +the clustered header-and-index read went from 18 requests to 3, the bounded +query's `amplification` went above 1.0 with `bytesOverFetched` to account for +it, eight parallel readers of one asset went from 152 requests to 25, and the +full sequential read did not move at all. + +### Added + +- **`libs/usd-asset-cache`**, the block cache, as a decorator over + `AssetReader`. It links `usdAssetIo` and nothing else: no transport, no + backend, no OpenUSD. Reads are expanded to whole blocks and served from them; + the final block of an asset is stored at its true length and never padded; + adjacent and near-adjacent fetches are merged into one request; concurrent + readers that miss the same block issue one request and the rest wait; blocks + are evicted LRU under a process-wide budget shared across assets; and a read + large enough to be a streaming pass bypasses the whole thing. + [CACHE.md](docs/architecture/CACHE.md) is the contract and the module + [README](libs/usd-asset-cache/README.md) states what it refuses to own. +- **A validator-keyed `CacheKey` from the first commit.** The key is + `resolvedIdentifier + validator + blockSize + blockIndex`, and the rule it + exists for is that equal identifiers never imply equal content: two revisions + published at one URL are two cache identities. The validator is an opaque byte + string here — never parsed, never compared to an `ETag`, never read for + recency — and exactly one other field of it is read, `strength`, exactly once. +- **Single-flight, across readers and not only across threads.** Eight threads + missing one block issue one request; so do eight independent readers of one + revision, because they share an identity and therefore share the store. That + second case is the one that moved the parallel-readers baseline, and it is why + the store is process-wide rather than per reader. +- **The cache counters of [METRICS.md](docs/architecture/METRICS.md) §2.2**, + populated: hits, misses, partial hits, requests saved by coalescing and by + single-flight, `bytesOverFetched`, evictions, and a resident high-water mark. +- **A third row in the shared boundary suite**, `cache over local`. The cache is + not a transport, so it is not a fourth backend — it is the same local backend + with a decorator on top, and entering it there is what makes "byte-for-byte + equivalence with the uncached path over the full suite" an assertion rather + than a claim. Every case runs unchanged. +- **`tests/cache-tuning`**, the measurement that chose the constants: a sweep of + five block sizes against four coalescing gaps over four access patterns, + against a real socket, with every byte verified. Recorded in + [BLOCK_POLICY.md](docs/reference/BLOCK_POLICY.md), which also labels the two + constants that were *not* measured as the bounds they are. +- **The four cache variables of + [CONFIGURATION.md](docs/reference/CONFIGURATION.md) §2**, read once when the + resolver is constructed. A block size that is not a power of two is rounded + down and the rounding is reported; a value outside the bounds is refused + rather than clamped. +- **`ReaderMetrics::AbsorbTransport` and `DetachFromRegistry`**, so that a + decorated stack reports one counter set instead of two. +- **Sanitizer coverage over all of it**, which needs no new lane: the module + tests, the boundary row, and the tuning sweep are `libs/` and `tests/`, which + is what `core-asan` and `core-tsan` already cover. Both are green over the + whole core tree, 25 of 25 each, under GCC 15.2. + +### Changed + +- **The resolver decorates every asset it opens.** `plugins/http-resolver` links + `usdasset::cache`, which [WORKSPACE.md](docs/architecture/WORKSPACE.md) §2 has + admitted since the workspace contract was written and this release is the + first to take. `httpResolver_stage` now asserts from the fixture server's log + that a 4 KiB window out of a megabyte costs a block and not the megabyte — + a bound rather than an exact range, because the exact-bytes property is what + CACHE.md §3 trades away on purpose. +- **The recorded I/O baseline holds every scenario twice**, with the cache and + without it, in one run of one harness. METRICS.md §6 asks a release that + changes I/O behavior for the counter values before *and* after, and this is + the first release that changes them on purpose. `tests/baseline` gained the + cache on its link line and a set of assertions for the cached rows: the + server's log is the independent witness for the byte count, the request count + is asserted to be below the uncached row, and the full sequential read is + asserted to be *identical* rather than merely close. +- **The boundary suite's mid-read revision case now asserts bytes as well as a + status**, and asserts them in the right place. It reports `AssetChanged` for a + read at an offset the reader has not read before; for a range it has already + read, it accepts either `AssetChanged` or byte-for-byte what the first read + returned, and rejects the new revision's bytes. This is a strengthening rather + than a relaxation — the byte comparison is new, and the old case would have + passed a backend that rebound *and* reported `AssetChanged`. It is also what a + reader with a cache under it can satisfy honestly: §2.1 of + [ASSET_READER.md](docs/architecture/ASSET_READER.md) says a reader that + *observes* a changed validator fails subsequent reads, and a hit observes + nothing and returns the revision the reader is bound to. +- **`selectivity` on the bounded query went from 0.0025 to 0.0112**, on purpose. + Alignment converts request count into transferred bytes, and one percent of a + 128 MiB asset to answer a query against it is still the sentence the + architecture is made of. The cost is in `bytesOverFetched`, which is reported + beside the saving rather than instead of it. +- The metrics dump prints the cache block and the two cache ratios, including + the zeroes. A dump that hid them would make "no cache ran" and "the cache + never hit" the same output. + +### Fixed + +Nothing. No defect in `v0.2.0` was found by this work. ## `v0.2.0` — 2026-08-20 diff --git a/README.md b/README.md index 11bcced..3aecb9b 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,11 @@ index, and the chunks actually in view — not 10 GB. **`v0.2.0` is released: a `UsdStage` opens over HTTP. The read contract, the local backend, the shared boundary suite, the hostile-server corpus, the HTTP -backend, and the `ArResolver` bundle are in the tree and passing. There is no -cache.** +backend, and the `ArResolver` bundle are in the tree and passing.** + +**`v0.3.0` is in the tree and unreleased: the block cache. A clustered read of a +remote asset now costs three requests where it cost eighteen, and eight parallel +readers of one asset move what one reader moves.** That ordering is the point. `v0.1.0` shipped a local file reader, which is not interesting; what was interesting is that it arrived with the harness that makes @@ -42,12 +45,23 @@ from server behavior. `v0.2.0` cashed that: the HTTP backend passes the `v0.1.0` boundary suite **unchanged**, against an independent oracle, and separately against 18 hostile-server behaviors on a real socket. -It also makes this project's first performance claim, and it is a counter on a +It also made this project's first performance claim, and it is a counter on a named fixture rather than a sentence: **a bounded query moved 324 KiB of a 128 MiB asset — 0.0025 of it — and every byte moved was a byte the caller asked -for.** The record is -[docs/reference/BASELINE.md](docs/reference/BASELINE.md), and `amplification` is -exactly 1.000000 because there is nothing yet to over-fetch. +for.** + +`v0.3.0` is the release that changes those numbers on purpose, and it changes +them in both directions. Seventeen clustered reads of a header and an index went +from 18 requests to 3; eight parallel readers went from 152 to 25 and now move +one reader's worth of bytes between them; the full sequential read did not move +at all. The bounded query's `selectivity` got *worse*, 0.0025 to 0.0112, because +alignment converts request count into transferred bytes, and 1191936 bytes of +what it moved are `bytesOverFetched` — reported beside the saving rather than +instead of it. Both +records are counters on a named fixture: +[BASELINE.md](docs/reference/BASELINE.md) for what the shipped configuration +costs, and [BLOCK_POLICY.md](docs/reference/BLOCK_POLICY.md) for why it is that +configuration. What the tree actually does is in [docs/reference/CAPABILITY_MATRIX.md](docs/reference/CAPABILITY_MATRIX.md); what diff --git a/docs/README.md b/docs/README.md index 93146b2..ae15d1f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,14 +10,17 @@ the summary is a documentation bug. When a summary disagrees with workspace contract wins; structural changes must update that contract first. This repository is at `v0.2.0`, released 2026-08-20; its -[record](releases/v0.2.0.md) states what shipped and what did not. The read -contract, the local backend, the shared boundary suite, the hostile-server -corpus, the HTTP backend, and the `ArResolver` bundle are implemented, and a -`UsdStage` opens over HTTP; no cache is. What the tree actually contains is -stated in [reference/CAPABILITY_MATRIX.md](reference/CAPABILITY_MATRIX.md), and -what a bounded query costs is in -[reference/BASELINE.md](reference/BASELINE.md); everything else here is contract -and plan. +[record](releases/v0.2.0.md) states what shipped and what did not. `v0.3.0` is +in the tree and unreleased: the block cache, its measured constants, and the +resolver that now decorates every asset it opens. The read contract, the local +backend, the shared boundary suite, the hostile-server corpus, the HTTP backend, +the `ArResolver` bundle, and the cache are implemented, and a `UsdStage` opens +over HTTP. What the tree actually contains is stated in +[reference/CAPABILITY_MATRIX.md](reference/CAPABILITY_MATRIX.md), what a bounded +query costs is in [reference/BASELINE.md](reference/BASELINE.md), and why the +cache's constants are what they are is in +[reference/BLOCK_POLICY.md](reference/BLOCK_POLICY.md); everything else here is +contract and plan. | Category | Answers | Start here | | --- | --- | --- | @@ -60,7 +63,11 @@ and plan. - [reference/BASELINE.md](reference/BASELINE.md) is the current recorded I/O baseline: the five scenarios METRICS.md §6 requires, the fixture they ran against, and what is asserted rather than merely reported. A release record - copies it at its tag; it is rewritten whenever I/O behavior changes. + copies it at its tag; it is rewritten whenever I/O behavior changes. From + `v0.3.0` it holds every scenario twice, with the cache and without it. +- [reference/BLOCK_POLICY.md](reference/BLOCK_POLICY.md) is the measurement that + chose the cache's block size and coalescing gap, the reasoning from it, and + the two constants it labels as bounds rather than tuned values. ## The one-sentence contract diff --git a/docs/architecture/ASSET_READER.md b/docs/architecture/ASSET_READER.md index 978f0e6..079c14e 100644 --- a/docs/architecture/ASSET_READER.md +++ b/docs/architecture/ASSET_READER.md @@ -151,6 +151,16 @@ and not a consistency feature layered on later: | Backend, on mismatch | Fail the read with `AssetChanged`; never rebind, never retry into the new revision | | Cache (`v0.3.0`) | Key on the validator, so an entry from revision A cannot serve revision B | +The first row of that table is where the word *observes* earns its place. A +reader that answers from bytes it captured under its own binding — a block cache +serving a hit — observes nothing, and what it returns is the revision it is +bound to. That is the guarantee holding, not an exception to it: the failure +§2.1 exists to prevent is one reader composing bytes from two revisions, and a +reader that never leaves revision A cannot. `AssetChanged` is reported by the +layer that reaches the transport, on the reads that reach it. The boundary suite +states both halves; see +[BOUNDARY_SUITE.md](../contributing/BOUNDARY_SUITE.md) §3. + A backend that cannot obtain a usable validator is still bound for its lifetime — see §7.3 — but the binding is best-effort, and it says so through `IdentityStability::Unavailable`. diff --git a/docs/architecture/CACHE.md b/docs/architecture/CACHE.md index d07e164..ae3a671 100644 --- a/docs/architecture/CACHE.md +++ b/docs/architecture/CACHE.md @@ -4,13 +4,28 @@ This document fixes block caching, request coalescing, single-flight de-duplication, and cache identity. It is the contract for `usdAssetCache`, which is a decorator over `AssetReader` and knows no transport concept. -Status: planned for `v0.3.0`, with optional persistence in `v0.4.0`. Nothing -here is implemented. +Status: implemented in `libs/usd-asset-cache` as of `v0.3.0`, except §8, which +is `v0.4.0`. The block model, coalescing, single-flight, the key, and eviction +are in the tree and tested; the constants in §3 and §4 are measured and the +measurement is [BLOCK_POLICY.md](../reference/BLOCK_POLICY.md); the counters in +§9 populate and are recorded in [BASELINE.md](../reference/BASELINE.md). The architecture below is unchanged by the `v0.2.0` reordering; what changed is -that the validator it keys on already exists when the cache lands. There is no +that the validator it keys on already existed when the cache landed. There is no interim URL-keyed cache and no migration away from one. +Two things the implementation makes more specific than this document did, and +both are narrower rather than wider: + +- **A read *at least* as large as the bypass threshold bypasses**, where §3 says + "larger than". The boundary had to fall somewhere and it falls on the side + that caches less. +- **Sharing an entry between two readers requires a strong validator.** §6 keys + every entry on the validator, and §8 makes strength the test for outliving a + reader; the implementation applies the same test one level earlier, to + outliving *this* reader. A weak or absent validator still caches, privately, + for the reader's lifetime, which is what §6 promises. + ## 1. Which cache this is Two caches exist in the whole system, and confusing them is the failure this @@ -45,7 +60,7 @@ the cache is a decorator: the local backend is used without it. ## 3. Block model ```text -blockSize a power of two, default chosen by measurement in v0.3.0 +blockSize a power of two; 65536 by default, chosen by measurement blockIndex offset / blockSize blockRange [blockIndex * blockSize, (blockIndex + 1) * blockSize) ``` @@ -81,6 +96,13 @@ Both numbers are recorded with the measurement that produced them, per [METRICS.md](METRICS.md). A tuned constant without a recorded measurement is a guess with a decimal point. +They are, and the record is [BLOCK_POLICY.md](../reference/BLOCK_POLICY.md): a +maximum gap of one block and a maximum merged length of 8 MiB. The record also +says the thing a tuning document is most tempted to leave out — that at the +shipped block size the gap does not currently bind on any measured pattern. It +is 1 because that is the entire measured benefit where the benefit exists, and +because 2 and 4 were measured and bought nothing anywhere. + ## 5. Single-flight Concurrent readers that miss the same block issue **one** request. The second @@ -115,6 +137,12 @@ other validator question belongs to the backend, per §7.1 of construct it has become an HTTP cache, and the local backend stops being a usable oracle for the cached path. +The key's identity half — identifier, validator, block size — is interned by the +store, so a per-block lookup costs two integers rather than two string +comparisons. That is an implementation detail of where the strings live and not +of what the key is: two readers whose identities compare equal share entries, +and two whose identities differ never do, which is what this section is about. + The rule that follows is the whole point: ```text @@ -131,11 +159,20 @@ reader. ## 7. Eviction - The cache has a bounded memory budget. It never grows to the asset size. + 128 MiB by default. - Eviction is LRU by default, per process, with the budget shared across assets so one enormous asset cannot starve the rest of the stage. - Eviction is invisible to correctness. An evicted block is re-fetched; it is never served stale, and never served zero-filled. +The implementation stripes the store, and the eviction order is therefore LRU +within a stripe rather than globally. That is a consequence of §5's ban on a +global lock — a store-wide LRU order needs a store-wide lock on every hit — and +it is admissible precisely because of the third rule above: eviction is +invisible to correctness, so an approximate order costs a re-fetch and nothing +else. The stripe count falls back toward one for a small budget, which makes the +order exact where a test can see it. + ## 8. Persistence — Planned (`v0.4.0`) An on-disk cache is admitted only after validators land, because a persistent @@ -176,3 +213,15 @@ saved by coalescing, requests saved by single-flight, and evictions. These are not diagnostics. They are the evidence for the project's central claim, and a cache change without a before-and-after number is not reviewable. + +`v0.3.0` records its before and after in one table: +[BASELINE.md](../reference/BASELINE.md) measures every scenario twice, with the +cache and without it, in one run of one harness. + +The counters are per reader, and a decorated stack has one reader as far as the +counters are concerned — the outermost. The two ends of a stack disagree about +what `bytesRequested` means, so the decorator keeps what the caller asked for +and takes from the reader underneath only what crossed the transport +(`ReaderMetrics::AbsorbTransport`). A stack that folded both would compute +`amplification` over a denominator that is two different measurements added +together. diff --git a/docs/architecture/METRICS.md b/docs/architecture/METRICS.md index d32ead8..417c576 100644 --- a/docs/architecture/METRICS.md +++ b/docs/architecture/METRICS.md @@ -8,15 +8,28 @@ lifetime and process aggregate in §3, and the environment-keyed dump in §5 are implemented in `libs/usd-asset-io` (`usdAssetIo/Metrics.h`) and populated by both backends. The HTTP counters populate, including the requests issued by validator capture and by conditional range requests, and `retryCount` and `redirectCount` -are asserted from tests rather than assumed. The cache counters in §2.2 are -defined and stay at zero until `v0.3.0`. +are asserted from tests rather than assumed. The cache counters in §2.2 populate +from `v0.3.0`, out of `libs/usd-asset-cache`. + +A note the cache made necessary. Counters are per reader, and a decorated stack +has one counter set as far as this document is concerned: the outermost +reader's. The two ends of a stack disagree about what `bytesRequested` means — +to the cache it is what the caller asked for, to the reader underneath it is +what the cache asked for expanded to whole blocks — so the outer set keeps the +caller's ask and the cache's service, and takes from the inner set only what +crossed the transport. A stack that folded both would compute `amplification` +over a denominator that is two different measurements added together. The baselines in §6 are recorded. `v0.2.0` is the first release that *could* record one — bytes now cross a network — and the fixture that was missing exists: `tests/baseline` serves one synthetic asset of 128 MiB, which is where `selectivity` starts meaning something and the kilobyte corpus assets stopped. The current record is [BASELINE.md](../reference/BASELINE.md); a release record -copies it at its tag. +copies it at its tag. From `v0.3.0` it holds each scenario twice, with the cache +and without it, because the rule below asks a release that changes I/O behavior +for the values before *and* after and that release is the first to change them +on purpose. What chose the cache's constants is a second record, +[BLOCK_POLICY.md](../reference/BLOCK_POLICY.md). ## 1. Why this is a contract and not a debug feature diff --git a/docs/architecture/WORKSPACE.md b/docs/architecture/WORKSPACE.md index 20c1ba8..960916c 100644 --- a/docs/architecture/WORKSPACE.md +++ b/docs/architecture/WORKSPACE.md @@ -5,8 +5,7 @@ module identities, dependency directions, root responsibilities, artifact naming, and change invariants. A structural change that contradicts this document must change this document first. -Status: everything in §1 exists and is tested except `usdAssetCache`, which is -`v0.3.0`, and the reserved backends. A directory is created when its first +Status: everything in §1 exists and is tested except the reserved backends. A directory is created when its first tested capability exists; see the [roadmap](../roadmap/README.md). ## 1. Components @@ -16,7 +15,7 @@ tested capability exists; see the [roadmap](../roadmap/README.md). | `usdAssetIo` | `libs/usd-asset-io` | plain CMake/OpenStrata static library | implemented (`v0.1.0`) | The transport-independent core: the `AssetReader` random-access contract, `AssetMetadata`, byte-range and validator value types, the typed diagnostic vocabulary, and the metrics counter definitions. Contains no transport, no cache, and no OpenUSD. | | `usdAssetLocal` | `libs/usd-asset-local` | plain CMake/OpenStrata static library | implemented (`v0.1.0`) | The local-file backend: positional reads against a file handle, size discovery, and filesystem-derived validators. It is the correctness oracle every other backend is compared against. | | `usdAssetHttp` | `libs/usd-asset-http` | plain CMake/OpenStrata static library | implemented (`v0.2.0`) | The HTTP backend: range requests, metadata requests, redirects, timeouts, bounded retry, response framing validation, and validator extraction. Owns the third-party HTTP client dependency; it is the only module that may name one, and it names it privately, in one translation unit, behind an internal transport seam. | -| `usdAssetCache` | `libs/usd-asset-cache` | plain CMake/OpenStrata static library | planned (`v0.3.0`) | Aligned block caching, read expansion, request coalescing, single-flight de-duplication, eviction under a memory budget, and cache statistics. It is a decorator over `AssetReader`, keyed by an opaque validator. | +| `usdAssetCache` | `libs/usd-asset-cache` | plain CMake/OpenStrata static library | implemented (`v0.3.0`); persistence is `v0.4.0` | Aligned block caching, read expansion, request coalescing, single-flight de-duplication, eviction under a memory budget, and cache statistics. It is a decorator over `AssetReader`, keyed by an opaque validator. | | `http-resolver` | `plugins/http-resolver` | OpenStrata plugin bundle (`usd-asset-resolver`) | implemented (`v0.2.0`); asset-info exposure is `v0.4.0` | The OpenUSD `ArResolver` implementation: URI scheme registration for `http` and `https`, URI normalization, relative and anchored resolution, asset-info exposure, and the `ArAsset` adapter over `AssetReader`. It is the only module that includes an OpenUSD header. Owns its `HTTPxxx` diagnostic codes. | | `usdAssetS3`, `usdAssetPackage`, `usdAssetWasm` | `libs/` | plain libraries | reserved, not implemented | Additional backends targeting the unchanged `AssetReader` contract. A backend that cannot be expressed through it is a design question, not a feature request. | @@ -37,8 +36,11 @@ http-resolver -> usdAssetIo, usdAssetCache, usdAssetLocal, usdAssetHttp http-resolver -> OpenUSD (ar, tf, arch, js, plug, vt) ``` -What the bundle links today is `usdAssetHttp` and the Ar surface, and nothing -else on either list. `usdAssetLocal` is permitted and unused: a local path is +What the bundle links today is `usdAssetHttp`, `usdAssetCache`, and the Ar +surface, and nothing else on either list. The cache edge is taken as of +`v0.3.0`: the resolver decorates every asset it opens, and binds it into the +process-wide block store by the identifier and the validator the backend +captured. `usdAssetLocal` is permitted and unused: a local path is the primary resolver's business, and a URI-scheme resolver that reached for the local backend would be answering for paths it does not claim. The `js`, `plug`, and `vt` components are what `ar` itself needs in a non-monolithic build; a @@ -73,12 +75,15 @@ half: a corpus that could name `StatusCode` would start asserting the backend's interpretation, and a disagreement between the two would stop being evidence. Its reverse edges — a test linking both the fixture server and something that -reads from it — are legal, and there are exactly four: +reads from it — are legal, and there are exactly five: ```text tests/boundary/backends/boundary_http_main.cpp -> usdAssetHttp, fixture server tests/corpus (usdAssetHttp_test_projection) -> usdAssetHttp, fixture server -tests/baseline (usdAssetHttp_baseline) -> usdAssetHttp, fixture server +tests/baseline (usdAssetHttp_baseline) -> usdAssetHttp, usdAssetCache, + fixture server +tests/cache-tuning (usdAssetCache_tuning) -> usdAssetCache, usdAssetHttp, + fixture server plugins/http-resolver/tests/test_stage.cpp -> OpenUSD, fixture server ``` @@ -97,13 +102,26 @@ server's own raw client besides, because "must not be worse than a plain download" needs a plain download performed by a client that is not the one under test. -The fourth is the only place in this repository that links OpenUSD and the +The third also links the cache from `v0.3.0`, because that release is the first +to change the numbers on purpose and METRICS.md §6 asks a release that changes +I/O behavior for the counter values before *and* after. One harness, one +fixture, each scenario twice. + +The fourth is the measurement that chose the cache's constants. It needs a +server for a reason of its own rather than the shared one: the constants are +about round trips, and a sweep over a local file would be a sweep over a cost +that does not exist there. It is also the only place that links the cache and a +backend at once, which is not the cache learning what a transport is — it holds +an `AssetReader` and nothing else — but a measurement of a stack, made where the +stack is. + +The fifth is the only place in this repository that links OpenUSD and the fixture server at once, and it is the one test that can assert the release's actual claim: that a `UsdStage` opens over HTTP. It reaches the backend only through `ArResolver`, which is the point — a test that linked `usdAssetHttp` directly would be asserting the backend again rather than the bundle. -The first three live outside `libs/` rather than in the backend's own tests, and +The first four live outside `libs/` rather than in a module's own tests, and that placement is load-bearing rather than tidy: a module's tests must not depend on anything outside `libs/`, or `ost library build libs/usd-asset-http` — which builds the module alone — stops working. @@ -234,6 +252,11 @@ libs/usd-asset-http/src/*.cpp libs/usd-asset-cache/src/*.cpp Block alignment, coalescing, single-flight, eviction. No transport. + BlockPlan.h is the arithmetic with no state, no lock, and no reader, and it + is separated for the reason ResolveReadRange is separated in usdAssetIo: + this is where the off-by-one lives, and it should be checkable without + provisioning an asset. It is internal and not installed. + libs/usd-asset-io/include/** Contracts only. Header-heavy by design; an implementation that belongs to a backend must not appear here. The one thing here that is shared logic @@ -246,12 +269,19 @@ tests/boundary/src/** its oracle on purpose. tests/baseline/** - The recorded I/O baseline: the five scenarios METRICS.md §6 requires, the - fixture they run against, and the shape a release record pastes. It asserts - byte counts, which are exact, and reports ratios and wall clock, which are - about the fixture and the runner. Measurement only -- it owns no read - semantics, and a case it would be the first to catch belongs in - tests/boundary instead. + The recorded I/O baseline: the five scenarios METRICS.md §6 requires, + each measured with the cache and without it, the fixture they run against, + and the shape a release record pastes. It asserts byte counts, which are + exact, and reports ratios and wall clock, which are about the fixture and + the runner. Measurement only -- it owns no read semantics, and a case it + would be the first to catch belongs in tests/boundary instead. + +tests/cache-tuning/** + The block-policy measurement: a sweep of block size against coalescing gap + over the access patterns METRICS.md §6 names, plus one pattern of its own, + which exists because it is the only one in which the gap can bind at all. It + chooses constants and asserts correctness; it chooses nothing from wall + clock, and says so. Record: reference/BLOCK_POLICY.md. tests/fixture-server/src/** The hostile corpus: a loopback origin, its socket layer, and the request @@ -292,7 +322,7 @@ The bundle declares `kind: usd-asset-resolver` and | `CMakePresets.json` | The `default` (whole repo), `core` (libs only, no OpenUSD), `core-msvc` (the same, through the Visual Studio generator), `core-asan`, and `core-tsan` configure, build, and test presets | | `VERSION` | The single source of the release version | | `LICENSE`, `NOTICE` | Apache-2.0, and the third-party record the release gate checks | -| `tests/` | Cross-module tests: the shared boundary suite, which belongs to no single backend; the hostile-server fixture corpus, which belongs to no module because it is the other side of the boundary; and the recorded I/O baseline, which belongs to no module because it measures the whole path | +| `tests/` | Cross-module tests: the shared boundary suite, which belongs to no single backend; the hostile-server fixture corpus, which belongs to no module because it is the other side of the boundary; the recorded I/O baseline, which belongs to no module because it measures the whole path; and the block-policy sweep, which belongs to no module because it measures a stack | | `docs/` | Contracts, plans, and records | `openstrata.toml` gains a `[workspace] members` declaration once more than one diff --git a/docs/contributing/BOUNDARY_SUITE.md b/docs/contributing/BOUNDARY_SUITE.md index 5171b68..ad61424 100644 --- a/docs/contributing/BOUNDARY_SUITE.md +++ b/docs/contributing/BOUNDARY_SUITE.md @@ -9,8 +9,11 @@ Wasm — is admitted by. This document fixes what the suite must contain and how a backend is entered into it. -Status: implemented in `tests/boundary`, with the local backend entered as its -first row. The fixed cases in §3, the property cases in §4, the concurrency +Status: implemented in `tests/boundary`, with three rows entered: the local +backend, the HTTP backend, and the block cache over the local backend. The third +is not a fourth transport — the cache is a decorator — and it is what makes +"byte-for-byte equivalence with the uncached path over the full suite" an +assertion rather than a claim. The fixed cases in §3, the property cases in §4, the concurrency cases, and the sanitizer builds in §5 all pass — the last of these under the `sanitizers` job in `.github/workflows/core-ci.yml`, and first recorded locally in [report 01](../reports/ost/01-2026-08-16-v0.1.0-ci-without-a-support-matrix.md). @@ -77,13 +80,30 @@ Every backend runs all of these, unchanged: | Concurrent reads on one reader | No interleaving of one caller's bytes into another's buffer | | Short read below EOF | `InvalidResponse` — never a hole, never a silent truncation | | Cancellation | `Cancelled` promptly, where the backend admits cancellation | -| Mid-read revision change | `AssetChanged`, never mixed bytes, for backends that can simulate it | +| Mid-read revision change | `AssetChanged` on a read that reaches the transport; never the new revision's bytes on one that does not. For backends that can simulate it | The last two rows are conditional on the backend, and the condition is declared by the backend's entry in the suite rather than discovered by a skipped test. A backend that cannot simulate a revision change says so; the local backend can (rewrite the file underneath an open reader) and does. +The revision row is stated in two halves because a decorator forced the +distinction, and the distinction was always there. §2.1 of +[ASSET_READER.md](../architecture/ASSET_READER.md) says a reader that +*observes* a changed validator fails subsequent reads. A read that reaches the +transport observes; a read a block cache answers from bytes it captured under +the same binding observes nothing, and what it hands back is the revision the +reader is bound to — which is the guarantee rather than an exception to it. So +the case asserts `AssetChanged` at an offset the reader has not read before, and +for a range it has already read it asserts the thing that is true of every +backend: either `AssetChanged`, or byte-for-byte what the first read returned. +Never revision B. + +That is a strengthening and not a relaxation. Before `v0.3.0` the case compared +a status and nothing else, and a backend that had quietly rebound and returned +the new revision's bytes *with* an `AssetChanged` code would have passed it. The +byte comparison is new, and every row passes it unchanged. + Asset sizes are chosen so that these cases are distinct: at minimum an empty asset, a one-byte asset, an asset smaller than one block, an asset exactly one block, and an asset spanning several blocks with a short final block. diff --git a/docs/design/DESIGN_POLICY.md b/docs/design/DESIGN_POLICY.md index cba1514..b4ee904 100644 --- a/docs/design/DESIGN_POLICY.md +++ b/docs/design/DESIGN_POLICY.md @@ -49,11 +49,20 @@ possible later consequences of that abstraction, not inputs to it. ## 2. Current Assessment -The read contract, the local backend, and the shared boundary suite are -implemented and passing. No resolver, no transport, and no cache is. The -contracts under [architecture/](../architecture/) were written before their -implementation, which is deliberate: the boundary is the product, and it is -cheaper to fix here than in five consumers. +The read contract, the local backend, the shared boundary suite, the +hostile-server corpus, the HTTP backend, the `ArResolver` bundle, and the block +cache are implemented and passing. What is not is persistence, identity exposed +to consumers, and every transport after HTTP. The contracts under +[architecture/](../architecture/) were written before their implementation, +which is deliberate: the boundary is the product, and it is cheaper to fix here +than in five consumers — and every one of those implementations has since landed +against a contract that did not have to move to accept it. + +Invariant 11 below is worth checking against the tree rather than assuming, and +it holds: the cache's block size and coalescing gap come from a recorded sweep +(`tests/cache-tuning`, [BLOCK_POLICY.md](../reference/BLOCK_POLICY.md)), and the +two constants in that set that were *not* measured are labelled there as the +bounds they are rather than presented as tuned values. The properties to establish, in order, are in the [roadmap](../roadmap/README.md). The invariants to preserve from the first diff --git a/docs/reference/CAPABILITY_MATRIX.md b/docs/reference/CAPABILITY_MATRIX.md index ea7d61d..827cbf7 100644 --- a/docs/reference/CAPABILITY_MATRIX.md +++ b/docs/reference/CAPABILITY_MATRIX.md @@ -4,13 +4,14 @@ This document describes what the current tree implements. It is not a plan. Intent lives in the [roadmap](../roadmap/README.md); contracts live in [architecture/](../architecture/). -Last updated: 2026-08-20, against `main` at `v0.2.0`. +Last updated: 2026-08-20, against `main` at `v0.3.0`. ## Summary **The read contract, the local backend, the shared boundary suite, the -hostile-server corpus, the HTTP backend, and the `ArResolver` bundle are -implemented. A `UsdStage` opens over HTTP. There is still no cache.** +hostile-server corpus, the HTTP backend, the `ArResolver` bundle, and the block +cache are implemented. A `UsdStage` opens over HTTP, and a clustered read of it +costs three requests where it used to cost eighteen.** `libs/usd-asset-io` fixes the `AssetReader` contract, the typed diagnostic vocabulary, the validator value types, and the metrics counters. @@ -45,13 +46,28 @@ emits the `HTTPxxx` codes the diagnostics contract allocated. A consumer opens a remote stage with no HTTP code of its own; `httpResolver_test_stage` does exactly that against the hostile fixture corpus, over a real socket. -What is still missing is the cache: every read is a request, deliberately, so -that the request pattern is visible before it is optimized. Identity is captured -and used but not yet exposed to consumers, which is `v0.4.0`. The release's I/O -baseline is recorded: `tests/baseline` runs the five scenarios METRICS.md §6 -requires against a 128 MiB fixture on loopback, and the numbers are in -[BASELINE.md](BASELINE.md). A bounded query moves a quarter of one percent of -that asset and every byte of it is a byte the caller asked for. +`libs/usd-asset-cache` is the newest thing here and the first that changes what +the numbers say. It is a decorator over `AssetReader` and knows no transport +concept: read expansion to whole blocks, coalescing bounded by a gap and a +length, single-flight so that N threads missing one block issue one request, +LRU eviction under a process-wide budget, and a bypass for reads large enough to +be a streaming pass. It is keyed on the validator from its first commit, so an +entry from one revision cannot serve a read of another, and it is entered into +the shared boundary suite as a row — `cache over local`, every case unchanged, +byte-equivalent to the reader underneath. + +The resolver takes it. Every asset the bundle opens is decorated and bound into +the process store, and the four cache variables in +[CONFIGURATION.md](CONFIGURATION.md) are read at construction. + +What is still missing is persistence: nothing outlives the process, which is +`v0.4.0`, and so does exposing identity to consumers. The release's I/O baseline +is recorded twice over, with the cache and without it, in +[BASELINE.md](BASELINE.md); what chose the cache's constants is +[BLOCK_POLICY.md](BLOCK_POLICY.md). A clustered header-and-index read went from +18 requests to 3, eight parallel readers of one asset went from 152 requests to +25 and now move what one reader moves, and the full sequential read is byte for +byte and request for request what it was. The whole tree still builds and tests with `-DUSD_HTTP_RESOLVER_BUILD_PLUGIN=OFF` on a machine with no OpenUSD @@ -111,7 +127,8 @@ not planned explicitly out of scope | `GetBuffer()` whole-asset materialization | not planned, ever | Returns null by contract; see §4.1 of [RESOLVER.md](../architecture/RESOLVER.md) | | Interoperability with whole-buffer FileFormat Plugins | not planned, ever | Incompatible with the remote random-access path by construction | | Asset info and identity stability | planned (`v0.4.0`) | `Stable` / `Unstable` / `Unavailable` exposed to consumers | -| Environment-variable configuration | implemented | The five transport bounds in [CONFIGURATION.md](CONFIGURATION.md); a bad value warns and takes the default | +| Environment-variable configuration | implemented | All nine variables in [CONFIGURATION.md](CONFIGURATION.md) — five transport bounds and four cache values. A bad value warns and takes the default; an adjusted one warns and takes the adjustment | +| Block cache under every opened asset | implemented | The bundle decorates every `ArAsset` it hands out and binds it into the process store by identifier and validator | | `ArResolverContext` configuration | planned (`v0.6.0`) | Per stage; the environment form is a process-wide bootstrap | | Write support | not planned | Fails explicitly | @@ -126,10 +143,12 @@ not planned explicitly out of scope | `If-Range` with a `Last-Modified` validator | implemented, not covered by the corpus | The fixture server compares `If-Range` only against its `ETag`, so the server-side half cannot be exercised there. Unit-tested against a scripted transport | | Revision binding, one reader to one revision | implemented | Both backends. A correctness property of range reads, not of the cache | | `AssetChanged` detection | implemented | Local: the file identity re-derived after every transferring read. HTTP: two independent detectors — a `200` answering a conditional range, and a response whose validator or complete length contradicts the capture, including a `416` whose `bytes */` does. Never repaired silently, never rebound | -| In-memory block cache | planned (`v0.3.0`) | See [CACHE.md](../architecture/CACHE.md); validator-keyed from the start | -| Request coalescing | planned (`v0.3.0`) | Measured gap and length thresholds | -| Single-flight de-duplication | planned (`v0.3.0`) | Tested under ThreadSanitizer | -| Bounded eviction | planned (`v0.3.0`) | Process-wide budget | +| In-memory block cache | implemented | `libs/usd-asset-cache`, validator-keyed from the first commit. 64 KiB blocks, LRU under a 128 MiB process budget, single-flight, and a 1 MiB bypass. Entered into the boundary suite as its own row | +| Request coalescing | implemented | Adjacent and near-adjacent blocks merged into one request, bounded by a one-block gap and 8 MiB. Both numbers measured: [BLOCK_POLICY.md](BLOCK_POLICY.md), which also records that the gap does not bind at the shipped block size | +| Single-flight de-duplication | implemented | Per block, across readers as well as threads. `usdAssetCache_singleflight` races eight of each; the ThreadSanitizer lane is where it means something | +| Bounded eviction | implemented | LRU under a process-wide budget shared across assets. Striped, so the order is LRU within a stripe — a global order would need a global lock, which §7 of the design policy forbids, and eviction is invisible to correctness | +| Cache identity shared between readers | implemented | Only for a strong validator. A weak or absent one caches privately for the reader's lifetime and drops on close | +| Large-read bypass | implemented | A read at least as large as the bypass threshold goes straight to the transport and stores nothing, which is what keeps the full sequential read from regressing | | On-disk persistence | planned (`v0.4.0`), may defer | Strong validator only | | Content-addressed identity | not planned in v0.x | Revisited only for cross-stage sharing | | Generated USD caching | not planned, ever | Owned by the consuming plugin repository | @@ -142,7 +161,7 @@ not planned explicitly out of scope | Credential elision in messages and dumps | implemented | Query string and authority userinfo both removed, visibly | | `HTTPxxx` plugin codes | implemented | Every code in the table except `HTTP102`, which ADR-0002 defers. Errors as `TF_RUNTIME_ERROR`, cancellation as `TF_WARN`, an impossible request as `TF_CODING_ERROR` | | Per-asset I/O counters | implemented | Defined in `usdAssetIo`, populated by both backends, folded into a process aggregate. The HTTP backend populates `requestCount`, `metadataRequestCount`, `retryCount`, `redirectCount`, `bytesRequested`, `bytesTransferred`, and all three latency histograms | -| Cache counters | defined, not populated | Fields exist and stay at zero until `libs/usd-asset-cache` in `v0.3.0` | +| Cache counters | implemented | Populated by `libs/usd-asset-cache`, including `bytesOverFetched`, and recorded in [BASELINE.md](BASELINE.md). A decorated stack reports one counter set, the outermost reader's | | Latency distributions | implemented | p50 / p90 / p99 / max, as power-of-two bucket estimates | | Metrics dump on `USD_HTTP_RESOLVER_METRICS_DUMP` | implemented | Aggregate plus top assets, at process exit, to stderr | | Recorded baselines | implemented | `tests/baseline`, the five scenarios in METRICS.md §6 against a 128 MiB loopback fixture. The record is [BASELINE.md](BASELINE.md); byte and request counts are asserted, ratios and durations are reported | @@ -170,7 +189,7 @@ not planned explicitly out of scope | Redirect scheme-downgrade rejection | implemented | Not in the corpus and cannot be: the fixture server speaks plaintext HTTP, so there is no `https` to downgrade from. Tested in `usdAssetHttp` against a scripted `Location` | | Mid-read revision-change tests, HTTP | implemented | Both halves: `ValidatorChangeMidRead` in the corpus projection, and the boundary suite's own republish-underneath-an-open-reader case | | No credential in a message, asserted | implemented | The corpus projection opens a failing URL carrying userinfo and a query token and checks the rendered status for both | -| Amplification baselines | implemented | `tests/baseline`, registered as `usdAssetHttp_io_baseline` so that a byte count which moves fails a lane rather than waiting for a release run. `amplification` is exactly 1.0 in every scenario that moves a byte, because there is no cache to over-fetch | +| Amplification baselines | implemented | `tests/baseline`, registered as `usdAssetHttp_io_baseline` so that a byte count which moves fails a lane rather than waiting for a release run. each scenario is measured with the cache and without it, which is the before-and-after METRICS.md §6 asks a release that changes I/O behavior for | ## Consumers diff --git a/docs/reference/CONFIGURATION.md b/docs/reference/CONFIGURATION.md index 52ad8a7..83f1b8c 100644 --- a/docs/reference/CONFIGURATION.md +++ b/docs/reference/CONFIGURATION.md @@ -1,8 +1,8 @@ # Configuration This document defines the configuration surface. The five transport bounds are -implemented as of `v0.2.0` and are read by `plugins/http-resolver`; the cache -variables are planned alongside the cache they control, and the +implemented as of `v0.2.0` and the four cache variables as of `v0.3.0`; all nine +are read by `plugins/http-resolver`, once, when the resolver is constructed. The `ArResolverContext` form arrives in `v0.6.0`. ## 1. Two mechanisms, in order @@ -26,10 +26,10 @@ that the defaults are wrong. | Variable | Default | Meaning | | --- | --- | --- | -| `USD_HTTP_RESOLVER_BLOCK_SIZE` | measured in `v0.3.0` | Cache block size in bytes; rounded to a power of two | -| `USD_HTTP_RESOLVER_CACHE_BUDGET` | measured in `v0.3.0` | Process-wide cache budget in bytes | -| `USD_HTTP_RESOLVER_COALESCE_GAP` | measured in `v0.3.0` | Maximum gap, in blocks, merged into one request | -| `USD_HTTP_RESOLVER_MAX_REQUEST_BYTES` | measured in `v0.3.0` | Upper bound on a single merged request | +| `USD_HTTP_RESOLVER_BLOCK_SIZE` | `65536` | Cache block size in bytes; rounded down to a power of two, and the rounding is reported | +| `USD_HTTP_RESOLVER_CACHE_BUDGET` | `134217728` | Process-wide cache budget in bytes, shared across assets | +| `USD_HTTP_RESOLVER_COALESCE_GAP` | `1` | Maximum gap, in blocks, merged into one request | +| `USD_HTTP_RESOLVER_MAX_REQUEST_BYTES` | `8388608` | Upper bound on a single merged request | | `USD_HTTP_RESOLVER_CONNECT_TIMEOUT_MS` | `10000` | Connection deadline | | `USD_HTTP_RESOLVER_READ_TIMEOUT_MS` | `30000` | Deadline from connection established to status line received | | `USD_HTTP_RESOLVER_TOTAL_TIMEOUT_MS` | `300000` | Total per-request deadline, headers and body | @@ -46,7 +46,12 @@ The three deadlines are the ones the backend separates so that `Timeout` value read is the one the resolver was constructed with: these are process-wide, and per-stage values are what `ArResolverContext` is for in `v0.6.0`. -Two rules that follow from the code and are worth stating rather than +The four cache defaults are measured constants and the measurement that chose +them is [BLOCK_POLICY.md](BLOCK_POLICY.md). Two of them are labelled there as +bounds rather than tuned values, which is a distinction this table cannot carry +and that record can. + +Three rules that follow from the code and are worth stating rather than discovering: - **`0` is rejected for the three deadlines and accepted for the two counters.** @@ -54,9 +59,17 @@ discovering: §10 of the [design policy](../design/DESIGN_POLICY.md) exists to forbid. For the counters it means "do not retry" and "do not follow", which are both legitimate things to ask for. -- **One bad value does not discard the other four.** Each variable is parsed +- **One bad value does not discard the other eight.** Each variable is parsed independently, so a configuration that is mostly right stays mostly in force, and the warning names the variable, its value, and what was wrong with it. +- **An adjustment is reported, not only a rejection.** A block size that is not + a power of two is rounded *down* — rounding up would silently double the bytes + every miss moves — and a coalescing gap too wide to fit under + `USD_HTTP_RESOLVER_MAX_REQUEST_BYTES` is capped. Both are diagnostics. An + operator who set 100000 and got 65536 should learn it from a log rather than + from a byte count. Values outside the block-size or budget bounds are refused + rather than clamped: a budget below one block means the caller wanted no + cache, and there is no variable for that. ## 3. What is not configurable @@ -75,6 +88,12 @@ turn a correctness property into a deployment mistake: - **`https` to `http` redirect following.** Always refused. - **Response framing validation.** Always on. A `206` that does not cover the requested range is always `InvalidResponse`. +- **The cache bypass threshold.** A read at least this large skips the cache + entirely, and it is a constant rather than a variable in `v0.3.0`. It exists + to stop a streaming pass evicting the whole working set, which is a + correctness-of-policy rule and not a tuning knob; a deployment that could set + it to zero could turn the full sequential read into the regression + [BASELINE.md](BASELINE.md) gates against. - **Credentials.** Never read from a variable in this list. When authentication arrives it arrives through a credential provider resolved from the environment or the context, and no credential is ever named in a variable diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index f975dbd..0d87e72 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -166,6 +166,10 @@ appears in any diagnostic. The release that makes the architecture perform rather than merely work. +Status: implemented and unreleased. All four exit criteria below are met, the +sanitizer lanes included. What has not happened is the release gate; see +[implementation status](implementation-status.md). + Scope: an aligned block cache; read expansion to block boundaries; coalescing of adjacent and near-adjacent block fetches into one request; single-flight so that N threads missing the same block issue one request; eviction with a diff --git a/docs/roadmap/implementation-status.md b/docs/roadmap/implementation-status.md index f351113..00c9db4 100644 --- a/docs/roadmap/implementation-status.md +++ b/docs/roadmap/implementation-status.md @@ -39,6 +39,12 @@ byte and request counts exactly, and reports the ratios; the record is bounded query: 0.0025 of a 128 MiB asset moved to answer it, with `amplification` at exactly 1.0 because there is no cache to over-fetch. +Phase 3 is implemented and unreleased. `libs/usd-asset-cache` is in the tree, +entered into the shared boundary suite as its own row, wired into the resolver, +and measured: the constants come from a sweep rather than from a decimal point, +and the release's before-and-after is one run of one harness. What it has not +had is a release gate walked over it. + **`v0.2.0` is released.** The gate is walked and [its record](../releases/v0.2.0.md) is written. Gates 4 and 6 bound for the first time and both pass; gate 9 turned out not to bind, because it binds a release @@ -135,12 +141,18 @@ therefore ship unexercised. | Task | Status | | --- | --- | -| `libs/usd-asset-cache`: alignment, expansion, eviction | Outstanding | -| Validator-keyed `CacheKey` from the first commit | Outstanding | -| Coalescing with measured thresholds | Outstanding | -| Single-flight, tested under TSan | Outstanding | -| Cache counters, including `bytesOverFetched` | Outstanding | -| Block size and gap threshold measurement, recorded | Outstanding | +| `libs/usd-asset-cache`: alignment, expansion, eviction | Done — a decorator over `AssetReader` that links `usdAssetIo` and nothing else. Reads expand to whole blocks, the final block is stored at its true length, and eviction is LRU under a process-wide budget shared across assets | +| Validator-keyed `CacheKey` from the first commit | Done — identifier, validator, block size, block index. Two revisions at one URL are two identities, and the suite proves it rather than the code asserting it | +| Coalescing with measured thresholds | Done — a one-block gap and an 8 MiB ceiling, both from `tests/cache-tuning`. [BLOCK_POLICY.md](../reference/BLOCK_POLICY.md) also records that the gap does not bind at the shipped block size, which is the part a tuning document is most tempted to leave out | +| Single-flight, tested under TSan | Done — per block, across readers as well as threads: eight threads missing one block issue one request, and eight readers of one revision issue one between them, with a latch that makes the moment single-flight has to work happen rather than hoping for it. Run under `core-tsan` and under `core-asan`, 25 of 25 tests green in each, GCC 15.2 | +| Cache counters, including `bytesOverFetched` | Done — every counter in METRICS.md §2.2, and one counter set per decorated stack rather than two | +| Block size and gap threshold measurement, recorded | Done — `tests/cache-tuning`, sixty-six runs over a real socket, recorded in [BLOCK_POLICY.md](../reference/BLOCK_POLICY.md) | +| The boundary suite passing against `cache over local`, unchanged | Done — a third row, 244 fixed cases, the property cases, and the concurrency cases. Not one line of the suite relaxed | +| A read large enough to be a streaming pass bypasses the cache | Done — and it is what keeps the full sequential read byte for byte and request for request what it was, which the baseline asserts rather than observes | +| The resolver takes the cache | Done — every `ArAsset` the bundle hands out is decorated and bound into the process store, and `httpResolver_stage` asserts from the server's log that a 4 KiB window out of a megabyte costs a block and not the megabyte | +| The four cache variables in CONFIGURATION.md | Done — read once at resolver construction; a bad value warns and takes the default, an adjusted one warns and takes the adjustment | +| Recorded before-and-after baseline | Done — [BASELINE.md](../reference/BASELINE.md), every scenario measured twice in one run of one harness | +| On-disk persistence | Deferred to `v0.4.0` by the roadmap, deliberately: the validator exists, and what does not yet exist is a release's worth of evidence that capture is correct | ## Phase 4 — identity exposure and persistence (`v0.4.0`) @@ -231,13 +243,51 @@ dependency, resolved as libcurl in ## Next -1. Phase 3, whose success is defined against the numbers just recorded: fewer - requests for the clustered index read and for parallel readers, a - `bytesOverFetched` that is honest about what block alignment costs, and a - full sequential read that does not regress. The table it is measured against - is [BASELINE.md](../reference/BASELINE.md) § *What the next release has to +1. The `v0.3.0` release gate, and its record. Everything the release is defined + by is in the tree and green; what has not happened is walking + [the gate](../releases/README.md) over it and writing down what that found. + Gate 6 is the interesting one this time, because it is the first release in + which the byte counts moved on purpose and the gate has to be satisfied by a + *recorded* change rather than by an unchanged table. +2. Phase 4, whose scope is what a persistent entry has to prove before it is + written: identity exposed through `GetAssetInfo`, and the strong-validator + rule for reuse across opens. The rows it is measured against are + [BASELINE.md](../reference/BASELINE.md) § *What the next release has to move*. +Done, and no longer next: the sanitizer lanes over the cache. Both are green +over the whole core tree — 25 of 25 under `address,undefined` and 25 of 25 under +`thread`, GCC 15.2 — and the ones that matter for this release are the three the +cache added: `usdAssetCache_singleflight`, the `cached-local` boundary row, and +the block-policy sweep, which drives the cache over a real socket at five block +sizes. TSan is not optional for this module, because single-flight is where a +naive implementation deadlocks and asserting a concurrency property in prose +asserts nothing. + +Done, and no longer next: Phase 3 itself. What building it surfaced was a +disagreement between two documents that had never been made to disagree before, +and it was worth resolving in the contract rather than in the code. The boundary +suite's mid-read revision case asserted `AssetChanged` on a re-read of a range +the reader had already read; a block cache answers that read from bytes it +captured under the same binding, observes nothing, and returns the revision the +reader is bound to. §2.1 of ASSET_READER.md already said *observes*, and +CACHE.md §6 already admitted in-memory caching for the reader's lifetime, so the +suite was asserting something narrower than the contract it is the executable +form of. The case now asserts `AssetChanged` at an offset the reader has not +read, and for a repeated range asserts either `AssetChanged` or byte-for-byte +the bound revision — never the new one. That is a strengthening: the byte +comparison is new, and it would have caught a backend that rebound *and* +reported `AssetChanged`, which the old case would have passed. + +Two smaller things the work surfaced, both recorded where they matter. The +first is that a decorated stack cannot have two counter sets: the two ends +disagree about what `bytesRequested` means, and summing them makes +`amplification` a ratio over two different measurements added together. The +second is that the coalescing gap does not bind at the block size the +measurement chose, which is written into +[BLOCK_POLICY.md](../reference/BLOCK_POLICY.md) rather than left for a reader to +infer from a table of identical rows. + Done, and no longer next: the `v0.2.0` release gate and its record. Two of the three gates that had been not-applicable in `v0.1.0` bound and passed; the third, gate 9, turned out not to bind at all, because it binds a release that publishes diff --git a/libs/usd-asset-cache/README.md b/libs/usd-asset-cache/README.md new file mode 100644 index 0000000..ea06f99 --- /dev/null +++ b/libs/usd-asset-cache/README.md @@ -0,0 +1,211 @@ +# usdAssetCache + +## Purpose + +An aligned block cache over `AssetReader`, so that the many small clustered +reads a format plugin issues stop becoming many small requests. + +It is a decorator, not a backend. It holds a reader it did not construct, it +knows no transport concept, and it is keyed by an opaque validator. That is what +lets the local backend stay a usable oracle for the cached path: the shared +boundary suite runs against `local` and against `cache over local` and compares +the two, and it could not if this module knew what HTTP was. + +Normative contract: [CACHE.md](../../docs/architecture/CACHE.md). The constants +it ships with, and the measurement that chose them, are +[BLOCK_POLICY.md](../../docs/reference/BLOCK_POLICY.md). + +## Responsibilities + +- Expanding a read to whole blocks, and serving it from them. +- Storing the final block of an asset at its true length, never padded. +- Merging adjacent and near-adjacent block fetches into one request, bounded by + a maximum gap and a maximum length. +- Single-flight: concurrent readers that miss the same block issue one request, + and the rest wait. +- Eviction under a bounded, process-wide memory budget, shared across assets. +- Bypassing itself entirely for a read large enough to be a streaming pass. +- Populating the cache counters of + [METRICS.md](../../docs/architecture/METRICS.md) §2.2, including + `bytesOverFetched`, which is what block alignment costs. + +## Non-responsibilities + +- **No transport.** No URL, no header, no status code, no client library. It + cannot tell what it is decorating. +- **No revalidation.** A reader is bound to one revision for its lifetime + (ASSET_READER.md §2.1), the blocks this module holds for it were captured + under that binding, and serving them is serving the bound revision. + `AssetChanged` is reported by the reader underneath, on the reads that reach + it. A hit reaches nothing and observes nothing. +- **No validator interpretation.** The validator's `value` is a byte string + here and nothing else: never parsed, never compared to an `ETag`, never read + for recency. Exactly one other field is read, `strength`, exactly once, to + decide whether an entry may be shared with a reader that did not store it. +- **No persistence.** On-disk entries are `v0.4.0`, and CACHE.md §8 states what + has to be true before one is written. +- **No read-ahead.** A read is expanded to the blocks it touches and no + further. Prefetch is research, not a feature of this release. +- **No format knowledge.** It stores bytes at offsets and has no idea whether + they are a header, an index, or a point record. + +## Public API + +```text +usdAssetCache/CacheOptions.h blockSize, budgetBytes, coalesceGapBlocks, + maxRequestBytes, bypassThresholdBytes, and + Normalized() +usdAssetCache/CacheKey.h AssetIdentity, CacheKey, IsShareable +usdAssetCache/BlockCache.h the block store, its bindings, and its stats +usdAssetCache/CachedAssetReader.h the decorator, Wrap, and WrapAsset +``` + +`Wrap` takes the reader to decorate, that reader's `ReaderMetrics`, the options, +and the store. Passing the metrics is what makes a decorated stack report one +set of counters instead of two; the caller supplies it because only the caller +knows the concrete backend, and `AssetReader` deliberately carries no metrics +accessor. + +`WrapAsset` is the same thing in the shape a backend's open returns. A failed +open passes through untouched, and so does a reader whose metadata says it +cannot serve random access — caching a reader that cannot seek would store one +block and miss forever after. + +## Dependencies + +`usdAssetIo`, and the standard library's threading. Nothing else, ever: + +- **OpenUSD is not required.** No file in this module includes an OpenUSD + header, and the module builds and tests with plain CMake on a machine with no + USD runtime installed. If that stops being true, the module is in the wrong + directory (WORKSPACE.md invariant 2). +- No backend, no transport, no HTTP client, no third-party library at all. + +## Data flow + +```text +Read(offset, size) + -> ResolveReadRange, once, in usdAssetIo + -> size >= bypassThresholdBytes ? straight to the reader underneath, stored + nowhere + -> otherwise: expand to whole blocks + for each block: resident -> copy out + absent -> claim it, and fetch + claimed -> wait for whoever claimed it + merge the claimed blocks into runs, bounded by gap and by length + publish each fetched block, evict under the budget +``` + +## Error and diagnostic behavior + +The same typed vocabulary every module uses, and nothing added to it. This +module produces exactly two statuses of its own: + +| Condition | Code | +| --- | --- | +| `Wrap` given no reader to decorate | `InvalidArgument` | +| A fetch delivered fewer bytes than the block extent, below EOF | `InvalidResponse`, via the shared `ShortReadStatus` | + +Everything else is the decorated reader's status, forwarded unchanged. A failed +read returns `bytesRead == 0`: the bytes a partial fetch left in the caller's +buffer are not reported as read. + +A block claimed by a fetch that then fails is handed back rather than left +pending, and the readers waiting on it acquire it again and do the work +themselves. A waiter is never failed by another reader's transport. + +## Threading and ownership + +What may be called concurrently: + +- Any number of threads may call `Read` on one `CachedAssetReader`, at any + offsets, overlapping or not. +- Any number of `CachedAssetReader`s may share one `BlockCache`, and they do by + default: the process store is the normal binding. +- `Metadata()`, `Options()`, `Metrics()`, and `SnapshotMetrics()` are callable + from any thread at any time. + +What may not: `BlockCache::ConfigureProcess` and `ClearForTesting` are not +concurrent with anything. The first is refused while any binding is alive and +says so by returning `false`; the second is for tests. + +There is no global lock. The store is striped and every lock is per stripe, so +a read of one block never waits on a lock a read of an unrelated block holds +(§7 of the design policy). The number of stripes falls back toward one for a +small budget, which makes eviction order exact for a test that fills one. + +Buffers: `dst` is caller-owned and this module retains no reference to it after +returning; it writes only within `[dst, dst + size)`. A cached block is +immutable once published and is held by `shared_ptr`, so eviction drops the +store's reference and the bytes live exactly as long as the last reader looking +at them. + +**No network requests.** This module issues none and cannot: it has no +transport. Every byte it does not already hold, it asks the reader underneath +for. + +## Build and test + +```sh +cmake -S . -B build/core -DUSD_HTTP_RESOLVER_BUILD_PLUGIN=OFF +cmake --build build/core +ctest --test-dir build/core -R usdAssetCache +``` + +| Test | What it is | +| --- | --- | +| `usdAssetCache_plan` | The arithmetic, with no reader and no thread: alignment, coalescing, the over-fetch charge, option normalization, key identity | +| `usdAssetCache_cache` | What the cache does to the reader underneath — requests, bytes, hits, eviction, identity sharing | +| `usdAssetCache_singleflight` | Threads. The ThreadSanitizer target for this module | +| `boundary_cached_local_*` | The shared boundary suite, unchanged, over `cache over local` | +| `usdAssetCache_block_policy` | The block-policy sweep, over a real socket, at five block sizes and four gaps | + +The first three need nothing but a compiler. The last two live outside `libs/`, +because a module's tests must not depend on anything outside it. + +All five run under the sanitizer presets without a lane of their own, because +they are `libs/` and `tests/` and that is what `core-asan` and `core-tsan` +cover: + +```sh +cmake --preset core-tsan && cmake --build --preset core-tsan +ctest --preset core-tsan +``` + +TSan is not optional for this module. Single-flight is where a naive +implementation deadlocks, and asserting a concurrency property in prose asserts +nothing. + +## Known limitations + +- **The coalescing gap does no work at the shipped block size.** Measured, and + recorded rather than hidden: at 64 KiB blocks none of the recorded access + patterns produces a gap to merge across, and gaps of 0, 1, 2 and 4 give + identical numbers. It binds at 4 KiB blocks, where it is worth seven requests. + See BLOCK_POLICY.md. +- **`bytesOverFetched` is charged at fetch time and never refunded.** A block + fetched for one read and then consumed by fifteen more is still counted as + over-fetch for the fourteen-fifteenths of it the first read did not want. The + refund shows up in `cacheHitRatio` instead, and the two are reported together + for that reason. +- **`peakResidentBytes` is an upper bound, not an exact high-water mark.** Each + stripe tracks its own peak and the store sums them; the true peak of the sum + is never higher and can be lower. Maintaining an exact one would take a lock + across every stripe on every publish. +- **Sharing requires a strong validator.** A weak or absent one caches + privately, for the reader's lifetime, and drops on close. That is stricter + than CACHE.md §8's table, which admits in-memory caching at any strength; the + strictness is about sharing between *two* readers, where the binding that + makes weak safe no longer holds. +- **A read larger than the bypass threshold is never cached**, even when it is + issued twice. + +## Planned work + +- `v0.4.0`: persistence, admitted per asset by validator strength, and the + identity exposure that goes with it (CACHE.md §8). +- `v0.5.0`: the first measurement over real distance. The block size chosen + here is chosen on request counts under a stated premise about round-trip cost + that loopback cannot price; `v0.5.0` is where the premise gets tested. +- `v0.6.0`: cache configuration through `ArResolverContext`, so that two stages + in one process can have two policies. diff --git a/openstrata.ci.yaml b/openstrata.ci.yaml index 3146a41..944bafa 100644 --- a/openstrata.ci.yaml +++ b/openstrata.ci.yaml @@ -86,7 +86,9 @@ cells: # exited PRECONDITION_FAILED with "no plugin bundles found in the workspace # member set", which is one of the three reasons this file did not exist yet. # plugins/http-resolver is the bundle that fixed it, and the rung now reports - # 1 bundle, 3 libraries, 3 library edges, valid. + # 1 bundle, 4 libraries, 4 library edges, valid -- the fourth library and the + # fourth edge are libs/usd-asset-cache and its one permitted edge onto + # usdAssetIo, landed in v0.3.0. - name: workspace-graph-pr-linux kind: workspace lane: pull_request diff --git a/tests/README.md b/tests/README.md index be0d58d..4308375 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,7 +8,8 @@ belongs to no single module. | `boundary/` | The shared boundary suite: the executable form of the read contract, and the thing every backend is admitted by | | `fixture-server/` | The hostile-server corpus: a loopback HTTP origin that misbehaves on request, and the conditions only a server can produce | | `corpus/` | The projection of each corpus behavior onto the typed vocabulary — which `Behavior` produces which `StatusCode` | -| `baseline/` | The recorded I/O baseline: the five scenarios METRICS.md §6 requires, measured against a fixture large enough for `selectivity` to mean something | +| `baseline/` | The recorded I/O baseline: the five scenarios METRICS.md §6 requires, each measured with the cache and without it, against a fixture large enough for `selectivity` to mean something | +| `cache-tuning/` | The block-policy measurement: a sweep of block size against coalescing gap, which is what chose the cache's constants | The first three are not the same suite and none substitutes for another. The boundary suite carries the correctness argument, over an oracle, for every backend. The @@ -29,6 +30,33 @@ fails the run. ctest --test-dir build/core -R usdAssetHttp_corpus_projection ``` +## The block-policy sweep + +`cache-tuning/` is the other measurement here, and it answers a different +question from the baseline: not "what does the shipped configuration cost" but +"why is the shipped configuration that one". §5 of the +[design policy](../docs/design/DESIGN_POLICY.md) says cache behavior is measured +before it is tuned, and §4 of [CACHE.md](../docs/architecture/CACHE.md) says the +coalescing numbers are recorded with the measurement that produced them. + +It runs four access patterns through the cache at five block sizes and four +gaps — sixty-six runs, each against a fresh store — and verifies every byte. The +verification is the part of it that is a test: the cache runs over a real socket +at five block sizes, and a block boundary that is wrong at one of them fails the +lane. The table it prints is the measurement, and the record is +[BLOCK_POLICY.md](../docs/reference/BLOCK_POLICY.md). + +```sh +ctest --test-dir build/core -R usdAssetCache_block_policy +USD_ASSET_TUNING_ASSET_BYTES=8388608 ./build/core/tests/cache-tuning/usdAssetCache_tuning +``` + +Nothing in it chooses a default from wall clock. Loopback has no round-trip time +worth the name, and the whole argument for merging small reads is about a +round-trip time this harness cannot produce; the defaults come from the request +counts and the byte counts, under a premise about round-trip cost that the +record states outright and `v0.5.0` is where it gets tested. + ## The baseline `baseline/` is not a fourth correctness suite. It answers gate 6 of @@ -120,6 +148,21 @@ Adding a transport adds a row, not a suite. A row declares four things: | Cancellation | Not admitted -- declared, not skipped | | Revision simulation | Admitted: rewrite the file underneath the open reader | +There is a third row, and it is not a transport at all: `cache over local`. +`backends/boundary_cached_local_main.cpp` runs the whole suite against +`usdAssetCache` decorating `usdAssetLocal`, which is what makes "byte-for-byte +equivalence with the uncached path over the full suite" an assertion rather than +a claim. Its block size is 4096 rather than the shipped 65536, deliberately: the +suite's interesting offsets are powers of two around 65536, and a block size of +65536 would put every one of them exactly on a boundary, which is the one case +block arithmetic never gets wrong. Its budget is small enough that eviction runs +during the suite rather than sitting unexercised. + +It is over the local backend rather than the HTTP one for a reason worth +stating: a cached row over a socket would be measuring two things at once, and +the cache knows no transport concept — if this row had needed one, the decorator +would have acquired knowledge WORKSPACE.md invariant 5 forbids it. + `backends/boundary_local_main.cpp` is the whole of it. The HTTP row is `backends/boundary_http_main.cpp` beside it, and it is what the claim above cost: one file, one line in `tests/boundary/CMakeLists.txt`, and no change to From 18f513a27da8b63e3ea7110ec58fd69a503b80f1 Mon Sep 17 00:00:00 2001 From: snkmcb Date: Fri, 21 Aug 2026 12:16:07 +0900 Subject: [PATCH 4/4] Declare the cache edge where ost reads it, not only where CMake does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two bundle cells have been failing since the cache landed, and the two local lanes and core-ci.yml have been green throughout, which is the whole shape of the defect. A bundle declares its library edges twice: to the build graph, which resolves them as in-tree targets, and in openstrata.plugin.yaml, which is the list `ost plugin build` installs into the workspace prefix before it configures the bundle standalone. usdAssetCache was added to the first and not the second. usdAssetHttp carries usdAssetIo transitively, so the bundle never had to name it and the descriptor's one-line list had never been wrong before. usdAssetCache is that library's sibling over usdAssetIo rather than anything above it -- the cache is a decorator and knows no transport concept -- so nothing carries it and it has to be named. Reproduced and fixed against the path the cells take, not against the in-tree build: `ost plugin build plugins/http-resolver` now installs usdAssetCache into the workspace prefix and configures, and `ost plugin test --up-to 1` is 8 pass, 0 fail, 4 skip. The Windows leg of that still needs CMAKE_PREFIX_PATH in the environment for libcurl, which is blocking item 3 and unrelated. `verify: graph` is the rung that would have caught this, and it counts the edge: it now reports 5 library edges where it reported 4. Both places that state the edge list say so -- WORKSPACE.md §2 next to the list a reader would otherwise trust, and the cell's comment in openstrata.ci.yaml, which had the old count. Co-Authored-By: Claude Opus 5 --- docs/architecture/WORKSPACE.md | 13 ++++++++++++- docs/roadmap/implementation-status.md | 12 ++++++++++++ openstrata.ci.yaml | 9 ++++++--- plugins/http-resolver/openstrata.plugin.yaml | 17 ++++++++++++++--- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/architecture/WORKSPACE.md b/docs/architecture/WORKSPACE.md index 960916c..30b9813 100644 --- a/docs/architecture/WORKSPACE.md +++ b/docs/architecture/WORKSPACE.md @@ -42,7 +42,18 @@ surface, and nothing else on either list. The cache edge is taken as of process-wide block store by the identifier and the validator the backend captured. `usdAssetLocal` is permitted and unused: a local path is the primary resolver's business, and a URI-scheme resolver that reached for the -local backend would be answering for paths it does not claim. The `js`, `plug`, +local backend would be answering for paths it does not claim. + +A bundle's edges are declared twice and both declarations bind. The root build +graph resolves them as in-tree targets, and `plugins/http-resolver/openstrata.plugin.yaml` +declares them again under `requires.libraries` — which is the list +`ost plugin build` installs into the workspace prefix before it configures the +bundle standalone. The two are not redundant: a library the bundle links and +does not declare there builds perfectly in-tree, in every local lane and in +`core-ci.yml`, and fails only the bundle cells, with a `find_package` that +cannot be satisfied. `usdAssetHttp` carries `usdAssetIo` transitively; +`usdAssetCache` is its sibling over `usdAssetIo` and is carried by nothing, so +it has to be named. The `js`, `plug`, and `vt` components are what `ar` itself needs in a non-monolithic build; a resolver reads bytes and hands them over, so no `usd` or `usdGeom` component appears. diff --git a/docs/roadmap/implementation-status.md b/docs/roadmap/implementation-status.md index 00c9db4..6443cc3 100644 --- a/docs/roadmap/implementation-status.md +++ b/docs/roadmap/implementation-status.md @@ -279,6 +279,18 @@ the bound revision — never the new one. That is a strengthening: the byte comparison is new, and it would have caught a backend that rebound *and* reported `AssetChanged`, which the old case would have passed. +One thing the work surfaced late, in CI rather than locally, and it is the kind +worth writing down because no local lane can see it. A bundle declares its +library edges twice — once to the build graph and once in +`openstrata.plugin.yaml` — and only the second is what `ost plugin build` +installs into the workspace prefix before configuring the bundle standalone. +`usdAssetCache` was added to the bundle's CMake and not to its descriptor, which +builds correctly in-tree, in both local lanes and in `core-ci.yml`, and fails +exactly the two bundle cells with a `find_package` that cannot be satisfied. +The rung that would have caught it is `verify: graph`, which counts the edge: +it now reports 5 library edges where it reported 4. WORKSPACE.md §2 says so +now, next to the edge list a reader would otherwise trust. + Two smaller things the work surfaced, both recorded where they matter. The first is that a decorated stack cannot have two counter sets: the two ends disagree about what `bytesRequested` means, and summing them makes diff --git a/openstrata.ci.yaml b/openstrata.ci.yaml index 944bafa..66df8b1 100644 --- a/openstrata.ci.yaml +++ b/openstrata.ci.yaml @@ -86,9 +86,12 @@ cells: # exited PRECONDITION_FAILED with "no plugin bundles found in the workspace # member set", which is one of the three reasons this file did not exist yet. # plugins/http-resolver is the bundle that fixed it, and the rung now reports - # 1 bundle, 4 libraries, 4 library edges, valid -- the fourth library and the - # fourth edge are libs/usd-asset-cache and its one permitted edge onto - # usdAssetIo, landed in v0.3.0. + # 1 bundle, 4 libraries, 5 library edges, valid. v0.3.0 added two of those + # edges: libs/usd-asset-cache onto usdAssetIo, which is the only one the cache + # is allowed, and the bundle onto libs/usd-asset-cache. The second is why this + # rung is worth running -- it is declared in openstrata.plugin.yaml, and a + # bundle that links a library without declaring it there builds in-tree and + # fails the bundle cells with a find_package that cannot be satisfied. - name: workspace-graph-pr-linux kind: workspace lane: pull_request diff --git a/plugins/http-resolver/openstrata.plugin.yaml b/plugins/http-resolver/openstrata.plugin.yaml index 6e365ca..4cea6b2 100644 --- a/plugins/http-resolver/openstrata.plugin.yaml +++ b/plugins/http-resolver/openstrata.plugin.yaml @@ -24,9 +24,18 @@ provides: - usd-resolver:https requires: capabilities: [usd-stage-read] - # The only workspace edge this bundle needs. usdAssetHttp carries usdAssetIo - # transitively, and libcurl privately -- ADR-0003 keeps it out of every - # installed header, so it is not an edge anything above this line can see. + # The two workspace edges this bundle needs, and they are siblings rather than + # a chain: usdAssetHttp carries usdAssetIo transitively and libcurl privately + # -- ADR-0003 keeps libcurl out of every installed header, so it is not an + # edge anything above this line can see -- and usdAssetCache is over + # usdAssetIo too, not over the backend. The cache is a decorator and knows no + # transport concept (WORKSPACE.md §2), so nothing carries it but this line. + # + # This list is what `ost plugin build` installs into the workspace prefix + # before it configures the bundle, which makes it load-bearing rather than + # descriptive: a library the bundle links and does not declare here builds + # in-tree and fails the bundle cell with a `find_package` that cannot be + # satisfied. # # usdAssetLocal is deliberately absent: this resolver serves `http` and # `https`, and a local path is the primary resolver's business (RESOLVER.md @@ -34,6 +43,8 @@ requires: libraries: - id: usdAssetHttp version: ">=0.2,<0.3" + - id: usdAssetCache + version: ">=0.3,<0.4" usd: plug_info: plugin/resources/httpResolver/plugInfo.json tests: