mirror of
https://github.com/adamhathcock/sharpcompress.git
synced 2026-11-15 17:02:03 +00:00
389 lines
21 KiB
Markdown
389 lines
21 KiB
Markdown
# XZ BCJ pooling and decoder profiling
|
||
|
||
## Branch and changes
|
||
|
||
Evaluated on 2026-10-24 on `fix/xz-bcj-streaming`. The baseline is
|
||
`0aaf9b92` (the streaming BCJ correctness fix); the pooled version is
|
||
`5c46363f`. Both libraries are clean Release builds in separate source exports,
|
||
using the same harness and inputs. The original broken BCJ implementation is
|
||
not a meaningful performance baseline: it can omit conversions or corrupt
|
||
output, so it would not perform equivalent work.
|
||
|
||
Changes evaluated:
|
||
|
||
- The shared 8 KiB BCJ buffer is rented from `ArrayPool<byte>.Shared` and returned
|
||
exactly once on disposal. Processing is bounded by the logical buffer size and
|
||
valid input count, not an oversized rent's capacity.
|
||
- All six XZ executable filters use that buffer: x86, ARM, ARM-Thumb, PowerPC,
|
||
SPARC, and IA-64. Unprocessed instruction tails remain buffered between reads.
|
||
- XZ blocks retain their filter objects for cleanup. Advancing to another block
|
||
disposes the previous block, and disposing an XZ stream releases its current
|
||
block. This returns the BCJ buffer and the LZMA2 decoder's existing pooled
|
||
dictionary/range buffers; previously the filter chain was not disposed.
|
||
- Filters dispose their own wrappers, not caller-owned input streams. The LZMA2
|
||
wrapper explicitly leaves its compressed input open. Disposed readers reject
|
||
further reads before they can use or rent buffers again.
|
||
|
||
No range-coder, LZMA decoding, checksum, or async scheduling optimization is
|
||
included in the pooled comparison.
|
||
|
||
The saved library diff was checked against these commits byte-for-byte. The
|
||
exported baseline DLL SHA-256 is
|
||
`f7c9c5ac700315205268a0f1aec0c8517289e6f81bfd9d1459d0ec3c17d5985c`;
|
||
the pooled DLL SHA-256 is
|
||
`ab7cee7f00bcc5e37f967ca0f74ff3c34a8eb93ee128cf08ca41488987106961`.
|
||
|
||
## Workloads and measurement
|
||
|
||
### Complete XZ decoding
|
||
|
||
The controlled workload is **16.36 MiB compressed** (17,159,680 bytes),
|
||
**32 MiB decoded** (33,554,432 bytes), in one XZ block using x86 BCJ, LZMA2,
|
||
an 8 MiB dictionary, and CRC64. Python 3.9.6's liblzma-backed `lzma.compress`
|
||
created it with preset 6. A generator seeded with `1471` alternates 64 KiB of
|
||
random bytes and 64 KiB of structured x86 records. Each 16-byte structured
|
||
record contains CALL and JMP instructions targeting fixed absolute addresses.
|
||
This exercises both incompressible data and BCJ-friendly instructions.
|
||
|
||
Compressed SHA-256:
|
||
`d1b182144055a3d1767944f32081bc2142f703372dae9d28f5073b629a733e1b`.
|
||
|
||
Decoded SHA-256:
|
||
`9150fb1e85094a06b7aeb197908a30d3a8466e4d298f629d13b3f0ca99243715`.
|
||
|
||
Every extraction drains through the XZ checksum and verifies the decoded byte
|
||
count. Memory-backed scenarios preload compressed bytes outside measurement.
|
||
File-backed scenarios use a 4 KiB `FileStream` buffer, `SequentialScan`, and
|
||
`FileOptions.Asynchronous`. Filesystem-cache effects are warm, not cold-storage
|
||
performance. The synchronous API, async array API (`Task<int>`), and async memory
|
||
API (`ValueTask<int>`) all use 64 KiB requests with a nonzero destination offset.
|
||
|
||
Each version/scenario runs in a fresh process with two warmups and seven measured
|
||
extractions. Version order alternates between scenarios. Release `net10.0` runs
|
||
disable tiered compilation. No builds or tests compete with the timed runs.
|
||
Measurements use .NET 10.0.13 on Apple M4/macOS 27.0.1 ARM64.
|
||
|
||
Allocation figures are exact process-wide `GC.GetTotalAllocatedBytes(true)`
|
||
deltas around extraction **and disposal**. They include the caller buffer and
|
||
stream construction, but exclude compressed-input preloading, process startup,
|
||
and between-iteration logging. They are allocation counts, not live memory or
|
||
peak working set. The pooled decoder still needs an 8 MiB dictionary and an
|
||
8 KiB BCJ buffer while decoding.
|
||
|
||
### Isolated executable filters
|
||
|
||
Each of the six filters separately decodes 1 MiB of repeated architecture-specific
|
||
encoded instructions, without LZMA2 or XZ checksum work. Request sizes are 1, 16,
|
||
and 10,240 bytes, using sync and both async read overloads. Input setup is excluded;
|
||
the destination buffer and filter lifetime are included. These artificial inputs
|
||
isolate wrapper/transform costs and do not represent typical executable files.
|
||
|
||
### Sampling traces
|
||
|
||
Separate traces use `dotnet-trace` 10.0.750501 with `dotnet-sampled-thread-time`, CLR GC
|
||
allocation ticks, and custom measured-phase markers. XZ traces contain three
|
||
extractions after two warmups. Each isolated-filter trace contains 10,000
|
||
iterations after warmup. TraceEvent 3.1.21 performs analysis; startup and warmup
|
||
events are excluded using the phase markers.
|
||
|
||
Stack shares are sampled managed thread time, not exact CPU instruction counts.
|
||
The report separates stacks containing SharpCompress work from unrelated runtime
|
||
background stacks. File-backed stacks can include waits/safepoints, and inlining
|
||
can attribute helper costs to their caller. Allocation ticks provide sampled site
|
||
attribution; the untraced exact allocation deltas are the authoritative totals.
|
||
|
||
## Fresh results
|
||
|
||
Medians per complete **32 MiB** XZ extraction, including construction and disposal:
|
||
|
||
| Input / API | Baseline ms | Pooled ms | Baseline allocated bytes | Pooled allocated bytes |
|
||
| --- | ---: | ---: | ---: | ---: |
|
||
| Memory / sync | 943.8 | 869.3 | 8,482,480 | 69,584 |
|
||
| Memory / async array | 6,806.8 | 6,718.0 | 8,952,504 | 523,184 |
|
||
| Memory / async memory | 6,607.1 | 6,770.7 | 8,952,504 | 523,184 |
|
||
| File / sync | 1,128.0 | 898.2 | 8,486,704 | 74,096 |
|
||
| File / async array | 7,892.0 | 7,885.2 | 15,116,192 | 8,695,936 |
|
||
| File / async memory | 7,899.9 | 17,081.7 | 14,392,056 | 10,287,064 |
|
||
|
||
The memory-backed comparison saves approximately **8 MiB per extraction**:
|
||
**99.2% of warm sync allocations** and **94.2% of warm async allocations**.
|
||
Most of this comes from returning the existing pooled LZMA2 dictionary; the BCJ
|
||
buffer alone accounts for only about 8 KiB. Pooling the BCJ buffer without fixing
|
||
filter-chain disposal would not return either lease reliably.
|
||
|
||
This is **not evidence of a general throughput improvement**. Memory-backed
|
||
async process CPU medians were 6,508 ms before versus 6,520 ms after for the
|
||
array API, and 6,466 ms versus 6,518 ms for the memory API. The file/memory-async
|
||
case was substantially slower after the change in this run (process CPU medians
|
||
7,748 ms versus 10,064 ms, with additional elapsed waiting time). That result is
|
||
retained, not discarded. File-backed scheduling, suspension allocations, warm
|
||
pool behavior, and machine state can vary; the smaller file allocation reductions
|
||
should not be treated as precise attribution to a specific buffer. A separate
|
||
repeatability/async-I/O investigation is needed before claiming file throughput
|
||
benefits or explaining that slowdown.
|
||
|
||
### All six isolated filters
|
||
|
||
Synchronous 1 MiB drains with **10,240-byte reads**, including the destination
|
||
buffer and construction/disposal:
|
||
|
||
| Filter | Baseline ms | Pooled ms | Baseline allocated bytes | Pooled allocated bytes |
|
||
| --- | ---: | ---: | ---: | ---: |
|
||
| x86 | 0.387 | 0.388 | 19,064 | 10,528 |
|
||
| ARM | 0.614 | 0.607 | 18,992 | 11,128 |
|
||
| ARM-Thumb | 0.422 | 0.427 | 18,992 | 11,128 |
|
||
| PowerPC | 0.751 | 0.721 | 18,992 | 11,128 |
|
||
| SPARC | 0.763 | 0.817 | 18,992 | 11,128 |
|
||
| IA-64 | 0.325 | 0.328 | 18,992 | 11,128 |
|
||
|
||
The async memory API similarly saves about 8 KiB across all six architectures;
|
||
the non-x86 cases allocate roughly 19,064 bytes before versus 11,200 bytes after
|
||
(SPARC's baseline median was 18,776). Their pooled elapsed medians range from
|
||
0.327 ms for IA-64 to 0.798 ms for SPARC. The async array path still allocates
|
||
roughly 26 KiB per drain because its non-cached integer results require tasks.
|
||
Full per-architecture/per-overload results and all seven samples are in
|
||
`results.json`; these short microbenchmarks do not establish a CPU speedup.
|
||
|
||
### Small-read overhead remains
|
||
|
||
Isolated x86, **16-byte requests**, again per 1 MiB:
|
||
|
||
| API | Baseline allocated bytes | Pooled allocated bytes | Baseline ms | Pooled ms |
|
||
| --- | ---: | ---: | ---: | ---: |
|
||
| Sync | 8,504 | 304 | 0.660 | 0.743 |
|
||
| Async array | 9,446,240 | 9,438,376 | 1.709 | 1.796 |
|
||
| Async memory | 9,056 | 1,192 | 3.437 | 3.436 |
|
||
|
||
The array API's approximately 9 MiB remains after pooling: each 16-byte result
|
||
is outside the runtime's small integer task cache and passes through multiple
|
||
`Task<int>` wrappers. By contrast, 1-byte results can use cached tasks. The memory
|
||
path has low allocations but substantial call/state-machine overhead for tiny
|
||
reads; low allocations do not imply it is faster than the array path.
|
||
|
||
### CPU and sampled allocation attribution
|
||
|
||
All **18 traces** contain one complete measured phase and report **zero lost
|
||
events**. The pooled memory-backed sync trace attributes 82.6% of workload leaf
|
||
samples to `Decoder.CodeFastCore`, 8.8% to CRC64, 4.4% to the fast matched-literal
|
||
helper, and 2.1% to x86 BCJ conversion. The corresponding baseline shares are
|
||
83.2%, 8.4%, 4.8%, and 2.0%: pooling does not move the main CPU bottleneck.
|
||
|
||
The pooled memory-backed async trace instead attributes 30.6% of workload leaf
|
||
samples to literal `DecodeNormalAsync`, 16.7% to `BitDecoder.DecodeAsync`, 12.6%
|
||
to async builder `Start`, and 11.3% to `Normalize2Async`. These per-symbol async
|
||
operations dominate despite the input being in memory. The corresponding
|
||
baseline distribution is similar. There is no optimized `CodeFastCore` hotspot
|
||
on this path.
|
||
|
||
The isolated pooled-filter traces attribute 88.4–94.5% of workload leaf samples
|
||
to the architecture's converter (x86 91.5%, ARM 93.6%, ARM-Thumb 92.4%, PowerPC
|
||
94.4%, SPARC 94.5%, IA-64 88.4%). Buffer copying accounts for roughly 4–8%.
|
||
These are instruction-heavy synthetic inputs; their rankings are not a
|
||
cross-architecture throughput prediction for real executables.
|
||
|
||
Baseline memory-backed XZ allocation ticks attribute approximately 24 MiB across
|
||
three measured extractions to dictionary arrays at `OutWindow.EnsureCapacity`.
|
||
The pooled memory-backed measured phases contain no sampled allocations at that
|
||
site; remaining async samples principally identify `Task<int>` results in the
|
||
read chain. All six pooled isolated-filter traces similarly contain no sampled
|
||
recurring BCJ-buffer allocations at `SetBaseStream`; their output buffers are
|
||
still allocated by the harness. Per-site sampling weights are approximate and
|
||
must not be substituted for the exact allocation table above.
|
||
|
||
File-backed traces still contain dictionary pool misses and suspended async
|
||
state-machine allocations. Most file-backed thread-time leaf samples are in
|
||
`Monitor.Enter_Slowpath`; these sampled stacks include waits/safepoints and do
|
||
**not** demonstrate that a lock consumes 98% of instruction CPU time. They do
|
||
not explain the adverse elapsed result by themselves, nor justify a lock rewrite.
|
||
|
||
## Remaining opportunities
|
||
|
||
These describe the profiled pooling baseline; completed follow-ups are recorded
|
||
separately below rather than rewriting the historical measurements.
|
||
|
||
- Async LZMA2 currently calls the per-symbol async decoder, whereas sync decoding
|
||
can use the optimized range-decoder path. A future experiment should separate
|
||
asynchronous input refills from CPU-only decoding. It must not replace async I/O
|
||
with blocking reads, weaken cancellation, overread a chunk, or bypass corruption
|
||
checks. This evaluation does not make that change.
|
||
- Small async array reads can allocate a `Task<int>` at multiple wrapper layers
|
||
for a non-cached read result. Pooling an 8 KiB buffer cannot remove that cost.
|
||
Pure delegation and a ValueTask-based internal read chain are separate candidates,
|
||
with exception/cancellation semantics requiring regression coverage.
|
||
- Calling the memory overload on `XZStream` does not make its whole chain
|
||
allocation-free: the stream still uses its array-based async implementation.
|
||
The isolated BCJ memory path and complete XZ memory path must be measured
|
||
separately rather than assuming the overload name guarantees equivalent costs.
|
||
- Checksum lookup tables are shared, not per-block scratch allocations. There is
|
||
no evidence here to justify pooling them. Checksum work must remain enabled in
|
||
future throughput comparisons.
|
||
|
||
## Follow-up: remove duplicate BCJ result tasks
|
||
|
||
On `perf/xz-async-decoding`, the first follow-up keeps the internal BCJ read as
|
||
a `ValueTask<int>` and creates a task only at the existing public array boundary.
|
||
Already-buffered reads complete without entering an async state machine. Custom
|
||
subclasses installing a synchronously throwing input stream retain the previous
|
||
faulted/canceled-task behavior; sync signatures are unchanged.
|
||
|
||
A fresh before/after comparison against the pooled baseline uses the same isolated
|
||
1 MiB x86 workload, 16-byte array reads, two warmups, seven measured drains,
|
||
Release .NET 10, and disabled tiered compilation. Median allocations fall from
|
||
**9,438,376 to 4,719,784 bytes**; median elapsed time is **1.695 to 0.963 ms**.
|
||
This is a small-read wrapper result, not a complete XZ throughput claim. Logs and
|
||
the follow-up harness are in the parent artifact directory as `fix1-before.log`,
|
||
`fix1-after.log`, and `fix1-harness/`.
|
||
|
||
All **728 targeted Release tests** pass, including allocation guards for all six
|
||
architectures and fault/cancellation regressions. All six library targets build,
|
||
and net48 test compilation passes. The remaining public `Task<int>` result cannot
|
||
be removed without changing that API; a native memory-based XZ chain remains a
|
||
separate follow-up.
|
||
|
||
## Follow-up: native XZ memory read chain
|
||
|
||
The next change adds native memory/ValueTask reads to `XZStream` and `XZBlock`
|
||
on modern targets, carries memory-based I/O through BCJ refills, and completes
|
||
already-buffered reads without traversing multiple async wrappers. All buffered
|
||
bytes still update the block check exactly once. Array API signatures and sync
|
||
array-based I/O remain unchanged. Native/custom destination memory works with
|
||
all four XZ check types; SHA-256 uses bounded pooled scratch when required.
|
||
|
||
A fresh comparison against the first follow-up (`de7a5d9`) uses the built-in
|
||
1 MiB x86+LZMA2 fixture, 16-byte memory reads at offset 5, two warmups, seven
|
||
measured complete drains, Release .NET 10, and disabled tiered compilation.
|
||
Median allocations fall from **14,180,048 to 15,056 bytes**, with elapsed medians
|
||
**22.399 to 19.422 ms**. Both builds retain checksum verification and return the
|
||
expected output length. This does not yet address the async range-decoder CPU
|
||
bottleneck or establish file-backed throughput improvements.
|
||
|
||
Artifacts are `fix2-before.log`, `fix2-after.log`, and `fix2-harness/` in the parent
|
||
directory. The harness's before/after DLLs were explicitly bound and hash-checked
|
||
after observing that MSBuild reused an earlier reference when switching HintPath
|
||
properties; `fix2-harness/comparison.json` records the actual measured hashes.
|
||
|
||
All **764 targeted Release tests** pass, including yielding async-only input,
|
||
seekable/forward-only streams, native/custom memory, mixed async overloads,
|
||
bounded allocations, empty/canceled reads, and checksum corruption. All six
|
||
library targets and net48 test compilation pass. The issue sample still matches
|
||
liblzma in all 24 read-size/overload/offset combinations.
|
||
|
||
## Follow-up: bounded async LZMA2 fast decoding
|
||
|
||
The third follow-up changes modern-target LZMA2 async decoding, not raw LZMA or
|
||
legacy-target decoding. Each compressed LZMA2 chunk declares at most 64 KiB of
|
||
compressed data. That payload is filled asynchronously into the range decoder's
|
||
existing pooled fast-input lease. The existing optimized decoder then performs
|
||
CPU-only work on that buffer; it never makes a synchronous call to the supplied
|
||
input stream. There is no second request-sized compressed-input allocation.
|
||
|
||
CPU-only batches are capped at **16 KiB of output between cancellation checks**,
|
||
even when the caller asks for much more. The fast probability bank is reused
|
||
across continuation chunks and reinitialized on property/state resets. Scalar
|
||
models are not also allocated for that path. The decoder still validates LZMA2
|
||
compressed/uncompressed counts, end markers, pending output, and outer XZ checks.
|
||
|
||
Input refills are bounded by the declared payload, not the rented array's
|
||
capacity. Incomplete fills cannot enter unsafe decoding; truncation retains the
|
||
`DataErrorException` category, and disposal returns partially filled leases.
|
||
Cancellation after a complete payload fill can resume without losing buffered
|
||
data. Tests exercise yielding async-only non-seekable input, one-byte/short reads,
|
||
following-chunk sentinels, malformed/truncated payloads, retries, oversized
|
||
poisoned rents, cancellation, model reuse, and disposal.
|
||
|
||
A fresh comparison against `31f3476` reuses the complete **32 MiB** mixed XZ
|
||
workload and 64 KiB native-memory requests. Each version/input runs in a fresh
|
||
process with two warmups and seven measured complete drains; version order is
|
||
reversed for the file scenario. Release .NET 10 uses disabled tiered compilation.
|
||
No builds or tests compete with these measurements.
|
||
|
||
| Input / memory-async API | Before ms | After ms | Before process CPU ms | After process CPU ms | Before allocated bytes | After allocated bytes |
|
||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||
| Memory | 5,963.0 | 769.2 | 5,961.2 | 769.2 | 80,672 | 72,600 |
|
||
| File | 6,812.9 | 768.7 | 6,932.0 | 780.6 | 6,195,096 | 3,721,104 |
|
||
|
||
This workload's elapsed medians improve by approximately **7.8x memory-backed**
|
||
and **8.9x file-backed**; process CPU reductions support the decoding explanation,
|
||
not merely reduced waiting. These results do not establish a speedup for raw
|
||
LZMA, legacy targets, other data distributions, or cold storage. File allocations
|
||
still include suspension costs and pool misses. All original checksum work remains.
|
||
|
||
Artifacts and hash-checked before/after DLLs are in `fix3-harness/` in the parent
|
||
directory. Its `evaluate.py`, `results.json`, `library-hashes.json`, and raw logs
|
||
record the comparison. Both versions decode to the same byte count; the updated
|
||
build additionally passes full SHA-256 verification of this workload through all
|
||
three read APIs and all 24 byte-for-byte issue-sample combinations.
|
||
|
||
All **969 targeted Release tests**, including 7z, pass. All six library targets
|
||
and net48 test compilation pass; all 84 benchmark cases still smoke-test cleanly.
|
||
The final complete **.NET 10 Release suite passes: 3,629 tests, no failures or skips**.
|
||
|
||
A final measured-phase async memory trace confirms the intended path change:
|
||
**81.4%** of workload leaf samples are in `CodeFastCore`, **8.2%** in CRC64,
|
||
and **5.5%** in the fast matched-literal helper. Per-bit async decoder helpers no
|
||
longer dominate. This trace contains 1,478 workload samples, one complete measured
|
||
phase, and zero lost events. Its raw trace, Speedscope file, and analysis JSON are
|
||
`fix3-harness/after-memory.*` / `after-memory-analysis.json`. Sampling limitations
|
||
remain the same as in the original evaluation.
|
||
|
||
The checksum tables remain shared and unchanged: the profiling report identified
|
||
no per-block table-allocation problem to fix.
|
||
|
||
## Artifacts and reproduction
|
||
|
||
The controlled comparison is isolated from preliminary sample-file measurements:
|
||
|
||
```text
|
||
/private/var/folders/6c/j56r6kqn5yj7q1_c6vm5c0t80000gn/T/opencode/xz-bcj-profile/evaluate/
|
||
```
|
||
|
||
This directory contains `prepare.py`, `run.py`, `trace.py`, the shared harness
|
||
source, separate baseline/pooled source exports and harness builds, library
|
||
hashes, the evaluated library diff, repository state, fixture generator and
|
||
hashes, raw timing logs, JSON results, traces, Speedscope files, and analyzer
|
||
source. Preparation and runs do not change the repository checkout.
|
||
|
||
```sh
|
||
python3 prepare.py
|
||
python3 run.py
|
||
python3 trace.py
|
||
```
|
||
|
||
`prepare.py` anchors its source comparison to `0aaf9b92`, builds both libraries
|
||
and harnesses, and creates the fixture. `run.py` performs the sequential untraced
|
||
comparison. `trace.py` separately rebuilds the harness/analyzer before collecting
|
||
traces; do not run it concurrently with measurements. Open `.speedscope.json`
|
||
files in <https://www.speedscope.app/>. Filenames identify version, filter/API,
|
||
input source, and read size. Raw Speedscope files include warmup; the analysis JSON
|
||
uses only the measured phase.
|
||
|
||
`XzBcjBenchmarks` provides repeatable repository benchmarks with built-in inputs:
|
||
|
||
```sh
|
||
dotnet run --project tests/SharpCompress.Performance -c Release -- --filter '*XzBcjBenchmarks*'
|
||
```
|
||
|
||
These BenchmarkDotNet cases are not the controlled before/after harness above.
|
||
They cover all six isolated filters and a built-in 1 MiB x86+LZMA2 XZ fixture, with
|
||
1-byte, 16-byte, 10,240-byte, and 1 MiB requests. Set
|
||
`SHARPCOMPRESS_XZ_PROFILE_FILE` to substitute a local `.xz` file for the XZ cases;
|
||
isolated-filter cases are unchanged.
|
||
|
||
## Validation
|
||
|
||
- The complete **.NET 10 Release suite passes: 3,571 tests, no failures or skips**.
|
||
- All **718 targeted XZ, branch-converter, LZMA, and Tar tests** pass on .NET 10.
|
||
- New lifetime tests verify oversized poisoned rents, exact-once return, reads
|
||
after disposal, cancellation/read failures, early disposal, block completion,
|
||
corrupt checksums, and caller-owned input preservation. Repeated filter
|
||
construction has a bounded-allocation regression test.
|
||
- The issue #1471 `7z2605-linux-x64.tar.xz` sample matches liblzma byte-for-byte in
|
||
all **24** combinations of sync/async overload, request size, and destination
|
||
offset. All 6,958,080 decoded bytes and the XZ checksum match.
|
||
- Both isolated comparison builds decode the complete synthetic workload to the
|
||
expected SHA-256 through sync and both async read overloads.
|
||
- All **84 repository benchmark cases** were smoke-tested for output lengths and
|
||
XZ checks. These smoke runs are not the before/after timing measurements.
|
||
- All six library target frameworks build without warnings; net48 tests compile.
|
||
net48 tests were not executed on macOS.
|