diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index facd40fe9c8..7c32cd68722 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: hooks: - id: check-added-large-files # Retain the exact lossless cross-platform performance evidence archives. - exclude: ^docs/benchmarks/((executor-copy-audit|bounded-read-ahead)-20261101|(directory-task-slots|packed-visit-snapshots|bounded-match-results|useful-match-workers|direct-evaluator-workers|derived-content-handoff|immutable-default-catalogs|prepared-diff-configuration|prepared-hash-operands|aggregate-comparison-totals|byte-shingle-representations|archive-input-buffers)-20261102|(digest-and-record-storage|caller-owned-streams|direct-source-materialization)-20261103)\.json\.gz$ + exclude: ^docs/benchmarks/((executor-copy-audit|bounded-read-ahead)-20261101|(directory-task-slots|packed-visit-snapshots|bounded-match-results|useful-match-workers|direct-evaluator-workers|derived-content-handoff|immutable-default-catalogs|prepared-diff-configuration|prepared-hash-operands|aggregate-comparison-totals|byte-shingle-representations|archive-input-buffers)-20261102|(digest-and-record-storage|caller-owned-streams|direct-source-materialization|hash-field-binding|hash-field-binding-tag)-20261103)\.json\.gz$ - id: check-merge-conflict - id: check-yaml # BINARY_TYPES below: archive containers are binary but can START with text (a phar begins with diff --git a/CHANGELOG.md b/CHANGELOG.md index 79cd18f663c..c9051ebdcdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ # 0.11.0 +- Bind explicit hash-field qualifiers once while preserving dynamic defaults, copied templates and snapshot lifetimes. + - Fill bounded source ranges directly into final storage and reuse owned decoder blocks during materialization, preserving read limits, errors and budgets. - Reuse caller-owned input storage for memory-backed and nested archive-member streams while preserving read errors, sparse content and shared budgets. diff --git a/TODO.md b/TODO.md index b9d422043b0..3c6070e02b3 100644 --- a/TODO.md +++ b/TODO.md @@ -468,8 +468,9 @@ EP/JC/P work; candidates are not speedup claims until measured on both platforms - [x] O22a: Qualify snapshot-owned digest observers against owning consumption, preserving cache insertion stability, invalidation, local-snapshot fallback, read errors and derived handoff. Keep [the separate mechanisms and controls](docs/benchmarks/digest-and-record-storage-20261103.md). -- [ ] O22b: Qualify invariant field-hash binding without enlarging every unrelated template segment - or hiding typed state in strings. Preserve dynamic defaults and cold/non-hash controls. +- [x] O22b: Qualify invariant field-hash binding without enlarging every unrelated template segment + or hiding typed state in strings. Preserve dynamic defaults and cold/non-hash controls in + [the binding report](docs/benchmarks/hash-field-binding-20261103.md), including rejected setup work. - [x] O22c1: Qualify direct plain-record construction and bulk C-escaping on both hosts, retaining tiny colored controls and shared display/tree consumers in the O22a report. - [ ] O22c2: Qualify command-owned reusable record/row buffers and streaming machine formats. diff --git a/docs/benchmarks/hash-field-binding-20261103.json.gz b/docs/benchmarks/hash-field-binding-20261103.json.gz new file mode 100644 index 00000000000..d527f8b87f8 Binary files /dev/null and b/docs/benchmarks/hash-field-binding-20261103.json.gz differ diff --git a/docs/benchmarks/hash-field-binding-20261103.md b/docs/benchmarks/hash-field-binding-20261103.md new file mode 100644 index 00000000000..edcea5f33f6 --- /dev/null +++ b/docs/benchmarks/hash-field-binding-20261103.md @@ -0,0 +1,202 @@ + + + +# Compact hash-field binding (2026-11-03) + +## Decision and contracts + +Bind static hash qualifiers when a field template is compiled. Two bytes beside the existing +post-processing enum hold an algorithm catalog ID and encoding/default choice. IDs derive from +`hash::AlgorithmNames`; there is no second algorithm catalog, string-encoded state, per-segment +allocation or added string view. Tracked template storage is unchanged for every measured shape. +This conservative capacity accounting is not an allocator/RSS measurement. + +Explicit algorithms and encodings avoid repeated qualifier parsing. Defaults still resolve against +each render context, including changes after compilation, copied/moved templates, invalid defaults, +and post-render substitutions/extractions. Independent explicit choices ignore invalid unrelated +defaults. Local snapshot results remain owned; supplied snapshot digest views keep their existing +lifetimes and invalidation behavior. Static diagnostics, unknown-field behavior and output bytes +remain unchanged. No public flag, dependency, worker, lock, content cache or host-I/O route is added. + +The first prototype duplicated hash qualifier validation during compilation and loaded a binding +tag to dispatch every field. The selected refinement validates the bound result once, binds bare +and transformed defaults directly, and reuses the already loaded field function for dispatch. +This removes most of the extra compilation cost. It still adds work to some unrelated field paths; +those controls and absolute costs are retained below. + +## Protocol + +Frozen parent `8c12718f8c04b8e2772e9133ecb4e6fac025da2c` includes the synchronized #1068 parent +and #1069 materialization implementation. Filename-aware formatting precedes frozen builds. +Compiler hashes, configurations, release link actions, source identities and binary sizes are +retained. Both hosts use the same baseline and common tests/benchmark support. + +The first comparison has 47 independent cases x three sessions x two variants (282 participant-case +runs per host). Refinement compares baseline, first prototype and selected implementation over the +same 47 cases x three sessions (423 runs per host). The reference/first binaries are reused only +after proving identical compiler/configuration and exact binary hashes. Populations remain separate. + +Independent confirmation adds 20 native cases x three sessions, with three participants: baseline, +selected, and a byte-identical baseline repeat. Every case discards a warmup per participant, checks +all stdout and exit codes against independently constructed paths/hashlib digests, and retains the +fastest seven of nine rounds (1,800 invocations per host including warmups). Native fixtures cover +10/1,000/10,000 files, long-path controls, one/three requested workers, listing and ordinary/transformed +hash fields. Linux fixtures use tmpfs and CPU affinity; macOS uses warm APFS without pinning. + +Separate memory confirmation has 16 cases x three sessions x three participants (144 runs per host). +Every memory case retains nine repetitions/fastest seven. Batches of at most eight compatible +family/CPU cases amortize untimed Google Benchmark metadata startup; each retains its own fixture, +warmup and measured repetitions. Whole engine cases use an in-memory VFS. No timing overlaps builds, +lint or instrumentation on the same host. Reference-repeat movement is shown, never subtracted. + +Hash-binding scenario IDs: `0` context defaults (`md5/base64`); `1` explicit `md5/base64`; +`2` explicit `md5` with context encoding; `3` context algorithm with explicit `base64`; +`4` post-render substitution using context defaults. The preceding counts are repeated fields +(`1`/`16`) and warm-cache choice (`0` cold/`1` warm). Compile scenarios are `0` name, +`1` ordinary mixed fields, `2` bare hash, `3` explicit hash and `4` mixed/transformed hash fields. + +## Memory and whole-engine results + +Median paired-session elapsed-time changes; negative means faster. Initial is the complete refined +three-variant comparison, not the rejected prototype. A missing confirmation cell means that case +was not repeated in the separate confirmation matrix. All raw cases remain in the archive. + +| Case | Linux initial | Linux confirm | Linux repeat | macOS initial | macOS confirm | macOS repeat | +| :--------------------------------------------- | ------------: | ------------: | -----------: | ------------: | ------------: | -----------: | +| engine/production/hash-fields-repeat/10/1 | -1.72% | n/a | n/a | -2.96% | n/a | n/a | +| engine/production/hash-fields-repeat/10/3 | -1.82% | n/a | n/a | -3.48% | n/a | n/a | +| engine/production/hash-fields-repeat/1000/1 | -4.12% | -4.32% | +0.31% | -3.04% | -4.96% | +0.60% | +| engine/production/hash-fields-repeat/1000/3 | -3.48% | n/a | n/a | -5.65% | n/a | n/a | +| engine/production/hash-fields-transform/10/1 | -1.21% | n/a | n/a | -1.01% | n/a | n/a | +| engine/production/hash-fields-transform/10/3 | -1.28% | n/a | n/a | +1.96% | n/a | n/a | +| engine/production/hash-fields-transform/1000/1 | -6.12% | n/a | n/a | -1.58% | n/a | n/a | +| engine/production/hash-fields-transform/1000/3 | -4.39% | -5.39% | +1.39% | -3.77% | -1.59% | +1.70% | +| engine/production/name/10/1 | -0.29% | n/a | n/a | +0.88% | n/a | n/a | +| engine/production/name/10/3 | +0.37% | n/a | n/a | +1.03% | n/a | n/a | +| engine/production/name/1000/1 | +0.76% | n/a | n/a | -0.43% | n/a | n/a | +| engine/production/name/1000/3 | +0.27% | +0.14% | -0.09% | +0.30% | -0.13% | +1.08% | +| fields/components/10/8 | +1.16% | n/a | n/a | +3.73% | n/a | n/a | +| fields/components/1000/8 | +1.93% | +1.31% | +0.40% | +5.25% | -1.30% | -2.84% | +| fields/name/10/8 | +2.33% | n/a | n/a | +1.61% | n/a | n/a | +| fields/name/1000/8 | +2.03% | +2.08% | +0.24% | +1.02% | -0.48% | -1.46% | +| output/compile-fields/0 | -0.14% | -0.48% | +0.00% | -5.99% | +6.82% | +0.41% | +| output/compile-fields/1 | +2.02% | +0.32% | +0.51% | -1.54% | +1.42% | -3.09% | +| output/compile-fields/2 | -0.06% | -0.02% | +0.21% | +8.24% | -2.61% | -4.35% | +| output/compile-fields/3 | +4.89% | +4.40% | +0.29% | -3.55% | -5.96% | +0.86% | +| output/compile-fields/4 | +3.48% | +2.81% | +0.28% | +1.95% | -1.29% | +0.47% | +| output/hash-binding/1/0/0 | +0.43% | n/a | n/a | +2.70% | n/a | n/a | +| output/hash-binding/1/0/1 | -10.74% | -9.49% | -0.03% | -9.67% | -11.85% | -0.99% | +| output/hash-binding/1/0/2 | -10.41% | n/a | n/a | -10.21% | n/a | n/a | +| output/hash-binding/1/0/3 | +0.28% | n/a | n/a | +1.29% | n/a | n/a | +| output/hash-binding/1/0/4 | +2.08% | n/a | n/a | -0.99% | n/a | n/a | +| output/hash-binding/1/1/0 | +0.65% | +0.15% | -0.42% | -4.38% | -1.45% | +4.87% | +| output/hash-binding/1/1/1 | -43.51% | -43.41% | -0.04% | -48.42% | -46.52% | +0.58% | +| output/hash-binding/1/1/2 | -43.31% | n/a | n/a | -48.32% | n/a | n/a | +| output/hash-binding/1/1/3 | -0.36% | n/a | n/a | -4.05% | n/a | n/a | +| output/hash-binding/1/1/4 | -0.30% | n/a | n/a | -1.25% | n/a | n/a | +| output/hash-binding/16/0/0 | -0.94% | n/a | n/a | -0.90% | n/a | n/a | +| output/hash-binding/16/0/1 | -33.89% | -34.68% | -0.09% | -45.52% | -46.77% | -2.26% | +| output/hash-binding/16/0/2 | -33.14% | n/a | n/a | -43.09% | n/a | n/a | +| output/hash-binding/16/0/3 | -3.33% | n/a | n/a | -6.87% | n/a | n/a | +| output/hash-binding/16/0/4 | +5.07% | n/a | n/a | -1.93% | n/a | n/a | +| output/hash-binding/16/1/0 | -2.09% | -1.95% | +0.13% | -3.20% | -1.86% | +1.86% | +| output/hash-binding/16/1/1 | -41.12% | -41.34% | +0.59% | -57.33% | -55.54% | +1.28% | +| output/hash-binding/16/1/2 | -39.26% | n/a | n/a | -55.61% | n/a | n/a | +| output/hash-binding/16/1/3 | -2.94% | n/a | n/a | +0.61% | n/a | n/a | +| output/hash-binding/16/1/4 | +5.42% | n/a | n/a | -1.02% | n/a | n/a | +| output/hash-fields/1/0 | +1.16% | n/a | n/a | -0.83% | n/a | n/a | +| output/hash-fields/1/1 | +2.39% | n/a | n/a | -7.96% | n/a | n/a | +| output/hash-fields/16/0 | +0.51% | n/a | n/a | -2.58% | n/a | n/a | +| output/hash-fields/16/1 | -0.49% | n/a | n/a | -5.73% | n/a | n/a | +| output/record/15/0/0 | +1.29% | n/a | n/a | -0.17% | n/a | n/a | +| output/record/160/0/0 | +1.86% | n/a | n/a | -0.86% | n/a | n/a | + +The explicit cached kernels improve roughly 41-56% in confirmation. Larger engine workloads +improve about 2-5%; real commands gain much less because traversal, content hashing and output remain. +The Linux name-field control retains about a 2% cost (roughly one nanosecond per rendered entry). +Mac field/compile controls vary across populations, including slower isolated cells; do not infer +a universal improvement from the explicit-hash kernels. + +## Native confirmation + +| Fixture / action | CPUs | Linux selected | Linux repeat | macOS selected | macOS repeat | +| :--------------------------- | ---: | -------------: | -----------: | -------------: | -----------: | +| flat10 / fields | 1 | +0.15% | +0.89% | +0.39% | +0.62% | +| flat10 / fields-transform | 1 | -0.94% | -0.29% | +0.87% | +0.24% | +| flat10 / listing | 1 | -1.23% | -0.10% | +0.60% | +0.35% | +| flat10 / fields | 3 | -0.69% | +1.15% | +0.44% | +0.10% | +| flat10 / fields-transform | 3 | +0.15% | -0.76% | +0.01% | -0.18% | +| flat10 / listing | 3 | -2.21% | -0.90% | +0.15% | +0.65% | +| flat1000 / fields | 1 | -0.86% | +0.93% | -0.29% | -0.01% | +| flat1000 / fields-transform | 1 | +0.06% | +0.76% | -0.14% | -0.10% | +| flat1000 / listing | 1 | +0.01% | +0.32% | +0.04% | -0.61% | +| flat1000 / fields | 3 | -2.95% | +0.42% | -0.46% | -0.30% | +| flat1000 / fields-transform | 3 | -2.91% | +0.09% | -0.16% | +0.33% | +| flat1000 / listing | 3 | -0.91% | -0.78% | +0.42% | +0.84% | +| flat10000 / fields | 1 | -1.48% | +0.22% | -0.66% | -0.14% | +| flat10000 / fields-transform | 1 | -0.64% | +0.34% | -0.03% | -0.14% | +| flat10000 / listing | 1 | +1.03% | -2.54% | +0.35% | +0.17% | +| flat10000 / fields | 3 | -1.46% | +0.24% | -0.16% | +0.10% | +| flat10000 / fields-transform | 3 | -0.02% | +1.12% | -0.26% | -0.01% | +| flat10000 / listing | 3 | +0.54% | -1.84% | +0.41% | +0.22% | +| long1000 / listing | 1 | -0.40% | -0.18% | +0.37% | +0.04% | +| long1000 / listing | 3 | +1.55% | +1.62% | -0.22% | -0.11% | + +## Cold and unrelated controls + +Absolute initial refined times below use each benchmark's reported unit. A fields/name case renders +1,000 entries per iteration, so a microsecond per iteration equals a nanosecond per entry here. +Do not compare a whole-engine iteration directly with a single-field or compilation iteration. + +| Case | Host | Baseline | Selected | Unit | +| :----------------------- | :---- | -------: | -------: | :--- | +| fields/name/1000/8 | linux | 43.09 | 44.22 | us | +| fields/name/1000/8 | macos | 238.37 | 238.40 | us | +| fields/components/1000/8 | linux | 110.82 | 112.97 | us | +| fields/components/1000/8 | macos | 373.52 | 391.59 | us | +| output/compile-fields/0 | linux | 34.70 | 34.64 | ns | +| output/compile-fields/0 | macos | 76.59 | 71.50 | ns | +| output/compile-fields/2 | linux | 34.56 | 34.55 | ns | +| output/compile-fields/2 | macos | 69.96 | 77.18 | ns | +| output/compile-fields/3 | linux | 56.89 | 60.07 | ns | +| output/compile-fields/3 | macos | 128.68 | 121.68 | ns | + +The first prototype increased bare-hash compilation by about 42-49% and explicit-hash compilation +by about 29-33% in its initial population. It is not selected. Its complete source, data and setup +failures remain separate, including the initially over-complex Compile extension, corrected scoped +lint findings and failed partial-edit build. The selected source is formatted and passes actual lint; +no complexity threshold or policy was relaxed. + +## Correctness and footprint + +Three new committed test cases exercise changing defaults across copied/moved templates, every +algorithm from the shared catalog with destroyed temporary qualifier storage, and invalid dynamic +defaults with independent explicit fields and transformations. Existing field, derivation, snapshot, +extraction, lifetime, invalidation, output and fuzz tests remain. + +Both hosts pass 313 selected root-and-extension release suites and nine focused ASan suites. +Linux also passes nine MSan and nine TSan suites; its complete release selection requires working +FUSE. Actual scoped lint passes the changed implementation, test and common benchmark on both hosts; +the refined implementation passes a focused recheck. Separate three-TU checks explicitly include +the changed XFF header on each host because the inherited coordinator still filters for MBO headers; +that existing tooling defect remains tracked independently in TODO.md. All six explicit header checks +pass. Source guards preserve exactly the selected four production/test/benchmark files after +source-swapping builds and before/after validation. +Normal enforcing hooks and current-head GitHub checks remain required for merge readiness. + +- linux: unstripped full CLI 18,159,248 -> 18,160,688 bytes (+1,440). +- macos: unstripped full CLI 8,158,392 -> 8,158,760 bytes (+368). + +O22c2 reusable command output and the other performance candidates remain open. + +## Lossless evidence + +[hash-field-binding-20261103.json.gz](hash-field-binding-20261103.json.gz): 1,148,942 bytes; SHA-256 `e5e1a4c0468510bcb2daaf2dda872405513435e51c7475f60c3e594e7aa5db35`. + +## Coverage-driven dispatch refinement + +The final main-based CI run exposed the obsolete parser used only as a function-identity tag. +The [direct-tag refinement](hash-field-binding-tag-20261103.md) removes it and qualifies the +result against the exact pushed implementation. The original populations and footprint above +remain unchanged evidence for the earlier implementation; the linked report records the final +refinement, its mixed small-kernel costs and updated binary-size deltas separately. diff --git a/docs/benchmarks/hash-field-binding-tag-20261103.json.gz b/docs/benchmarks/hash-field-binding-tag-20261103.json.gz new file mode 100644 index 00000000000..f32d9d4ca1a Binary files /dev/null and b/docs/benchmarks/hash-field-binding-tag-20261103.json.gz differ diff --git a/docs/benchmarks/hash-field-binding-tag-20261103.md b/docs/benchmarks/hash-field-binding-tag-20261103.md new file mode 100644 index 00000000000..676ea761c60 --- /dev/null +++ b/docs/benchmarks/hash-field-binding-tag-20261103.md @@ -0,0 +1,59 @@ +# Hash-field binding: direct tag refinement + +The main-based coverage run for PR #1070 identified an obsolete hash parser whose address was +used to classify bound fields, although rendering no longer called it. The implementation now +uses the existing nonzero hash-binding tag and removes that parser. A segment is a field when +it has an ordinary renderer or a hash binding; literal and unknown-field behavior, content +counts, extraction, transforms, validation and dynamic defaults retain their contracts. + +This refinement is measured against the exact pushed `d95860e8e4aa5ed57e442b8b7e91b8be88fee2d2` +implementation, separately from the [original qualification](hash-field-binding-20261103.md). +It adds no bytes to the segment layout, public API, dependencies, locks or content caches. + +## Comparison + +Each host runs 16 in-memory cases and 20 native cases in three independent sessions, with nine +interleaved repetitions and the fastest seven retained. Native cases discard warmups and check +complete output and exits against independent hashlib expectations. They cover 10, 1,000 and +10,000 files, long paths, ordinary listing, repeated fields and transformed hash fields with one +or three requested workers. Linux uses tmpfs and CPU affinity; macOS uses warm APFS. No release +timing overlaps builds, lint, tests or instrumentation on the same host. Negative changes mean +less elapsed time. These are paired changes from the already optimized implementation. + +| Case | Linux | macOS | +| :------------------------------------------------------------ | ---------------: | ---------------: | +| Sixteen warm explicit hash fields | -7.00% | -7.98% | +| Sixteen warm default hash fields | -3.28% | -5.71% | +| Whole-engine repeated hash fields, 1,000 files / 1 worker | -3.23% | -0.87% | +| Whole-engine transformed hash fields, 1,000 files / 3 workers | -1.52% | +0.15% | +| Ordinary name-field kernel | +0.04% | +5.08% | +| Ordinary component-field kernel | +0.83% | +3.32% | +| Single warm explicit hash field | +13.13% | -1.87% | +| Native command change range, all controls retained | -2.01% to +3.51% | -0.55% to +0.75% | +| Unstripped full CLI byte change | -4,896 | -208 | + +Small kernels have mixed costs; do not describe every row as faster. The single-field Linux +cell and ordinary-field macOS cells remain in the lossless results with absolute times. +Compilation changes span -1.59% to +1.35% on Linux and -6.69% to +5.27% on macOS. +The original optimization's populations remain separate, rather than pooling timings across +this refinement or subtracting unrelated percentage changes. + +## Verification and evidence + +The added regression distinguishes hash tags from literals, escaped hash text and unknown fields, +and checks rendering, content counts and extraction classification. Existing hash extraction, +all-catalog algorithms, changing defaults, copied/moved templates, snapshot lifetimes and fuzz +cases remain active. All 313 release suites pass on each host. Nine focused ASan suites pass on each host, and nine +MSan and nine TSan suites pass on Linux. Three explicit changed-header consumer checks pass per +host. Focused Linux coverage of changed production field lines reaches 86/88 (97.73%) and +67/70 branches (95.71%); this scoped check does not replace the full CI coverage gate. +Complete release, sanitizer and explicit-header checks are recorded in the +companion lossless archive; current-head GitHub checks remain authoritative for merge readiness. + +[Lossless sources, manifests, scripts, raw samples and validation logs](hash-field-binding-tag-20261103.json.gz) +retain compiler/configuration identities, old/tag binary sizes and hashes, all successful output +oracles and the original coverage failure. The finite-catalog overflow guard remains intact; +coverage is fixed by deleting dead code, without excluding executable lines or weakening policy. + +Archive size: 585,438 bytes. SHA-256: +`558057de3b7c3f6cb8f84632721ad0297deae2e8bd050c7edd2099ba2ef9ebd6`. diff --git a/docs/performance-analysis.md b/docs/performance-analysis.md index fb2e40c6778..0b21e13b8c2 100644 --- a/docs/performance-analysis.md +++ b/docs/performance-analysis.md @@ -3543,10 +3543,9 @@ own warmup, nine repetitions and fastest-seven selection with rotating participa The Linux case-isolated matrix remains separate; compare within a recorded protocol rather than silently treating absolute measurements from different protocols as interchangeable. -O22b still needs typed field-hash binding. Compact IDs/default-use bits may fit existing segment -padding, but actual layout, renderer-call overhead, changing context defaults and cold/non-hash -controls must be qualified before using that space. Do not encode binding state in arbitrary -strings or add a view-rich allocation to every segment. +O22b's compact field-hash binding is qualified below, including layout, renderer-call overhead, +changing defaults and cold/non-hash controls. It uses existing segment padding without encoding +state in strings or adding a view-rich allocation to every segment. O22c2 still needs command-owned reusable output buffers and streaming rows. Keep owning/stateless APIs intact; a private stateful record writer could reuse capacity on the coordinator and lend a @@ -3583,7 +3582,7 @@ in the raw artifact. The frozen formatter, source guards, independent full-outpu all selected root/extension release tests and focused sanitizers remain part of qualification. The initial nested fixture command missed `--archive-depth=2`; its warmup oracle failed before accepting any native row. The corrected matrix uses explicit depth two. O19b seeking/index/replay -and admission, and O19d direct final-buffer materialization, remain open. +and admission remain open; O19d direct final-buffer materialization is qualified below. ### O19d: fill final range and materialization storage @@ -3622,3 +3621,39 @@ reserved storage directly; its expected owned-read counter changes from one to z parent lifetimes, content and shared-budget tests remain. No new public flag, external dependency, worker, lock, metadata probe or host-I/O bypass is introduced. O19b seeking/index/replay/admission remains open. + +### O22b: bind hash fields without growing unrelated segments + +A private two-byte binding fits beside the existing post-processing enum. Algorithm IDs derive +from the existing `hash::AlgorithmNames` catalog; encoding choices distinguish explicit values +from context defaults. This avoids qualifier parsing and default work for explicit choices while +preserving changed defaults on every render, copies/moves, invalid specifications, post-render +transforms and snapshot ownership/invalidation. No borrowed qualifier view is retained. + +Compilation validates the binding once. Bare/transformed defaults bind directly, and rendering +uses the existing nonzero binding tag to classify hash fields; ordinary renderers retain their direct calls. +A coverage-driven refinement removes an obsolete parser that had served only as a function-identity +tag. The first prototype duplicated static +validation and read a separate tag for every field. It increased hash compilation costs by about +30-50% and is not selected; complete first/refined populations remain separate. + +[Cross-platform qualification](benchmarks/hash-field-binding-20261103.md) includes warm/cold +explicit, partial and default specs, compilation, ordinary fields, whole-engine in-memory runs and +native listing/hash-field commands. Explicit cached kernels improve about 41-56% in independent +confirmation; larger in-memory commands improve about 2-5%. Native gains are smaller. The Linux +simple-name field kernel retains about a 2% cost, roughly one nanosecond per entry; cold and Mac +controls vary across populations. Preserve their absolute times and identical-baseline movement; +this is not a universal rendering speedup. + +Tracked template storage is unchanged across measured shapes. This is conservative capacity +accounting, not an allocator or RSS claim. Full CLI footprint increases 1,440 bytes on Linux and +368 bytes on macOS. Three new tests cover changing defaults across copies/moves, all shared-catalog +algorithms and invalid dynamic defaults with independent explicit choices. Existing derivation, +extraction, snapshot, output and fuzz suites remain; complete release and focused sanitizer +qualification passes both hosts. O22c2 command-owned buffers and streaming rows remain open. + +The [direct-tag confirmation](benchmarks/hash-field-binding-tag-20261103.md) retains old/tag sources +and three-session cross-platform measurements. Sixteen warm explicit fields improve another 7-8%, +while small ordinary kernels and one Linux single-hash cell retain costs. Native differences remain +small, and the full CLI decreases 4,896 bytes on Linux and 208 bytes on macOS relative to the prior +binding implementation. A structural regression preserves literal/hash/extraction distinctions. diff --git a/release-site.json b/release-site.json index 2bd84c56fd5..17a391f7aae 100644 --- a/release-site.json +++ b/release-site.json @@ -80,7 +80,9 @@ "docs/benchmarks/archive-input-buffers-20261102.md": "docs/benchmarks/archive-input-buffers-20261102.html", "docs/benchmarks/digest-and-record-storage-20261103.md": "docs/benchmarks/digest-and-record-storage-20261103.html", "docs/benchmarks/caller-owned-streams-20261103.md": "docs/benchmarks/caller-owned-streams-20261103.html", - "docs/benchmarks/direct-source-materialization-20261103.md": "docs/benchmarks/direct-source-materialization-20261103.html" + "docs/benchmarks/direct-source-materialization-20261103.md": "docs/benchmarks/direct-source-materialization-20261103.html", + "docs/benchmarks/hash-field-binding-20261103.md": "docs/benchmarks/hash-field-binding-20261103.html", + "docs/benchmarks/hash-field-binding-tag-20261103.md": "docs/benchmarks/hash-field-binding-tag-20261103.html" }, "generated_html": { "XFF.md": { diff --git a/xff/presentation/fields/fields.cc b/xff/presentation/fields/fields.cc index 7e8c94307ab..39f616770e7 100644 --- a/xff/presentation/fields/fields.cc +++ b/xff/presentation/fields/fields.cc @@ -306,12 +306,9 @@ const vfs::FileSystem& ContentFileSystem(const RenderContext& ctx) { // {hash:/base64}); empty parts fall back to the --hash-algorithm / --hash-encoding defaults // (sha256 / hex when unset). An unknown algorithm or encoding, or an unreadable file, renders // empty (the field convention). Reads the file, so it is expensive. -StringOrView HashField(std::string_view, std::string_view qualifier, const RenderContext& ctx) { - const std::string_view default_algo = ctx.hash_algorithm.empty() ? "sha256" : ctx.hash_algorithm; - const hash::Encoding default_encoding = hash::ParseEncoding(ctx.hash_encoding).value_or(hash::Encoding::kHex); - const std::optional spec = hash::ParseSpec(qualifier, default_algo, default_encoding); - if (!spec.has_value()) { - return ""; // unknown algorithm or encoding -> empty, like an unknown field +StringOrView HashValue(const std::optional& spec, const RenderContext& ctx) { + if (!spec) { + return ""; } if (ctx.content) { return ctx.content->DigestView(ContentFileSystem(ctx), ctx.path, *spec).value_or(""); @@ -524,7 +521,7 @@ constexpr auto kFieldTable = mbo::container::MakeLimitedMap( FieldEntry{"fuzzy", &FuzzyField}, FieldEntry{"gid", &GidField}, FieldEntry{"group", &GroupField}, - FieldEntry{"hash", &HashField}, + FieldEntry{"hash", nullptr}, // Bound by HashBinding; no generic per-entry parser. FieldEntry{"inode", &InodeField}, FieldEntry{"lang", &LanguageField}, FieldEntry{"lang-color", &LanguageColorField}, @@ -1006,7 +1003,7 @@ absl::Status ValidateFieldName(std::string_view name) { return IsKnownField(name) ? absl::OkStatus() : absl::InvalidArgumentError(absl::StrCat("unknown field '", name, "'")); } -absl::Status ValidateNativeQualifier(FieldFn renderer, std::string_view qualifier) { +absl::Status ValidateNativeQualifier(FieldFn renderer, std::string_view qualifier, bool valid_hash) { if (IsRewriteQualifier(qualifier) || IsExtractQualifier(qualifier)) { return ValidateTransform(qualifier); } @@ -1016,8 +1013,8 @@ absl::Status ValidateNativeQualifier(FieldFn renderer, std::string_view qualifie if (renderer == &MtimeField || renderer == &AtimeField || renderer == &CtimeField || renderer == &BtimeField) { return absl::OkStatus(); // Custom time patterns may contain arbitrary literal text. } - if (renderer == &HashField) { - return hash::ParseSpec(qualifier, "sha256").has_value() + if (renderer == nullptr) { + return valid_hash ? absl::OkStatus() : absl::InvalidArgumentError(absl::StrCat("invalid hash algorithm or encoding '", qualifier, "'")); } @@ -1135,6 +1132,66 @@ struct Template::Transform { std::vector scalar; }; +Template::HashBinding Template::HashBinding::Bind(FieldFn fn, std::string_view qualifier, bool native_qualifier) { + if (fn != nullptr) { + return {}; + } + if (!native_qualifier || qualifier.empty()) { + return {.algorithm = 1}; + } + static constexpr std::uint8_t kInvalid = std::numeric_limits::max(); + const auto spec = hash::ParseSpec(qualifier, "sha256"); + if (!spec) { + return {.algorithm = kInvalid}; + } + const auto names = hash::AlgorithmNames(); + if (names.size() >= kInvalid - 1) { + return {.algorithm = kInvalid}; + } + const auto position = std::ranges::distance(names.begin(), std::ranges::find(names, spec->algo)); + return { + .algorithm = spec->defaults.algorithm ? std::uint8_t{1} : static_cast(position + 2), + .encoding = spec->defaults.encoding ? std::uint8_t{0} + : static_cast(spec->encoding == hash::Encoding::kHex ? 1 : 2), + }; +} + +std::optional Template::HashBinding::Resolve( + std::string_view default_algorithm, + hash::Encoding default_encoding) const { + std::string_view name; + if (algorithm == 1) { + name = default_algorithm.empty() ? "sha256" : default_algorithm; + if (!hash::IsAlgorithm(name)) { + return std::nullopt; + } + } else { + const auto names = hash::AlgorithmNames(); + if (algorithm < 2 || static_cast(algorithm - 2) >= names.size()) { + return std::nullopt; + } + name = names.at(algorithm - 2); + } + const hash::Encoding resolved_encoding = encoding == 0 ? default_encoding + : encoding == 1 ? hash::Encoding::kHex + : hash::Encoding::kBase64; + return hash::AlgoEncoding{.algo = name, .encoding = resolved_encoding, .defaults = DefaultsUsed()}; +} + +hash::DefaultUsage Template::HashBinding::DefaultsUsed() const { + return {.algorithm = algorithm == 1, .encoding = encoding == 0}; +} + +StringOrView Template::Segment::Value(const RenderContext& context) const { + if (hash_binding.algorithm != 0) { + const hash::Encoding encoding = hash_binding.encoding == 0 + ? hash::ParseEncoding(context.hash_encoding).value_or(hash::Encoding::kHex) + : hash::Encoding::kHex; + return HashValue(hash_binding.Resolve(context.hash_algorithm, encoding), context); + } + return fn(key, post == PostProcess::kNone ? std::string_view(qualifier) : std::string_view{}, context); +} + std::size_t Template::StorageBytes() const { const auto operations_bytes = [](const std::vector& operations) { std::size_t bytes = operations.capacity() * sizeof(Transform::Operation); @@ -1215,7 +1272,6 @@ Template Template::Compile(std::string_view tmpl) { compiled.validation_.Update(ValidateFieldName(field->name)); auto [fn, key] = ResolveName(field->name); // builtin field, {0}..{N} capture, or {env.NAME} std::string& qualifier = field->qualifier; - compiled.validation_.Update(ValidateNativeQualifier(fn, qualifier)); // Classify the qualifier: an s/// rewrite, an m/// per-line extraction, or a path-component // extraction is a post-render transform; anything else is the field's own format argument. const Segment::PostProcess post = IsRewriteQualifier(qualifier) ? Segment::PostProcess::kRewrite @@ -1223,8 +1279,17 @@ Template Template::Compile(std::string_view tmpl) { : IsPathComponent(qualifier) ? Segment::PostProcess::kComponent : Segment::PostProcess::kNone; const auto transform = Transform::Bind(qualifier); - compiled.segments_.push_back( - {.fn = fn, .key = std::move(key), .qualifier = std::move(qualifier), .post = post, .transform = transform}); + const HashBinding binding = HashBinding::Bind(fn, qualifier, post == Segment::PostProcess::kNone); + compiled.validation_.Update( + ValidateNativeQualifier(fn, qualifier, binding.algorithm != std::numeric_limits::max())); + compiled.segments_.push_back({ + .fn = fn, + .key = std::move(key), + .qualifier = std::move(qualifier), + .post = post, + .hash_binding = binding, + .transform = transform, + }); i = field->next; } else { if (ch == '}') { @@ -1262,15 +1327,13 @@ bool Template::NeedsTimeZone() const { hash::DefaultUsage Template::HashDefaultsUsed() const { hash::DefaultUsage usage; for (const Segment& segment : segments_) { - if (segment.fn != &HashField) { + if (segment.hash_binding.algorithm == 0) { continue; } - const std::string_view qualifier = - segment.post == Segment::PostProcess::kNone ? segment.qualifier : std::string_view{}; - const auto spec = hash::ParseSpec(qualifier, "sha256"); - if (spec.has_value()) { - usage.algorithm |= spec->defaults.algorithm; - usage.encoding |= spec->defaults.encoding; + if (segment.hash_binding.algorithm != std::numeric_limits::max()) { + const auto defaults = segment.hash_binding.DefaultsUsed(); + usage.algorithm |= defaults.algorithm; + usage.encoding |= defaults.encoding; } } return usage; @@ -1281,10 +1344,8 @@ content::Derivation Template::ContentDerivation(std::string_view algorithm, hash for (const Segment& segment : segments_) { if (segment.fn == &LinesField) { request.lines = true; - } else if (segment.fn == &HashField) { - const std::string_view qualifier = - segment.post == Segment::PostProcess::kNone ? segment.qualifier : std::string_view{}; - const auto spec = hash::ParseSpec(qualifier, algorithm.empty() ? "sha256" : algorithm, encoding); + } else if (segment.hash_binding.algorithm != 0) { + const auto spec = segment.hash_binding.Resolve(algorithm, encoding); if (spec) { request.Include({.digests = {{.algorithm = std::string(spec->algo), .encoding = spec->encoding}}}); } @@ -1294,8 +1355,9 @@ content::Derivation Template::ContentDerivation(std::string_view algorithm, hash } std::size_t Template::ContentFieldCount() const { - return static_cast(absl::c_count_if( - segments_, [](const Segment& segment) { return segment.fn == &HashField || segment.fn == &LinesField; })); + return static_cast(absl::c_count_if(segments_, [](const Segment& segment) { + return segment.hash_binding.algorithm != 0 || segment.fn == &LinesField; + })); } std::string Template::Render(const RenderContext& context) const { @@ -1306,13 +1368,11 @@ std::string Template::Render(const RenderContext& context) const { } std::string out; for (const Segment& segment : segments_) { - if (segment.fn != nullptr) { + if (segment.IsField()) { // A post-render transform (s/// rewrite or path-component) renders the field // with no qualifier, then transforms the value; otherwise the qualifier is the // field's own format argument. - const bool transform = segment.post != Segment::PostProcess::kNone; - const StringOrView field_value = - segment.fn(segment.key, transform ? std::string_view{} : segment.qualifier, entry); + const StringOrView field_value = segment.Value(entry); const std::string_view value = field_value.view(); switch (segment.post) { case Segment::PostProcess::kComponent: out.append(PathComponent(value, segment.qualifier).view()); break; @@ -1339,23 +1399,23 @@ std::optional> Template::AsExtraction(const RenderConte return std::nullopt; } const Segment& segment = segments_.front(); - if (segment.fn == nullptr || segment.post != Segment::PostProcess::kExtract + if (!segment.IsField() || segment.post != Segment::PostProcess::kExtract || segment.transform->separator.has_value()) { return std::nullopt; } - const StringOrView value = segment.fn(segment.key, std::string_view{}, context); // base value, no qualifier + const StringOrView value = segment.Value(context); // base value, no qualifier return segment.transform->Extract(value.view()); } bool Template::IsExtraction() const { - return segments_.size() == 1 && segments_.front().fn != nullptr + return segments_.size() == 1 && segments_.front().IsField() && segments_.front().post == Segment::PostProcess::kExtract && !segments_.front().transform->separator.has_value(); } bool Template::HasUnreducedExtraction() const { return absl::c_any_of(segments_, [](const Segment& segment) { - return segment.fn != nullptr && segment.post == Segment::PostProcess::kExtract + return segment.IsField() && segment.post == Segment::PostProcess::kExtract && !segment.transform->separator.has_value(); }); } diff --git a/xff/presentation/fields/fields.h b/xff/presentation/fields/fields.h index 1d2df309308..4255224744a 100644 --- a/xff/presentation/fields/fields.h +++ b/xff/presentation/fields/fields.h @@ -171,7 +171,21 @@ class Template { private: struct Transform; - // A literal run (fn == nullptr -> emit `literal`) or a field reference: fn is + // Two bytes fit beside PostProcess without adding a view or allocation to each segment. + // Algorithm IDs come from hash::AlgorithmNames, never a second algorithm catalog. + struct HashBinding { + static HashBinding Bind(FieldFn fn, std::string_view qualifier, bool native_qualifier); + std::optional Resolve(std::string_view default_algorithm, hash::Encoding default_encoding) + const; + hash::DefaultUsage DefaultsUsed() const; + + // Zero: unrelated field; one: context default; other values index the shared catalog. + std::uint8_t algorithm = 0; + // Zero: context default; one: hex; two: base64. + std::uint8_t encoding = 0; + }; + + // A literal run (no renderer or hash binding) or a field reference: fn is // the renderer and `key` its bound argument (capture index, {env.NAME} var, ...). struct Segment { // How the qualifier transforms the rendered value: none (the qualifier is the @@ -186,7 +200,12 @@ class Template { std::string key; std::string qualifier; PostProcess post = PostProcess::kNone; + HashBinding hash_binding; std::shared_ptr transform; + + bool IsField() const { return fn != nullptr || hash_binding.algorithm != 0; } + + mbo::StringOrView Value(const RenderContext& context) const; }; std::vector segments_; diff --git a/xff/presentation/fields/hash_snapshot_test.cc b/xff/presentation/fields/hash_snapshot_test.cc index d31c1b6737b..416a7e6aacb 100644 --- a/xff/presentation/fields/hash_snapshot_test.cc +++ b/xff/presentation/fields/hash_snapshot_test.cc @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "absl/status/status.h" @@ -23,8 +24,12 @@ namespace { using ::mbo::testing::EqualsText; using ::mbo::testing::IsOk; +using ::testing::AllOf; using ::testing::ElementsAre; using ::testing::Eq; +using ::testing::Field; +using ::testing::IsFalse; +using ::testing::IsTrue; using ::testing::Optional; struct HashFs final : vfs::FileSystem { @@ -110,6 +115,97 @@ TEST_F(HashSnapshotTest, ExtractionWithoutASuppliedSnapshotRetainsTheLocalDigest EXPECT_THAT(fs.reads, Eq(2)); } +TEST_F(HashSnapshotTest, BoundHashTagsRemainDistinctFromLiteralAndUnknownSegments) { + const auto compiled = Template::Compile("literal {{hash}} {name} [{hash:md5/hex}] {unknown}"); + EXPECT_THAT(IsKnownField("hash"), IsTrue()); + EXPECT_THAT(IsKnownField("unknown"), IsFalse()); + EXPECT_THAT(compiled.ContentFieldCount(), Eq(1)); + EXPECT_THAT(Template::Compile("literal {{hash}} {name} {unknown}").ContentFieldCount(), Eq(0)); + const auto digest = hash::HashData("md5", fs.bytes).value_or("unsupported"); + EXPECT_THAT(compiled.Render(Context(true)), EqualsText("literal {hash} file [" + digest + "] ")); + EXPECT_THAT(compiled.IsExtraction(), IsFalse()); + EXPECT_THAT(compiled.HasUnreducedExtraction(), IsFalse()); + EXPECT_THAT(compiled.AsExtraction(Context(true)), Eq(std::nullopt)); +} + +TEST_F(HashSnapshotTest, DefaultUsageFollowsBoundHashTagsWithoutCountingLiteralOrInvalidFields) { + const auto algorithm = &hash::DefaultUsage::algorithm; + const auto encoding = &hash::DefaultUsage::encoding; + EXPECT_THAT( + Template::Compile("literal {{hash}} {name} {unknown} {hash:md5/hex} {hash:unknown}").HashDefaultsUsed(), + AllOf(Field("algorithm", algorithm, IsFalse()), Field("encoding", encoding, IsFalse()))); + EXPECT_THAT( + Template::Compile("{hash}").HashDefaultsUsed(), + AllOf(Field("algorithm", algorithm, IsTrue()), Field("encoding", encoding, IsTrue()))); + EXPECT_THAT( + Template::Compile("{hash:md5}").HashDefaultsUsed(), + AllOf(Field("algorithm", algorithm, IsFalse()), Field("encoding", encoding, IsTrue()))); + EXPECT_THAT( + Template::Compile("{hash:/base64}").HashDefaultsUsed(), + AllOf(Field("algorithm", algorithm, IsTrue()), Field("encoding", encoding, IsFalse()))); + EXPECT_THAT( + Template::Compile("{hash:s/^/SUM-/}").HashDefaultsUsed(), + AllOf(Field("algorithm", algorithm, IsTrue()), Field("encoding", encoding, IsTrue()))); +} + +TEST_F(HashSnapshotTest, BoundHashFieldsKeepContextDefaultsDynamicAcrossCopiesAndMoves) { + const auto compiled = Template::Compile("{hash}|{hash:md5}|{hash:/base64}|{hash:sha256/hex}"); + // NOLINTNEXTLINE(performance-unnecessary-copy-initialization): exercise independent copied template storage. + const auto copy = compiled; + auto moved_from = copy; + const auto moved = std::move(moved_from); + for (const std::string_view algorithm : {"md5", "sha256", ""}) { + for (const std::string_view encoding : {"hex", "base64", "invalid", ""}) { + SCOPED_TRACE(algorithm); + SCOPED_TRACE(encoding); + auto context = Context(true); + context.hash_algorithm = algorithm; + context.hash_encoding = encoding; + const auto selected_algorithm = algorithm.empty() ? std::string_view("sha256") : algorithm; + const auto selected_encoding = encoding == "base64" ? hash::Encoding::kBase64 : hash::Encoding::kHex; + const auto digest = [&](std::string_view name, hash::Encoding output) { + return hash::HashData(name, fs.bytes, output).value_or("unsupported"); + }; + const std::string expected = + digest(selected_algorithm, selected_encoding) + "|" + digest("md5", selected_encoding) + "|" + + digest(selected_algorithm, hash::Encoding::kBase64) + "|" + digest("sha256", hash::Encoding::kHex); + EXPECT_THAT(compiled.Render(context), EqualsText(expected)); + EXPECT_THAT(copy.Render(context), EqualsText(expected)); + EXPECT_THAT(moved.Render(context), EqualsText(expected)); + } + } + EXPECT_THAT(fs.reads, Eq(1)); +} + +TEST_F(HashSnapshotTest, EveryCatalogAlgorithmBindsWithoutDependingOnQualifierStringStorage) { + for (const std::string_view algorithm : hash::AlgorithmNames()) { + SCOPED_TRACE(algorithm); + const auto compiled = Template::Compile("{hash:" + std::string(algorithm) + "/base64}"); + auto context = Context(true); + context.hash_algorithm = "invalid-default"; + context.hash_encoding = "invalid-default"; + EXPECT_THAT( + compiled.Render(context), + EqualsText(hash::HashData(algorithm, fs.bytes, hash::Encoding::kBase64).value_or("unsupported"))); + } + EXPECT_THAT(fs.reads, Eq(1)); +} + +TEST_F(HashSnapshotTest, InvalidDynamicAlgorithmOnlyEmptiesFieldsWhichConsumeIt) { + const auto compiled = Template::Compile("[{hash}][{hash:/base64}][{hash:md5/hex}][{hash:s/^/SUM-/}]"); + auto context = Context(true); + context.hash_algorithm = "invalid-default"; + const auto md5 = hash::HashData("md5", fs.bytes).value_or("unsupported"); + EXPECT_THAT(compiled.Render(context), EqualsText("[][][" + md5 + "][SUM-]")); + context.hash_algorithm = "md5"; + EXPECT_THAT( + compiled.Render(context), + EqualsText( + "[" + md5 + "][" + hash::HashData("md5", fs.bytes, hash::Encoding::kBase64).value_or("") + "][" + md5 + + "][SUM-" + md5 + "]")); + EXPECT_THAT(fs.reads, Eq(1)); +} + TEST_F(HashSnapshotTest, UnknownAlgorithmsAndReadFailuresStayEmptyUntilInvalidation) { const auto compiled = Template::Compile("[{hash:unknown}][{hash}][{hash}]"); fs.fail = true; diff --git a/xff/presentation/render/output_benchmark.cc b/xff/presentation/render/output_benchmark.cc index 6c9dcbb73a1..36967ac3992 100644 --- a/xff/presentation/render/output_benchmark.cc +++ b/xff/presentation/render/output_benchmark.cc @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // Isolate record construction and cached digest consumption from host filesystem costs. +#include #include #include #include @@ -96,6 +97,79 @@ void HashFields(benchmark::State& state) { } } +// Explicit/partial/default specs isolate binding from digest work, with invalid context defaults +// proving that independent fields do not accidentally consume those defaults. +void HashBindingFields(benchmark::State& state) { + struct Scenario { + std::string_view format; + std::string_view algorithm; + std::string_view encoding; + std::string_view prefix; + }; + + static constexpr auto kScenarios = std::to_array({ + {.format = "{hash}", .algorithm = "md5", .encoding = "base64"}, + {.format = "{hash:md5/base64}", .algorithm = "invalid", .encoding = "invalid"}, + {.format = "{hash:md5}", .algorithm = "invalid", .encoding = "base64"}, + {.format = "{hash:/base64}", .algorithm = "md5", .encoding = "invalid"}, + {.format = "{hash:s/^/SUM-/}", .algorithm = "md5", .encoding = "base64", .prefix = "SUM-"}, + }); + const auto& scenario = kScenarios.at(static_cast(state.range(2))); + const auto count = static_cast(state.range(0)); + const bool warm = state.range(1) != 0; + const OutputFs fs; + const vfs::Metadata metadata; + content::Snapshot snapshot; + const fields::RenderContext context{ + .path = "virtual/file", + .metadata = metadata, + .fs = fs, + .content = snapshot, + .tz = absl::UTCTimeZone(), + .hash_algorithm = scenario.algorithm, + .hash_encoding = scenario.encoding, + }; + std::string format; + std::string expected; + const auto digest = hash::HashData("md5", "alpha\nbeta\n", hash::Encoding::kBase64).value_or("unsupported"); + for (std::size_t index = 0; index < count; ++index) { + format.append(scenario.format).push_back('|'); + expected.append(scenario.prefix).append(digest).push_back('|'); + } + const auto compiled = fields::Template::Compile(format); + if (!compiled.Validate().ok() || compiled.Render(context) != expected) { + state.SkipWithError("bound digest field differs from known fixture"); + return; + } + state.counters["template_storage_bytes"] = static_cast(compiled.StorageBytes()); + for (auto iteration : state) { + benchmark::DoNotOptimize(iteration); + if (!warm) { + snapshot.Invalidate(); + } + auto rendered = compiled.Render(context); + benchmark::DoNotOptimize(rendered); + } +} + +void CompileFields(benchmark::State& state) { + static constexpr auto kFormats = std::to_array({ + "{name}", + "literal-{path}-{name}-{size}", + "{hash}", + "{hash:md5/base64}", + "{hash:s/^/SUM-/}|{hash:sha256/hex}|{hash:/base64}", + }); + const auto format = kFormats.at(static_cast(state.range(0))); + const auto expected = fields::Template::Compile(format).StorageBytes(); + state.counters["template_storage_bytes"] = static_cast(expected); + for (auto iteration : state) { + benchmark::DoNotOptimize(iteration); + auto compiled = fields::Template::Compile(format); + benchmark::DoNotOptimize(compiled); + } +} + } // namespace } // namespace xff::render @@ -119,6 +193,20 @@ int main(int argc, char** argv) { ->Repetitions(9) ->MinTime(0.02) ->MinWarmUpTime(0.01); + benchmark::RegisterBenchmark("output/hash-binding", xff::render::HashBindingFields) + ->ArgsProduct({{1, 16}, {0, 1}, {0, 1, 2, 3, 4}}) + ->UseRealTime() + ->Unit(benchmark::kNanosecond) + ->Repetitions(9) + ->MinTime(0.02) + ->MinWarmUpTime(0.01); + benchmark::RegisterBenchmark("output/compile-fields", xff::render::CompileFields) + ->DenseRange(0, 4) + ->UseRealTime() + ->Unit(benchmark::kNanosecond) + ->Repetitions(9) + ->MinTime(0.02) + ->MinWarmUpTime(0.01); benchmark::RunSpecifiedBenchmarks(); benchmark::Shutdown(); }