-
Notifications
You must be signed in to change notification settings - Fork 0
0057 the npy read serves a stream and a buffer differently
Status: accepted · Date: 2026-08-30 · Refines: 0056
#474 built the first cross-language row where
both sides read the same .npy and return something searchable — np.load against
NpyFile.Read followed by
EmbeddingIndex.FromBlock. Every
other index row puts our JSON artifact against numpy's raw block, which prices a format and
reports it as a speed; this one prices the ingest and nothing else.
Its first reading on a hosted runner, three rounds on the same 15 360 128 bytes, was
0.21–0.23× wall and 0.19× cpu — numpy four to five times faster on bytes neither side has to
decode. #466 found why, and it is not the
language: the block was copied three times before an index held it, where numpy copies it
once. The stream became a bounded buffer, the buffer became an exact byte[], those bytes
became the float[] a block carries — and the block was copied once more into the index's store.
Removing the second of those alone, in this lot's first commit, moved the row to 0.34–0.36× wall and 0.29–0.30× cpu on the same runner: 6.106 / 5.645 / 6.035 ms before it, 4.039 / 4.317 ms after, about 1.8 ms. The performance guide carries that dispatch's rounds in full.
What the delta is not is a price for one copy. This decision was argued as though 15.36 MB of
memcpy cost what those two readings differ by. It does not: the guide prices the same copy at
about 1.2 ms at numpy's own rate, and the dispatch that took a whole copy of this block out moved
the row by nothing measurable. What the readings support is that the copies left were worth
attacking — not what one of them costs. Which one paid is in Consequences.
Two callers reach the reader, and they do not want the same thing. One holds a Stream and has
no bytes of its own. The other already holds the whole file — a blob, a cache entry, an embedded
resource — and has therefore already accepted its lifetime. A single entry point has to serve the
second as though it were the first, and copy what it was handed.
Two entry points, one contract each.
NpyFile.Read(Stream) reads the payload
straight into the float[] the block carries. The header is read in stages, so the element count
is known — and refused against ArtifactLoadOptions — before anything is allocated. It costs one
copy on net10.0 and two on netstandard2.0 — the split below — asks nothing of its caller
either way, and the array it fills is one nobody else holds.
NpyFile.Read(ReadOnlyMemory<byte>)
copies nothing. NpyBlock.Values aliases the caller's bytes through an internal
MemoryManager<float> over the payload slice, and those bytes must not change while the block
lives — the contract
EmbeddingIndex.Load(ReadOnlyMemory<byte>)
already states, for the same reason and to the same kind of caller.
NpyBlock.OwnedArray is filled by the stream reader and by nothing else. It is what
EmbeddingIndex.FromOwnedBlock
may adopt under 0056,
and only the stream path has an array to surrender. A borrowed block leaves it null because there
is no array behind it, and a block built by hand leaves it null because 0056's ownership transfer
is reached through the method that documents it or not at all.
What the four routes cost, against numpy's one copy:
| route | copies |
|---|---|
Read(Stream) → FromBlock
|
2 |
Read(Stream) → FromOwnedBlock(block.OwnedArray)
|
1 |
Read(ReadOnlyMemory<byte>) → FromBlock
|
1 |
Read(ReadOnlyMemory<byte>) → FromOwnedBlock
|
not available |
The fourth is refused rather than made to look available: a view has no array to surrender, so
OwnedArray is null and the adopting factory cannot be reached with it.
-
What was refused is a view on every path —
NpyBlock.Valuesaliasing the payload whether the bytes came from a stream or from the caller. It removes the byte-to-float copy without restructuring the read, which is why it looked cheapest. It caps the chain at two copies, because a view cannot be adopted and so forecloses removing the copy into the index, and it charges an aliasing contract to every caller including the one who only passed aFileStream. Reading into the array dominates it: one copy rather than two, and no contract at all on the path most callers take. -
The
netstandard2.0split is a consequence of this shape, not a discovery made under it. Reading a stream straight into a destination of one's own choosing needsStream.Read(Span<byte>), andStream.ReadExactlywith it; both are .NET 7 and later. The sharedStreamFill.Exactlyreads into the destination directly onnet10.0and stages through an 80 KB chunk otherwise, so the stream overload costs one copy there and two here — one public API and one behaviour at two speeds, the splitVectorMath.Dotalready makes and whichStreamFill's own remarks name as its precedent. The memory overload copies nothing on either target, nothing about it depending on aStreamAPI. -
What it measured, which is not what this decision argued. The row went from 0.19× of
numpy's cpu to 1.00–1.13× — parity in the first round and slightly ahead in the other two —
and from 0.21–0.23× of its wall to 1.21–1.25×. cpu is the column this project trusts, so the
honest reading is parity to slightly ahead, where it was four to five times behind. But the
read this decision is about contributed none of it: measured alone, against an untouched
neighbour, staging the payload into the array moved the row by nothing. All of it came from
adopting, because
FromBlockwas allocating a second 15.36 MB store on the large object heap to copy into andFromOwnedBlockallocates none — the mechanism 0054 priced on the artifact buffer. So the shape decided here is what made the win reachable, throughOwnedArray, and not what delivered it. The figures and the anchors are in the performance guide; the copy that paid for nothing is #480. -
What would change this decision is a caller found holding a
NpyBlockpast the lifetime of the bytes it borrowed — a block cached beyond a pooled buffer's return, or read from memory that is then rewritten. That would make the memory overload's contract the wrong default and argue for a copying overload beside it, which is an addition to a published package rather than a change to one.
- 0001-target-framework
- 0002-unicode-comparison-unit
- 0003-provenance-and-licensing
- 0004-levenshtein-myers-backlog
- 0005-hamming-jellyfish-divergence
- 0006-ratcliff-autojunk
- 0007-metaphone-scope
- 0008-italian-enza-nltk-divergence
- 0009-sample-consumes-a-local-feed
- 0010-stop-word-list-provenance
- 0011-persistence-format
- 0012-per-package-versioning
- 0013-sentencepiece-parity-scope
- 0014-precompiled-normalizer
- 0015-sonar-rules-in-the-build
- 0016-metrics-package-placement
- 0017-bpe-parity-scope
- 0018-multiclass-roc-auc-parallelism-is-opt-in
- 0019-the-net-analysers-run-in-the-build-too
- 0020-normalize-is-a-projection-not-a-parameter
- 0021-multioutput-is-a-method-not-an-enum
- 0022-added-token-matching-flags
- 0023-byte-level-decode-substitutes
- 0024-weighted-median-averages-within-scikit-learns-epsilon
- 0025-quickselect-replaces-a-full-sort-for-the-median
- 0026-r2-and-explainedvariance-split-their-undefined-cases-differently
- 0027-r2-and-explainedvariance-vectorize-only-a-single-output
- 0028-log1p-is-kahans-identity-not-math-log-1-plus-x
- 0029-balanced-accuracy-adjusted-is-left-to-ieee-754-at-the-edge
- 0030-cohen-kappa-keeps-scikit-learns-expected-matrix-orientation
- 0031-nosamplecorrect-mirrors-numpys-float64-upcast
- 0032-fbeta-substitutes-tp-predicted-and-support-algebraically
- 0033-compensated-sum-is-neumaiers-variant
- 0034-dropout-is-refused-for-want-of-a-user
- 0035-a-null-pre-split-is-removed-with-invert-not-isolated
- 0036-a-member-may-ship-without-an-oracle-if-it-says-so
- 0037-the-guards-run-before-the-commit
- 0038-the-gate-confronts-an-exception-tag-with-the-page-that-documents-it
- 0039-mutual-information-returns-zero-on-an-empty-input
- 0040-a-curve-is-a-sealed-class-per-curve
- 0041-one-sample-file-per-public-class
- 0042-phonetic-encoders-refuse-a-null-word
- 0043-the-equality-table-is-sized-to-the-pattern
- 0044-compression-belongs-to-the-caller
- 0045-a-console-call-carries-its-reason-on-the-line
- 0046-check-adr-immutable-runs-in-ci-only
- 0047-one-gate-per-kernel-not-one-per-alphabet
- 0048-the-gate-depends-on-the-kernel-and-the-alphabet
- 0049-two-gates-per-kernel-tested-where-the-width-is-known
- 0050-the-sentencepiece-bpe-lineage-stays-a-bpe-model
- 0051-the-save-paths-cost-is-the-buffer-not-the-encoding
- 0052-pre-sizing-the-artifact-file-buys-nothing-on-a-delayed-allocation-filesystem
- 0053-the-payload-buffer-is-not-pooled-because-residency-outlives-the-load
- 0054-the-payload-buffer-is-pooled-after-all-because-the-collection-is-the-cost
- 0055-the-artifact-gets-a-binary-sidecar-once-a-block-can-be-ingested-whole
- 0056-a-block-may-be-adopted-and-the-invariant-is-the-callers-to-keep
- 0057-the-npy-read-serves-a-stream-and-a-buffer-differently
- 0058-the-npy-ingest-is-memcpy-bound-and-the-allocation-is-not-the-cost
- 0059-phase-0-verifications-two-confirmed-voids-do-not-survive-nuget
- benchmark_latest
- decisions
- equivalence
- matplotlib
- migration
- nightly_run
- numpy
- pandas
- performance
- pytorch
- seaborn
- sklearn
- statsmodels