Skip to content

pre-size chunk buffer to avoid realloc fragmentation; fix nbit/scaleoffset no-op return values#6389

Open
brtnfld wants to merge 10 commits into
HDFGroup:developfrom
brtnfld:4481
Open

pre-size chunk buffer to avoid realloc fragmentation; fix nbit/scaleoffset no-op return values#6389
brtnfld wants to merge 10 commits into
HDFGroup:developfrom
brtnfld:4481

Conversation

@brtnfld

@brtnfld brtnfld commented May 1, 2026

Copy link
Copy Markdown
Collaborator
  1. Pre-size chunk buffer before the filter pipeline (H5Dchunk.c)

When reading a filtered chunk, the decompression buffer was initialized at the compressed on-disk size. Filters like deflate grow this buffer via repeated realloc() calls, doubling its size when the output exceeds capacity. On Windows, this fragments the heap and causes read times to increase steadily across successive iterations.

The fix pre-sizes the buffer to the full uncompressed chunk_size when a filter pipeline is active, so the output buffer is large enough from the start and no realloc is needed.

Fixes #4481 and Fixes #4513.

  1. Return nbytes instead of *buf_size in nbit and scaleoffset no-op paths (H5Znbit.c, H5Zscaleoffset.c)

Per the HDF5 filter API, nbytes is the count of valid data bytes in the buffer; *buf_size is the allocated buffer capacity. These two values were historically always equal on the read path, so either worked in practice. The pre-sizing change above makes *buf_size > nbytes on compressed reads for the first time, exposing two latent bugs:

H5Znbit.c: the no-compress pass-through path (cd_values[1] == 1) returned *buf_size instead of nbytes. The downstream filter (e.g., Fletcher32) then treated the full pre-sized buffer as valid input and computed a checksum over uninitialized bytes, causing filter pipeline failures.
H5Zscaleoffset.c: the no-process path (when the data already spans the full bit range and no scaling is needed) had the same *buf_size vs nbytes mistake. No current test exercises this path through a multi-filter pipeline where the size mismatch would be observable, but the fix is correct by the same API reasoning.

@github-project-automation github-project-automation Bot moved this to To be triaged in HDF5 - TRIAGE & TRACK May 1, 2026
@brtnfld brtnfld marked this pull request as draft May 1, 2026 17:24
@brtnfld brtnfld added the Component - C Library Core C library issues (usually in the src directory) label May 1, 2026
@brtnfld

brtnfld commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

I will need to do a PR to netcdf for a fix.

Comment thread src/H5Dchunk.c Outdated
* This avoids repeated realloc() inside the filter pipeline (e.g., deflate's
* doubling strategy when the output buffer fills), which on Windows fragments
* the heap and causes read time to increase steadily across iterations. */
if ((old_pline && old_pline->nused) && buf_alloc < chunk_size)

@fortnern fortnern May 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure you need the first condition - you can probably assert that chunk_nbytes == chunk_size if there are no filters (I think it was checked earlier):

if (buf_alloc < chunk_size) {
    assert(old_pline && old_pline->nused);
    buf_alloc = chunk_size;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revised it.

@brtnfld brtnfld force-pushed the 4481 branch 2 times, most recently from fb096a0 to 6ab8428 Compare May 1, 2026 19:26
@brtnfld brtnfld marked this pull request as ready for review May 1, 2026 21:37
fortnern
fortnern previously approved these changes May 1, 2026
brtnfld added 2 commits May 8, 2026 08:56
…Windows

When reading a filtered (compressed) chunk, filters like deflate initialize
their output buffer using *buf_size (the pipeline's buffer capacity hint).
With *buf_size set to chunk_disk_size (the compressed size), deflate grows
its output buffer via repeated realloc() doubling. On Windows this fragments
the heap and causes read times to increase steadily across iterations.

Set buf_alloc to MAX(chunk_disk_size, chunk_size) immediately before calling
H5Z_pipeline so filters pre-allocate their output at the full uncompressed
size and avoid realloc. The inbuf is still allocated at chunk_disk_size —
only the hint passed to the pipeline is enlarged — so there is no
double-large-allocation overhead and peak memory stays at
chunk_disk_size + chunk_size rather than 2 * chunk_size.
For incompressible chunks where chunk_disk_size > chunk_size the hint
remains at chunk_disk_size, satisfying H5Z_pipeline's post-filter size
validation.

Fixes HDFGroup#4481 and HDFGroup#4513.
… paths

Per the HDF5 filter API, nbytes is the count of valid data bytes in the
buffer, while *buf_size is the allocated buffer capacity. These two values
were historically always equal on the read path, so either worked in
practice. The pre-sizing change (which sets buf_alloc = chunk_size before
the filter pipeline) makes *buf_size > nbytes for compressed reads,
exposing the latent bug.

In H5Znbit.c: the no-compress pass-through (cd_values[1] == 1) was
returning *buf_size, causing the next filter (e.g. fletcher32) to treat
the full pre-sized buffer as valid data and compute a checksum over
uninitialised bytes.

In H5Zscaleoffset.c: the no-process path had the same pattern. No current
test exercises this path through a multi-filter pipeline where the size
mismatch would be observable, but the fix is correct by the same API
reasoning.
brtnfld and others added 3 commits May 8, 2026 10:33
Instead of allocating a fresh output buffer (H5MM_malloc), copy the
compressed input to a small temp buffer and resize *buf in-place via
H5resize_memory. On Windows, HeapReAlloc can often extend an existing
heap block without moving it, avoiding the cost of finding and committing
a fresh large allocation for every chunk read.

This is combined with the pre-sizing hint in H5Dchunk.c that sets
buf_alloc = chunk_size before the pipeline call, so the resize goes
directly to chunk_size in one step with no realloc loop.
…pacity

Replace the hint-only buf_alloc enlargement with an actual
H5D__chunk_mem_realloc() before calling H5Z_pipeline.  Every filter now
sees *buf_size equal to the real buffer capacity, not just an advisory
hint.  This fixes a bounds-check regression in H5Zscaleoffset where the
read path uses *buf_size as the end-of-buffer sentinel in
H5_IS_BUFFER_OVERFLOW and as the input-size argument to
H5Z__scaleoffset_decompress; an inflated hint caused false-negative
overflow checks and potential over-reads when scaleoffset is the on-disk
filter.

H5Zdeflate is simplified accordingly: the up-front H5resize_memory(*buf,
nalloc) is removed since the buffer is already at nalloc on entry.  The
inbuf copy is retained (still needed to read compressed input while
writing uncompressed output into the same buffer), as is the
realloc-doubling loop for the uncommon case where output exceeds
chunk_size.

Also add test/chunk_deflate_perf.c, a standalone benchmark that times
per-chunk deflate reads over multiple passes to detect the steady read-time
increase caused by heap fragmentation on Windows (issues HDFGroup#4481 / HDFGroup#4513).
brtnfld added 5 commits May 8, 2026 11:08
Add optional 4th argument to specify the output HDF5 file path
(default: chunk_deflate_perf.h5 in CWD).  Allows running develop
and fix builds against separate files so pass 1 is cold-cache for
both and the two runs don't share page-cache state.
Not suitable for the test suite; intended for manual Windows validation
only.  Keep locally if needed.
…pacity

Allocate the chunk read buffer at MAX(chunk_disk_size, chunk_size) from
the start rather than allocating at chunk_disk_size and immediately
reallocating.  The read only fills chunk_disk_size bytes regardless of
buffer size, so there is no cost to the larger initial allocation and the
separate realloc step is eliminated.

Every filter now receives *buf_size equal to the actual buffer capacity
with no additional allocation needed.  On Windows a single HeapAlloc at
the correct size avoids the repeated realloc-doubling in the deflate
filter that fragments the heap and causes read times to increase over
successive iterations (issues HDFGroup#4481 / HDFGroup#4513).  Also fixes the
scaleoffset bounds-check regression where *buf_size was used as an
end-of-buffer sentinel.
Reverts the deflate in-place decompression experiment and the H5Dchunk.c
pre-sizing changes back to the state at 9ab5e19, keeping the nbit
and scaleoffset no-op path fixes.
…ipeline

The previous approach allocated the buffer at chunk_disk_size and then
bumped buf_alloc to chunk_size as a hint to the pipeline, causing *buf_size
to misrepresent the actual allocation.  Any filter that writes up to
*buf_size bytes into *buf would overflow.

Allocate at MAX(chunk_disk_size, chunk_size) upfront so the buffer and
the hint given to filters are always consistent.  For incompressible chunks
where chunk_disk_size >= chunk_size the allocation is unchanged.
@ajelenak ajelenak added this to the HDF5 2.2.0 milestone Jun 2, 2026
@lrknox lrknox removed their request for review June 11, 2026 21:21
@github-actions github-actions Bot added the stale label Jun 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has had no activity for 30 days and has been marked stale. Push a commit or comment to keep it open, or it will be flagged for maintainer review.

@github-actions

Copy link
Copy Markdown
Contributor

Review Checklist

This PR touches the following areas. Each needs a sign-off
from its listed owners before merging.

✅ All areas have been signed off.

@brtnfld

brtnfld commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

Will benchmark on Windows before merging

@github-actions github-actions Bot removed the stale label Jun 26, 2026
@brtnfld

brtnfld commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Windows benchmark: PR #6389 chunk-buffer pre-sizing fix

Setup: Windows 11, Intel oneAPI 2026.1 — icx compiled the HDF5 C library under test (Release, CMake+Ninja, zlib fetched via FetchContent); icx-cl compiled the C++ benchmark harness. HDF5 built twice from git worktrees:

Mechanism, verified at the source level: H5Z_filter_deflate in H5Zdeflate.c starts its internal nalloc at *buf_size (the buf_alloc computed in H5D__chunk_lock()) and only doubles+reallocs if that runs out mid-inflate(). Pre-fix, buf_alloc = chunk_disk_size (the small on-disk compressed size), so the loop grows repeatedly. Post-fix, buf_alloc = MAX(chunk_disk_size, chunk_size), so it's already sufficient and the loop never reallocates.

Benchmark: Mirrors issue #4481's repro shape — each iteration alternates between a full read of dataset A and a strided (stride-2) hyperslab read of a differently-chunked dataset B, both deflate-compressed with highly compressible (~constant) data so chunk_disk_size ≪ chunk_size:

Dataset Chunks Chunk shape Access pattern
A 30,000 8×8 doubles (512B) full read
B 20,000 12×12 doubles (1152B) stride-2 hyperslab (10,000 chunks touched)

Run sequentially, 1000 iterations each (open file → read A → read B → close, per iteration):

Baseline (pre-fix) Fix (PR #6389) Improvement
Avg A+B time/iter (all 1000 iters) 0.2581 s 0.2039 s 21.0% faster
Avg A+B time/iter (steady-state, iters 50–999) 0.2581 s 0.2029 s 21.4% faster
Max single iteration 0.3507 s 0.2562 s
Steady-state working set ~55.5 MiB ~176.3 MiB (see note)

Prior, simpler benchmarks (single dataset, few large chunks) showed a similar, consistent effect at smaller scale:

Scale Chunks Dataset Iterations Speedup
Small 64 × 512×512 128 MB 300 27.5%
Larger 128 × 512×512 256 MB 600 25.9%

~21–28% faster chunked reads, consistently, across every scale tested — directly attributable to PR #6389 pre-sizing the chunk buffer to the full uncompressed chunk_size up front in H5D__chunk_lock().

Memory note (hypothesis, not confirmed): the fix's process working set ramps from ~47 MiB to a steady ~176 MiB within the first ~50 iterations, then stays flat through iteration 1000 (not a leak — a one-time plateau, confirmed by sampling working set at 100-iteration intervals across the full run). A plausible explanation is Windows' Low-Fragmentation Heap growing a dedicated bucket in response to the fix's steady, fixed-size allocation pattern, while baseline's varying realloc-growth sizes may not trigger the same bucket promotion — but this wasn't verified with a heap profiler, so treat it as a working theory rather than a demonstrated mechanism. Real trade-off either way: worth being aware of on memory-constrained Windows deployments, though the speed win is the clear, verified headline.

I did not reproduce the original issue's escalating multi-second slowdown across iterations (#4481/#4513 saw read time climb from 17s→22s over 30 iterations on datasets orders of magnitude larger) — regression slope restricted to the steady-state window (iters 50–999) shows both variants drifting <2% of their average, i.e. essentially flat/noise. That escalating effect likely needs a much larger, longer-running dataset to visibly fragment the heap to the point of measurably slower allocation, rather than just a larger steady-state reservation.

@brtnfld

brtnfld commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Attempted reproduction of #4481/#4513's escalating-slowdown symptom

Following up on the benchmark results above: I attempted a dedicated reproduction of the specific symptom reported in #4481 (read time climbing over successive iterations, 17s→22s over 30 iterations) — not just the general throughput improvement.

Methodology: scaled a synthetic two-dataset benchmark to match the original repro's actual regime as closely as possible without the original files (hosted externally, inaccessible):

  • Chunk sizes matched to the original's chunk dimensions: ~16KB (20×10×10 doubles) and ~550KB (70×20×50 doubles) — spanning both the LFH small-block and large-block/VirtualAlloc Windows heap regimes.
  • Same structural pattern: full read of one dataset + stride-2 hyperslab read of a second, fresh file open/close every iteration.
  • Ran 500 iterations (~11M total chunk allocate/free cycles), exceeding the original repro's estimated ~8.1M cycles (30 iterations × ~270K chunks/iteration).

Result: no escalation in either variant.

Baseline (pre-fix) Fix (PR #6389)
Average 3.7831 s 3.5181 s (7% faster)
Full-run drift (regression slope × 500 iters) 0.7% (noise) 0.2% (noise)
Working-set growth over 500 iters +1 MB +26 MB
10×50-iteration windowed trend oscillates 3.75–3.82s, no direction flat, 3.51–3.53s band

An initial smaller run (150 iterations, ~3.3M cycles) showed a modest baseline drift (+3.7%) that looked like a promising signal — but it did not hold up or compound at the larger scale; it was noise from an undersized sample.

Conclusion: PR #6389 gives a consistent, real speedup here too (7%, in line with the 21–28% seen at other chunk sizes elsewhere in this thread), but I was not able to reproduce the specific escalating-degradation symptom from #4481/#4513, even after scaling chunk sizes and total cycle count to match or exceed the original report's regime. Plausible reasons this doesn't rule out the bug being real and fixed:

  • Our "pre-fix" baseline is current develop minus only this PR's 3-file diff — not literally HDF5 1.10.11 (the version in the original report). Years of unrelated changes sit between them.
  • Windows' heap manager itself has changed since 2024; the exact fragmentation pattern reported may not manifest identically on current Windows builds.
  • We don't have the reporter's actual files, only a size-matched synthetic approximation.

Net assessment: the fix is well-motivated (the realloc/copy churn it removes is real, confirmed at the source level in H5Zdeflate.c and the other filters) and delivers a consistent, verified performance improvement across every scale tested. Whether it specifically resolves the reported escalating-slowdown behavior remains empirically unconfirmed on my end — I'd frame the "Fixes #4481/#4513" claim as well-reasoned but not reproduced-and-verified, unless someone can test against the original files or a closer-matched environment.

@brtnfld

brtnfld commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Faithful reproduction of #4481's exact repro code — escalation confirmed and measurably reduced

Following up further: I rewrote the benchmark to use the issue's actual repro code (not just a size-matched synthetic approximation) — unlimDsetRead()/hypersladDsetRead() copied near-verbatim, including details a synthetic rewrite easily loses:

  • Reading H5T_NATIVE_FLOAT from H5T_NATIVE_DOUBLE-stored data (triggers HDF5's type-conversion path)
  • A stride-{2,2,2} hyperslab (all three dimensions, not just one)
  • Dataset 1's unlimited first dimension (forces the Extensible Array chunk index, not Fixed Array)

Files were synthesized locally (same chunk shapes/dims as the repro: 20×10×10 and 70×20×50. 100 iterations against both baseline (pre-fix) and fix (PR #6389):

Baseline (pre-fix) Fix (PR #6389)
hypersladDsetRead rise (iter 0-4 avg → plateau) +22.6% (5.29s → 6.49s) +10.9% (4.25s → 4.71s)
unlimDsetRead rise +7.8% +5.1%
Windowed trend (10-iter blocks, hypersladDsetRead) 5.72, 6.21, 6.56, 6.33, 6.30, 6.43, 6.42, 6.48, 6.52, 6.47 — climbs then plateaus 4.34, 4.52, 4.58, 4.58, 4.61, 4.64, 4.66, 4.70, 4.72, 4.72 — climbs more gently, plateaus lower

This is a real reproduction of the reported effect (unlike an earlier, simpler synthetic benchmark that showed no escalation at all) — both variants climb early then plateau, consistent with a heap-fragmentation-style effect rather than noise. PR #6389 roughly halves the escalation but doesn't eliminate it.

Why it's reduced, not eliminated

This PR changed the size passed in, not the allocator. Every chunk-cache eviction during these reads is still a raw allocator round-trip. The fix removes the 4-9 reallocs-per-chunk it used to cost; it doesn't remove the fact that it's still raw malloc/free, still on the one allocator (Windows') where this apparently matters. That predicts exactly what we see: escalation reduced by roughly half, not eliminated.

Net assessment: PR #6389 is a real, verified, substantial improvement — both in raw throughput (21-28% faster reads across every benchmark scale) and in reducing (not eliminating) the specific escalating-slowdown behavior from #4481/#4513.

@brtnfld

brtnfld commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Full investigation summary: PR #6389, the escalating-slowdown symptom, and what's actually happening

This is a complete write-up of an extended investigation into whether PR #6389 resolves the specific behavior reported in #4481/#4513 (read time escalating over successive iterations), beyond the general performance benchmarks posted earlier in this thread.

1. Baseline performance win (high confidence, independently verified)

PR #6389's chunk-buffer pre-sizing gives a real, consistent 21-28% faster read throughput for compressed chunked datasets, verified across multiple scales and confirmed at the source level: H5Zdeflate.c's decompression loop starts its buffer at *buf_size and only grows it if insufficient — the fix makes it sufficient from the start, eliminating the realloc-doubling loop entirely. This holds regardless of anything below.

2. Reproducing the escalating-slowdown symptom specifically

A first synthetic benchmark (matched read/storage types, single-axis stride) showed no escalation in either baseline or fix — flat in both. It was missing key ingredients.

A faithful reproduction, using the issue's actual unlimDsetRead/hypersladDsetRead code (including H5T_NATIVE_FLOAT reads from H5T_NATIVE_DOUBLE data, and a 3D stride-{2,2,2} hyperslab), did reproduce real escalation:

Baseline (pre-fix) Fix (PR #6389 alone)
hypersladDsetRead (B) rise +22.6% +10.9%
unlimDsetRead (A) rise +7.8% +5.1%

PR #6389 roughly halves the escalation but doesn't eliminate it, in this repeated-H5Fopen/H5Fclose scenario matching the original repro's structure.

3. Isolating where the residual escalation actually lives

Ruled out two plausible culprits by reading the code: type-conversion buffers (H5Dio.c, uses H5FL_BLK_MALLOC — free-list) and the Extensible Array chunk index (H5EAhdr.c, uses H5FL_FAC_MALLOC — also free-list). Neither explained it.

An experimental patch extending the free list to filtered chunk buffers (not shippable — it breaks third-party filter plugins that manage their own buffers via raw malloc/realloc/free) gave only a small improvement (10.9%→9.4%), confirming raw allocation of filtered chunks is a factor but not the dominant one.

The decisive diagnostic: open the file once and read repeatedly, vs. reopening every iteration.

Scenario Baseline Fix (PR #6389 alone)
Reopen every iteration +22.6% / +7.8% +10.9% / +5.1% (residual)
File opened once +57.8% / +12.7% ~0% / ~0% (fully eliminated)

This proved PR #6389 fully resolves the chunk-read-path escalation — the residual only appears when repeatedly reopening, meaning it's tied to something that runs on every H5Fopen/H5Dopen, not on repeated chunk reads.

4. The real remaining driver: speculative metadata loads, same pattern, different subsystem

H5C__load_entry (H5Centry.c) implements a "speculative load" mechanism for metadata cache entries whose on-disk size isn't known upfront: read an initial guessed size, then H5MM_realloc (raw, not free-list) if the guess was wrong. This is architecturally the same bug pattern as the original chunk-buffer issue, just in the metadata cache (H5AC/H5C) instead of the chunk cache (H5D) — a subsystem PR #6389 never touches.

Two fixed, small initial-size guesses were found and tested by simply increasing them:

Constant Location Purpose Original
H5O_SPEC_READ_SIZE H5Opkg.h Object header initial read (every H5Dopen) 512 bytes
H5HL_SPEC_READ_SIZE H5HLcache.c Local heap initial read (link-name resolution) 512 bytes

Both are plausibly undersized for any dataset with real complexity (chunked + filtered + named + grouped, as ours are). Increasing both to 4096 bytes progressively closed the gap:

Configuration A rise B rise
Pure fix (PR #6389 only) +5.1% +10.9%
+ object header fix (512→4096) +0.8% +7.7%
+ local heap fix (512→4096) -3.0% (flat/noise) +5.0%

Dataset A (unlimited-dim, Extensible Array index) is now fully resolved. Dataset B (no unlimited dim, simpler structure, but larger ~547KB chunks and a 3D strided selection) continues to shrink with each fix but retains a smaller residual (~5%), suggesting at least one more small contributor exists, not yet identified — the pattern of roughly halving with each fix suggests diminishing but real remaining sources of the same kind.

Net assessment

  • PR pre-size chunk buffer to avoid realloc fragmentation; fix nbit/scaleoffset no-op return values #6389 is a real, substantial, verified fix, independently confirmed to (a) deliver 21-28% faster reads regardless of the escalation question, and (b) fully eliminate the escalating-slowdown behavior in the chunk-read path itself (proven decisively by the open-once test).
  • The residual escalation that survives in the reopen-every-iteration scenario is a second, distinct issue — the same "small fixed guess + raw realloc" pattern recurring independently in HDF5's metadata cache (object header and local heap loading), unrelated to filters or compression, affecting essentially any dataset opened repeatedly on Windows. This is out of scope for PR pre-size chunk buffer to avoid realloc fragmentation; fix nbit/scaleoffset no-op return values #6389 and would need its own issue/fix — likely either enlarging these speculative-read defaults or (better, but bigger) routing metadata cache image buffers through a free-list the way H5FL already does for other hot allocation paths in this codebase.
  • Both metadata-cache constant increases here were quick experimental verification changes, not vetted for correctness/compatibility (e.g., interaction with very small files, non-default page-buffering configs) — they're evidence of the mechanism, not a proposed patch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Component - C Library Core C library issues (usually in the src directory)

Projects

Status: To be triaged

4 participants