diff --git a/include/vaptvupt_api.h b/include/vaptvupt_api.h index f19c85f..63761ff 100644 --- a/include/vaptvupt_api.h +++ b/include/vaptvupt_api.h @@ -1,9 +1,9 @@ /* - * VaptVupt — Zupt Integration API + * VaptVupt — VaptVupt Integration API * SPDX-License-Identifier: GPL-3.0-or-later * Copyright 2026 Cristian. * - * ZUPT-COMPAT: This is the API that Zupt calls. It wraps the internal + * EMBED-COMPAT: This is the API that a host application calls. It wraps the internal * VaptVupt API with sensible defaults for backup workloads: * - Checksum always enabled (data integrity is critical for backups) * - Adaptive window selection (auto-detect optimal wlog per file) diff --git a/include/zupt.h b/include/zupt.h index 817e4d8..2511b0d 100644 --- a/include/zupt.h +++ b/include/zupt.h @@ -50,10 +50,10 @@ #define ZUPT_PRODUCT_EXTENSION ".zupt" /* on-disk archive extension (kept stable) */ #define ZUPT_PRODUCT_TAGLINE "Post-quantum backup compression" -#define ZUPT_VERSION_STRING "5.0.0" +#define ZUPT_VERSION_STRING "5.1.0" /* Vendored codec release (upstream tag) — single source for display strings. * The codec's own VV_VERSION_* is its internal API version, not the release. */ -#define ZUPT_CODEC_RELEASE "2.60.4" +#define ZUPT_CODEC_RELEASE "2.65.0" #define ZUPT_FORMAT_MAJOR 1 #define ZUPT_FORMAT_MINOR 6 diff --git a/src/vaptvupt_api.c b/src/vaptvupt_api.c index 928062f..04cbd15 100644 --- a/src/vaptvupt_api.c +++ b/src/vaptvupt_api.c @@ -4,15 +4,22 @@ * Copyright (c) 2025-2026 Cristian Cezar Moisés * * ZUPT-COMPAT: thin wrapper over vv_compress/vv_decompress with - * backup-optimized defaults for VaptVupt 2.60.4. + * backup-optimized defaults for VaptVupt 2.65.0. * * Defaults applied here (per ZUPT_INTEGRATION.md, Sprint 122): * - opts.checksum = 0 (Zupt's HMAC-SHA256 / AES-GCM-SIV outer * already authenticates the compressed * bytes; XXH64 footer is redundant work * and saves ~10% encode time) - * - opts.format_v2 = 1 (4-7% better binary ratio; v2.33.0+ - * decoders read v2 frames transparently) + * - opts.format_v2 = 0 (AUTO). Since codec v2.61.0 the encoder + * auto-enables min_match=3 ('T' blocks) for + * binary-detected input and keeps 'S' blocks + * for text. FORCING format_v2=1 routes text + * through the binary/greedy path and HALVES the + * extreme-mode ratio (text 7.6x -> 3.7x, + * measured on codec 2.65.0); auto keeps the + * optimal parser on text and still wins on + * binary. Never force it here. * - VV_DECOMPRESS_SKIP_CHECKSUM on decode (matched pair to * checksum=0 on encode; saves ~30% on real * fixtures, 2-5x on AEAD-wrapped data) @@ -43,14 +50,15 @@ int64_t vvz_compress(const uint8_t *src, size_t src_len, opts.format_v2 = 0; } else if (level <= 7) { opts.mode = VV_MODE_BALANCED; - opts.format_v2 = 1; /* 4-7% better binary ratio (v2.33.0+ decoders) */ + opts.format_v2 = 0; /* AUTO: v2 for binary, optimal 'S' for text. + * Forcing v2 halves text ratio — see header. */ opts.filter_auto = 1; /* BCJ on recognised ELF/PE/Mach-O input * (codec 2.55.0): no-op on everything else. * Blocks where a filter fired need a * v2.54.0+ decoder (tool >= 3.9.0). */ } else { opts.mode = VV_MODE_EXTREME; - opts.format_v2 = 1; /* 4-7% better binary ratio (v2.33.0+ decoders) */ + opts.format_v2 = 0; /* AUTO (see BALANCED / header note) */ opts.filter_auto = 1; /* see BALANCED note above */ } diff --git a/src/vv_ans.c b/src/vv_ans.c index c323372..6c5e437 100644 --- a/src/vv_ans.c +++ b/src/vv_ans.c @@ -181,6 +181,21 @@ static void build_dec(const uint16_t norm[NSYM], const uint8_t sp[ANS_L], vva_dec_entry_t dec[ANS_L]) { uint16_t occ[NSYM]; memset(occ, 0, sizeof(occ)); + /* SPRINT 125: per-symbol nb_max/low_count were recomputed (including + * an ilog2 while-loop) for every one of the 4096 slots; hoist them + * to one 256-entry precompute pass — identical values, ~16× fewer + * ilog2 evaluations per table build (3-4 builds per block on both + * encode and decode sides). */ + int8_t nbmax_tab[NSYM]; + int16_t lowcnt_tab[NSYM]; + for (int s = 0; s < NSYM; s++) { + uint16_t f = norm[s]; + if (f == 0 || f == (uint16_t)ANS_L) { nbmax_tab[s] = 0; lowcnt_tab[s] = 0; continue; } + int flg = ilog2(f); + int nb = ANS_LOG - flg; + nbmax_tab[s] = (int8_t)nb; + lowcnt_tab[s] = (int16_t)((1 << (flg + 1)) - (int)f); + } for (int x = 0; x < ANS_L; x++) { uint8_t s = sp[x]; uint16_t f = norm[s]; @@ -189,9 +204,8 @@ static void build_dec(const uint16_t norm[NSYM], const uint8_t sp[ANS_L], dec[x].symbol = s; dec[x].nbits = 0; dec[x].baseline = 0; continue; } - int flg = ilog2(f); - int nb_max = ANS_LOG - flg; - int low_count = (1 << (flg + 1)) - (int)f; + int nb_max = nbmax_tab[s]; + int low_count = lowcnt_tab[s]; /* On a VALID normalized table, f ∈ [1, ANS_L) here (f==0 and * f==ANS_L are handled above), so flg ≤ ANS_LOG-1 and nb_max ≥ 1, * and the shifts below are well-defined. A CORRUPT stream can @@ -265,15 +279,34 @@ static inline int enc_sym(const enc_ctx_t *c, uint32_t state, uint8_t sym, int base = c->cum[sym], cnt = c->cum[sym + 1] - base; if (!cnt) return -1; if (cnt == ANS_L) { *bv = 0; *bn = 0; return 0; } - for (int i = base; i < base + cnt; i++) { - uint32_t bl = c->o[i].bl; - int nb = c->o[i].nb; - if (state >= bl && state < bl + (1u << nb)) { - *bv = state - bl; *bn = nb; - return (int)c->o[i].slot; - } + /* SPRINT 124: O(1) slot lookup replacing a linear scan that + * averaged f/2 iterations (up to ~2048 for a dominant symbol — + * 10-15% of encode wall). + * + * The occurrence windows for a symbol with normalized freq f + * tile [0, ANS_L) exactly (see build_dec): occurrences + * k < low_count have nb_max = ANS_LOG - ilog2(f) bits and + * baseline k << nb_max; the rest have nb_max-1 bits. Baselines + * ascend with k and build_enc keeps c->o[] baseline-sorted, so + * c->o[base + k] IS occurrence k — the window containing `state` + * is directly computable. Produces bit-identical output to the + * scan (same slot, same bits). */ + int flg = ilog2((uint32_t)cnt); + int nb_max = ANS_LOG - flg; + uint32_t low_count = (1u << (flg + 1)) - (uint32_t)cnt; + uint32_t threshold = low_count << nb_max; + uint32_t k, nb; + if (state < threshold) { + nb = (uint32_t)nb_max; + k = state >> nb_max; + } else { + nb = (uint32_t)(nb_max - 1); + k = low_count + ((state - threshold) >> nb); } - return -1; + const enc_occ_t *e = &c->o[base + k]; + *bv = state - e->bl; + *bn = (int)nb; + return (int)e->slot; } /* ═══════════════════════════════════════════════════════════════ @@ -1477,6 +1510,11 @@ static size_t parse_sequences(const uint8_t *tokens, size_t tok_len, ll -= LL_MAX; } + /* SPRINT 125: re-check after the split loop — the while() guard + * at the top of the outer loop does not cover seqs consumed by + * splits within this iteration. */ + if (nseq >= seq_cap) return 0; + seqs[nseq].litlen = (uint32_t)ll; seqs[nseq].lit_offset = (uint32_t)nlits; nlits += ll; @@ -1511,10 +1549,89 @@ static size_t parse_sequences(const uint8_t *tokens, size_t tok_len, nseq++; } + /* SPRINT 125 (defense in depth): if the loop stopped because + * seq_cap was reached with tokens still unparsed, the parse is + * TRUNCATED — encoding it would silently drop sequences and emit a + * corrupt block. Unreachable with a correctly-sized seq_cap (see + * the caller's bound derivation), but fail closed regardless. */ + if (tp < tp_end) return 0; + *total_lits = nlits; return nseq; } +/* ═══════════════════════════════════════════════════════════════ + * LITERAL-CODER SIZE ESTIMATION (SPRINT 124) + * + * The literal-format race used to FULLY encode every candidate + * (ANS4 + ANS1 + Huffman + Huffman4) and keep one — measured at + * 6-21% of encode wall, nearly all discarded. One histogram plus + * analytic size estimates picks the winner first; only the winner + * is actually encoded. + * ═══════════════════════════════════════════════════════════════ */ + +/* Unlimited-depth Huffman code lengths, for size estimation only. + * (The real coder limits depth to 15; the difference is a handful of + * bits on pathological distributions — irrelevant for choosing.) */ +static void est_huff_lengths(const uint32_t freq[NSYM], uint8_t len[NSYM]) { + int leaf_sym[NSYM]; + int n = 0; + for (int s = 0; s < NSYM; s++) { + len[s] = 0; + if (freq[s]) leaf_sym[n++] = s; + } + if (n == 0) return; + if (n == 1) { len[leaf_sym[0]] = 1; return; } + + /* Leaves sorted ascending by freq (insertion sort, n ≤ 256). */ + for (int i = 1; i < n; i++) { + int t = leaf_sym[i]; + int j = i - 1; + while (j >= 0 && freq[leaf_sym[j]] > freq[t]) { + leaf_sym[j + 1] = leaf_sym[j]; + j--; + } + leaf_sym[j + 1] = t; + } + + /* Two-queue Huffman: leaves (sorted) + internal nodes (created in + * nondecreasing weight order). Nodes 0..n-1 are leaves; n.. are + * internal. 2n-1 ≤ 511 nodes total. */ + uint64_t w[2 * NSYM]; + int16_t parent[2 * NSYM]; + for (int i = 0; i < n; i++) { w[i] = freq[leaf_sym[i]]; parent[i] = -1; } + int q1 = 0; /* next unconsumed leaf */ + int q2 = n; /* next unconsumed internal node */ + int nn = n; /* next node id to create */ + for (int made = 0; made < n - 1; made++) { + int a, b; + /* pick two smallest among q1-front and q2-front */ + a = (q2 >= nn || (q1 < n && w[q1] <= w[q2])) ? q1++ : q2++; + b = (q2 >= nn || (q1 < n && w[q1] <= w[q2])) ? q1++ : q2++; + w[nn] = w[a] + w[b]; + parent[nn] = -1; + parent[a] = (int16_t)nn; + parent[b] = (int16_t)nn; + nn++; + } + /* Depth of each node = depth(parent) + 1; parents always have + * higher ids, so one reverse pass suffices. */ + uint8_t depth[2 * NSYM]; + depth[nn - 1] = 0; + for (int i = nn - 2; i >= 0; i--) + depth[i] = (uint8_t)(depth[parent[i]] + 1); + for (int i = 0; i < n; i++) + len[leaf_sym[i]] = depth[i] ? depth[i] : 1; +} + +/* log2(v) in 1/256 units via ilog2 + linear mantissa interpolation + * (max error ~0.09 bits — fine for candidate selection). */ +static inline uint32_t log2_fp8(uint32_t v) { + int t = ilog2(v); + uint32_t mant = ((v << 8) >> t); /* in [256, 512) */ + return (uint32_t)t * 256u + (mant - 256u); +} + /* ═══════════════════════════════════════════════════════════════ * ENCODE SEQUENCES * @@ -1529,12 +1646,26 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l const uint32_t *ml_base_tab, int disable_huf4) { if (!tok_len) { *dst_len = 0; return VVA_OK; } + /* SPRINT 126: API-misuse guard. Every internal caller passes one + * block's tokens (<= ~1.13 MB), but this entry point is public; + * bound tok_len so the arena size arithmetic below cannot wrap on + * absurd direct-API inputs. 1 GiB is orders of magnitude above any + * legal block token stream. */ + if (tok_len > ((size_t)1 << 30)) return VVA_ERR_PARAM; /* Parse into sequences. * PERF: one combined alloc for seqs + lit_buf. The sizeof(seq_t) * is ≥ 4 bytes so natural alignment for both is satisfied. Saves * 1 malloc/free pair per call. */ - size_t max_seqs = tok_len; /* Upper bound */ + /* SPRINT 125: tight sequence-count bound. Every sequence with a + * match consumes >= 3 token bytes (1 token byte + 2-3 offset bytes); + * zero-match sequences arise only from the final literal-only token + * (<= 1) and from LL_MAX splits (<= total_lits/65535 <= + * tok_len/65535). The old bound (max_seqs = tok_len) allocated + * 16 bytes of seq_t per TOKEN BYTE — ~17 MB of scratch per 1 MB + * block; this bound cuts that ~3x. parse_sequences fails closed if + * the bound were ever wrong (truncation guard). */ + size_t max_seqs = tok_len / 3 + tok_len / 65535 + 8; size_t seqs_sz = max_seqs * sizeof(seq_t); size_t total_scratch = seqs_sz + tok_len; uint8_t *base_scratch = (uint8_t *)malloc(total_scratch); @@ -1550,10 +1681,35 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l size_t nseq = parse_sequences(tokens, tok_len, lit_buf, tok_len, seqs, max_seqs, &total_lits, off_bytes, min_match); if (nseq == 0) { free(base_scratch); return VVA_ERR_CORRUPT; } - /* ─── Encode literals with 4-way ANS ─── */ + /* ─── SPRINT 126: one block-scratch arena ─── + * + * After parse_sequences, nseq and total_lits pin every remaining + * scratch size, so the 6 per-block mallocs that used to follow + * (lit_enc, seq_scratch memoization arrays, LL build tables, ML/OF + * build tables, the bitpair staging array, and the sequence + * bitstream) collapse into ONE allocation with computed offsets — + * one malloc/free pair per block instead of six, and one cleanup + * pointer on every error path. Layout keeps 4/8-byte-aligned + * sections first; sizes are the exact bounds the individual + * allocations used. ML/OF tables are reserved unconditionally + * (40 KB) even when match_count == 0 — a bound, not a leak. */ size_t lit_cap = vva_bound(total_lits); - uint8_t *lit_enc = (uint8_t *)malloc(lit_cap); - if (!lit_enc) { free(base_scratch); return VVA_ERR_NOMEM; } + size_t a_codes_sz = (nseq * sizeof(uint8_t) + 3) & ~(size_t)3; + size_t a_stream_sz = a_codes_sz + nseq * sizeof(uint32_t) + nseq * sizeof(int); + size_t tab_one_sz = ANS_L + ANS_L * sizeof(vva_dec_entry_t); +#define VVA_A8(x) (((x) + 7) & ~(size_t)7) + size_t off_pairs = 0; + size_t off_scratch = off_pairs + VVA_A8(nseq * 6 * sizeof(bitpair_t)); + size_t off_lltab = off_scratch + VVA_A8(3 * a_stream_sz); + size_t off_mloftab = off_lltab + VVA_A8(tab_one_sz); + size_t off_lit = off_mloftab + VVA_A8(2 * tab_one_sz); + size_t off_bs = off_lit + VVA_A8(lit_cap); + size_t arena_sz = off_bs + VVA_A8(nseq * 6 * 4 + 16); + uint8_t *arena = (uint8_t *)malloc(arena_sz); + if (!arena) { free(base_scratch); return VVA_ERR_NOMEM; } + + /* ─── Encode literals with 4-way ANS ─── */ + uint8_t *lit_enc = arena + off_lit; size_t lit_enc_len = 0; uint8_t lit_fmt = 0; /* 0=raw, 1=ANS4, 2=ANS1, 3=Huffman, 4=Huffman4 (Sprint 104) */ @@ -1582,6 +1738,99 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l * unchanged otherwise — existing decoders reject lit_fmt={3,4} * with VVA_ERR_CORRUPT, so this is a decoder-incompatible * format change (requires v2.46.0+ for fmt=3, v2.47+ for fmt=4). */ + if (total_lits >= 4096) { + /* ─── SPRINT 124: estimate-based single-encode selection. + * + * One histogram, then analytic sizes: ANS4 cost is the + * table-quantized Σ f·(ANS_LOG − log2(norm_f)) plus its + * header; Huffman cost is exact given code lengths (built + * without a bitstream pass). Only the winner is encoded, + * directly into lit_enc. ANS1 is dropped here: it can + * undercut ANS4 by at most ~26 header bytes, which is + * noise at ≥4096 literals. The old full race burned + * 6-21% of total encode wall on discarded encodes. */ + uint32_t hist[NSYM]; + memset(hist, 0, sizeof(hist)); + for (size_t i = 0; i < total_lits; i++) hist[lit_buf[i]]++; + + int active = 0, max_sym = 0; + for (int s = 0; s < NSYM; s++) + if (hist[s]) { active++; max_sym = s; } + + uint16_t norm_est[NSYM]; + memset(norm_est, 0, sizeof(norm_est)); + normalize_freq(hist, norm_est); + uint64_t bits256 = 0; + for (int s = 0; s < NSYM; s++) { + if (!hist[s]) continue; + uint32_t nf = norm_est[s] ? norm_est[s] : 1; + bits256 += (uint64_t)hist[s] * + ((uint32_t)ANS_LOG * 256u - log2_fp8(nf)); + } + size_t tbl_hdr = (active <= 64) ? (size_t)(2 + 3 * active) + : (size_t)(2 + 2 * (max_sym + 1)); + size_t ans4_est = (size_t)(bits256 / 2048u) + tbl_hdr + 26; + + uint8_t hlen[NSYM]; + est_huff_lengths(hist, hlen); + uint64_t hbits = 0; + for (int s = 0; s < NSYM; s++) + hbits += (uint64_t)hist[s] * hlen[s]; + size_t huf_est = (size_t)(hbits / 8u) + 130; + size_t huf4_est = huf_est + 12; + + /* Two-finalist race with estimate-gated skips. + * + * The estimates are systematically OPTIMISTIC (linear log2 + * interpolation undershoots; tANS state costs and lane + * overheads are approximated low), so `est >= raw` proves + * the real encode cannot beat raw literals — a safe skip + * that turns incompressible-literal blocks (sensor data) + * into an immediate raw store with zero encode passes. + * When a candidate is plausible it is actually encoded: + * measured sizes decide, exactly like the old 4-way race, + * but with at most 2 encodes (ANS1 dropped — bounded + * ~26 B win; huf-vs-huf4 resolved by their fixed ~12 B + * structural delta instead of dual encodes). */ + uint8_t hb_fmt = disable_huf4 ? 3 : 4; + size_t hb_est = disable_huf4 ? huf_est : huf4_est; + if (!disable_huf4 && huf_est + 32 < huf4_est) { + hb_fmt = 3; hb_est = huf_est; + } + + lit_fmt = 0; + lit_enc_len = 0; + if (ans4_est < total_lits) { + size_t out_len = 0; + if (vva_encode4(lit_buf, total_lits, lit_enc, lit_cap, &out_len) == VVA_OK && + out_len < total_lits) { + lit_enc_len = out_len; + lit_fmt = 1; + } + } + if (hb_est < total_lits && + (lit_fmt == 0 || hb_est < lit_enc_len + lit_enc_len / 8)) { + uint8_t *alt_buf = (uint8_t *)malloc(lit_cap); + if (alt_buf) { + size_t alt_len = 0; + int aok = (hb_fmt == 4) + ? (vvh_encode4(lit_buf, total_lits, alt_buf, lit_cap, &alt_len) == VVH_OK) + : (vvh_encode(lit_buf, total_lits, alt_buf, lit_cap, &alt_len) == VVH_OK); + if (aok && alt_len < total_lits && + (lit_fmt == 0 || alt_len < lit_enc_len)) { + memcpy(lit_enc, alt_buf, alt_len); + lit_enc_len = alt_len; + lit_fmt = hb_fmt; + } + free(alt_buf); + } + } + if (lit_fmt == 0) { + /* Raw literals (lit_cap = vva_bound(total_lits) ≥ total_lits). */ + memcpy(lit_enc, lit_buf, total_lits); + lit_enc_len = total_lits; + } + } else { size_t ans4_len = 0, ans1_len = 0, huf_len = 0, huf4_len = 0; uint8_t *ans4_buf = (uint8_t *)malloc(lit_cap); uint8_t *ans1_buf = (uint8_t *)malloc(lit_cap); @@ -1654,6 +1903,7 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l lit_fmt = 0; } free(ans4_buf); free(ans1_buf); free(huf_buf); free(huf4_buf); + } } /* ─── Count ML, OF, and LL code frequencies ─── */ @@ -1687,16 +1937,11 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l * Net cost: 1 extra malloc region (~14 × nseq bytes), 0 extra * malloc calls. Net saving: the backward pass becomes lookups * instead of re-computation. */ - size_t codes_sz = (nseq * sizeof(uint8_t) + 3) & ~(size_t)3; + size_t codes_sz = a_codes_sz; size_t extra_sz = nseq * sizeof(uint32_t); - size_t nbits_sz = nseq * sizeof(int); - /* 3 streams × (codes + extra + nbits) */ - uint8_t *seq_scratch = (uint8_t *)malloc(3 * (codes_sz + extra_sz + nbits_sz)); - if (!seq_scratch) { - free(base_scratch); free(lit_enc); - return VVA_ERR_NOMEM; - } - size_t stream_sz = codes_sz + extra_sz + nbits_sz; + /* 3 streams × (codes + extra + nbits) — carved from the arena. */ + uint8_t *seq_scratch = arena + off_scratch; + size_t stream_sz = a_stream_sz; uint8_t *seq_of_code = seq_scratch; uint32_t *seq_of_extra = (uint32_t *)(seq_scratch + codes_sz); int *seq_of_nbits = (int *)(seq_scratch + codes_sz + extra_sz); @@ -1799,23 +2044,20 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l ll_hdr_sz = write_hdr_v2(norm_ll, ll_hdr_buf, 600); if (!ll_hdr_sz) goto seq_fail; - /* PERF: one combined alloc for sp_ll + dec_ll. sp_ll lives in - * the first ANS_L bytes, dec_ll follows with alignment (16-byte - * aligned vs 8-byte reads is satisfied since ANS_L=4096 is - * already 4KB-aligned). Saves 1 malloc/free pair. */ + /* sp_ll lives in the first ANS_L bytes of the arena's LL-table + * section, dec_ll follows (ANS_L=4096 keeps dec_ll aligned). */ size_t sp_sz = ANS_L; - size_t dec_sz = ANS_L * sizeof(vva_dec_entry_t); - uint8_t *ll_tables = (uint8_t *)malloc(sp_sz + dec_sz); - if (!ll_tables) goto seq_fail; + uint8_t *ll_tables = arena + off_lltab; uint8_t *sp_ll = ll_tables; vva_dec_entry_t *dec_ll = (vva_dec_entry_t *)(ll_tables + sp_sz); spread_symbols(norm_ll, sp_ll); build_dec(norm_ll, sp_ll, dec_ll); enc_ll_ctx = build_enc(norm_ll, sp_ll, dec_ll); - free(ll_tables); if (!enc_ll_ctx) goto seq_fail; } + enc_ctx_t *enc_ml_ctx = NULL; + enc_ctx_t *enc_of_ctx = NULL; if (match_count > 0) { /* Treat ML codes as a small-alphabet problem */ uint32_t raw_ml[NSYM], raw_of[NSYM]; @@ -1834,14 +2076,10 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l - /* ─── Build encode tables ─── - * PERF: one combined alloc for sp_ml + dec_ml + sp_of + dec_of - * (4 fixed-size ANS_L-based buffers). Saves 3 malloc/free pairs. */ + /* ─── Build encode tables (in the arena's ML/OF section) ─── */ size_t sp_sz = ANS_L; size_t dec_sz = ANS_L * sizeof(vva_dec_entry_t); - size_t combo_sz = (sp_sz + dec_sz) * 2; - uint8_t *ml_of_tables = (uint8_t *)malloc(combo_sz); - if (!ml_of_tables) goto seq_fail; + uint8_t *ml_of_tables = arena + off_mloftab; uint8_t *sp_ml = ml_of_tables; vva_dec_entry_t *dec_ml = (vva_dec_entry_t *)(ml_of_tables + sp_sz); uint8_t *sp_of = ml_of_tables + sp_sz + dec_sz; @@ -1849,23 +2087,36 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l spread_symbols(norm_ml, sp_ml); build_dec(norm_ml, sp_ml, dec_ml); - enc_ctx_t *enc_ml_ctx = build_enc(norm_ml, sp_ml, dec_ml); + enc_ml_ctx = build_enc(norm_ml, sp_ml, dec_ml); spread_symbols(norm_of, sp_of); build_dec(norm_of, sp_of, dec_of); - enc_ctx_t *enc_of_ctx = build_enc(norm_of, sp_of, dec_of); + enc_of_ctx = build_enc(norm_of, sp_of, dec_of); - free(ml_of_tables); if (!enc_ml_ctx || !enc_of_ctx) { free_enc(enc_ml_ctx); free_enc(enc_of_ctx); + enc_ml_ctx = enc_of_ctx = NULL; goto seq_fail; } + } - /* ─── Encode ML/OF codes + extra bits in reverse ─── */ - /* Collect bitpairs for ANS-coded symbols + raw extra bits */ - size_t pair_cap = nseq * 6; /* 3 ANS + 3 extra max per seq */ - bitpair_t *pairs = (bitpair_t *)malloc(pair_cap * sizeof(bitpair_t)); - if (!pairs) { free_enc(enc_ml_ctx); free_enc(enc_of_ctx); goto seq_fail; } + /* ─── Encode ML/OF/LL codes + extra bits in reverse ─── + * + * SPRINT 124 (latent-corruption fix): this section — including the + * LL encoding — used to live INSIDE the match_count > 0 branch. A + * block whose token stream contains no matches at all (pure + * literal run) then wrote the LL table header but NO sequence + * bitstream, while the decoder unconditionally decodes an LL code + * per sequence — it read garbage from an empty stream and failed + * (or worse, produced short output). The case was unreachable + * while emit_block sent every csz >= braw token stream straight + * to RAW storage; the relaxed raw_gate made it reachable. The LL + * bitstream must be written whenever nseq > 0, with ML/OF work + * still gated per-sequence on matchlen > 0. */ + { + /* Collect bitpairs for ANS-coded symbols + raw extra bits + * (arena section; capacity nseq * 6 = 3 ANS + 3 extra per seq). */ + bitpair_t *pairs = (bitpair_t *)(arena + off_pairs); state_ml = 0; state_of = 0; state_ll = 0; size_t npairs = 0; @@ -1904,7 +2155,7 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l uint32_t bv; int bn; int slot = enc_sym(enc_ml_ctx, state_ml, mc, &bv, &bn); if (slot < 0) { - free(pairs); free_enc(enc_ml_ctx); free_enc(enc_of_ctx); + free_enc(enc_ml_ctx); free_enc(enc_of_ctx); goto seq_fail; } pairs[npairs].val = (uint32_t)bv; @@ -1925,7 +2176,7 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l uint32_t bv; int bn; int slot = enc_sym(enc_of_ctx, state_of, oc, &bv, &bn); if (slot < 0) { - free(pairs); free_enc(enc_ml_ctx); free_enc(enc_of_ctx); + free_enc(enc_ml_ctx); free_enc(enc_of_ctx); goto seq_fail; } pairs[npairs].val = (uint32_t)bv; @@ -1950,7 +2201,7 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l uint32_t bv; int bn; int slot = enc_sym(enc_ll_ctx, state_ll, lc, &bv, &bn); if (slot < 0) { - free(pairs); free_enc(enc_ml_ctx); free_enc(enc_of_ctx); + free_enc(enc_ml_ctx); free_enc(enc_of_ctx); goto seq_fail; } pairs[npairs].val = (uint32_t)bv; @@ -1967,15 +2218,13 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l * Each pair is up to 32 bits (ANS slot = 14 bits + extra up to 18). * Allocate 4 bytes per pair + 16-byte safety margin. */ size_t bs_cap = npairs * 4 + 16; - seq_bs = (uint8_t *)malloc(bs_cap); - if (!seq_bs) { free(pairs); goto seq_fail; } + seq_bs = arena + off_bs; /* arena section, sized nseq*6*4 + 16 >= bs_cap */ ans_bw_t w; ans_bw_init(&w, seq_bs, bs_cap); for (size_t i = npairs; i > 0; i--) ans_bw_add(&w, pairs[i - 1].val, pairs[i - 1].nb); seq_bs_len = ans_bw_flush(&w); - free(pairs); } /* Litlens are now ANS-coded in the sequence bitstream — no varints needed */ @@ -2033,17 +2282,15 @@ static vva_error_t vva_encode_sequences_impl(const uint8_t *tokens, size_t tok_l *dst_len = (size_t)(op - dst); } - free(base_scratch); free(lit_enc); - free(seq_scratch); + free(base_scratch); + free(arena); free_enc(enc_ll_ctx); - free(seq_bs); return VVA_OK; seq_fail: - free(base_scratch); free(lit_enc); - free(seq_scratch); + free(base_scratch); + free(arena); free_enc(enc_ll_ctx); - free(seq_bs); return VVA_ERR_OVERFLOW; } @@ -2125,7 +2372,14 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, if (VV_UNLIKELY(total_lits > dst_cap)) return VVA_ERR_CORRUPT; /* Decode literals based on format byte */ - uint8_t *lit_buf = (uint8_t *)malloc(total_lits + 16); + /* SPRINT 126: one allocation for the literal buffer AND the decode + * tables (previously 2 mallocs; the table section was itself fused + * from 4 in Sprint 125). The table space (52 KB) is reserved + * unconditionally up front so the whole block scratch is a single + * malloc/free — its exact use is decided at table-build below. */ + size_t lit_sec = (total_lits + 16 + 7) & ~(size_t)7; + size_t tab_sec = ANS_L + 3 * (ANS_L * sizeof(vva_dec_entry_t)); + uint8_t *lit_buf = (uint8_t *)malloc(lit_sec + tab_sec); if (!lit_buf) return VVA_ERR_NOMEM; if (total_lits > 0 && lit_enc_len > 0) { @@ -2208,6 +2462,46 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, if (ll_hdr_sz > 0) read_hdr_v2(p, ll_hdr_sz, norm_ll); p += ll_hdr_sz; + /* SPRINT 125: hoisted table validation. Two invariants are enforced + * once per block so the old per-sequence `code >= VVA_*_CODES` + * branch (one per iteration, on the critical path between the table + * load and the bit read) becomes tautological and is removed from + * the hot loop below: + * + * (1) No out-of-range symbol has nonzero frequency — bounds every + * spread-table entry's symbol. + * (2) Frequencies sum to exactly ANS_L — guarantees spread_symbols + * fills ALL 4096 slots. Without this, a corrupt underfull + * header leaves stale scratch bytes in unfilled slots, whose + * "symbols" bypass check (1) entirely (caught by UBSan as an + * OOB index into ll_extra[36] during validation of this very + * change). normalize_freq guarantees sum == ANS_L on every + * valid stream, so this rejects only corrupt input. + * + * This is STRICTER than the old per-sequence check: malformed + * tables are rejected up front instead of only when a decode path + * lands on a bad entry. Tables that the decode loop never consults + * (ML/OF when match_count == 0; all of them when the loop body + * cannot run) are exempt from (2) for wire compatibility. */ + { + uint32_t sum_ml = 0, sum_of = 0, sum_ll = 0; + for (int s = 0; s < NSYM; s++) { + sum_ml += norm_ml[s]; sum_of += norm_of[s]; sum_ll += norm_ll[s]; + if (s >= VVA_OF_CODES && VV_UNLIKELY(norm_of[s])) { + free(lit_buf); return VVA_ERR_CORRUPT; + } + if (s >= VVA_ML_CODES && VV_UNLIKELY(norm_ml[s] | norm_ll[s])) { + free(lit_buf); return VVA_ERR_CORRUPT; + } + } + if (VV_UNLIKELY(sum_ll != ANS_L && (total_lits > 0 || match_count > 0))) { + free(lit_buf); return VVA_ERR_CORRUPT; + } + if (VV_UNLIKELY(match_count > 0 && (sum_ml != ANS_L || sum_of != ANS_L))) { + free(lit_buf); return VVA_ERR_CORRUPT; + } + } + /* Read initial states */ if (p + 6 > end) { free(lit_buf); return VVA_ERR_CORRUPT; } uint32_t state_ml = (uint32_t)p[0] | ((uint32_t)p[1] << 8); p += 2; @@ -2228,34 +2522,32 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, * NULL-deref's dec_of and dec_ml. Found by libFuzzer + ASan. * Fix: always allocate all 3 tables. The decode-loop dereferences * are safe because state masks bound the index to ANS_L. */ + /* SPRINT 125: one allocation for the spread scratch + decode tables + * (previously 4 separate mallocs — measurable on small blocks). + * When match_count == 0, the ML/OF tables are never consulted for + * real decode work (the loop `continue`s before the OF/ML reads), + * but the ILP eager-loads at the loop top still index them — alias + * them to the LL table: valid, initialized memory, zero build and + * zero memset cost (replaces two 16 KB sentinel memsets). */ vva_dec_entry_t *dec_ml = NULL, *dec_of = NULL, *dec_ll = NULL; { - uint8_t *sp_tmp = (uint8_t *)malloc(ANS_L); - dec_ml = (vva_dec_entry_t *)malloc(ANS_L * sizeof(vva_dec_entry_t)); - dec_of = (vva_dec_entry_t *)malloc(ANS_L * sizeof(vva_dec_entry_t)); - dec_ll = (vva_dec_entry_t *)malloc(ANS_L * sizeof(vva_dec_entry_t)); - if (!sp_tmp || !dec_ll || !dec_ml || !dec_of) { - free(sp_tmp); free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); - return VVA_ERR_NOMEM; - } + size_t dec_sz = ANS_L * sizeof(vva_dec_entry_t); + uint8_t *seq_tables = lit_buf + lit_sec; /* reserved above */ + uint8_t *sp_tmp = seq_tables; + dec_ll = (vva_dec_entry_t *)(seq_tables + ANS_L); + spread_symbols(norm_ll, sp_tmp); + build_dec(norm_ll, sp_tmp, dec_ll); if (match_count > 0) { + dec_ml = (vva_dec_entry_t *)(seq_tables + ANS_L + dec_sz); + dec_of = (vva_dec_entry_t *)(seq_tables + ANS_L + 2 * dec_sz); spread_symbols(norm_ml, sp_tmp); build_dec(norm_ml, sp_tmp, dec_ml); spread_symbols(norm_of, sp_tmp); build_dec(norm_of, sp_tmp, dec_of); } else { - /* Initialize ml/of tables to safe sentinel values so any - * unintended read (e.g., the ILP eager-load in the decode - * loop when match_count == 0) returns predictable data - * rather than dereferencing uninitialized memory. The - * loop guard prevents these values from being used in - * actual sequence reconstruction. */ - memset(dec_ml, 0, ANS_L * sizeof(vva_dec_entry_t)); - memset(dec_of, 0, ANS_L * sizeof(vva_dec_entry_t)); + dec_ml = dec_ll; + dec_of = dec_ll; } - spread_symbols(norm_ll, sp_tmp); - build_dec(norm_ll, sp_tmp, dec_ll); - free(sp_tmp); } /* Initialize bitstream reader for sequence data */ @@ -2307,7 +2599,8 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, * guarantees litlen+matchlen fit without per-iter overflow checking. * (Reserving only ONE run let a crafted final sequence write up to * 65535 bytes past op_end — a heap overflow; the +64 caller slack was - * far too small to absorb it.) + * far too small to absorb it. ZUPT AUDIT FIX, carried across codec + * re-vendors until upstreamed.) * * SPRINT 46: raised from 1<<20 to 1<<24. The 3-byte offset wire * encoding (off_bytes==3 for wlog>16) represents offsets up to @@ -2361,7 +2654,7 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, size_t iter_count = 0; while (lit_pos < total_lits || matches_decoded < match_count) { if (VV_UNLIKELY(++iter_count > max_iters)) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_CORRUPT; } /* PERF: issue all 3 ANS table lookups early so CPU can overlap @@ -2391,33 +2684,13 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, vva_dec_entry_t eof = dec_of[state_of & (ANS_L - 1)]; vva_dec_entry_t eml = dec_ml[state_ml & (ANS_L - 1)]; - /* SPRINT 27 (v2.50.1): combine the 3 per-iteration OOB code - * validators into 1 branch. Previously each of ll_code, of_code, - * ml_code had a separate `if (VV_UNLIKELY(code >= MAX)) return` - * — three predicted-not-taken branches per iteration. ORing - * the three bool comparisons into a single mask lets the compiler - * use one branch and parallel SIMD-style comparisons. - * - * Found via profile-driven analysis on v2.50.0 (Sprint 27). The - * three branches were each individually cheap when not taken, - * but they sit on the critical path between the table-read - * latency (L1/L2 miss on the random-walk index) and the - * subsequent bit-read, where they delay state-update of the - * NEXT iteration. Folding to one branch removes 2 branch slots - * and lets the comparator ALU run in parallel with the load - * latency for ell/eof/eml. - * - * Note: VVA_LL_CODES == VVA_ML_CODES == 36, VVA_OF_CODES == 27. - * Use the strictest bound (27) as a quick-fail mask; codes 27-35 - * are still legal for LL/ML and fall through to the per-code - * tail check below. This catches the most common adversarial - * encoding (high-symbol garbage) at zero cost on the common path. */ - if (VV_UNLIKELY(((unsigned)ell.symbol >= VVA_LL_CODES) | - ((unsigned)eof.symbol >= VVA_OF_CODES) | - ((unsigned)eml.symbol >= VVA_ML_CODES))) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); - return VVA_ERR_CORRUPT; - } + /* SPRINT 125: the per-iteration OOB code check (Sprint 27's + * combined branch) is gone — table symbols are validated once + * at header-parse time above, so every entry in dec_ll/dec_of/ + * dec_ml carries an in-range symbol by construction. Same + * security property (out-of-range codes on corrupt input are + * rejected, now earlier and unconditionally), one branch less + * on the critical path between the table load and the bit read. */ /* ── Decode LL: state, extra, final litlen ── */ uint32_t ll_bits = ans_br_read(&r, ell.nbits); @@ -2428,11 +2701,11 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, size_t litlen = ll_decode(ll_code, ll_extra_val); if (VV_UNLIKELY(lit_pos + litlen > total_lits)) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_CORRUPT; } if (VV_UNLIKELY(!in_safe_zone && op + litlen > op_end)) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_OVERFLOW; } if (litlen > 0) { @@ -2510,15 +2783,15 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, * op_safe_end = op_end - SAFEZONE_MAX_MATCH, and matchlen is * always ≤ SAFEZONE_MAX_MATCH by wire format. */ if (VV_UNLIKELY(offset == 0 || offset > SAFEZONE_MAX_OFFSET)) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_CORRUPT; } if (VV_UNLIKELY(!in_safe_zone && offset > (uint32_t)(op - dst_base))) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_CORRUPT; } if (VV_UNLIKELY(!in_safe_zone && op + matchlen > op_end)) { - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_ERR_OVERFLOW; } @@ -2605,7 +2878,7 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len, } *dst_len = (size_t)(op - dst); - free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf); + free(lit_buf); return VVA_OK; } diff --git a/src/vv_decoder.c b/src/vv_decoder.c index 2f923b7..92c8a49 100644 --- a/src/vv_decoder.c +++ b/src/vv_decoder.c @@ -195,19 +195,23 @@ decode_block_tokens_impl( VV_PREFETCH(op + ll - off_raw); } - if (ll > 0) + /* SPRINT 125: wildcopy for the dominant ll <= 14 case. The loop + * guards reserve 48 bytes of readable input (ip < ip_safe; ip has + * advanced by only the 1 token byte since, as ll <= 14 implies no + * extension bytes) and 72 bytes of writable output (op < op_safe; + * op unchanged since entry), so one unconditional 16-byte copy is + * in-bounds and replaces memcpy's branchy variable-size dispatch. + * The extra bytes past ll are overwritten by the next copy. */ + if (VV_LIKELY(ll <= 14)) { + memcpy(op, ip, 16); + } else { memcpy(op, ip, ll); + } ip += ll; op += ll; if (VV_UNLIKELY(ip >= ip_end)) break; - /* Bound the 2-/3-byte offset read against the compressed-block end. - * Without this a crafted block whose last literal advances ip to - * ip_end-1 (or ip_end-2 for 3-byte offsets) makes vv_read16 / the - * 3-byte load read past the heap buffer. The general/tail decode path - * already carries this guard; the AVX2 fast paths were missing it. */ - if (VV_UNLIKELY(ip + off_bytes > ip_end)) return VV_ERR_CORRUPT; uint32_t offset; if (off_bytes == 2) { offset = vv_read16(ip); @@ -284,19 +288,23 @@ decode_block_tokens_impl( VV_PREFETCH(op + ll - off_raw); } - if (ll > 0) + /* SPRINT 125: wildcopy for the dominant ll <= 14 case. The loop + * guards reserve 48 bytes of readable input (ip < ip_safe; ip has + * advanced by only the 1 token byte since, as ll <= 14 implies no + * extension bytes) and 72 bytes of writable output (op < op_safe; + * op unchanged since entry), so one unconditional 16-byte copy is + * in-bounds and replaces memcpy's branchy variable-size dispatch. + * The extra bytes past ll are overwritten by the next copy. */ + if (VV_LIKELY(ll <= 14)) { + memcpy(op, ip, 16); + } else { memcpy(op, ip, ll); + } ip += ll; op += ll; if (VV_UNLIKELY(ip >= ip_end)) break; - /* Bound the 2-/3-byte offset read against the compressed-block end. - * Without this a crafted block whose last literal advances ip to - * ip_end-1 (or ip_end-2 for 3-byte offsets) makes vv_read16 / the - * 3-byte load read past the heap buffer. The general/tail decode path - * already carries this guard; the AVX2 fast paths were missing it. */ - if (VV_UNLIKELY(ip + off_bytes > ip_end)) return VV_ERR_CORRUPT; uint32_t offset; if (off_bytes == 2) { offset = vv_read16(ip); diff --git a/src/vv_encoder.c b/src/vv_encoder.c index 400078b..74a58aa 100644 --- a/src/vv_encoder.c +++ b/src/vv_encoder.c @@ -159,10 +159,28 @@ static inline int32_t extend_match(const uint8_t *a, const uint8_t *b, len += 32; } #endif + /* SPRINT 124: 8-byte xor/ctz stride for the post-8 region. This TU + * is deliberately built without -mavx2 (baseline portability), so + * before this loop existed every match longer than 8 bytes extended + * one byte per iteration — measured at 7-8% of encode wall on + * long-match corpora. Same technique as the fast path above. */ + while (len + 8 <= max_len) { + uint64_t va, vb; + memcpy(&va, a + len, 8); + memcpy(&vb, b + len, 8); + uint64_t x = va ^ vb; + if (x) return len + (__builtin_ctzll(x) >> 3); + len += 8; + } while (len < max_len && a[len] == b[len]) len++; return len; } +/* Branch-free floor(log2(v)); v=0 maps to 0. */ +static inline int enc_ilog2(uint32_t v) { + return 31 - __builtin_clz(v | 1); +} + /* ═══════════════════════════════════════════════════════════════ * MATCHER: hash chain with 5-byte hash + rep-match * ═══════════════════════════════════════════════════════════════ */ @@ -512,8 +530,11 @@ chain_match_ex(const matcher_t *m, const uint8_t *data, /* Pipeline priming: look 4 chain entries ahead. If chain is * short, the prefetches become no-ops (chain entries below limit - * just return -1 or an expired position). */ - if (ref >= limit && ref < pos) { + * just return -1 or an expired position). + * SPRINT 124: only prime for deep walks. At depth 4 (fast mode, + * window trial) the priming loads cost more than the misses they + * hide — measured 5-8% of fast-mode encode wall. */ + if (depth >= 8 && ref >= limit && ref < pos) { __builtin_prefetch(data + ref, 0, 0); int32_t r1 = chain_arr[ref & chain_mask]; if (r1 >= limit && r1 < pos) { @@ -562,6 +583,22 @@ chain_match_ex(const matcher_t *m, const uint8_t *data, if (max > (int32_t)m->max_match) max = (int32_t)m->max_match; int32_t len = 4 + extend_match(data + pos + 4, data + ref + 4, max - 4); if (len > best_len) { + /* SPRINT 124: offset-cost-aware acceptance. The walk + * goes newest→oldest, so a later candidate always has + * a larger offset. SEQ codes offsets as log2 buckets + + * extra bits, so the farther match costs ~dbits more; + * each extra matched byte saves ~6 bits of literals. + * Without this check a barely-longer match at 512 KB + * displaces a same-ish match at 200 B, and the diverse + * offsets also break rep-offset streaks downstream. + * Only affects greedy/lazy paths — the optimal parser + * collects candidates via opt_collect and prices + * offsets itself. */ + if (best_len >= 4) { + int dbits = enc_ilog2((uint32_t)(pos - ref)) + - enc_ilog2((uint32_t)*best_off); + if ((len - best_len) * 6 < dbits) { ref = next_ref; continue; } + } best_len = len; *best_off = pos - ref; if (len >= 256) return best_len; @@ -604,6 +641,12 @@ chain_match_ex(const matcher_t *m, const uint8_t *data, if (max > (int32_t)m->max_match) max = (int32_t)m->max_match; int32_t len = 4 + extend_match(data + pos + 4, data + ref4 + 4, max - 4); if (len > best_len) { + /* Same offset-cost-aware acceptance as the hash5 walk. */ + if (best_len >= 4) { + int dbits = enc_ilog2((uint32_t)(pos - ref4)) + - enc_ilog2((uint32_t)*best_off); + if ((len - best_len) * 6 < dbits) { ref4 = next_ref4; continue; } + } best_len = len; *best_off = pos - ref4; if (len >= 256) return best_len; @@ -857,20 +900,137 @@ typedef struct { uint32_t off; int32_t len; } opt_cand_t; * lever has been measured (matters more for nci-class fixtures). */ static inline int32_t opt_lit_price(void) { return 8; } -/* match bit price: cost_const(14) + log2(off) + ml_extra; rep ~2 bits */ -static inline int32_t opt_match_price(const matcher_t *m, uint32_t off, int32_t len) { - int is_rep = (off == m->rep[0] || off == m->rep[1] || off == m->rep[2]); +/* SPRINT 129: per-byte literal prices from the block's byte histogram. + * The flat-8 model (Sprint 44) was chosen as the best single constant, + * but the real literal coder delivers ~4-6 bits/byte on text and 7-8 + * on dense binary — the flat constant over-prices text literals, so + * the parser substitutes marginal matches where literals are cheaper + * in reality. This is the "two-pass repricing" refinement that Sprint + * 44's note deferred, using the raw block histogram as the literal- + * distribution estimate (the true literal stream excludes match- + * covered bytes, but the distributions track closely in practice). + * price[b] = round(log2(N / hist[b])) clamped to [VV_OPT_LIT_MIN, 14]; + * unseen bytes cannot appear as literals and get the ceiling. The + * clamp floor guards degenerate blocks (a byte at ~100% frequency + * would price to 0 and make literal runs look free). Constants swept + * on the 11-file corpus — see CHANGELOG v2.64.0. */ +#ifndef VV_OPT_LIT_MIN +#define VV_OPT_LIT_MIN 2 +#endif +#ifndef VV_OPT_LIT_BLEND +#define VV_OPT_LIT_BLEND 6 +#endif +/* fwd decl: the greedy parser (defined below) doubles as the residual- + * literal estimator for the optimal parser's pricing prepass. */ +static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_len, + uint8_t *dst, size_t dst_cap, + matcher_t *m, vv_mode_t mode, int min_match); + +/* SPRINT 130: histogram the literal bytes of an LZ token stream (the + * residual literals a parse actually leaves), walking the same wire + * layout extract_literals does but only counting. Returns total + * literal count, or 0 on a malformed stream (caller falls back to the + * raw-block histogram). */ +static size_t tok_lit_hist(const uint8_t *tokens, size_t tok_len, + int off_bytes, uint32_t hist[256]) { + const uint8_t *tp = tokens, *tp_end = tokens + tok_len; + size_t total = 0; + while (tp < tp_end) { + uint8_t token = *tp++; + size_t ll = token >> 4; + size_t mc = token & 0x0F; + if (ll == 15) { + do { + if (tp >= tp_end) return 0; + uint8_t b = *tp++; + ll += b; + if (b < 255) break; + } while (tp < tp_end); + } + if ((size_t)(tp_end - tp) < ll) return 0; + for (size_t i = 0; i < ll; i++) hist[tp[i]]++; + total += ll; + tp += ll; + if (tp >= tp_end) break; + if ((size_t)(tp_end - tp) < (size_t)off_bytes) return 0; + tp += off_bytes; + if (mc == 15) { + do { + if (tp >= tp_end) return 0; + uint8_t b = *tp++; + if (b < 255) break; + } while (tp < tp_end); + } + } + return total; +} + +static void opt_build_lit_prices_from_hist(const uint32_t hist[256], size_t n, + int32_t lit_bits[256]) { + for (int s = 0; s < 256; s++) { + if (!hist[s] || !n) { lit_bits[s] = 14; continue; } + /* ratio8 = (n / hist[s]) in 24.8 fixed point; log2(ratio8) = + * log2(n/hist) + 8. Round via the mantissa bit below the MSB. */ + uint32_t ratio8 = (uint32_t)(((uint64_t)n << 8) / hist[s]); + int t = enc_ilog2(ratio8); + int bits = t - 8; + if (t >= 1 && ((ratio8 >> (t - 1)) & 1)) bits++; /* round half up */ + if (bits < VV_OPT_LIT_MIN) bits = VV_OPT_LIT_MIN; + if (bits > 14) bits = 14; + /* Blend toward the flat-8 prior: a histogram estimate is still + * an approximation of the coder's delivered cost, and pricing + * from it unblended over-buys literals (measured; see the + * v2.64.0 sweep). blend/8 parts per-byte estimate, rest flat. */ + lit_bits[s] = (VV_OPT_LIT_BLEND * bits + (8 - VV_OPT_LIT_BLEND) * 8) / 8; + } +} + +/* match bit price: cost_const(14) + log2(off) + ml_extra; rep ~2 bits. + * + * SPRINT 128: priced against a caller-supplied rep set instead of + * m->rep. The matcher's rep state is a greedy-parser search heuristic + * that nothing updates during an optimal parse (it stayed {0,0,0} for + * every all-extreme frame, so rep pricing here was dead code), and the + * wire's rep state is PER-BLOCK and PATH-DEPENDENT: the SEQ encoder + * and decoder both start each block at {0,0,0} and evolve it per + * emitted sequence. The DP now threads that exact state through + * per-position rep histories (see compress_block_optimal). */ +/* A rep match saves the offset EXTRA bits, not the per-sequence + * overhead: it still spends full LL/OF/ML code symbols (~10 bits). + * The explicit-match constant 14 approximates that overhead plus + * slack, so the rep price must stay close beneath it — pricing reps + * near-free makes the DP shred long matches into chains of short rep + * matches, each paying the un-modeled sequence overhead (measured: + * -15% ratio on logs at rep=2). Constant swept on the 11-file corpus. */ +#ifndef VV_OPT_REP_BITS +#define VV_OPT_REP_BITS 10 +#endif +static inline int32_t opt_match_price(const uint32_t reps[3], uint32_t off, int32_t len) { + int is_rep = (off == reps[0] || off == reps[1] || off == reps[2]); int32_t log2_off = 0; uint32_t o = off; while (o > 1) { o >>= 1; log2_off++; } - int32_t off_bits = is_rep ? 2 : (14 + log2_off); + int32_t off_bits = is_rep ? VV_OPT_REP_BITS : (14 + log2_off); int32_t ml_extra = 0, v = len - VV_MIN_MATCH; if (v >= 15) ml_extra = 8 * (v / 255 + 1); return off_bits + ml_extra; } -/* Collect match candidates at pos (longest per distinct offset). */ +/* Wire rep-history update rule — must mirror vva_encode_sequences' + * enc_rep update (and the decoder's dec_rep) exactly: push only when + * the offset differs from rep[0]. */ +static inline void opt_rep_push(uint32_t dst[3], const uint32_t src3[3], uint32_t off) { + if (off == src3[0]) { + dst[0] = src3[0]; dst[1] = src3[1]; dst[2] = src3[2]; + } else { + dst[0] = off; dst[1] = src3[0]; dst[2] = src3[1]; + } +} + +/* Collect match candidates at pos (longest per distinct offset). + * SPRINT 128: rep candidates come from the DP path's rep history. */ static int opt_collect(const matcher_t *m, const uint8_t *data, - int32_t pos, int32_t end, opt_cand_t *cands) { + int32_t pos, int32_t end, opt_cand_t *cands, + const uint32_t reps[3]) { int n = 0; int32_t max_dist = (int32_t)((1u << m->wlog) - 1); int32_t limit = pos - max_dist; if (limit < 0) limit = 0; @@ -880,7 +1040,7 @@ static int opt_collect(const matcher_t *m, const uint8_t *data, uint32_t pos4; memcpy(&pos4, data + pos, 4); for (int r = 0; r < 3; r++) { - uint32_t roff = m->rep[r]; + uint32_t roff = reps[r]; if (roff == 0 || (int32_t)roff > pos) continue; const uint8_t *a = data + pos, *b = data + pos - roff; int32_t l = 0; while (l < max && a[l] == b[l]) l++; @@ -913,15 +1073,60 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, int off_bytes = (m->wlog > 16) ? 3 : 2; int32_t N = (int32_t)block_len; - /* DP arrays indexed by offset-from-base [0..N]. */ + /* DP arrays indexed by offset-from-base [0..N]. + * SPRINT 128: prep[i] is the wire rep-offset history of the best + * path reaching position i (zstd-btopt-style approximation: paths + * that lose on price but would carry better reps are dropped). + * prep[0] = {0,0,0} because the SEQ encoder and decoder both reset + * their rep state at every block boundary. */ int32_t *price = (int32_t *)malloc(sizeof(int32_t) * (N + 1)); int32_t *plen = (int32_t *)malloc(sizeof(int32_t) * (N + 1)); uint32_t *poff = (uint32_t *)malloc(sizeof(uint32_t) * (N + 1)); + uint32_t (*prep)[3] = (uint32_t (*)[3])malloc(sizeof(uint32_t[3]) * (N + 1)); opt_cand_t *cands = (opt_cand_t *)malloc(sizeof(opt_cand_t) * VV_OPT_MAX_CAND); - if (!price || !plen || !poff || !cands) { free(price); free(plen); free(poff); free(cands); return 0; } + if (!price || !plen || !poff || !prep || !cands) { free(price); free(plen); free(poff); free(prep); free(cands); return 0; } for (int32_t i = 0; i <= N; i++) { price[i] = VV_OPT_PRICE_INF; plen[i] = 0; poff[i] = 0; } price[0] = 0; + prep[0][0] = prep[0][1] = prep[0][2] = 0; + + /* SPRINT 129/130: entropy-aware per-byte literal prices for this + * block. The distribution that matters is the RESIDUAL literal + * stream (bytes a parse leaves uncovered), not the raw block — the + * raw histogram is dominated by exactly the repetitive content + * that matches will remove. A depth-4 greedy prepass on a private + * throwaway matcher (no shared-state pollution, ~1% of the DP's + * runtime) estimates that stream; its token output is histogrammed + * and discarded. Falls back to the raw-block histogram if the + * prepass cannot run. */ + int32_t lit_bits[256]; + { + uint32_t hist[256]; + memset(hist, 0, sizeof(hist)); + size_t nlit = 0; + matcher_t mp; + if (matcher_init(&mp, m->wlog, 4)) { + mp.accel = 2; + mp.max_match = m->max_match; + size_t pcap = block_len + block_len / 255 + 1024; + uint8_t *ptok = (uint8_t *)malloc(pcap); + if (ptok) { + size_t pcsz = compress_block(src, start_pos, block_len, ptok, + pcap, &mp, VV_MODE_ULTRA_FAST, min_match); + if (pcsz > 0) + nlit = tok_lit_hist(ptok, pcsz, off_bytes, hist); + free(ptok); + } + matcher_free(&mp); + } + if (nlit == 0) { + /* Prepass unavailable or block fully covered: raw fallback. */ + memset(hist, 0, sizeof(hist)); + for (int32_t i = 0; i < N; i++) hist[src[base + i]]++; + nlit = (size_t)N; + } + opt_build_lit_prices_from_hist(hist, nlit, lit_bits); + } /* Forward DP. We also must keep the matcher hash chains populated as we * advance, so matches reference earlier positions correctly. We insert @@ -944,13 +1149,16 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, } int32_t ip = base + i; - /* literal edge */ - int32_t lp = price[i] + opt_lit_price(); - if (lp < price[i + 1]) { price[i + 1] = lp; plen[i + 1] = 1; poff[i + 1] = 0; } + /* literal edge (literals leave the rep history unchanged) */ + int32_t lp = price[i] + lit_bits[src[ip]]; + if (lp < price[i + 1]) { + price[i + 1] = lp; plen[i + 1] = 1; poff[i + 1] = 0; + prep[i + 1][0] = prep[i][0]; prep[i + 1][1] = prep[i][1]; prep[i + 1][2] = prep[i][2]; + } /* match edges */ if (ip + min_match <= end) { - int nc = opt_collect(m, src, ip, end, cands); + int nc = opt_collect(m, src, ip, end, cands, prep[i]); /* Find the longest candidate. */ int32_t best_len = 0; uint32_t best_off = 0; for (int c = 0; c < nc; c++) { @@ -966,9 +1174,12 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, * the whole match. */ int32_t use = best_len; if (i + use > N) use = N - i; - int32_t np = price[i] + opt_match_price(m, best_off, use); + int32_t np = price[i] + opt_match_price(prep[i], best_off, use); int32_t j = i + use; - if (np < price[j]) { price[j] = np; plen[j] = use; poff[j] = best_off; } + if (np < price[j]) { + price[j] = np; plen[j] = use; poff[j] = best_off; + opt_rep_push(prep[j], prep[i], best_off); + } /* Insert boundary positions only (match-skip heuristic), * then jump the DP cursor to the match end. */ int32_t end5 = end - 5; @@ -985,9 +1196,12 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, if (i + mlen > N) mlen = N - i; if (mlen < min_match) continue; for (int32_t L = mlen; L >= min_match; L--) { - int32_t np = price[i] + opt_match_price(m, moff, L); + int32_t np = price[i] + opt_match_price(prep[i], moff, L); int32_t j = i + L; - if (np < price[j]) { price[j] = np; plen[j] = L; poff[j] = moff; } + if (np < price[j]) { + price[j] = np; plen[j] = L; poff[j] = moff; + opt_rep_push(prep[j], prep[i], moff); + } if (L > min_match + 8 && L < mlen) L = min_match + 9; } } @@ -1000,7 +1214,7 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, /* Worst case every position is a literal: N entries. */ int32_t *seq_len = (int32_t *)malloc(sizeof(int32_t) * (N + 1)); uint32_t *seq_off = (uint32_t *)malloc(sizeof(uint32_t) * (N + 1)); - if (!seq_len || !seq_off) { free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off); return 0; } + if (!seq_len || !seq_off) { free(price); free(plen); free(poff); free(prep); free(cands); free(seq_len); free(seq_off); return 0; } int32_t ns = 0, cur = N; while (cur > 0) { int32_t L = plen[cur]; @@ -1021,7 +1235,7 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, size_t ll = (size_t)(src + pos - lit_start); size_t needed = 1 + (ll >= 15 ? ll / 255 + 2 : 0) + ll + 2 + ((size_t)L / 255 + 2); if ((size_t)(op - dst) + needed > dst_cap) { - free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off); + free(price); free(plen); free(poff); free(prep); free(cands); free(seq_len); free(seq_off); return 0; } op += emit_seq(op, lit_start, ll, (size_t)L, O, off_bytes, min_match); @@ -1035,13 +1249,13 @@ static size_t compress_block_optimal(const uint8_t *src, size_t start_pos, size_t ll = (size_t)(src + end - lit_start); size_t needed = 1 + (ll >= 15 ? ll / 255 + 2 : 0) + ll; if ((size_t)(op - dst) + needed > dst_cap) { - free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off); + free(price); free(plen); free(poff); free(prep); free(cands); free(seq_len); free(seq_off); return 0; } op += emit_seq(op, lit_start, ll, 0, 0, off_bytes, min_match); } - free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off); + free(price); free(plen); free(poff); free(prep); free(cands); free(seq_len); free(seq_off); return (size_t)(op - dst); } @@ -1062,10 +1276,12 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_ int32_t end = (int32_t)(start_pos + block_len); const uint8_t *lit_start = src + start_pos; int off_bytes = (m->wlog > 16) ? 3 : 2; - uint32_t failures = 0; /* consecutive no-match positions (for --accel skip) */ + uint32_t failures = 0; /* consecutive no-match positions (for accel skip) */ + uint32_t nmatch = 0; /* matches found in this block (early-RAW bail) */ while (pos < end - min_match) { int32_t mlen = 0, moff = 0; + int pos_inserted = 0; /* ─── Step 1: Try rep-match (free, no hash lookup) ─── */ int32_t rep_idx = -1; @@ -1133,6 +1349,7 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_ pos + 1 < end - min_match) { /* Check pos+1 */ matcher_insert(m, src, pos, end); + pos_inserted = 1; int32_t noff = 0; int32_t nlen = chain_match(m, src, pos + 1, end, &noff); @@ -1191,6 +1408,7 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_ int rhs = moff_bits * (nlen + 1); if (lhs < rhs) { pos++; + pos_inserted = 0; /* the inserted position is now pos-1 */ mlen = nlen; moff = noff; rep_idx = nri; /* may have shifted from explicit→rep or vice versa */ @@ -1237,17 +1455,22 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_ * * Saves ~3 instructions per insert. Measured +4% encode * speedup on Silesia fast mode (Sprint 29). */ + /* SPRINT 124: when the lazy probe already inserted pos and + * we did not shift, start at pos+1 — re-inserting pos would + * put a self-duplicate link in the chain, lengthening every + * future walk through that bucket. */ + int32_t ins_first = pos + (pos_inserted ? 1 : 0); if (mlen >= 16) { /* Long match: only insert boundary positions */ int32_t end5 = end - 5; - for (int32_t j = pos; j < pos + 3 && j <= end5; j++) + for (int32_t j = ins_first; j < pos + 3 && j <= end5; j++) matcher_insert_fast(m, src, j); for (int32_t j = pos + mlen - 3; j < pos + mlen && j <= end5; j++) matcher_insert_fast(m, src, j); } else { /* Short match: insert all positions */ int32_t end5 = end - 5; - for (int32_t j = pos; j < pos + mlen && j <= end5; j++) + for (int32_t j = ins_first; j < pos + mlen && j <= end5; j++) matcher_insert_fast(m, src, j); } @@ -1255,14 +1478,28 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_ pos += mlen; lit_start = src + pos; failures = 0; /* matched: reset the no-match run */ + nmatch++; } else { - matcher_insert(m, src, pos, end); - /* --accel: skip ahead over unmatchable regions. accel==0 keeps - * the byte-identical default (advance 1). The skipped positions - * are not hashed/inserted and simply become literals. */ + if (!pos_inserted) matcher_insert(m, src, pos, end); + /* Accel: skip ahead over unmatchable regions. accel==0 keeps + * the byte-identical old default (advance 1). The skipped + * positions are not hashed/inserted and simply become + * literals. SPRINT 124: balanced/extreme cap the stride at 8 + * — on sparse-match data (struct-of-floats) an unbounded + * ramp skips over match starts and costs double-digit ratio; + * fast mode keeps the full lz4-style ramp. */ if (m->accel) { - pos += 1 + (int32_t)(((uint32_t)failures * m->accel) >> 6); + uint32_t step = 1 + (((uint32_t)failures * m->accel) >> 6); + if (mode >= VV_MODE_BALANCED && step > 8) step = 8; + pos += (int32_t)step; failures++; + /* Early RAW bail: 128 KB into the block with zero + * matches means this block is going raw anyway (csz + * would exceed braw). Returning 0 makes the caller + * emit a RAW block without paying for the rest of the + * parse or the literal memcpys. */ + if (nmatch == 0 && pos - (int32_t)start_pos >= (1 << 17)) + return 0; } else { pos++; } @@ -1375,25 +1612,55 @@ static size_t extract_literals( * - dst/dst_cap: output buffer * * Returns bytes written to dst on success, or 0 on overflow. */ +/* SPRINT 124: high-watermark tracking for the secure-zero scrub. + * Scrubbing full buffer capacities (~4 MB) per vv_compress call cost + * up to 14% of encode wall on fast inputs; only bytes actually written + * can hold plaintext, so tracking write watermarks preserves the + * Sprint 117 security property at a fraction of the cost. */ +typedef struct { + size_t tmp, lit, stripped, ent_front, ent_back; +} scrub_wm_t; + +static inline void wm_max(size_t *wm, size_t used) { + if (used > *wm) *wm = used; +} + static size_t emit_block(const uint8_t *src, size_t block_start, size_t braw, int last, matcher_t *m, vv_mode_t mode, uint8_t wlog, uint8_t *tmp, size_t tcap, uint8_t *lit_buf, size_t lit_cap, uint8_t *stripped, uint8_t *ent_buf, size_t ent_cap, uint8_t *dst, size_t dst_cap, int min_match, - int compat_v246_5) { + int compat_v246_5, scrub_wm_t *wm) { uint8_t *op = dst; /* SPRINT 42/43 RATIO PROGRAM: extreme mode uses the whole-block optimal * parser; balanced/fast keep greedy/lazy. csz==0 (overflow/alloc) flows - * into the raw-store branch below. */ + * into the raw-store branch below. + * + * SPRINT 124: on format-v2 (binary-detected) input, extreme uses the + * deep greedy/lazy parser instead. The optimal DP prices every match + * at full log2(offset) cost — it has no rep-offset model — so on + * rep-heavy record data (struct-of-floats, sensor logs) it loses + * 15-20% ratio to the rep-aware greedy path, and on incompressible + * binary it pays a full O(N·depth) DP just to store raw (the greedy + * path has skip acceleration and an early-RAW bail). Text-like input + * keeps the optimal parser, where it wins 3-11% over greedy. */ size_t csz; - if (mode >= VV_MODE_EXTREME) + int v2_block = (min_match < (int)VV_MIN_MATCH); + if (mode >= VV_MODE_EXTREME && !v2_block) csz = compress_block_optimal(src, block_start, braw, tmp, tcap, m, min_match); else csz = compress_block(src, block_start, braw, tmp, tcap, m, mode, min_match); + if (wm) wm_max(&wm->tmp, csz); - if (csz == 0 || csz >= braw) { + /* SPRINT 124: in balanced/extreme, a token stream slightly larger + * than raw can still win AFTER entropy coding — on low-match data + * (struct-of-floats, sensor logs) nearly all the compression comes + * from the entropy stage over literals, not from matches. Only the + * entropy-less fast path must reject csz >= braw outright. */ + size_t raw_gate = (mode >= VV_MODE_BALANCED) ? braw + braw / 8 : braw; + if (csz == 0 || csz >= raw_gate) { /* Incompressible: store raw */ if ((size_t)(op - dst) + 4 + braw > dst_cap) return 0; uint32_t bh = vv_bh_pack(VV_BLOCK_RAW, last, (uint32_t)braw); @@ -1423,8 +1690,9 @@ static size_t emit_block(const uint8_t *src, size_t block_start, size_t braw, seq_block_sz = 4 + 3 + 1 + seq_len; seq_valid = 1; } + if (wm) wm_max(&wm->ent_front, seq_len); - /* Path B: literal-only entropy ('I' or 'C') */ + /* Path B: literal-only entropy ('I' or 'A') */ size_t stripped_len = 0; size_t lit_count = 0; uint8_t *ent_buf2 = ent_buf + ent_cap / 2; @@ -1433,97 +1701,49 @@ static size_t emit_block(const uint8_t *src, size_t block_start, size_t braw, uint8_t ent_tag = 0; size_t ent_block_sz = (size_t)-1; - int try_path_b = 1; - /* PERF / dead-code prune (v2.53.3): Path B (literal-only 'I'/'C' - * entropy) has a measured 0% win rate against Path A (SEQ) across - * all real inputs tested (text, binary, logs, CSV) — SEQ always - * codes the same literals at least as small while also coding the - * matches. Path B can only conceivably win on a block where SEQ - * failed to find structure (its compressed size approaches raw). - * So skip Path B's extract_literals + redundant ANS encodes - * whenever SEQ is valid and already beats raw by a clear margin - * (seq_block_sz < braw*7/8). On blocks where SEQ does not compress - * (>= braw*7/8) Path B still runs, preserving the only case it - * could win. Verified byte-identical on all 12 Silesia (balanced + - * extreme) and on binary/log/CSV; the ratio gate guards against any - * regression. This removes redundant per-block work; it is a - * code-cleanliness change, not a measurable speedup (Path B was not - * the encode bottleneck — that is the depth-24 chain walk). */ - if (seq_valid && seq_block_sz < (braw * 7 / 8)) - try_path_b = 0; - if (mode == VV_MODE_BALANCED && seq_valid && seq_block_sz < (braw / 3)) { - /* SPRINT 29 (revised in v2.15): always try Path B in BALANCED - * mode, comparing both costs and picking the smaller. The - * earlier "skip Path B if seq compressed >3:1" heuristic - * (added in Sprint 28 for speed) saved ~30% encode time but - * hurt ratio on text-heavy data — Silesia dickens/reymont - * showed Path B's 'C' tag would have produced 5-10% smaller - * output but never got the chance. - * - * v2.15 trade-off: encoder is ~25% slower in BALANCED mode - * but ratio improves measurably on text. Decode speed is - * unaffected (decoder doesn't care which tag was chosen). - * - * In ULTRA_FAST/FAST modes the original skip remains in - * effect because those modes are throughput-priority. */ - (void)try_path_b; - } + /* Path B gate (v2.53.3, revised SPRINT 124): Path B has a + * measured 0% win rate against Path A (SEQ) on real inputs — + * SEQ codes the same literals at least as small while also + * coding the matches. Run it only when SEQ failed or produced + * weak output (>= 7/8 of raw). Path B is v1-only (its stripped + * tokens carry v1 matchlen bias), so on the v2 path skip the + * work entirely — the result could never be emitted. + * + * SPRINT 124: the CTX (order-1) coder is gone from this path. + * It ran exactly when SEQ was weak — low-redundancy binary — + * where it burned 50% of encode wall (sensors-class inputs) + * and, per the Sprint 53 measurements, never won a block. */ + int try_path_b = !use_v2 && (!seq_valid || + seq_block_sz >= (braw * 7 / 8)); if (try_path_b) { lit_count = extract_literals(tmp, csz, lit_buf, lit_cap, stripped, &stripped_len, off_bytes); + if (wm) { + wm_max(&wm->lit, lit_count); + wm_max(&wm->stripped, stripped_len); + } if (lit_count > 0) { - /* SPRINT 53: skip the expensive CTX (order-1 context) - * path when sequence coding is already winning by a - * big margin. Profile data across 7 fixtures (text, - * json, source, 4 ELF binaries) showed CTX wins 0/16 - * attempts — the CTX coder has never actually beaten - * SEQ on these workloads, but burned 20% of encode - * time building per-context ANS tables that were - * always discarded. - * - * Heuristic: skip CTX when seq_block_sz already does - * better than 2:1 compression (seq_block_sz < braw/2). - * Path A (SEQ) essentially never loses to Path B (CTX) - * when the LZ matcher found strong matches. CTX only - * matters for low-redundancy data where SEQ produces - * close-to-raw output — exactly the case where - * seq_block_sz ≥ braw/2. - * - * Falls back to ANS4 / ANS as literal coders in the - * unchanged code below. These are ~10× cheaper than - * CTX to build. Net encode-time savings measured in - * SPRINT 53 CHANGELOG entry. - * - * Security/correctness: this is purely an encoder - * heuristic. Decoder is unchanged. Output wire format - * still meets spec. Worst case on a pathological - * input where CTX would have won: we produce slightly - * larger output via ANS4 or ANS. Ratio gate guards - * against any real regression. */ - int skip_ctx = seq_valid && seq_block_sz < (braw * 4 / 5); - if (!skip_ctx && mode >= VV_MODE_BALANCED && lit_count >= 4096) { - vva_error_t aerr = vva_encode_ctx(lit_buf, lit_count, - ent_buf2, ent_cap2, &ent_len); - if (aerr == VVA_OK) ent_tag = VV_ENTROPY_CTX; - } + vva_error_t aerr = vva_encode4(lit_buf, lit_count, + ent_buf2, ent_cap2, &ent_len); + if (aerr == VVA_OK) ent_tag = VV_ENTROPY_ANS4; if (!ent_tag) { - vva_error_t aerr = vva_encode4(lit_buf, lit_count, - ent_buf2, ent_cap2, &ent_len); - if (aerr == VVA_OK) ent_tag = VV_ENTROPY_ANS4; - } - if (!ent_tag) { - vva_error_t aerr = vva_encode(lit_buf, lit_count, - ent_buf2, ent_cap2, &ent_len); + aerr = vva_encode(lit_buf, lit_count, + ent_buf2, ent_cap2, &ent_len); if (aerr == VVA_OK) ent_tag = VV_ENTROPY_ANS; } if (ent_tag) { ent_block_sz = 4 + 3 + 1 + 2 + 2 + ent_len + stripped_len; } + if (wm) wm_max(&wm->ent_back, ent_len); } } size_t raw_block_sz = 4 + 3 + csz; + /* Raw-store block size: with the relaxed raw_gate above, csz may + * exceed braw, so every candidate must also beat plain storage. */ + size_t store_sz = 4 + braw; + if (raw_block_sz > store_sz) raw_block_sz = store_sz; if (seq_valid && seq_block_sz <= ent_block_sz && seq_block_sz < raw_block_sz) { if ((size_t)(op - dst) + seq_block_sz > dst_cap) return 0; @@ -1554,21 +1774,20 @@ static size_t emit_block(const uint8_t *src, size_t block_start, size_t braw, op[0] = (uint8_t)(ent_len); op[1] = (uint8_t)(ent_len >> 8); op += 2; memcpy(op, ent_buf2, ent_len); op += ent_len; memcpy(op, stripped, stripped_len); op += stripped_len; - } else if (!use_v2) { + } else if (!use_v2 && csz < braw) { /* Plain VV_BLOCK_COMPRESSED carries raw v1-format tokens. * For v2, we must not emit these — the decoder would - * reconstruct matchlen with +4 instead of +3. Fall to RAW - * block instead (handled below via "else" when raw_block_sz - * is smaller). We reach this branch only when the previous - * conditions all failed AND we're NOT v2. */ - if ((size_t)(op - dst) + raw_block_sz > dst_cap) return 0; + * reconstruct matchlen with +4 instead of +3. Guarded on + * csz < braw because the relaxed raw_gate can let a token + * stream slightly larger than raw reach this point. */ + if ((size_t)(op - dst) + 4 + 3 + csz > dst_cap) return 0; uint32_t bh = vv_bh_pack(VV_BLOCK_COMPRESSED, last, (uint32_t)braw); memcpy(op, &bh, 4); op += 4; op[0] = (uint8_t)(csz); op[1] = (uint8_t)(csz >> 8); op[2] = (uint8_t)(csz >> 16); op += 3; memcpy(op, tmp, csz); op += csz; } else { - /* v2 path, sequence coding didn't fit/help: emit RAW. */ + /* Nothing beat plain storage: emit RAW. */ if ((size_t)(op - dst) + 4 + braw > dst_cap) return 0; uint32_t bh = vv_bh_pack(VV_BLOCK_RAW, last, (uint32_t)braw); memcpy(op, &bh, 4); op += 4; @@ -1696,32 +1915,55 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, size_t sz16 = 0, sz20 = 0; /* SPRINT 93 audit: matcher_init can fail; if it does, skip * the trial (this path is a perf-tuning probe — falling - * back to default wlog is safe). */ + * back to default wlog is safe). + * SPRINT 124: trials run with accel=2 so incompressible + * inputs no longer pay two full 128 KB parses just to + * decide "store raw". Both trials use the same accel, so + * the 16-vs-20 comparison stays apples-to-apples. */ if (matcher_init(&m16, 16, 4)) { + m16.accel = 2; sz16 = compress_block(src, 0, trial_len, trial_buf, trial_cap, &m16, VV_MODE_ULTRA_FAST, VV_MIN_MATCH); matcher_free(&m16); } matcher_t m20; if (matcher_init(&m20, 20, 4)) { + m20.accel = 2; sz20 = compress_block(src, 0, trial_len, trial_buf, trial_cap, &m20, VV_MODE_ULTRA_FAST, VV_MIN_MATCH); matcher_free(&m20); } free(trial_buf); if (sz20 > 0 && sz16 > 0 && sz20 < (sz16 * 97 / 100)) wlog = 20; - /* Binary-like detection: best trial ratio < 2:1 */ + /* Binary-like detection: best trial ratio < 2:1. A zero + * size means the early-RAW bail fired — maximally + * incompressible, so binary-like by definition. */ size_t best_sz = (sz20 > 0 && sz20 < sz16) ? sz20 : sz16; - if (best_sz > 0 && best_sz * 2 > trial_len) enable_hash4 = 1; + if (best_sz == 0 || best_sz * 2 > trial_len) enable_hash4 = 1; } } + /* SPRINT 124: adaptive format v2 (decided here because the window + * overrides below must not fire for v2-routed input). min_match=3 + * ('T' blocks) is a measured 14%+ ratio win on struct-of-floats/ + * record binary and 2-3% on ELF, while slightly HURTING text/JSON + * ratio and decode speed (more, shorter sequences). Auto-enable + * exactly where it wins: binary-detected inputs. Suppressed by + * the compat flag because 'T' blocks require a v2.33.0+ decoder. + * Explicit opts->format_v2 still forces it for any input. */ + int use_v2_fmt = opts->format_v2 || + (enable_hash4 && opts->mode >= VV_MODE_BALANCED && + !opts->compat_v246_5_decoder); + /* SPRINT 67: size-based wlog override. The trial above often * misses wins that only become visible past the 128 KB trial * boundary (long-range refs in multi-MB files). Override to - * wlog=18 for files ≥ 3 MB when the trial left wlog at 16. */ + * wlog=18 for files ≥ 3 MB when the trial left wlog at 16. + * SPRINT 124: not for v2-routed (binary) input — the greedy + * parser regresses badly on rep-heavy data with large windows + * (diverse far offsets break rep streaks and bloat OF codes). */ if (opts->window_log == 0 && opts->mode >= VV_MODE_BALANCED && - wlog == 16 && src_len >= 3145728) { + !use_v2_fmt && wlog == 16 && src_len >= 3145728) { wlog = 18; } @@ -1747,7 +1989,11 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, * Memory at wlog=24: chain[wsz]+hash4_chain[wsz] = 2*4*16M = 128 MB * matcher. Acceptable for extreme ("max ratio, will wait"). */ if (opts->window_log == 0 && opts->mode >= VV_MODE_EXTREME && - src_len > (1u << 20)) { + !use_v2_fmt && src_len > (1u << 20)) { + /* SPRINT 124: v2-routed (binary) extreme input uses the greedy + * parser (no rep model in the optimal DP), and greedy + large + * window is a measured 15-30% ratio LOSS on rep-heavy data — + * keep the trial-chosen window there. */ uint8_t want = 20; uint64_t s = src_len; while ((1ull << want) < s && want < 24) want++; @@ -1781,18 +2027,29 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, * by the balanced/extreme window-selection trial above stay at * single_probe==0 and produce bit-identical trial sizes. */ m.single_probe = (opts->mode == VV_MODE_ULTRA_FAST) ? 1 : 0; - m.accel = opts->accel > 64 ? 64 : opts->accel; + /* SPRINT 124: accel defaults ON. opts->accel == 0 now means "auto": + * fast mode gets the lz4-style ramp (2 → step 1 + failures/32), + * balanced/extreme a gentle one (1 → step 1 + failures/64, capped + * at 8 inside compress_block). This is what turns 1 MB of random + * bytes from a 24 ns/byte full-parse crawl into a near-memcpy RAW + * store. Explicit --accel values are honored unchanged. */ + { + uint32_t eff_accel = opts->accel; + if (eff_accel == 0) + eff_accel = (opts->mode >= VV_MODE_BALANCED) ? 1 : 2; + m.accel = eff_accel > 64 ? 64 : eff_accel; + } m.no_rep = opts->no_rep ? 1 : 0; /* Format v2 cap applies to EVERY match emitted from this matcher, * not just those produced via hash3. Set unconditionally when - * opts.format_v2 is active. */ - if (opts->format_v2) { + * the v2 format is active. */ + if (use_v2_fmt) { matcher_set_format_v2(&m); } /* Hash3 enablement is a separate, adaptive decision. Only fires * on binary-like data (enable_hash4) where length-3 matches * actually help. On text/JSON it stays off to avoid regressions. */ - if (opts->format_v2 && enable_hash4) { + if (use_v2_fmt && enable_hash4) { if (!matcher_enable_hash3(&m)) { matcher_free(&m); return VV_ERR_NOMEM; @@ -1817,7 +2074,14 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, * output. For small one-shot calls this avoids ~3 MB of wasted * allocation and page-faulting every call. */ lit_cap = block_bound; - ent_cap = vva_bound(block_bound); + /* SPRINT 124 (latent-corruption fix): ent_buf is shared by Path A + * (SEQ, writes at ent_buf[0..]) and Path B (literal entropy, + * writes at ent_buf + ent_cap/2). SEQ output on weak blocks can + * reach vva_bound(braw) — with ent_cap == vva_bound the halves + * OVERLAP and Path B silently clobbers SEQ's tail before the + * winner is chosen. Size the buffer so each half holds a full + * vva_bound worth of output. */ + ent_cap = 2 * vva_bound(block_bound); lit_buf = (uint8_t *)malloc(lit_cap); stripped = (uint8_t *)malloc(tcap); ent_buf = (uint8_t *)malloc(ent_cap); @@ -1836,10 +2100,12 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, memcpy(op, &bh, 4); op += 4; } - /* Format v2: when opts->format_v2 is set, encode with min_match=3. + /* Format v2 (explicit or adaptive): encode with min_match=3. * Produces 'T'-tagged ENTROPY blocks which only v2.33.0+ decoders * can read. Closes the real-binary compression gap vs gzip-9. */ - int min_match = opts->format_v2 ? 3 : (int)VV_MIN_MATCH; + int min_match = use_v2_fmt ? 3 : (int)VV_MIN_MATCH; + + scrub_wm_t wm = {0, 0, 0, 0, 0}; while (remaining > 0) { size_t braw = remaining > VV_MAX_BLOCK_SIZE ? VV_MAX_BLOCK_SIZE : remaining; @@ -1850,7 +2116,7 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, tmp, tcap, lit_buf, lit_cap, stripped, ent_buf, ent_cap, op, dst_cap - (size_t)(op - dst), min_match, - opts->compat_v246_5_decoder); + opts->compat_v246_5_decoder, &wm); if (written == 0) { free(lit_buf); free(stripped); free(ent_buf); free(tmp); matcher_free(&m); @@ -1861,11 +2127,19 @@ int64_t vv_compress_inner(const uint8_t *src, size_t src_len, } /* Sprint 117: scrub plaintext-derived working buffers before free - * to prevent heap-residue leak (defense in depth). */ - vv_secure_zero(tmp, tcap); - if (lit_buf) vv_secure_zero(lit_buf, lit_cap); - if (stripped) vv_secure_zero(stripped, tcap); - if (ent_buf) vv_secure_zero(ent_buf, ent_cap); + * to prevent heap-residue leak (defense in depth). + * SPRINT 124: scrub only up to each buffer's write watermark — + * bytes beyond it were never written and cannot hold plaintext. */ + vv_secure_zero(tmp, wm.tmp < tcap ? wm.tmp : tcap); + if (lit_buf) vv_secure_zero(lit_buf, wm.lit < lit_cap ? wm.lit : lit_cap); + if (stripped) vv_secure_zero(stripped, wm.stripped < tcap ? wm.stripped : tcap); + if (ent_buf) { + vv_secure_zero(ent_buf, wm.ent_front < ent_cap ? wm.ent_front : ent_cap); + size_t back_cap = ent_cap - ent_cap / 2; + if (wm.ent_back) + vv_secure_zero(ent_buf + ent_cap / 2, + wm.ent_back < back_cap ? wm.ent_back : back_cap); + } free(lit_buf); free(stripped); free(ent_buf); free(tmp); @@ -1986,8 +2260,13 @@ vv_cstream_t *vv_cstream_create(const vv_options_t *opts) { ctx->tmp = (uint8_t *)malloc(ctx->tcap); ctx->lit_cap = VV_MAX_BLOCK_SIZE; ctx->lit_buf = (uint8_t *)malloc(ctx->lit_cap); - ctx->stripped = (uint8_t *)malloc(ctx->lit_cap); - ctx->ent_cap = vva_bound(VV_MAX_BLOCK_SIZE); + /* SPRINT 124: stripped tokens can slightly exceed the raw block + * size now that emit_block lets csz ∈ [braw, braw*9/8) reach the + * entropy stage — size like tmp, not like lit_buf. */ + ctx->stripped = (uint8_t *)malloc(ctx->tcap); + /* SPRINT 124: 2× so Path A (front half) and Path B (back half) + * can never overlap — see the matching fix in vv_compress_inner. */ + ctx->ent_cap = 2 * vva_bound(VV_MAX_BLOCK_SIZE); ctx->ent_buf = (uint8_t *)malloc(ctx->ent_cap); /* Source window = 2 × window_size so a full block of input can @@ -2014,7 +2293,7 @@ void vv_cstream_destroy(vv_cstream_t *ctx) { * encrypted output. All are scrubbed to prevent heap-residue leak. */ if (ctx->tmp) vv_secure_zero(ctx->tmp, ctx->tcap); if (ctx->lit_buf) vv_secure_zero(ctx->lit_buf, ctx->lit_cap); - if (ctx->stripped) vv_secure_zero(ctx->stripped, ctx->lit_cap); + if (ctx->stripped) vv_secure_zero(ctx->stripped, ctx->tcap); if (ctx->ent_buf) vv_secure_zero(ctx->ent_buf, ctx->ent_cap); if (ctx->src_buf) vv_secure_zero(ctx->src_buf, ctx->src_cap); free(ctx->tmp); free(ctx->lit_buf); free(ctx->stripped); free(ctx->ent_buf); @@ -2168,7 +2447,8 @@ int vv_cstream_compress_chunk(vv_cstream_t *ctx, ctx->lit_buf, ctx->lit_cap, ctx->stripped, ctx->ent_buf, ctx->ent_cap, op, cap_left, stream_min_match, - ctx->opts.compat_v246_5_decoder); + ctx->opts.compat_v246_5_decoder, + NULL /* stream scrubs full caps at destroy */); if (block_sz == 0) return VV_ERR_OVERFLOW; op += block_sz; cap_left -= block_sz; } diff --git a/src/vv_huffman.c b/src/vv_huffman.c index d1440ff..082b708 100644 --- a/src/vv_huffman.c +++ b/src/vv_huffman.c @@ -85,8 +85,23 @@ static inline void br_init(br_t *r, const uint8_t *src, size_t len) { r->bits = 0; r->nbits = 0; r->src = src; r->pos = 0; r->len = len; } -/* Refill: load bytes until accumulator is full (≥56 bits) */ +/* Refill: load bytes until accumulator is full (≥56 bits). + * SPRINT 124: bulk 8-byte fast path. The byte-at-a-time loop was up + * to 7 dependent load-shift-or iterations firing every 3-4 symbols + * per stream — measured as the top cost of Huffman literal decode. + * One unaligned 8-byte load + mask absorbs the same bytes; the tail + * (<8 bytes left) keeps the exact byte loop. */ static inline void br_refill(br_t *r) { + if (r->pos + 8 <= r->len) { + unsigned absorbed = (63u - (unsigned)r->nbits) >> 3; /* 0..7 */ + uint64_t chunk; + memcpy(&chunk, r->src + r->pos, 8); + chunk &= ((uint64_t)1 << (absorbed * 8)) - 1; + r->bits |= chunk << r->nbits; + r->pos += absorbed; + r->nbits += (int)(absorbed * 8); + return; + } while (r->nbits <= 56 && r->pos < r->len) { r->bits |= (uint64_t)r->src[r->pos++] << r->nbits; r->nbits += 8; @@ -767,13 +782,69 @@ vvh_error_t vvh_decode4(const uint8_t *src, size_t src_len, } \ } while (0) + /* Variant without the per-symbol refill check, for rounds where a + * bulk refill has already guaranteed enough bits (see below). */ + #define DEC_ONE_NR(R, OUT) do { \ + uint32_t peek = br_peek(&(R), VVH_DECODE_BITS); \ + uint32_t entry = dec->table[peek]; \ + int sym = (int)(entry & 0xFF); \ + int len = (int)((entry >> 8) & 0xF); \ + if (VV_LIKELY(len > 0)) { \ + br_consume(&(R), len); \ + (OUT) = (uint8_t)sym; \ + } else { \ + int found = 0; \ + for (int s = 0; s < dec->slow_count; s++) { \ + int slen = dec->slow_len[s]; \ + uint32_t mask = (1u << slen) - 1; \ + if ((br_peek(&(R), slen) & mask) == dec->slow_code[s]) { \ + br_consume(&(R), slen); \ + (OUT) = dec->slow_sym[s]; \ + found = 1; \ + break; \ + } \ + } \ + if (!found) { free(dec); return VVH_ERR_CORRUPT; } \ + } \ + } while (0) + /* ─── 7. Hot loop: decode 4 symbols per iteration ─── */ /* Each iteration's 4 decodes are fully independent — different * readers, different table peeks, different output positions. * Modern OoO engines can pipeline 4 independent decode chains - * achieving ~1.8-2.2× speedup over single-stream. */ + * achieving ~1.8-2.2× speedup over single-stream. + * + * SPRINT 127: refill-hoisted fast rounds. One bulk refill per lane + * guarantees >= 56 accumulator bits (its 8-byte fast path applies + * whenever pos + 8 <= len, which the loop guard checks per lane), + * and three symbols consume at most 3 x VVH_MAX_CODE_LEN = 45 bits + * — so each round decodes 3 symbols per lane (12 outputs) with a + * single refill branch per lane instead of one per symbol. Bit + * consumption and decode order are identical to the per-symbol + * loop; corrupt input still bottoms out at the same slow-path + * check, and nbits cannot underflow (56 - 45 >= 0). The tail and + * the last rounds fall back to the checked DEC_ONE loop. */ size_t out_idx = 0; - for (size_t i = 0; i < Q; i++) { + size_t i = 0; + while (i + 3 <= Q && + r0.pos + 8 <= r0.len && r1.pos + 8 <= r1.len && + r2.pos + 8 <= r2.len && r3.pos + 8 <= r3.len) { + br_refill(&r0); br_refill(&r1); br_refill(&r2); br_refill(&r3); + for (int k = 0; k < 3; k++) { + uint8_t y0, y1, y2, y3; + DEC_ONE_NR(r0, y0); + DEC_ONE_NR(r1, y1); + DEC_ONE_NR(r2, y2); + DEC_ONE_NR(r3, y3); + dst[out_idx + 0] = y0; + dst[out_idx + 1] = y1; + dst[out_idx + 2] = y2; + dst[out_idx + 3] = y3; + out_idx += 4; + } + i += 3; + } + for (; i < Q; i++) { uint8_t y0, y1, y2, y3; DEC_ONE(r0, y0); DEC_ONE(r1, y1); @@ -793,6 +864,7 @@ vvh_error_t vvh_decode4(const uint8_t *src, size_t src_len, if (tail >= 3) { uint8_t y; DEC_ONE(r2, y); dst[out_idx++] = y; } #undef DEC_ONE + #undef DEC_ONE_NR /* Total bytes consumed: header + stream-size header + all 4 streams */ *src_consumed = streams_off + s0 + s1 + s2 + s3; diff --git a/src/zupt_format.c b/src/zupt_format.c index d032865..c7ee4bc 100644 --- a/src/zupt_format.c +++ b/src/zupt_format.c @@ -118,11 +118,33 @@ uint16_t zupt_resolve_auto_codec(void) { } static uint32_t auto_block_size(int level) { - if (level <= 2) return 131072; - if (level <= 4) return 131072; - if (level <= 6) return 262144; - if (level <= 7) return 262144; - return 524288; + /* The block IS the codec's LZ window: matches never cross a block + * boundary, so a small block throttles the "large-window extreme" + * parser (512 KiB gave text 3.75x where a whole-file window gives + * 7.6x — measured on codec 2.65.0). Higher levels therefore get a + * larger block. Trade-offs held in mind: (a) block size also sets + * --dedup granularity, so the speed-first low levels (where dedup is + * most used) stay small; and (b) extreme's optimal DP is ~O(block), + * so the extreme block is bounded at 8 MiB — 16 MiB bought only a few + * more percent of ratio for ~2.5x the encode time, not worth it as a + * default (raise it explicitly with -b for archival runs). Decode + * speed and memory are unaffected by block size. */ + if (level <= 2) return 131072; /* fast: speed + MT + dedup granularity */ + if (level <= 4) return 1u << 20; /* 1 MiB */ + if (level <= 6) return 2u << 20; /* 2 MiB */ + if (level <= 7) return 4u << 20; /* 4 MiB balanced: ~free, big ratio win */ + return 8u << 20; /* 8 MiB extreme: large usable window */ +} + +/* Block size when --dedup is active. Dedup detects duplicate BLOCKS, so a + * large block almost never finds a duplicate (an 8 MiB block rarely repeats + * byte-exactly), collapsing the dedup ratio to 1.0x — directly opposed to the + * large-window compression goal, which they share the one block_size knob for. + * With --dedup the user has chosen block-level dup detection, so pick a small + * block that actually finds repeats (256 KiB is the classic dedup granularity; + * finer than that costs index memory for little gain on real backups). */ +static uint32_t auto_block_size_dedup(int level) { + return level <= 2 ? 131072u : 262144u; } void zupt_format_size(uint64_t b, char *buf, size_t cap) { if (b < 1024) snprintf(buf, cap, "%llu B", (unsigned long long)b); @@ -768,7 +790,7 @@ zupt_error_t zupt_compress_files(const char *output_path, const char **disk_paths, int num_files, zupt_options_t *opts) { - if (opts->block_size == 0) opts->block_size = auto_block_size(opts->level); + if (opts->block_size == 0) opts->block_size = opts->dedup ? auto_block_size_dedup(opts->level) : auto_block_size(opts->level); /* Resolve AUTO codec before compression */ if (opts->codec_id == ZUPT_CODEC_AUTO) @@ -1307,7 +1329,7 @@ zupt_error_t zupt_compress_solid(const char *output_path, const char **disk_paths, int num_files, zupt_options_t *opts) { - if (opts->block_size == 0) opts->block_size = auto_block_size(opts->level); + if (opts->block_size == 0) opts->block_size = opts->dedup ? auto_block_size_dedup(opts->level) : auto_block_size(opts->level); if (opts->block_size < 524288) opts->block_size = 524288; /* Resolve AUTO codec before compression */