From 271c9ad3150371dce3e82df0e2815a9dc60bc099 Mon Sep 17 00:00:00 2001 From: mxsm Date: Wed, 12 Aug 2026 16:28:31 +0800 Subject: [PATCH] feat: add zero-copy allocation fast paths --- .github/workflows/ci.yaml | 23 +++++ .github/workflows/performance.yml | 50 +++++++++++ .github/workflows/release.yml | 6 ++ CHANGELOG.md | 11 +++ PERFORMANCE.md | 53 ++++++++++++ README.md | 22 ++++- benches/shared_backing.rs | 61 ++++++++++++- scripts/bench-all.ps1 | 15 +++- scripts/bench-all.sh | 12 ++- scripts/tests/test_allocation_evidence.py | 78 +++++++++++++++++ scripts/tests/test_repository_contracts.py | 7 ++ scripts/verify-allocation-evidence.py | 99 ++++++++++++++++++++++ src/cheetah_string/construct.rs | 20 ++++- src/cheetah_string/convert.rs | 12 ++- src/cheetah_string/query.rs | 7 +- src/cheetah_string/traits.rs | 42 +++++++-- src/lib.rs | 7 +- tests/allocation_contract.rs | 89 +++++++++++++++++++ tests/api_extensions.rs | 18 ++++ tests/basic.rs | 30 +++++++ 20 files changed, 636 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/performance.yml create mode 100644 PERFORMANCE.md create mode 100644 scripts/tests/test_allocation_evidence.py create mode 100644 scripts/verify-allocation-evidence.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fcaad6c..be62cc9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -106,6 +106,29 @@ jobs: - name: Run repository contracts run: python -m unittest discover -s scripts/tests -v + performance-contracts: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up stable Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c + with: + toolchain: stable + + - name: Verify allocation contracts + run: cargo test --test allocation_contract --all-features -- --test-threads=1 + + - name: Capture shared-backing evidence + shell: bash + run: | + cargo bench --bench shared_backing -- __allocation_evidence_only__ --noplot 2>&1 | + tee target/allocation-evidence.log + + - name: Verify shared-backing evidence + run: python scripts/verify-allocation-evidence.py target/allocation-evidence.log + dependency-audit: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml new file mode 100644 index 0000000..60b5c66 --- /dev/null +++ b/.github/workflows/performance.yml @@ -0,0 +1,50 @@ +name: Performance contracts + +on: + pull_request: + branches: [main] + workflow_dispatch: + schedule: + - cron: "41 5 * * 2" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + deterministic-contracts: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up stable Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c + with: + toolchain: stable + + - name: Verify allocation contracts + run: cargo test --test allocation_contract --all-features -- --test-threads=1 + + - name: Verify layout contracts + run: cargo test --test layout_snapshot --all-features -- --nocapture + + - name: Capture shared-backing evidence + shell: bash + run: | + cargo bench --bench shared_backing -- __allocation_evidence_only__ --noplot 2>&1 | + tee target/allocation-evidence.log + + - name: Verify shared-backing evidence + run: python scripts/verify-allocation-evidence.py target/allocation-evidence.log + + - name: Upload allocation evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: allocation-evidence + path: target/allocation-evidence.log + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7839ee..2a27144 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,6 +71,12 @@ jobs: - name: Test no-default feature matrix run: cargo test --no-default-features --features serde,bytes,simd + - name: Verify allocation contracts + run: cargo test --test allocation_contract --all-features -- --test-threads=1 + + - name: Verify layout contracts + run: cargo test --test layout_snapshot --all-features -- --nocapture + - name: Check packaged MSRV consumers run: bash scripts/check-msrv-package.sh 1.95 diff --git a/CHANGELOG.md b/CHANGELOG.md index c889b3f..db79834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,17 @@ - Removed the unmaintained `smartstring` benchmark dependency and retired the stale score-verification scripts whose evidence manifest was not shipped. +### Performance and API + +- Added `CheetahString::from_arc_str` and `From>`; long values preserve + the input payload pointer with zero allocation, while short values remain + canonical inline strings. +- Removed temporary heap allocation from `From` and inline-sized + concatenation. +- Exposed the reverse, clone, and fused iterator guarantees of `lines()`. +- Added fail-closed allocation evidence for exact/spare freezes, shared input, + cloning, characters, and short concatenation. + ### Migration - Replace read-only `PackedCheetahString` values with `CheetahString`. diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 0000000..2addb53 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,53 @@ +# Performance contracts + +CheetahString separates deterministic performance contracts from timing +measurements. Allocation counts and object layout are merge/release gates; +Criterion timing and RSS observations are diagnostic because hosted runner +noise cannot establish a portable latency threshold. + +## Enforced 64-bit contracts + +| Operation | Maximum allocation events | Additional invariant | +|---|---:|---| +| Inline construction | 0 | UTF-8 length is at most 23 bytes | +| Static construction | 0 | Explicit static input remains borrowed | +| Long `Arc` adoption | 0 | Payload pointer is retained | +| `char` construction | 0 | Every Unicode scalar remains inline | +| Concatenation with a result of at most 23 bytes | 0 | Result remains inline | +| Long shared clone | 0 | Payload pointer is shared | +| Long borrowed construction | 1 | One live `Arc` backing | +| Exact-capacity owned/builder freeze | 1 | One live `Arc` backing | +| Spare-capacity owned/builder freeze | 2 | Shrink/reallocation plus `Arc` backing | +| `CheetahString` object size | N/A | 32 bytes on supported 64-bit targets | + +The allocation count includes allocation and reallocation events during the +measured conversion. It is intentionally different from the number of live +allocations retained by the result. + +## Reproducing the gates + +Run the direct contracts: + +```bash +cargo test --test allocation_contract --all-features -- --test-threads=1 +cargo test --test layout_snapshot --all-features -- --nocapture +``` + +Capture and validate the independent benchmark evidence: + +```bash +cargo bench --bench shared_backing -- __allocation_evidence_only__ --noplot \ + 2>&1 | tee target/allocation-evidence.log +python scripts/verify-allocation-evidence.py target/allocation-evidence.log +``` + +The verifier requires one schema-v2 `SHARED_BACKING_EVIDENCE` record and fails +closed when a required field is absent, an allocation count regresses, the +64-bit layout changes, or long `Arc` input does not retain its pointer. + +## Timing policy + +Criterion groups remain useful for comparing construction, cloning, inline +fast paths, and RocketMQ-shaped map workloads on the same machine. Their raw +timings and RSS samples must be reported with toolchain, CPU, operating system, +feature set, and source revision. They are not used as cross-run release gates. diff --git a/README.md b/README.md index 4a377de..b6c1fb6 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,13 @@ architecture. ## Design contract -| Storage | Condition | Construction allocation | Clone allocation | +| Input path | Storage | Allocation events during conversion | Clone allocation | |---|---|---:|---:| -| Inline | UTF-8 length ≤ 23 bytes | 0 | 0 | -| Static | `&'static str` | 0 | 0 | -| Shared | Other long text | 1 live backing allocation | 0 | +| Explicit `from_static_str` | Static | 0 | 0 | +| Other UTF-8 input ≤ 23 bytes | Inline | 0 | 0 | +| Long `Arc` | Shared | 0; payload pointer is retained | 0 | +| Long borrowed text or exact-capacity `String` | Shared | 1 | 0 | +| Long spare-capacity `String` / builder | Shared | 2: shrink/reallocate, then Arc backing | 0 | The representation has no mutable `Owned(String)` state. Construction history therefore cannot change clone complexity. Use: @@ -62,12 +64,16 @@ use cheetah_string::{CheetahBuilder, CheetahString}; let inline = CheetahString::from("orders"); let static_value = CheetahString::from_static_str("system-topic"); let shared = CheetahString::from_string("long-dynamic-value-".repeat(8)); +let adopted = CheetahString::from(std::sync::Arc::::from( + "ownership-preserving-shared-value", +)); let cloned = shared.clone(); assert_eq!(inline, "orders"); assert_eq!(static_value, "system-topic"); assert_eq!(shared, cloned); assert_eq!(shared.as_bytes().as_ptr(), cloned.as_bytes().as_ptr()); +assert_eq!(adopted, "ownership-preserving-shared-value"); let mut builder = CheetahBuilder::with_capacity(64); builder.push_str("orders"); @@ -106,6 +112,9 @@ assert_eq!(forward, ["a", "b", "c"]); let csv = CheetahString::from("a,b,c"); let reverse: Vec<_> = csv.split_char(',').rev().collect(); assert_eq!(reverse, ["c", "b", "a"]); + +let reverse_lines: Vec<_> = CheetahString::from("a\nb\nc").lines().rev().collect(); +assert_eq!(reverse_lines, ["c", "b", "a"]); ``` `split_str` is intentionally forward-only. Unsupported reverse iteration fails @@ -170,6 +179,9 @@ dedicated fixed CPU with two reversed base/head rounds. ```bash cargo test --test layout_snapshot --all-features cargo test --test allocation_contract --all-features -- --test-threads=1 +cargo bench --bench shared_backing -- __allocation_evidence_only__ --noplot \ + 2>&1 | tee target/allocation-evidence.log +python scripts/verify-allocation-evidence.py target/allocation-evidence.log cargo bench --bench comprehensive cargo bench --bench mq_properties cargo bench --bench mq_remoting_header @@ -179,6 +191,8 @@ cargo bench --bench mq_topic Hosted-runner and local benchmark results are diagnostic; they do not independently establish a release-grade performance pass. The versioned allocation and layout tests are the deterministic performance contracts. +See [Performance contracts](PERFORMANCE.md) for the exact enforced budgets and +the distinction between deterministic gates and diagnostic timing results. ## Safety and portability diff --git a/benches/shared_backing.rs b/benches/shared_backing.rs index 946248d..e49727c 100644 --- a/benches/shared_backing.rs +++ b/benches/shared_backing.rs @@ -245,6 +245,26 @@ fn emit_allocation_evidence() { let (arc_string_clone_allocs, arc_string_clone_bytes, arc_string_clone) = allocation_delta(|| arc_string_exact.clone()); + let (cheetah_borrowed_allocs, cheetah_borrowed_bytes, cheetah_borrowed) = + allocation_delta(|| CheetahString::from_slice(&long)); + let cheetah_exact_input = exact_string(1024); + let (cheetah_exact_allocs, cheetah_exact_bytes, cheetah_exact) = + allocation_delta(|| CheetahString::from_string(cheetah_exact_input)); + let cheetah_spare_input = spare_string(1024); + let (cheetah_spare_allocs, cheetah_spare_bytes, cheetah_spare) = + allocation_delta(|| CheetahString::from_string(cheetah_spare_input)); + let shared_source: Arc = Arc::from(long.as_str()); + let shared_source_pointer = shared_source.as_ptr(); + let (cheetah_arc_allocs, cheetah_arc_bytes, cheetah_arc) = + allocation_delta(|| CheetahString::from_arc_str(shared_source)); + let (cheetah_clone_allocs, cheetah_clone_bytes, cheetah_clone) = + allocation_delta(|| cheetah_exact.clone()); + let (cheetah_char_allocs, cheetah_char_bytes, cheetah_char) = + allocation_delta(|| CheetahString::from('🦀')); + let short_left = CheetahString::from("inline"); + let (cheetah_concat_allocs, cheetah_concat_bytes, cheetah_concat) = + allocation_delta(|| short_left + "-value"); + let arc_str_rss_before = current_rss_bytes(); let retained_arc_str = (0..10_000) .map(|index| ArcStrCandidate::owned(format!("RMQ_SYS_TRACE_TOPIC_{index:05}"))) @@ -259,7 +279,7 @@ fn emit_allocation_evidence() { let arc_string_rss_after = current_rss_bytes(); let evidence = json!({ - "schema_version": 1, + "schema_version": 2, "object_sizes": { "Inline|Arc": size_of::(), "Inline|Arc": size_of::(), @@ -277,8 +297,20 @@ fn emit_allocation_evidence() { "owned_exact": {"count": arc_string_exact_allocs, "bytes": arc_string_exact_bytes}, "owned_spare": {"count": arc_string_spare_allocs, "bytes": arc_string_spare_bytes}, "clone": {"count": arc_string_clone_allocs, "bytes": arc_string_clone_bytes} + }, + "CheetahString": { + "borrowed": {"count": cheetah_borrowed_allocs, "bytes": cheetah_borrowed_bytes}, + "owned_exact": {"count": cheetah_exact_allocs, "bytes": cheetah_exact_bytes}, + "owned_spare": {"count": cheetah_spare_allocs, "bytes": cheetah_spare_bytes}, + "from_arc_str": {"count": cheetah_arc_allocs, "bytes": cheetah_arc_bytes}, + "clone": {"count": cheetah_clone_allocs, "bytes": cheetah_clone_bytes}, + "char": {"count": cheetah_char_allocs, "bytes": cheetah_char_bytes}, + "short_concat": {"count": cheetah_concat_allocs, "bytes": cheetah_concat_bytes} } }, + "invariants": { + "from_arc_str_pointer_reused": cheetah_arc.as_bytes().as_ptr() == shared_source_pointer + }, "rss": { "Arc": { "before_bytes": arc_str_rss_before, @@ -305,6 +337,13 @@ fn emit_allocation_evidence() { arc_string_exact, arc_string_spare, arc_string_clone, + cheetah_borrowed, + cheetah_exact, + cheetah_spare, + cheetah_arc, + cheetah_clone, + cheetah_char, + cheetah_concat, retained_arc_string, )); } @@ -323,6 +362,10 @@ fn bench_construction(c: &mut Criterion) { group.bench_function("CheetahString/shared/borrowed", |b| { b.iter(|| black_box(CheetahString::from(black_box(borrowed.as_str())))) }); + let shared: Arc = Arc::from(borrowed.as_str()); + group.bench_function("CheetahString/shared/from_arc_str", |b| { + b.iter(|| CheetahString::from_arc_str(black_box(Arc::clone(&shared)))) + }); group.bench_function("Arc/owned_exact", |b| { b.iter_batched( || exact_string(1024), @@ -401,6 +444,21 @@ fn bench_construction(c: &mut Criterion) { group.finish(); } +fn bench_inline_fast_paths(c: &mut Criterion) { + let mut group = c.benchmark_group("inline_fast_paths"); + group.bench_function("char", |b| { + b.iter(|| black_box(CheetahString::from(black_box('🦀')))) + }); + group.bench_function("short_concat", |b| { + b.iter_batched( + || CheetahString::from("inline"), + |left| black_box(left + black_box("-value")), + BatchSize::SmallInput, + ) + }); + group.finish(); +} + fn bench_clone(c: &mut Criterion) { let value = exact_string(1024); let arc_str = ArcStrCandidate::owned(value.clone()); @@ -566,6 +624,7 @@ fn benchmarks(c: &mut Criterion) { emit_allocation_evidence(); bench_construction(c); bench_clone(c); + bench_inline_fast_paths(c); bench_mq_workloads(c); } diff --git a/scripts/bench-all.ps1 b/scripts/bench-all.ps1 index 58865b9..18dcbc9 100644 --- a/scripts/bench-all.ps1 +++ b/scripts/bench-all.ps1 @@ -93,8 +93,8 @@ $benchmarkIds = @( [ordered]@{ schema_version = 1 - capture_schema_version = "cheetah-string-capture-v2" - benchmark_schema_version = "cheetah-string-bench-v1" + capture_schema_version = "cheetah-string-capture-v3" + benchmark_schema_version = "cheetah-string-bench-v2" criterion_schema_version = "criterion-0.5" crate = "cheetah-string" git_sha = $gitSha @@ -154,10 +154,15 @@ Invoke-CargoCapture "allocation-contract.txt" @( ) Assert-TestExecuted "allocation-contract.txt" [ordered]@{ - schema_version = 1 + schema_version = 2 layout_contract = "passed" allocation_contract = "passed" clone_allocations_max = 0 + arc_str_adoption_allocations_max = 0 + char_allocations_max = 0 + inline_concat_allocations_max = 0 + owned_exact_freeze_allocations_max = 1 + owned_spare_freeze_allocations_max = 2 source = "tests/allocation_contract.rs" } | ConvertTo-Json -Depth 4 | Set-Content -Encoding utf8 -LiteralPath (Join-Path $ResultDir "contracts.json") Invoke-CargoCapture "layout-bench.txt" (@( @@ -187,6 +192,10 @@ Invoke-CargoCapture "simd.txt" (@( Invoke-CargoCapture "shared-backing.txt" (@( "bench", "--bench", "shared_backing", "--" ) + $criterionArguments) +& python (Join-Path $PSScriptRoot "verify-allocation-evidence.py") (Join-Path $ResultDir "shared-backing.txt") +if ($LASTEXITCODE -ne 0) { + throw "shared-backing allocation evidence verification failed" +} if (-not (Test-Path -LiteralPath $CriterionSource)) { throw "Criterion result directory is missing: $CriterionSource" diff --git a/scripts/bench-all.sh b/scripts/bench-all.sh index 95d2b89..34d4886 100755 --- a/scripts/bench-all.sh +++ b/scripts/bench-all.sh @@ -8,7 +8,7 @@ MODE=${4:-full} TOOLCHAIN=${CARGO_TOOLCHAIN:-} RESULT_DIR="bench-results/${VERSION}" CRITERION_DESTINATION="$RESULT_DIR/criterion" -CAPTURE_SCHEMA=cheetah-string-capture-v2 +CAPTURE_SCHEMA=cheetah-string-capture-v3 TARGET_ROOT=${CARGO_TARGET_DIR:-target} CRITERION_SOURCE="$TARGET_ROOT/criterion" @@ -96,7 +96,7 @@ json_escape() { printf '{\n' printf ' "schema_version": 1,\n' printf ' "capture_schema_version": "%s",\n' "$CAPTURE_SCHEMA" - printf ' "benchmark_schema_version": "cheetah-string-bench-v1",\n' + printf ' "benchmark_schema_version": "cheetah-string-bench-v2",\n' printf ' "criterion_schema_version": "criterion-0.5",\n' printf ' "crate": "cheetah-string",\n' printf ' "git_sha": "%s",\n' "$(json_escape "$GIT_SHA")" @@ -153,10 +153,15 @@ run_cargo allocation-contract.txt test --test allocation_contract --all-features require_test_passed allocation-contract.txt cat > "$RESULT_DIR/contracts.json" <<'JSON' { - "schema_version": 1, + "schema_version": 2, "layout_contract": "passed", "allocation_contract": "passed", "clone_allocations_max": 0, + "arc_str_adoption_allocations_max": 0, + "char_allocations_max": 0, + "inline_concat_allocations_max": 0, + "owned_exact_freeze_allocations_max": 1, + "owned_spare_freeze_allocations_max": 2, "source": "tests/allocation_contract.rs" } JSON @@ -179,6 +184,7 @@ run_cargo pattern.txt bench --bench pattern -- $CRITERION_ARGS run_cargo simd.txt bench --bench simd --features experimental-simd -- $CRITERION_ARGS # shellcheck disable=SC2086 run_cargo shared-backing.txt bench --bench shared_backing -- $CRITERION_ARGS +python3 "$SCRIPT_DIR/verify-allocation-evidence.py" "$RESULT_DIR/shared-backing.txt" [ -d "$CRITERION_SOURCE" ] || { echo "Criterion result directory is missing: $CRITERION_SOURCE" >&2 diff --git a/scripts/tests/test_allocation_evidence.py b/scripts/tests/test_allocation_evidence.py new file mode 100644 index 0000000..e4dae63 --- /dev/null +++ b/scripts/tests/test_allocation_evidence.py @@ -0,0 +1,78 @@ +import importlib.util +import json +import sys +import tempfile +import unittest +from copy import deepcopy +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "verify-allocation-evidence.py" +SPEC = importlib.util.spec_from_file_location("allocation_evidence", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +VERIFIER = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = VERIFIER +SPEC.loader.exec_module(VERIFIER) + + +def valid_evidence() -> dict: + return { + "schema_version": 2, + "object_sizes": {"CheetahString": 32}, + "allocations": { + "CheetahString": { + "borrowed": {"count": 1, "bytes": 1040}, + "owned_exact": {"count": 1, "bytes": 1040}, + "owned_spare": {"count": 2, "bytes": 2064}, + "from_arc_str": {"count": 0, "bytes": 0}, + "clone": {"count": 0, "bytes": 0}, + "char": {"count": 0, "bytes": 0}, + "short_concat": {"count": 0, "bytes": 0}, + } + }, + "invariants": {"from_arc_str_pointer_reused": True}, + } + + +class AllocationEvidenceTests(unittest.TestCase): + def test_valid_contract_passes(self) -> None: + VERIFIER.validate_evidence(valid_evidence()) + + def test_allocation_regression_fails_closed(self) -> None: + evidence = valid_evidence() + evidence["allocations"]["CheetahString"]["short_concat"]["count"] = 1 + + with self.assertRaisesRegex(VERIFIER.EvidenceError, "short_concat.count"): + VERIFIER.validate_evidence(evidence) + + def test_missing_or_false_pointer_invariant_is_rejected(self) -> None: + for value in (False, None): + with self.subTest(value=value): + evidence = deepcopy(valid_evidence()) + if value is None: + evidence["invariants"].pop("from_arc_str_pointer_reused") + else: + evidence["invariants"]["from_arc_str_pointer_reused"] = value + with self.assertRaises(VERIFIER.EvidenceError): + VERIFIER.validate_evidence(evidence) + + def test_log_parser_requires_one_evidence_record(self) -> None: + record = json.dumps(valid_evidence(), separators=(",", ":")) + parsed = VERIFIER.parse_log(f"noise\nSHARED_BACKING_EVIDENCE={record}\n") + self.assertEqual(parsed, valid_evidence()) + + with self.assertRaises(VERIFIER.EvidenceError): + VERIFIER.parse_log("no evidence here") + + def test_windows_utf16_capture_is_supported(self) -> None: + record = json.dumps(valid_evidence(), separators=(",", ":")) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "evidence.log" + path.write_text(f"SHARED_BACKING_EVIDENCE={record}\n", encoding="utf-16") + parsed = VERIFIER.parse_log(VERIFIER.read_log(path)) + + self.assertEqual(parsed, valid_evidence()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_repository_contracts.py b/scripts/tests/test_repository_contracts.py index 481746d..30e0c5c 100644 --- a/scripts/tests/test_repository_contracts.py +++ b/scripts/tests/test_repository_contracts.py @@ -65,6 +65,8 @@ def test_ci_contains_reproducible_engineering_gates(self) -> None: "python -m unittest discover -s scripts/tests -v", "cargo audit -D warnings", "scripts/check-msrv-package.sh 1.95", + "cargo test --test allocation_contract --all-features -- --test-threads=1", + "python scripts/verify-allocation-evidence.py", ): self.assertIn(command, ci) for command in ( @@ -74,6 +76,11 @@ def test_ci_contains_reproducible_engineering_gates(self) -> None: ): self.assertIn(command, safety) + performance = read(".github/workflows/performance.yml") + self.assertIn("cargo test --test allocation_contract --all-features -- --test-threads=1", performance) + self.assertIn("cargo bench --bench shared_backing -- __allocation_evidence_only__ --noplot", performance) + self.assertIn("python scripts/verify-allocation-evidence.py", performance) + def test_retired_score_governance_is_not_shipped(self) -> None: retired = ( "scripts/verify-score.py", diff --git a/scripts/verify-allocation-evidence.py b/scripts/verify-allocation-evidence.py new file mode 100644 index 0000000..7a7a7ec --- /dev/null +++ b/scripts/verify-allocation-evidence.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +PREFIX = "SHARED_BACKING_EVIDENCE=" + + +class EvidenceError(ValueError): + """Raised when allocation evidence does not satisfy the stable contract.""" + + +def parse_log(log: str) -> dict[str, Any]: + records = [line[len(PREFIX) :] for line in log.splitlines() if line.startswith(PREFIX)] + if len(records) != 1: + raise EvidenceError(f"expected exactly one allocation evidence record, found {len(records)}") + + try: + value = json.loads(records[0]) + except json.JSONDecodeError as error: + raise EvidenceError(f"allocation evidence is not valid JSON: {error}") from error + if not isinstance(value, dict): + raise EvidenceError("allocation evidence must be a JSON object") + return value + + +def read_log(path: Path) -> str: + data = path.read_bytes() + if data.startswith((b"\xff\xfe", b"\xfe\xff")): + return data.decode("utf-16") + return data.decode("utf-8-sig") + + +def require_mapping(value: Any, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise EvidenceError(f"{label} must be an object") + return value + + +def require_allocation( + allocations: dict[str, Any], name: str, expected_count: int, zero_bytes: bool = False +) -> None: + entry = require_mapping(allocations.get(name), f"allocations.CheetahString.{name}") + count = entry.get("count") + allocated_bytes = entry.get("bytes") + if type(count) is not int or count != expected_count: + raise EvidenceError( + f"allocations.CheetahString.{name}.count must be {expected_count}, found {count!r}" + ) + if type(allocated_bytes) is not int or allocated_bytes < 0: + raise EvidenceError(f"allocations.CheetahString.{name}.bytes must be a non-negative integer") + if zero_bytes and allocated_bytes != 0: + raise EvidenceError(f"allocations.CheetahString.{name}.bytes must be zero") + if expected_count > 0 and allocated_bytes == 0: + raise EvidenceError(f"allocations.CheetahString.{name}.bytes must record allocated storage") + + +def validate_evidence(evidence: dict[str, Any]) -> None: + if evidence.get("schema_version") != 2: + raise EvidenceError("schema_version must be 2") + + sizes = require_mapping(evidence.get("object_sizes"), "object_sizes") + if sizes.get("CheetahString") != 32: + raise EvidenceError("object_sizes.CheetahString must remain 32 on the 64-bit gate runner") + + allocation_groups = require_mapping(evidence.get("allocations"), "allocations") + allocations = require_mapping(allocation_groups.get("CheetahString"), "allocations.CheetahString") + require_allocation(allocations, "borrowed", 1) + require_allocation(allocations, "owned_exact", 1) + require_allocation(allocations, "owned_spare", 2) + for name in ("from_arc_str", "clone", "char", "short_concat"): + require_allocation(allocations, name, 0, zero_bytes=True) + + invariants = require_mapping(evidence.get("invariants"), "invariants") + if invariants.get("from_arc_str_pointer_reused") is not True: + raise EvidenceError("invariants.from_arc_str_pointer_reused must be true") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("log", type=Path, help="captured shared_backing benchmark output") + args = parser.parse_args() + + try: + evidence = parse_log(read_log(args.log)) + validate_evidence(evidence) + except (OSError, EvidenceError) as error: + print(f"allocation evidence rejected: {error}", file=sys.stderr) + return 1 + + print("allocation evidence verified") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/cheetah_string/construct.rs b/src/cheetah_string/construct.rs index cc83403..aaae89d 100644 --- a/src/cheetah_string/construct.rs +++ b/src/cheetah_string/construct.rs @@ -206,7 +206,8 @@ impl CheetahString { inner: InnerString::Inline(inline), } } else { - // Use Arc for long strings to avoid double allocation + // Arc is the final backing. Converting a String with spare + // capacity can require a shrink/reallocation before the Arc copy. let arc_str: Arc = s.into_boxed_str().into(); CheetahString { inner: InnerString::Shared(arc_str), @@ -214,6 +215,23 @@ impl CheetahString { } } + /// Creates a value from an owned `Arc`. + /// + /// Values up to 23 bytes are copied into canonical inline storage. Longer + /// values reuse the `Arc` payload without allocation or copying. + #[inline] + pub fn from_arc_str(s: Arc) -> Self { + if let Some(inline) = InlineStr::from_str(&s) { + CheetahString { + inner: InnerString::Inline(inline), + } + } else { + CheetahString { + inner: InnerString::Shared(s), + } + } + } + /// Creates a value from a shared owned `String`. /// /// A uniquely owned input can be consumed directly. A shared input is diff --git a/src/cheetah_string/convert.rs b/src/cheetah_string/convert.rs index f625b18..f04bc0c 100644 --- a/src/cheetah_string/convert.rs +++ b/src/cheetah_string/convert.rs @@ -31,6 +31,13 @@ impl From> for CheetahString { } } +impl From> for CheetahString { + #[inline] + fn from(s: Arc) -> Self { + CheetahString::from_arc_str(s) + } +} + impl<'a> From<&'a str> for CheetahString { #[inline] fn from(s: &'a str) -> Self { @@ -85,7 +92,7 @@ impl From> for CheetahString { } impl From for CheetahString { - /// Allocates an owned [`CheetahString`] from a single character. + /// Creates an inline [`CheetahString`] from a single character. /// /// # Example /// ```rust @@ -96,7 +103,8 @@ impl From for CheetahString { /// ``` #[inline] fn from(c: char) -> Self { - CheetahString::from_string(c.to_string()) + let mut buffer = [0; 4]; + CheetahString::from_slice(c.encode_utf8(&mut buffer)) } } diff --git a/src/cheetah_string/query.rs b/src/cheetah_string/query.rs index 4f2a8c6..89c90e6 100644 --- a/src/cheetah_string/query.rs +++ b/src/cheetah_string/query.rs @@ -284,6 +284,9 @@ impl CheetahString { /// Returns an iterator over the lines of the string. /// + /// The opaque iterator exposes reverse iteration, cloning, and fused + /// iteration because those capabilities are guaranteed by `str::Lines`. + /// /// # Examples /// /// ``` @@ -294,7 +297,9 @@ impl CheetahString { /// assert_eq!(lines, vec!["line1", "line2", "line3"]); /// ``` #[inline] - pub fn lines(&self) -> impl Iterator { + pub fn lines( + &self, + ) -> impl DoubleEndedIterator + Clone + core::iter::FusedIterator { self.as_str().lines() } diff --git a/src/cheetah_string/traits.rs b/src/cheetah_string/traits.rs index aa89cf4..e176957 100644 --- a/src/cheetah_string/traits.rs +++ b/src/cheetah_string/traits.rs @@ -5,9 +5,33 @@ use core::cmp::Ordering; use core::fmt::{self, Display}; use core::hash::{Hash, Hasher}; use core::ops::Add; +use core::str; +use super::repr::INLINE_CAPACITY; use super::CheetahString; +#[inline] +fn concatenate(left: &str, right: &str) -> CheetahString { + let len = left + .len() + .checked_add(right.len()) + .expect("concatenated string length overflow"); + + if len <= INLINE_CAPACITY { + let mut bytes = [0; INLINE_CAPACITY]; + bytes[..left.len()].copy_from_slice(left.as_bytes()); + bytes[left.len()..len].copy_from_slice(right.as_bytes()); + let value = + str::from_utf8(&bytes[..len]).expect("concatenating valid strings must preserve UTF-8"); + return CheetahString::from_slice(value); + } + + let mut value = String::with_capacity(len); + value.push_str(left); + value.push_str(right); + CheetahString::from_string(value) +} + impl PartialEq for CheetahString { #[inline] fn eq(&self, other: &Self) -> bool { @@ -147,10 +171,11 @@ impl Add<&str> for CheetahString { /// ``` #[inline] fn add(self, rhs: &str) -> Self::Output { - let mut value = String::with_capacity(self.len() + rhs.len()); - value.push_str(self.as_str()); - value.push_str(rhs); - CheetahString::from_string(value) + if rhs.is_empty() { + return self; + } + + concatenate(self.as_str(), rhs) } } @@ -191,13 +216,14 @@ impl Add for CheetahString { /// ``` #[inline] fn add(self, rhs: String) -> Self::Output { + if rhs.is_empty() { + return self; + } + if self.is_empty() { return CheetahString::from_string(rhs); } - let mut value = String::with_capacity(self.len() + rhs.len()); - value.push_str(self.as_str()); - value.push_str(&rhs); - CheetahString::from_string(value) + concatenate(self.as_str(), &rhs) } } diff --git a/src/lib.rs b/src/lib.rs index 2c34747..02c6933 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,8 +6,8 @@ //! //! [`CheetahString`] has one constructor-independent value contract: //! -//! - values up to 23 bytes are stored inline; -//! - static values borrow their `&'static str`; +//! - explicit static values borrow their `&'static str`; +//! - other values up to 23 bytes are stored inline; //! - other long values use a shared `Arc` backing. //! //! Long clones are bounded O(1) and allocate zero times. Append-heavy @@ -15,7 +15,8 @@ //! [`CheetahBuilder::finish`] to freeze the value or //! [`CheetahBuilder::into_string`] when mutation or spare capacity must //! continue. `from_string` freezes its input and does not retain a mutable -//! `String` representation. +//! `String` representation. Long `Arc` inputs can be adopted without +//! allocation through [`CheetahString::from_arc_str`]. //! //! The crate supports `no_std + alloc`. Optional `serde` integration preserves //! the text contract, while the `bytes` feature exposes `CheetahBytes` for diff --git a/tests/allocation_contract.rs b/tests/allocation_contract.rs index 4f1b11a..b0e084d 100644 --- a/tests/allocation_contract.rs +++ b/tests/allocation_contract.rs @@ -2,6 +2,7 @@ use cheetah_string::CheetahString; use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; use std::hint::black_box; +use std::sync::Arc; struct CountingAllocator; @@ -71,6 +72,94 @@ fn assert_clone_allocations(value: &CheetahString) { assert_eq!(&cloned, value); } +fn exact_string(len: usize) -> String { + let mut value = String::with_capacity(len); + value.extend(std::iter::repeat_n('x', len)); + assert_eq!(value.len(), value.capacity()); + value +} + +fn spare_string(len: usize) -> String { + let mut value = String::with_capacity(len * 2); + value.extend(std::iter::repeat_n('x', len)); + assert!(value.capacity() > value.len()); + value +} + +#[test] +fn arc_str_char_and_short_concat_are_zero_allocation_fast_paths() { + let source: Arc = Arc::from("a".repeat(1024)); + let source_pointer = source.as_ptr(); + let (count, bytes, shared) = measure(|| CheetahString::from(black_box(source))); + assert_eq!((count, bytes), (0, 0)); + assert_eq!(shared.as_bytes().as_ptr(), source_pointer); + + let short_source: Arc = Arc::from("short"); + let short_pointer = short_source.as_ptr(); + let (count, bytes, short) = measure(|| CheetahString::from(black_box(short_source))); + assert_eq!((count, bytes), (0, 0)); + assert_ne!(short.as_bytes().as_ptr(), short_pointer); + + let (count, bytes, ()) = measure(|| { + for scalar in 0..=char::MAX as u32 { + if let Some(character) = char::from_u32(scalar) { + let value = CheetahString::from(black_box(character)); + let mut encoded = [0; 4]; + assert_eq!(value.as_str(), character.encode_utf8(&mut encoded)); + black_box(value); + } + } + }); + assert_eq!((count, bytes), (0, 0), "every Unicode scalar is inline"); + + let left = CheetahString::from("inline"); + let (count, bytes, combined) = measure(|| black_box(left) + black_box("-value")); + assert_eq!((count, bytes), (0, 0)); + assert_eq!(combined, "inline-value"); + + let left = CheetahString::from("inline"); + let owned_right = String::from("-owned"); + let (count, bytes, combined) = measure(|| black_box(left) + black_box(owned_right)); + assert_eq!((count, bytes), (0, 0)); + assert_eq!(combined, "inline-owned"); +} + +#[test] +fn exact_and_spare_capacity_freeze_costs_are_explicit() { + let exact_input = exact_string(1024); + let (count, _, exact) = measure(|| CheetahString::from_string(exact_input)); + assert_eq!(count, 1, "exact-capacity String freeze creates Arc backing"); + assert_eq!(exact.len(), 1024); + + let spare_input = spare_string(1024); + let (count, _, spare) = measure(|| CheetahString::from_string(spare_input)); + assert_eq!( + count, 2, + "spare-capacity String freeze shrinks then creates Arc backing" + ); + assert_eq!(spare.len(), 1024); + + let mut exact_builder = cheetah_string::CheetahBuilder::with_capacity(1024); + exact_builder.push_str(&"x".repeat(1024)); + assert_eq!(exact_builder.len(), exact_builder.capacity()); + let (count, _, exact) = measure(|| exact_builder.finish()); + assert_eq!( + count, 1, + "exact-capacity builder freeze creates Arc backing" + ); + assert_eq!(exact.len(), 1024); + + let mut spare_builder = cheetah_string::CheetahBuilder::with_capacity(2048); + spare_builder.push_str(&"x".repeat(1024)); + assert!(spare_builder.capacity() > spare_builder.len()); + let (count, _, spare) = measure(|| spare_builder.finish()); + assert_eq!( + count, 2, + "spare-capacity builder freeze shrinks then creates Arc backing" + ); + assert_eq!(spare.len(), 1024); +} + #[test] fn v3_allocation_and_clone_contracts() { // Keep all measurements in one test. Run this binary with --test-threads=1 diff --git a/tests/api_extensions.rs b/tests/api_extensions.rs index 7dffa94..46fb8db 100644 --- a/tests/api_extensions.rs +++ b/tests/api_extensions.rs @@ -108,6 +108,24 @@ fn test_lines() { assert_eq!(lines3, Vec::<&str>::new()); } +#[test] +fn lines_exposes_reverse_clone_and_fused_capabilities() { + fn assert_capabilities<'a, I>(_: &I) + where + I: DoubleEndedIterator + Clone + core::iter::FusedIterator, + { + } + + let value = CheetahString::from("first\nsecond\nthird"); + let mut lines = value.lines(); + assert_capabilities(&lines); + + let cloned = lines.clone(); + assert_eq!(lines.next_back(), Some("third")); + assert_eq!(lines.next(), Some("first")); + assert_eq!(cloned.collect::>(), ["first", "second", "third"]); +} + #[test] fn test_chars() { let s = CheetahString::from("hello"); diff --git a/tests/basic.rs b/tests/basic.rs index 40293cd..8acf2f8 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -158,6 +158,36 @@ fn test_from_arc_string() { assert_eq!(s, "hello"); } +#[test] +fn from_arc_str_long_reuses_payload_pointer() { + let source: Arc = Arc::from("shared arc-str payload ".repeat(8)); + let pointer = source.as_ptr(); + + let value = CheetahString::from_arc_str(source); + + assert_eq!(value.as_bytes().as_ptr(), pointer); + assert!(value.len() > 23); +} + +#[test] +fn from_arc_str_trait_matches_named_constructor() { + let named = CheetahString::from_arc_str(Arc::::from("named constructor payload")); + let converted = CheetahString::from(Arc::::from("named constructor payload")); + + assert_eq!(named, converted); +} + +#[test] +fn short_arc_str_canonicalizes_without_reusing_heap_payload() { + let source: Arc = Arc::from("short"); + let pointer = source.as_ptr(); + + let value = CheetahString::from_arc_str(source); + + assert_eq!(value, "short"); + assert_ne!(value.as_bytes().as_ptr(), pointer); +} + #[test] fn test_into_string_copies_frozen_arc_string_value() { let value = "a".repeat(64);