[DO NOT MERGE] Pending bug fixes, for review - #145
Draft
ayzk wants to merge 8 commits into
Draft
Conversation
…ings Bug fixes only. Nothing here changes the compressed format or an interface signature, and every algorithm's output is byte-identical to master's (verified over 31 dataset/algorithm/error-bound combinations, plus decompressing files master produced). From the open pull requests, reviewed line by line: #131 Config::load read one byte past the end of the config blob #132 bounds-check the whole decompression path (see the two skips below) #133 shift-by-64 UB for a single-symbol Huffman tree #134 bounds-check HuffmanEncoderV2 tree loading #135 bounds-check XtcBasedEncoder's magicInts index; plug a leaked buffer #137 remaining_length accounting after Huffman decode in both predictors #138 non-finite float to int64_t cast UB in LinearQuantizer #139 scratch buffers leaked when compression throws; OMP chunk capacity was missing room for the size header Lossless_zstd writes Two parts of #132 are deliberately not taken: - It moves the quant_inds count from after the encoder's tree to before it so the tree is immediately followed by its encoded stream. That is a compressed format change and it is not versioned, so files written by any released SZ3 fail to decode. The bound it was buying is recovered instead by letting the caller supply it: HuffmanEncoder::set_decode_bound(), called by SZGenericCompressor once it has consumed the count, and by both predictors. load() no longer guesses the bound from its own buffer -- the tree and the stream are not required to share a buffer, and test_encoder.cpp puts them in separate ones. - It reinterprets LosslessInterface::decompress's `dstLen` as an input capacity when the caller provides the buffer. The declared contract is that it is an output, and callers do pass uninitialised values, so this fails nondeterministically (it broke LosslessTest.LosslessBypass here). The self-allocating branch, where a non-zero value is opt-in, is kept. Also fixed while reviewing: - HuffmanEncoderV2 sized its dense tables from an unbounded `maxval` read straight from the stream; #134 bounds the node count but not this. - HuffmanEncoderV2's node count is an int holding a value read as 64-bit, so the new bound is checked with an explicit sign test rather than an implicit conversion. - test_lossless.cpp passed an uninitialised size to decompress(). From the fz branch, verified to reproduce here: Lossless_bypass::compress ignored its destination capacity, so any payload larger than the caller's buffer was an unconditional heap overflow; TimeSeriesDecomposition violated its error bound 1.94x on the null-reference-frame path; ArithmeticEncoder sign-extended a shift, corrupting about half of all streams; HuffmanEncoder's stateNum narrowing overflowed on a wide bin range; RunlengthEncoder and two decompositions had no size_est(), so the compressor sized its buffer from 0; KmeansUtil had reserve+operator[] UB and an off-by-one read; the HDF5 filter sized its buffer below SZ_compress's own minimum; 24 headers were not self-contained under libstdc++ or libc++; .gitignore's bare `test` pattern hid every test file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Taking the fz-branch fixes file by file overwrote them. The magicInts index and the packed-data size both come from the compressed stream and were used unchecked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
num_elements * sizeof(T1) can overflow on its own, which is the computation the check is supposed to guard. Matches the scalar overload and PR #132. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It bounds that buffer by SZ_compress_size_bound, which is the size of the *output* buffer. compress() sizes the internal one as max(1000, 2 * (decomposition.size_est() + encoder.size_est() + sizeof(Q) * bins)), so for a 64-bit bin type it is far larger and valid streams are rejected. Every module in this tree emits int bins, so the bound happens to hold and these tests cannot reach it -- the fz branch has three modules that do (BitplaneEncoder, BitTruncationQuantizer, FixedPointQuantizer) and all three failed. A corrupted declared size is still caught, after the allocation, by the zstd frame check and the size comparison this PR adds to Lossless_zstd::decompress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #132 rejects N outside [1, 4] and dimensions whose product differs from the element count. Both hold for a config read from the end of a compressed stream, and neither holds for the HDF5 filter's cd_values: those carry placeholder zeros that set_local fills in later, as cdvalueHelper.py says in as many words. The check killed the whole filter -- 80 of cesm-atm's 160 integration cases aborted with "invalid number of dimensions", across every algorithm. The single-argument overload, which is what the filter and the OpenMP path use, now says so explicitly and skips the content checks; the bounded overload used by SZ_decompress keeps them. Verified against the real cesm-atm field through the HDF5 filter: five algorithms pass, and removing the guard reproduces the abort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The filter has to size its output buffer from SZ_compress_size_bound rather than from the raw chunk size, and nothing in the suite exercised that: a chunk small enough for the compressed block to exceed it never appeared. This adds a third chunk mode that asks for 8-element chunks, which the pre-fix filter rejects with "buffer not large enough" on every algorithm. The mode is restricted to a leading slice of at most 4096 chunks. Over a whole field it would mean tens of millions of chunks, and the HDF5 chunk index alone takes the process past a CI runner's memory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four buffers this tree allocates itself were malloc'd and then handed to a unique_ptr with a free() deleter, which left a raw pointer alias in scope beside the owner and never checked the allocation. new uchar[] leaves the bytes uninitialized -- which is what these buffers want, they are written in full -- and throws instead of returning null. Where the code needs a raw begin pointer, it takes a const alias rather than calling get() at each use. SZGenericCompressor::decompress keeps the free() deleter: that buffer is allocated by the lossless layer, not here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`build` matched only that exact name, so `build-release` and similar were not ignored; anchoring it to the repo root and globbing covers them. `.cache`, `.claude` and Python bytecode were not ignored at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DO NOT MERGE — 应 @ayzk 要求,此 PR 仅供审阅,不要合并。
master 上先前的合并已被撤销(
4ac704f4),master 的内容与57ce9e90完全一致。本分支保留全部内容待逐项审阅。新增一个提交(
06db6065):本树自己分配的四处 scratch buffer 改为unique_ptr<uchar[]>。原先是malloc之后交给带free()deleter 的unique_ptr,留了一个裸指针别名在作用域里,且四处都没检查分配结果。new uchar[]对uchar是 default-initialize(不清零 —— 实测 1.12 GB 下make_unique<uchar[]>要 151 ms,new uchar[]0 ms),失败时抛而不是返回 null。需要裸起始指针的地方用const别名而不是到处.get()。SZGenericCompressor::decompress那处保留free()deleter,因为那块缓冲是 lossless 层分配的。Bug fixes only. Nothing here changes the compressed format or an interface signature, and every algorithm's output is byte-identical to
master's -- verified over 31 combinations of dataset (mirandavelocityx.d64float64, cesm-atmODV_bcar1float32, the in-tree smoke file), algorithm and error bound, plus decompressing filesmasteritself produced.Supersedes #143.
The open pull requests, reviewed line by line
Config::loadcomputed the end of the config blob asc + confSizeafterchad already advanced past the length prefix, one byte too far.save()includes the prefix inconfSize, so the end isc0 + confSize. The last optional-field guard then fires with one byte left and reads a field that is not there -- past the end of the buffer, since the config is the last thing in the stream.SZ_decompress(which previously ignored thecmpSizeit was given entirely), a boundedConfig::loadoverload withN/bit-width/dims-product validation,Lossless_zstd(including returning a zstd error code as a size), Huffman tree loading,unpad_treechild indices, predictor selection indices,recover_unpred,unpred_sizebeforeresize, OMP thread counts and per-thread sizes, and RAII for buffers freed on the throwing paths.out1 << (64 - len)withlen == 0-- a single-symbol Huffman tree, which a constant field produces -- shifts a 64-bit value by 64.HuffmanEncoderV2::loadAsDFSOrderread a fixed header and a DFS bitstream with no bounds at all,reserve(tree.n << 1)overflowed, and it advanced the cursor without ever decrementingremaining_length.smallIdx, read from the compressed stream, indexed the fixed-sizemagicIntstable unchecked; the firstmallocinXtcBasedEncoderwas overwritten and leaked;Lossless_bypassdid not check itsmalloc.RegressionPredictor::loaddebitedremaining_lengthby the decoded bin count whiledecode()advances by the much smaller encoded byte count, so the budget ran out early;ComposedPredictor::loadhad the mirror-image bug and never debited it at all, leaving the bound too loose.static_cast<int64_t>(fabs(diff) * error_bound_reciprocal)is UB for NaN, infinities and huge magnitudes.free()d at the end of a function that can throw; and the OMP path's per-chunk capacity omitted the size headerLossless_zstd::compresswrites, so a poorly compressible chunk threw.The two parts of #132 not taken
The compressed format change. #132 moves the
quant_indscount from after the encoder's serialized tree to before it, so the tree is immediately followed by its encoded stream and the boundload()records is exact. That is a format change andSZ3_DATA_VERSIONis not bumped, so the version check passes and a file written by any released SZ3 fails with a misleadingSZ3 Huffman: invalid node count. Verified by decompressing amaster-produced file with #132's build.The bound it was buying is recovered without touching the format, by letting the caller supply it instead of having
load()infer it:SZGenericCompressorcalls it once it has consumed the count field, and both predictors call it because their tree and stream are contiguous.load()no longer records a bound at all: the tree and the encoded stream are not required to share a buffer, andtools/test/modules/test_encoder.cppdeliberately puts them in separate ones -- inferring the bound there made that test fail.The
dstLenreinterpretation. #132 treatsLosslessInterface::decompress'sdstLenas an input capacity when the caller supplies the buffer. The declared contract is that it is an output, and callers do pass uninitialised values --LosslessTest.LosslessBypassfails with it, andLosslessTest.LosslessZstdonly passed because the garbage happened to be large. The self-allocating branch, where a non-zero incoming value is opt-in and zero means no bound, is kept.Gaps found while reviewing, fixed here
HuffmanEncoderV2sizes its dense tables withveccode.resize(tree.maxval), andmaxvalis read straight from the stream. Bounds-check HuffmanEncoderV2 tree loading against corrupted input #134 bounds the node count but not this, leaving an unbounded allocation. Bounded by the encoder's own invariant (preprocess_encodeonly leavesusemp == 0whilemaxval < 1 << 28).tree.nis anintholding a value read as 64-bit, so Bounds-check HuffmanEncoderV2 tree loading against corrupted input #134's new bound is checked with an explicit sign test rather than letting the comparison convert it (which also silenced a-Wsign-comparewarning the PR introduced).tools/test/modules/test_lossless.cpppassed an uninitialised size todecompress().compressed block to exceed the raw chunk size never appeared.
test_h5_filter.pygains a thirdchunk mode that asks for 8-element chunks, which the pre-fix filter rejects with
buffer not large enoughon every algorithm. The mode is restricted to a leading slice of atmost 4096 chunks -- over a whole 280M-element field it would mean 35M chunks, and the HDF5 chunk
index alone takes the process to 15.3 GiB of a runner's 16 GiB. Measured in a 16 GiB container at
hacc's exact size: 9.6 GiB and passing.
From the
fzbranch, verified to reproduce hereLossless_bypass::compressignored its destination capacity andmemcpy'd the payload regardless -- an unconditional heap overflow whenever the payload is larger than the caller's buffer.ALGO_BIOMDXTCis the one algorithm that pairs its codec with bypass, so nothing shrinks the payload; AddressSanitizer on Linux caught it as a 59599-byte overflow, glibc reportedmunmap_chunk(): invalid pointer, and macOS's allocator did not abort at all.TimeSeriesDecompositionviolated its error bound 1.94x withdata_ts0 == nullptr:block_data's compress-side path has no write-back, so timestep 0 was never updated with its reconstruction whiledecompress()predicted from its own. 0.997x after the fix.ArithmeticEncodersign-extendedbytesToInt64_bigEndian(bytes) >> 20, pushing the value outside the 44-bitMAX_CODEwindow. A 60-stream sweep gave 30 correct, 23 wrong, 7 SIGSEGV.HuffmanEncoder'sstateNum = max - offset + 2narrows toint, so a wide bin range goes negative and the state-tablemallocis UB.RunlengthEncoder,NoPredictionDecompositionandInterpolationDecompositionhad nosize_est(), soSZGenericCompressorsized its buffer from 0 while the quantizer's unpredictable list could be arbitrarily large.KmeansUtilhadreserve()followed byoperator[]writes, anduniform_int_distribution(0, num)indexingdata[num]. Live inmdzwhendims[1] > 5000.SZ_compress's own minimum, so an explicit chunk of a few hundred elements aborted.fzonce a new test file did..gitignore's baretestpattern matchedtools/test/, hiding every test file from git.Verification
BUILD_TESTING=ON+BUILD_H5Z_FILTER=ON: 0 errors, 0 warnings, all tests passmaster; everymaster-produced file decompresses to the same bytesGenerated with Claude Code