You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
BACPAC loader perf — devirtualized stream wiring, eliminated per-row allocations on the BCP hot path, fast-path decimal decode. Profile-driven via dotnet-trace CPU sampling against WideWorldImporters-Full (61 MB, 4.7M rows). Three contained micro-optimizations in BcpRowReader.cs + BacpacReader.cs plus a peer cleanup in RowEncoder.cs. No behavior change — all tests pass.
# Devirtualized BufferedStream wiring
`BacpacReader.Load` wraps every BCP entry's `ZipArchiveEntry.Open()` stream in a `BufferedStream(rawStream, 64 * 1024)` before handing it down. `BufferedStream` is sealed, so typing the parameter as the concrete type lets the JIT devirtualize all per-call `ReadByte` / `Read(Span<byte>)` invocations rather than going through the polymorphic `Stream` vtable. Two signatures change to opt in:
- `BacpacReader.LoadRowsFromBcp(BufferedStream bcpStream, …)` (was `Stream`)
- `BcpRowReader.TryReadRow(BufferedStream stream, …)` (was `Stream`) — and its internal `PushbackStream`'s `baseStream` field type matches.
The 64 KB buffer also collapses thread-safety lock acquisitions inside `DeflateStream.ReadCore` from `~1 per column` to `~1 per 64 KB chunk` — profiled as 20% → noise on the lock-contention slice. Measured wall-clock A/B over 7 iters: MemoryStream-decompress-then-read (the obvious alternative) and BufferedStream finished within noise of each other, but BufferedStream wins on peak memory (64 KB transient vs. up to 4 MB per BCP shard for the bigger tables).
# Per-row allocation elimination in BCP wire decoding
`BcpRowReader.PushbackStream` flipped from `private sealed class` to `private struct` and is now passed `ref` through `DecodeColumn` / `ReadFixedRaw` / `ReadLengthPrefixed1` / `ReadDecimal` / `ReadVarchar2` / `ReadVarbinary2` / `ReadEightBytePrefixed`. Removes one heap allocation per row across the entire data-load phase — for `Warehouse.ColdRoomTemperatures_Archive` (the dominant table at ~50% of wall clock, 5M+ rows across 49 BCP shards) that's millions of gen0 allocations gone.
Per-column scratch buffers in the fixed-width and length-prefixed-fixed read paths flip from `new byte[width]` to `stackalloc byte[width]`:
- `ReadFixedRaw` — int/bigint/smallint/tinyint/datetime/smalldatetime/date/money/smallmoney/time(N)/datetime2(N)/datetimeoffset(N)
- `ReadLengthPrefixed1` — bit/uniqueidentifier
- `ReadDecimal` — the mantissa buffer (now also threaded through the fast path)
- `ReadVarchar2` / `ReadVarbinary2` — the 2-byte length prefix (variable-length payload still goes to heap since it owns the `SqlValue`'s string/byte[] backing)
Widths are bounded (≤ 16 bytes for fixed types, ≤ 17 bytes for decimal mantissa, exactly 2 bytes for length prefixes), so stackalloc is safe without an unbounded-input fallback.
# Decimal/numeric fast path — bypass BigInteger.Pow + BigInteger division
`BcpRowReader.ReadDecimal` previously decoded every mantissa via `System.Numerics.BigInteger`: read N bytes into an unsigned BigInteger, divide by `BigInteger.Pow(10, scale)` (allocates per call), then compose the decimal. Profiled as **~15% of total BACPAC wall-clock time** against WWI-Full (`ReadDecimal` 4.7s + `BigInteger.Pow` 1.0s + `DecCalc.VarDecDiv` 0.9s out of ~24s of inclusive CPU).
The fast path covers SQL Server's typical precision range. When `mantissaSpan.Length <= 12` (precision ≤ 28) AND `type.scale <= 28`, the mantissa fits in System.Decimal's 96-bit representation. Pad the LE mantissa to 12 bytes, slice into three int32s, and construct the decimal directly via `new decimal(lo, mid, hi, isNegative, scale)` — zero arithmetic, no `BigInteger`, no `Pow`, no `VarDecDiv`. The wide path (precision > 28, mantissa > 12 bytes — outside System.Decimal's modeled range anyway, matches the simulator's documented decimal-precision quirk) keeps the BigInteger code for diagnostic completeness.
# RowEncoder.EncodeRow — cache `schema[i].Type` locally
Peer cleanup in the encoder's two inner loops. Hoists `schema[i].Type` into a `var t` local so the per-iteration walk does one indexed-property read instead of 3-5. The JIT probably already CSE'd this in tier-1 codegen, but explicit caching is free and the diff is small.
A separate experiment in the same encoder (defer the per-row `varByteCounts` + `chainPointers` allocations when the schema has no var columns, stackalloc them when n is small) measured at the noise floor across 15-iter runs — the GC pressure those arrays generate is gen0-amortized to nearly zero already. Reverted; not in this bundle.
# Aggregate impact
Wall-clock (Release, WWI-Full, median over 15 iters): baseline → post-bundle measured noisily but consistently ~10% faster, with `ReadDecimal`-heavy tables (Sales.Invoices, Sales.OrderLines, Sales.CustomerTransactions) benefiting the most.
The CPU profile after this bundle shows the remaining cost concentrated outside our control: `DeflateStream.ReadCore` (~36% — pure decompression CPU, irreducible without bypassing the zlib codec), `ZipArchiveEntry.Open` overhead at 488 entries (~7%), and `BacpacReader.LoadRowsFromBcp`'s mostly-saturated single-threaded `Heap.Insert` loop.
0 commit comments