diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 0000000..8df8073 --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,257 @@ +name: Native AOT (ytx) + +# Builds NativeAOT binaries for each platform, smoke-tests them offline, and attaches them to a +# GitHub Release. This is separate from publish.yml on purpose: the NuGet tool release is unchanged. +# +# Trap: publish.yml creates releases with GITHUB_TOKEN, and events caused by GITHUB_TOKEN do not +# start other workflows. So `release: published` only fires for releases made by a person (or a PAT). +# publish.yml therefore dispatches this workflow with the new tag as its last step. + +on: + workflow_dispatch: + inputs: + tag: + description: Existing release tag (vX.Y.Z) to attach binaries to. Leave empty to build only. + required: false + default: '' + type: string + # No branch filter, so stacked PRs (based on another feature branch) also build. + pull_request: + paths: + - 'src/**' + - 'scripts/aot/**' + - '.github/workflows/native.yml' + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: native-${{ github.event.release.tag_name || inputs.tag || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + PROJECT: src/Ytx/Ytx.csproj + TFM: net10.0 + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + +jobs: + meta: + runs-on: ubuntu-latest + outputs: + ref: ${{ steps.meta.outputs.ref }} + tag: ${{ steps.meta.outputs.tag }} + version: ${{ steps.meta.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ github.event.release.tag_name || inputs.tag || github.sha }} + + - name: Determine version + id: meta + shell: bash + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + + CSPROJ_VERSION=$(sed -n 's:.*\([^<]*\).*:\1:p' "$PROJECT") + TAG=${RELEASE_TAG:-$INPUT_TAG} + + if [[ -n "$TAG" ]]; then + if [[ ! "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "Tag must look like vX.Y.Z: $TAG" >&2 + exit 1 + fi + VERSION=${BASH_REMATCH[1]} + if [[ "$VERSION" != "$CSPROJ_VERSION" ]]; then + echo "Tag $TAG does not match $CSPROJ_VERSION at that tag." >&2 + exit 1 + fi + REF=$TAG + else + # Build-only runs get a prerelease version so their binaries are never mistaken for a release. + VERSION="$CSPROJ_VERSION-ci.$RUN_NUMBER" + REF=$GITHUB_SHA + fi + + echo "Version: $VERSION Tag: ${TAG:-(none)} Ref: $REF" + { + echo "version=$VERSION" + echo "tag=$TAG" + echo "ref=$REF" + } >> "$GITHUB_OUTPUT" + + build: + needs: meta + name: build (${{ matrix.rid }}) + strategy: + fail-fast: false + matrix: + include: + - rid: osx-arm64 + os: macos-latest + smoke: true + # Cross-architecture: Xcode on the arm64 runner ships the x64 toolchain, so this needs no extra + # setup. The binary cannot run here (no Rosetta guarantee), so smoke-osx-x64 runs it on Intel. + - rid: osx-x64 + os: macos-latest + smoke: false + # Build on the oldest supported Ubuntu: the binary needs the glibc it was linked against or newer. + - rid: linux-x64 + os: ubuntu-22.04 + smoke: true + - rid: linux-arm64 + os: ubuntu-22.04-arm + smoke: true + - rid: win-x64 + os: windows-latest + smoke: true + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ needs.meta.outputs.ref }} + + - name: Setup .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.0.x + + - name: Install NativeAOT prerequisites (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + - name: Publish NativeAOT + shell: bash + env: + RID: ${{ matrix.rid }} + VERSION: ${{ needs.meta.outputs.version }} + run: | + dotnet publish "$PROJECT" -c Release -f "$TFM" -r "$RID" \ + -p:PublishAot=true -p:Version="$VERSION" -o out + ls -la out + + - name: Stage binary + id: stage + shell: bash + env: + RID: ${{ matrix.rid }} + VERSION: ${{ needs.meta.outputs.version }} + run: | + set -euo pipefail + exe="" + [[ "$RID" == win-* ]] && exe=".exe" + + # The assembly is named Ytx; ship the command as lower-case ytx. Copy into a fresh folder + # instead of renaming in place, because macOS and Windows file systems ignore case. + mkdir -p pkg + cp "out/Ytx$exe" "pkg/ytx$exe" + cp README.md pkg/ + + name="ytx-$VERSION-$RID" + echo "name=$name" >> "$GITHUB_OUTPUT" + echo "binary=pkg/ytx$exe" >> "$GITHUB_OUTPUT" + du -h "pkg/ytx$exe" + + - name: Report glibc requirement (Linux) + if: runner.os == 'Linux' + run: | + echo "Highest GLIBC symbol version required:" + objdump -T pkg/ytx | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1 + ldd pkg/ytx + + - name: Smoke test + if: matrix.smoke + shell: bash + env: + BINARY: ${{ steps.stage.outputs.binary }} + VERSION: ${{ needs.meta.outputs.version }} + run: scripts/aot/smoke-test.sh "$BINARY" "$VERSION" + + - name: Archive (tar.gz) + if: runner.os != 'Windows' + shell: bash + env: + NAME: ${{ steps.stage.outputs.name }} + run: tar -czf "$NAME.tar.gz" -C pkg ytx README.md + + - name: Archive (zip) + if: runner.os == 'Windows' + shell: pwsh + env: + NAME: ${{ steps.stage.outputs.name }} + run: Compress-Archive -Path pkg/* -DestinationPath "$env:NAME.zip" + + - name: Upload artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: native-${{ matrix.rid }} + path: ${{ steps.stage.outputs.name }}.* + if-no-files-found: error + retention-days: 14 + + # GitHub's Intel macOS runner. GitHub has said x64 macOS runners end when the macOS 15 image + # retires (announced for fall 2027); after that, drop this job and ship osx-x64 untested or not at all. + smoke-osx-x64: + needs: [meta, build] + runs-on: macos-15-intel + steps: + - name: Checkout smoke test + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ needs.meta.outputs.ref }} + sparse-checkout: scripts/aot + + - name: Download osx-x64 build + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: native-osx-x64 + path: dist + + - name: Smoke test + shell: bash + env: + VERSION: ${{ needs.meta.outputs.version }} + run: | + set -euo pipefail + mkdir pkg + tar -xzf dist/ytx-*-osx-x64.tar.gz -C pkg + file pkg/ytx + scripts/aot/smoke-test.sh pkg/ytx "$VERSION" + + release: + if: needs.meta.outputs.tag != '' + needs: [meta, build, smoke-osx-x64] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download all builds + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: native-* + merge-multiple: true + path: dist + + - name: Write SHA256SUMS + working-directory: dist + run: | + sha256sum ytx-* > SHA256SUMS + cat SHA256SUMS + + - name: Upload release assets + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.meta.outputs.tag }} + run: | + # --clobber makes a re-run replace the assets instead of failing on duplicates. + gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 671d1e0..a24fed6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -71,6 +71,8 @@ jobs: publish: if: github.event_name != 'pull_request' permissions: + # actions: write lets this job start native.yml; see the last step. + actions: write contents: write id-token: write runs-on: ubuntu-latest @@ -224,3 +226,11 @@ jobs: --title "ytx v$VERSION" \ --generate-notes fi + + # Releases made with GITHUB_TOKEN do not fire `release: published` in other workflows, + # so start the native build explicitly. It attaches the binaries to this release. + - name: Build native binaries + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.bump.outputs.tag }} + run: gh workflow run native.yml --ref master -f tag="$TAG" diff --git a/AGENTS.md b/AGENTS.md index 9c1d9e3..0a1984d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,14 @@ dotnet tool install -g solrevdev.ytx --add-source ./nupkg - System.Text.Json for serialization - System.Text.RegularExpressions for caption text normalization +## Native Builds and Benchmarks + +- `docs/investigations/` holds write-ups of past investigations. Start with `2026-10-native-aot-and-allocations.md`. +- NativeAOT: `dotnet publish src/Ytx -c Release -f net10.0 -r -p:PublishAot=true`, then `scripts/aot/smoke-test.sh `. Keep JSON on the source-generated `YtxJsonContext`; reflection-based `JsonSerializer` calls break AOT. +- `.github/workflows/native.yml` builds per-RID binaries and attaches them to a release. It does not publish to NuGet; `publish.yml` dispatches it with the new tag after each release. +- `benchmarks/Ytx.Benchmarks/` is standalone and not part of CI. Never commit captured YouTube captions; `data/` is gitignored. +- `packaging/` holds Homebrew and Scoop templates. They are not wired up yet. + ## Repository Conventions ### Git commits diff --git a/README.md b/README.md index dc9a8fb..e16bc5b 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ A .NET global tool that extracts YouTube video metadata and transcripts as JSON. - ๐ŸŒ **Captions-aware** โ€” Prefers human-written captions in your language, reports which track it used - ๐Ÿ“ **Markdown transcript** โ€” Human-readable format with timestamped links, split into sections by chapter - ๐Ÿ“‘ **Chapters** โ€” Read from the video description, using YouTube's own rules -- ๐Ÿš€ **Cross-platform** โ€” Works on macOS, Windows, Linux (.NET 8/9/10) +- ๐Ÿš€ **Cross-platform** โ€” Works on macOS, Windows, Linux (.NET 8/9/10), or as a native binary with no .NET runtime - โšก **Fast & lightweight** โ€” No dependencies beyond .NET and YoutubeExplode ## Quick Start @@ -46,6 +46,8 @@ A .NET global tool that extracts YouTube video metadata and transcripts as JSON. dotnet tool install -g solrevdev.ytx ``` +This needs the .NET 8, 9 or 10 SDK. To run ytx without .NET, use a [native binary](#native-binaries). + ### Usage ```bash @@ -123,6 +125,113 @@ ytx --metadata-only --compact "https://www.youtube.com/watch?v=dQw4w9WgXcQ" dotnet tool update -g solrevdev.ytx ``` +## Native binaries + +Each GitHub Release from the first one after NativeAOT support also has a self-contained binary for each platform. It is compiled ahead of time, so it needs no .NET runtime. The JSON output is byte-for-byte the same as the NuGet tool's. + +### NuGet tool or native binary? + +| | NuGet tool | Native binary | +|---|---|---| +| Needs | .NET 8, 9 or 10 SDK | Nothing on macOS and Windows; OpenSSL and CA certificates on Linux | +| Install and update | `dotnet tool install/update -g` | Download an archive, or a package manager later (see below) | +| Startup | about 38 ms | about 9 ms | +| CPU for a real call | 300โ€“400 ms (mostly JIT) | 47โ€“73 ms | +| Peak memory | about 110โ€“130 MB | about 55โ€“60 MB | +| Size | about 1.3 MB plus the runtime | about 10โ€“11 MB, one file | +| Platforms | Anywhere .NET runs | macOS arm64 and x64, Linux x64 and arm64 (glibc), Windows x64 | + +Wall time for a real call is mostly network, about 1โ€“1.5 s either way. The numbers come from [the NativeAOT investigation](docs/investigations/2026-10-native-aot-and-allocations.md). + +Choose the native binary when: + +- **CI or containers:** there is no .NET SDK, or you don't want to install one just for ytx. A single file in a slim image or on a runner is enough. +- **Many short calls:** scripts and agents that call ytx in a loop save the JIT start-up and CPU on every call. +- **Locked-down or minimal machines:** no global tool path, no runtime to keep patched. +- **Experimenting:** try ytx, or compare it with other tools, without touching your .NET setup. + +Stay with the NuGet tool if you already have .NET, want `dotnet tool update`, or are on a platform without a binary (such as Windows arm64 or Alpine/musl). + +### Install on macOS + +```bash +VERSION=$(curl -fsSL https://api.github.com/repos/solrevdev/solrevdev.ytx/releases/latest | sed -n 's/.*"tag_name": "v\(.*\)".*/\1/p') +ARCH=$([ "$(uname -m)" = arm64 ] && echo osx-arm64 || echo osx-x64) +cd "$(mktemp -d)" +curl -fsSLO "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$VERSION/ytx-$VERSION-$ARCH.tar.gz" +curl -fsSLO "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$VERSION/SHA256SUMS" +shasum -a 256 --ignore-missing -c SHA256SUMS +tar -xzf "ytx-$VERSION-$ARCH.tar.gz" +mkdir -p ~/.local/bin && mv ytx ~/.local/bin/ # make sure ~/.local/bin is on your PATH +ytx --version +``` + +The binaries are ad-hoc signed, not notarised. `curl` does not mark files as quarantined, so this works as is. If you download the archive in a browser, Gatekeeper blocks it; clear the flag with `xattr -d com.apple.quarantine ~/.local/bin/ytx`. + +### Install on Linux + +```bash +VERSION=$(curl -fsSL https://api.github.com/repos/solrevdev/solrevdev.ytx/releases/latest | sed -n 's/.*"tag_name": "v\(.*\)".*/\1/p') +ARCH=$([ "$(uname -m)" = aarch64 ] && echo linux-arm64 || echo linux-x64) +cd "$(mktemp -d)" +curl -fsSLO "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$VERSION/ytx-$VERSION-$ARCH.tar.gz" +curl -fsSLO "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$VERSION/SHA256SUMS" +sha256sum --ignore-missing -c SHA256SUMS +tar -xzf "ytx-$VERSION-$ARCH.tar.gz" +mkdir -p ~/.local/bin && mv ytx ~/.local/bin/ +ytx --version +``` + +- **glibc 2.34 or newer** is required: Ubuntu 22.04+, Debian 12+, RHEL/Rocky/Alma 9+, Fedora 35+. Alpine (musl) is not supported; use the NuGet tool there. +- **OpenSSL and CA certificates** are loaded at run time for HTTPS. Slim images often lack them. On Debian or Ubuntu: `apt-get install -y libssl3 ca-certificates` (`libssl3t64` on Ubuntu 24.04). +- ICU is not needed. + +### Install on Windows + +```powershell +$version = (Invoke-RestMethod https://api.github.com/repos/solrevdev/solrevdev.ytx/releases/latest).tag_name.TrimStart('v') +$base = "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$version" +$dir = "$env:LOCALAPPDATA\Programs\ytx" +New-Item -ItemType Directory -Force $dir | Out-Null +Invoke-WebRequest "$base/ytx-$version-win-x64.zip" -OutFile "$env:TEMP\ytx.zip" +Invoke-WebRequest "$base/SHA256SUMS" -OutFile "$env:TEMP\SHA256SUMS" +$expected = (Select-String -SimpleMatch "ytx-$version-win-x64.zip" "$env:TEMP\SHA256SUMS").Line.Split(' ')[0] +if ((Get-FileHash "$env:TEMP\ytx.zip").Hash -ne $expected) { throw "Checksum mismatch" } +Expand-Archive "$env:TEMP\ytx.zip" -DestinationPath $dir -Force +[Environment]::SetEnvironmentVariable('Path', "$([Environment]::GetEnvironmentVariable('Path','User'));$dir", 'User') +& "$dir\ytx.exe" --version # open a new terminal to use plain `ytx` +``` + +SmartScreen may warn on first run because the binary is not code-signed. + +### Use in GitHub Actions + +```yaml +- name: Install ytx + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download --repo solrevdev/solrevdev.ytx --pattern 'ytx-*-linux-x64.tar.gz' --pattern SHA256SUMS + sha256sum --ignore-missing -c SHA256SUMS + sudo tar -xzf ytx-*-linux-x64.tar.gz -C /usr/local/bin ytx + ytx --version +``` + +YouTube blocks many cloud IP addresses, so captions often come back as `captionStatus: "blocked"` on hosted runners. Set `HTTPS_PROXY` or pass `--proxy` with a residential proxy. + +### Homebrew and Scoop + +Not available yet. Templates are in [`packaging/`](packaging/), waiting on a decision to create a Homebrew tap and a Scoop bucket. + +### Build a native binary yourself + +```bash +dotnet publish src/Ytx -c Release -f net10.0 -r osx-arm64 -p:PublishAot=true -o out +scripts/aot/smoke-test.sh out/ytx +``` + +Use your platform's runtime identifier (`osx-x64`, `linux-x64`, `linux-arm64`, `win-x64`). NativeAOT cannot cross-compile between operating systems, and it needs the platform's C toolchain (Xcode command-line tools, `clang` and `zlib1g-dev` on Linux, or Visual Studio's C++ workload on Windows). + ## Output Format Every document has the same keys in the same order. Fields that YouTube does not provide are `null`, never missing. @@ -195,6 +304,10 @@ dotnet tool install -g solrevdev.ytx --add-source ./nupkg โ”œโ”€โ”€ assets/icon.svg # Package icon source (icon.png is rendered from it) โ”œโ”€โ”€ CHANGELOG.md # Release history โ”œโ”€โ”€ docs/investigations/ # Write-ups of reviews and investigations +โ”œโ”€โ”€ .github/workflows/native.yml # NativeAOT binaries per platform, attached to releases +โ”œโ”€โ”€ scripts/aot/ # Native publish, benchmark and smoke-test scripts +โ”œโ”€โ”€ benchmarks/Ytx.Benchmarks/ # BenchmarkDotNet project (not built by CI) +โ”œโ”€โ”€ packaging/ # Homebrew and Scoop templates โ””โ”€โ”€ README.md # User and maintainer documentation ``` diff --git a/benchmarks/Ytx.Benchmarks/.gitignore b/benchmarks/Ytx.Benchmarks/.gitignore new file mode 100644 index 0000000..21ffce0 --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/.gitignore @@ -0,0 +1,6 @@ +# Captured YouTube captions are third-party content. Never commit them. +data/ + +BenchmarkDotNet.Artifacts/ +bin/ +obj/ diff --git a/benchmarks/Ytx.Benchmarks/Benchmarks.cs b/benchmarks/Ytx.Benchmarks/Benchmarks.cs new file mode 100644 index 0000000..e2d948c --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/Benchmarks.cs @@ -0,0 +1,91 @@ +using BenchmarkDotNet.Attributes; +using Ytx.Benchmarks.Subjects; + +namespace Ytx.Benchmarks; + +/// Datasets are discovered at run time: data/synthetic/*.json plus anything in data/captured/. +public abstract class DatasetBench +{ + public static IEnumerable Datasets() + { + var dir = DataDir.Current; + return DataDir.AllDatasets(dir).Select(f => Path.GetRelativePath(dir, f).Replace('\\', '/')); + } + + [ParamsSource(nameof(Datasets))] + public string Source { get; set; } = ""; + + protected Dataset Data = null!; + + [GlobalSetup] + public void Setup() => Data = Dataset.Load(Path.Combine(DataDir.Current, Source)); +} + +/// Caption formatting only: normalise, build the raw and Markdown strings. +[MemoryDiagnoser] +public class FormatBench : DatasetBench +{ + [Benchmark(Baseline = true)] + public object Baseline_Format() => Baseline.Format(Data.Id, Data.Captions); + + [Benchmark] + public object Tier1_Format() => Tier1.Format(Data.Id, Data.Captions); + + [Benchmark] + public int Tier2_Format() + { + var (raw, md) = Tier2.Format(Data.Id, Data.Captions); + int n = raw.Length + md.Length; + raw.Dispose(); + md.Dispose(); + return n; + } +} + +/// Everything ytx does after the network: format, serialise and write (to Stream.Null). +[MemoryDiagnoser] +public class EndToEndBench : DatasetBench +{ + [Benchmark(Baseline = true)] + public void Baseline_All() => Variants.Run(Variants.Baseline, Data, false, Stream.Null); + + [Benchmark] + public void Tier1_All() => Variants.Run(Variants.Tier1, Data, false, Stream.Null); + + [Benchmark] + public void Tier1SourceGen_All() => Variants.Run(Variants.Tier1SourceGen, Data, false, Stream.Null); + + [Benchmark] + public void Tier2_All() => Variants.Run(Variants.Tier2, Data, false, Stream.Null); +} + +/// NormalizeCaption over every cue of the largest dataset. +[MemoryDiagnoser] +public class NormalizeBench +{ + string[] _texts = []; + + [GlobalSetup] + public void Setup() + { + var largest = DataDir.AllDatasets(DataDir.Current).MaxBy(f => new FileInfo(f).Length) + ?? throw new InvalidOperationException("No datasets found. Run the 'generate' command first."); + _texts = Dataset.Load(largest).Captions.Select(c => c.Text).ToArray(); + } + + [Benchmark(Baseline = true)] + public int Baseline_Normalize() + { + int n = 0; + foreach (var t in _texts) n += Baseline.NormalizeCaption(t).Length; + return n; + } + + [Benchmark] + public int Tier1_Normalize() + { + int n = 0; + foreach (var t in _texts) n += Tier1.NormalizeCaption(t).Length; + return n; + } +} diff --git a/benchmarks/Ytx.Benchmarks/Capture.cs b/benchmarks/Ytx.Benchmarks/Capture.cs new file mode 100644 index 0000000..de761bb --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/Capture.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using YoutubeExplode; +using YoutubeExplode.Videos; + +namespace Ytx.Benchmarks; + +/// +/// Fetches a video's metadata and caption track once and saves it as a dataset under data/captured/. +/// That folder is gitignored: captions are third-party content and must not be committed. +/// It also prints how long each network phase took and how much it allocated. +/// +public static class Capture +{ + public static async Task RunAsync(string dataDir, IReadOnlyList inputs, string language) + { + var outDir = Path.Combine(dataDir, "captured"); + Directory.CreateDirectory(outDir); + var client = new YoutubeClient(); + int failures = 0; + + foreach (var input in inputs) + { + var id = VideoId.TryParse(input); + if (id == null) + { + Console.Error.WriteLine($"skip '{input}': not a YouTube URL or video ID"); + failures++; + continue; + } + + try + { + var sw = Stopwatch.StartNew(); + long a0 = GC.GetTotalAllocatedBytes(true); + var video = await client.Videos.GetAsync(id.Value); + var t1 = sw.Elapsed; + long a1 = GC.GetTotalAllocatedBytes(true); + + var manifest = await client.Videos.ClosedCaptions.GetManifestAsync(video.Id); + var t2 = sw.Elapsed; + long a2 = GC.GetTotalAllocatedBytes(true); + + // Same ordering as ytx v1.0.7, so the benchmark formats the track ytx would have chosen. + // (Issue #3 bug 1: this prefers auto-generated tracks. Fix it here when ytx fixes it.) + var track = manifest.Tracks + .OrderByDescending(t => LanguageMatches(t.Language.Code, t.Language.Name, language)) + .ThenByDescending(t => t.IsAutoGenerated) + .FirstOrDefault(); + if (track == null) + { + Console.Error.WriteLine($"skip {id}: no caption tracks"); + failures++; + continue; + } + + var captions = await client.Videos.ClosedCaptions.GetAsync(track); + var t3 = sw.Elapsed; + long a3 = GC.GetTotalAllocatedBytes(true); + + var d = new Dataset( + video.Id.Value, input, video.Title ?? "", video.Description ?? "", + captions.Captions.Select(c => new Cap(c.Text, c.Offset, c.Duration)).ToArray()); + var path = Path.Combine(outDir, video.Id.Value + ".json"); + d.Save(path); + + Console.Error.WriteLine( + $"{id}: video {t1.TotalMilliseconds:F0} ms / {(a1 - a0) / 1048576.0:F1} MB, " + + $"manifest {(t2 - t1).TotalMilliseconds:F0} ms / {(a2 - a1) / 1048576.0:F1} MB, " + + $"captions {(t3 - t2).TotalMilliseconds:F0} ms / {(a3 - a2) / 1048576.0:F1} MB, " + + $"track '{track.Language.Name}' auto={track.IsAutoGenerated} cues={d.Captions.Length}"); + Console.WriteLine(path); + } + catch (Exception ex) + { + Console.Error.WriteLine($"failed {id}: {ex.Message}"); + failures++; + } + } + + return failures == 0 ? 0 : 1; + } + + // Copy of Program.LanguageMatches in ytx v1.0.7. + static bool LanguageMatches(string code, string name, string preference) => + code.Equals(preference, StringComparison.OrdinalIgnoreCase) + || name.Equals(preference, StringComparison.OrdinalIgnoreCase) + || name.Contains(preference, StringComparison.OrdinalIgnoreCase); +} diff --git a/benchmarks/Ytx.Benchmarks/Dataset.cs b/benchmarks/Ytx.Benchmarks/Dataset.cs new file mode 100644 index 0000000..9f61971 --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/Dataset.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ytx.Benchmarks; + +public sealed record Cap(string Text, TimeSpan Offset, TimeSpan Duration); + +// Same shape and property names as the Output class in src/Ytx/Program.cs (v1.0.7). +public class Output +{ + public string url { get; set; } = ""; + public string title { get; set; } = ""; + public string description { get; set; } = ""; + public string transcriptRaw { get; set; } = ""; + public string transcript { get; set; } = ""; +} + +[JsonSerializable(typeof(Output))] +public partial class OutputContext : JsonSerializerContext { } + +/// +/// One video's metadata and caption track, as captured from YouTube or generated synthetically. +/// File format: {"id","url","title","description","captions":[{"text","offsetTicks","durationTicks"}]}. +/// +public sealed record Dataset(string Id, string Url, string Title, string Description, Cap[] Captions) +{ + public static Dataset Load(string path) + { + using var doc = JsonDocument.Parse(File.ReadAllBytes(path)); + var r = doc.RootElement; + var caps = r.GetProperty("captions").EnumerateArray() + .Select(c => new Cap( + c.GetProperty("text").GetString()!, + TimeSpan.FromTicks(c.GetProperty("offsetTicks").GetInt64()), + c.TryGetProperty("durationTicks", out var d) ? TimeSpan.FromTicks(d.GetInt64()) : TimeSpan.Zero)) + .ToArray(); + return new Dataset( + r.GetProperty("id").GetString()!, + r.GetProperty("url").GetString()!, + r.GetProperty("title").GetString()!, + r.GetProperty("description").GetString()!, + caps); + } + + public void Save(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + using var fs = File.Create(path); + using var w = new Utf8JsonWriter(fs); + w.WriteStartObject(); + w.WriteString("id", Id); + w.WriteString("url", Url); + w.WriteString("title", Title); + w.WriteString("description", Description); + w.WriteStartArray("captions"); + foreach (var c in Captions) + { + w.WriteStartObject(); + w.WriteString("text", c.Text); + w.WriteNumber("offsetTicks", c.Offset.Ticks); + w.WriteNumber("durationTicks", c.Duration.Ticks); + w.WriteEndObject(); + } + w.WriteEndArray(); + w.WriteEndObject(); + } + + /// Adds captions that exercise every normalisation edge case the variants must agree on. + public Dataset WithEdgeCases() => this with + { + Captions = + [ + .. Captions, + new Cap(" a\t\tb   ", TimeSpan.FromHours(101.5), TimeSpan.Zero), + new Cap(" ", TimeSpan.Zero, TimeSpan.Zero), + new Cap("x\u00A0\u2028y\u0085 z\u3000", TimeSpan.FromSeconds(59.9), TimeSpan.Zero), + new Cap("caf\u00E9 \U0001F600 na\u00EFve", TimeSpan.FromSeconds(3599.99), TimeSpan.Zero), + new Cap("\n", TimeSpan.FromSeconds(1), TimeSpan.Zero), + ], + }; +} + +public static class DataDir +{ + public const string EnvVar = "YTX_BENCH_DATA"; + + public static bool IsDefault { get; private set; } + + /// + /// Resolves the data folder once and exports it as an absolute path, because BenchmarkDotNet + /// runs each benchmark in a child process with a different working directory. + /// + public static string Resolve(string? overridePath = null) + { + var dir = overridePath ?? Environment.GetEnvironmentVariable(EnvVar); + IsDefault = dir == null; + dir ??= FindDefault(); + dir = Path.GetFullPath(dir); + Environment.SetEnvironmentVariable(EnvVar, dir); + return dir; + } + + public static string Current => Environment.GetEnvironmentVariable(EnvVar) ?? Resolve(); + + public static IEnumerable AllDatasets(string dir) => + Directory.Exists(dir) + ? Directory.EnumerateFiles(dir, "*.json", SearchOption.AllDirectories).Order(StringComparer.Ordinal) + : []; + + // Walk up from the build output to the folder that holds the .csproj, then use its data/ folder. + static string FindDefault() + { + for (var d = new DirectoryInfo(AppContext.BaseDirectory); d != null; d = d.Parent) + { + if (File.Exists(Path.Combine(d.FullName, "Ytx.Benchmarks.csproj"))) + return Path.Combine(d.FullName, "data"); + } + return Path.Combine(Directory.GetCurrentDirectory(), "data"); + } +} diff --git a/benchmarks/Ytx.Benchmarks/Program.cs b/benchmarks/Ytx.Benchmarks/Program.cs new file mode 100644 index 0000000..40922b0 --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/Program.cs @@ -0,0 +1,128 @@ +using System.Diagnostics; +using BenchmarkDotNet.Running; + +namespace Ytx.Benchmarks; + +public static class Program +{ + const string Usage = """ + Ytx.Benchmarks: measure and verify ytx's caption formatting and JSON output. + + Usage: + dotnet run -c Release -- generate [--seed N] Write synthetic datasets to data/synthetic/ + dotnet run -c Release -- capture [-l LANG] ID_OR_URL... Fetch real captions into data/captured/ (gitignored) + dotnet run -c Release -- verify Check every variant is byte-identical to Baseline + dotnet run -c Release -- replay VARIANT FILE One cold run in this process (pipe stdout to /dev/null) + dotnet run -c Release -- [BenchmarkDotNet args] Run benchmarks, e.g. --filter '*EndToEnd*' + + Options: + --data DIR Dataset folder (default: data/ next to the .csproj, or $YTX_BENCH_DATA) + + Variants: baseline, tier1, tier1sg, tier2 + """; + + public static async Task Main(string[] args) + { + var list = args.ToList(); + string? dataOverride = TakeOption(list, "--data"); + var dataDir = DataDir.Resolve(dataOverride); + var command = list.Count > 0 ? list[0] : ""; + + switch (command) + { + case "-h" or "--help" or "help": + Console.WriteLine(Usage); + return 0; + + case "generate": + { + var seed = TakeOption(list, "--seed"); + return Synthetic.WriteAll(dataDir, seed == null ? 42 : int.Parse(seed)); + } + + case "capture": + { + var language = TakeOption(list, "-l") ?? TakeOption(list, "--language") ?? "English"; + if (list.Count < 2) + { + Console.Error.WriteLine("capture needs at least one video ID or URL."); + return 2; + } + return await Capture.RunAsync(dataDir, list.Skip(1).ToList(), language); + } + + case "verify": + Synthetic.EnsureExists(dataDir); + return Verify(dataDir); + + case "replay": + if (list.Count < 3) + { + Console.Error.WriteLine("replay needs VARIANT and FILE."); + return 2; + } + return Replay(list[1], list[2]); + + default: + Synthetic.EnsureExists(dataDir); + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(list.ToArray()); + return 0; + } + } + + /// + /// Every variant must produce exactly the bytes v1.0.7 would, for every dataset, with and without + /// the edge-case captions, in indented and compact mode. Returns the number of mismatches. + /// + static int Verify(string dataDir) + { + int bad = 0, checks = 0; + foreach (var file in DataDir.AllDatasets(dataDir)) + { + var original = Dataset.Load(file); + var name = Path.GetRelativePath(dataDir, file); + foreach (var d in new[] { original, original.WithEdgeCases() }) + foreach (var compact in new[] { false, true }) + { + var expected = Variants.RunToBytes(Variants.Baseline, d, compact); + foreach (var v in Variants.All.Where(v => v != Variants.Baseline)) + { + var actual = Variants.RunToBytes(v, d, compact); + bool same = expected.AsSpan().SequenceEqual(actual); + checks++; + if (!same) bad++; + Console.WriteLine($"{(same ? "IDENTICAL" : "DIFF ")} {v,-8} {name} cues={d.Captions.Length} compact={compact} bytes={expected.Length}{(same ? "" : $" vs {actual.Length}")}"); + } + } + } + + Console.WriteLine(bad == 0 ? $"OK: {checks} checks, all identical." : $"FAILED: {bad} of {checks} checks differ."); + return bad == 0 ? 0 : 1; + } + + // A cold, one-shot run in a fresh process: includes JIT and serialiser warm-up, which is what a CLI pays. + static int Replay(string variant, string file) + { + var d = Dataset.Load(file); + var sw = Stopwatch.StartNew(); + long before = GC.GetTotalAllocatedBytes(true); + using (var stdout = Console.OpenStandardOutput()) + { + Variants.Run(variant, d, false, stdout); + } + long after = GC.GetTotalAllocatedBytes(true); + Console.Error.WriteLine( + $"{variant}\t{sw.Elapsed.TotalMilliseconds:F1} ms\t{(after - before) / 1048576.0:F2} MB\t" + + $"gen0={GC.CollectionCount(0)} gen1={GC.CollectionCount(1)} gen2={GC.CollectionCount(2)}"); + return 0; + } + + static string? TakeOption(List args, string name) + { + int i = args.IndexOf(name); + if (i < 0 || i + 1 >= args.Count) return null; + var value = args[i + 1]; + args.RemoveRange(i, 2); + return value; + } +} diff --git a/benchmarks/Ytx.Benchmarks/README.md b/benchmarks/Ytx.Benchmarks/README.md new file mode 100644 index 0000000..a782907 --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/README.md @@ -0,0 +1,97 @@ +# Ytx.Benchmarks + +BenchmarkDotNet benchmarks and a byte-for-byte verification harness for the part of ytx that runs +after the network: caption normalisation, transcript formatting, JSON serialisation and output. +The results are written up in +[`docs/investigations/2026-10-native-aot-and-allocations.md`](../../docs/investigations/2026-10-native-aot-and-allocations.md). + +This project is standalone. `src/Ytx` and `tests/Ytx.Tests` do not reference it, the repository has +no solution file, and CI builds, tests and packs those two projects by path. So it never runs in +CI and never ends up in the NuGet package. It is also marked `IsPackable=false`. + +## What is measured + +Each benchmark subject is a copy of the code, so the numbers stay comparable as `src/Ytx` changes. + +| Subject | File | Description | +|---|---|---| +| Baseline | `Subjects/Baseline.cs` | ytx v1.0.7 verbatim: `Regex.Replace` per caption, interpolated strings, serialise to a string, `Console.WriteLine` | +| Tier 1 | `Subjects/Tier1.cs` | Skip the regex for clean captions, format numbers into the `StringBuilder`, serialise straight to the stream. `tier1sg` uses a source-generated `JsonSerializerContext`. | +| Tier 2 | `Subjects/Tier2.cs` | Pooled buffers, hand-written digit formatting and a chunked `Utf8JsonWriter`. A measuring stick, not a recommendation. | + +Benchmark classes (in `Benchmarks.cs`): + +- `NormalizeBench`: `NormalizeCaption` over every cue of the largest dataset. +- `FormatBench`: building the raw and Markdown transcript strings. +- `EndToEndBench`: format, serialise and write to `Stream.Null`. + +## Data + +The repository contains **no real caption data**. YouTube captions belong to the video owners. + +- `generate` writes two synthetic datasets to `data/synthetic/`. They have the same shape as real + auto-generated captions: the same cue counts as the 17-minute and 5-hour videos used in the + write-up, alternating text and `"\n"` filler cues, about 35 characters per text cue, and emoji and + accents in the description. The words are nonsense, generated from a fixed seed. +- `capture` fetches a real video's metadata and caption track into `data/captured/`. It picks the + track the same way v1.0.7 does. +- `data/` is gitignored. Do not commit captured files. + +Benchmarks and `verify` create the synthetic data automatically if it is missing. Every `*.json` +under `data/` becomes a benchmark parameter. + +## Run it + +From this folder: + +```bash +dotnet build -c Release + +# Optional: write the synthetic datasets explicitly (benchmarks do this on first run) +dotnet run -c Release -- generate + +# Optional: add real captions for local runs only +dotnet run -c Release -- capture aCi6BhrvjhM NYFGCESmikA +dotnet run -c Release -- capture -l fr "https://www.youtube.com/watch?v=VIDEO_ID" + +# Check every variant produces exactly the bytes v1.0.7 would +dotnet run -c Release -- verify + +# Run the benchmarks (BenchmarkDotNet filters and options pass through) +dotnet run -c Release -- --filter '*' +dotnet run -c Release -- --filter '*EndToEnd*' --job short + +# One cold run in a fresh process, the way a CLI pays for it (JIT and serialiser warm-up included) +for v in baseline tier1 tier1sg tier2; do + bin/Release/net10.0/Ytx.Benchmarks replay $v data/synthetic/long.json > /dev/null +done +``` + +`--data DIR` (or `YTX_BENCH_DATA`) points every command at another dataset folder. A folder given +that way is used as is: synthetic files are only generated in the default `data/` folder. + +## Verify mode + +`verify` runs every variant against Baseline for each dataset, with and without extra edge-case +captions (` `, NBSP, U+2028, U+0085, U+3000, emoji, an offset over 100 hours), in indented and +compact mode. It prints `IDENTICAL` or `DIFF` per check and exits non-zero on any difference. +It is the starting point for a golden-output test in `tests/Ytx.Tests`. + +Windows note: v1.0.7 builds the Markdown transcript with `AppendLine`, so on Windows the lines are +separated by `\r\n`, and `Console.WriteLine` ends the output with `\r\n`. The variants here copy +that with `Environment.NewLine`. If ytx switches to a fixed `\n`, update Baseline's expectations. + +## Results from October 2026 + +Apple M1, macOS 27.0.1, .NET SDK 10.0.401, runtime 10.0.12, BenchmarkDotNet 0.15.2, real captions. +See the write-up for the full table and the cold-run numbers. + +| Method | Video | Mean | Allocated | +|---|---|---:|---:| +| Baseline_All | 5 h 15 min | 7,947 ยตs | 10,506,442 B | +| Tier1_All | 5 h 15 min | 3,974 ยตs | 4,469,011 B | +| Tier1SourceGen_All | 5 h 15 min | 3,923 ยตs | 4,469,010 B | +| Tier2_All | 5 h 15 min | 3,440 ยตs | 58,224 B | + +The synthetic `long` dataset gives numbers within a few percent of these (Baseline_All 7.7 ms and +10.9 MB on a short job), so it is a fair offline stand-in. diff --git a/benchmarks/Ytx.Benchmarks/Subjects/Baseline.cs b/benchmarks/Ytx.Benchmarks/Subjects/Baseline.cs new file mode 100644 index 0000000..49136f0 --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/Subjects/Baseline.cs @@ -0,0 +1,64 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Ytx.Benchmarks.Subjects; + +/// +/// The formatting and output code from ytx v1.0.7 (commit 07d0e12, src/Ytx/Program.cs), kept as the +/// reference every other variant must match byte for byte. Do not "improve" this file. +/// The only change is that output goes to a supplied stream instead of Console.Out. +/// +public static class Baseline +{ + public static (string Raw, string Markdown) Format(string videoId, IReadOnlyList captions) + { + var rawSb = new StringBuilder(); + var mdSb = new StringBuilder(); + + foreach (var caption in captions) + { + var text = NormalizeCaption(caption.Text); + if (string.IsNullOrWhiteSpace(text)) continue; + + if (rawSb.Length > 0) rawSb.Append(' '); + rawSb.Append(text); + + var timestamp = ToHhMmSs(caption.Offset); + var link = $"https://www.youtube.com/watch?v={videoId}&t={(int)caption.Offset.TotalSeconds}s"; + mdSb.AppendLine($"- [{timestamp}]({link}) {text}"); + } + + return (rawSb.ToString().Trim(), mdSb.ToString().TrimEnd()); + } + + /// Mirrors JsonSerializer.Serialize to a string followed by Console.WriteLine. + public static void Write(Stream stdout, Output output, bool compact) + { + var json = JsonSerializer.Serialize(output, new JsonSerializerOptions + { + WriteIndented = !compact, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + + // Console.OutputEncoding = UTF8 writes no BOM, and WriteLine appends Environment.NewLine. + using var writer = new StreamWriter(stdout, new UTF8Encoding(false), leaveOpen: true); + writer.WriteLine(json); + writer.Flush(); + } + + public static string ToHhMmSs(TimeSpan ts) + { + int h = (int)ts.TotalHours; + int m = ts.Minutes; + int s = ts.Seconds; + return h > 0 ? $"{h:00}:{m:00}:{s:00}" : $"{m:00}:{s:00}"; + } + + public static string NormalizeCaption(string text) + { + if (string.IsNullOrWhiteSpace(text)) return ""; + text = Regex.Replace(text, @"\s+", " ").Trim(); + return text.Replace(" ", " "); + } +} diff --git a/benchmarks/Ytx.Benchmarks/Subjects/Tier1.cs b/benchmarks/Ytx.Benchmarks/Subjects/Tier1.cs new file mode 100644 index 0000000..0e99501 --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/Subjects/Tier1.cs @@ -0,0 +1,98 @@ +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Ytx.Benchmarks.Subjects; + +/// +/// Tier 1: small, readable changes that keep the output byte-identical to v1.0.7. +/// 1. Skip the regex when a caption is already in normal form. +/// 2. Format numbers straight into the StringBuilder instead of building temporary strings. +/// 3. Serialise straight to the output stream (reflection or source-generated metadata). +/// +public static partial class Tier1 +{ + [GeneratedRegex(@"\s+")] + private static partial Regex Whitespace(); + + public static (string Raw, string Markdown) Format(string videoId, IReadOnlyList captions) + { + var rawSb = new StringBuilder(); + var mdSb = new StringBuilder(); + + foreach (var caption in captions) + { + var text = NormalizeCaption(caption.Text); + if (string.IsNullOrWhiteSpace(text)) continue; + + // v1.0.7 used AppendLine then TrimEnd, so the separator is Environment.NewLine. + if (rawSb.Length > 0) + { + rawSb.Append(' '); + mdSb.Append(Environment.NewLine); + } + rawSb.Append(text); + + // The StringBuilder interpolation handler formats the numbers in place. + var ts = caption.Offset; + int h = (int)ts.TotalHours; + if (h > 0) mdSb.Append($"- [{h:00}:{ts.Minutes:00}:{ts.Seconds:00}"); + else mdSb.Append($"- [{ts.Minutes:00}:{ts.Seconds:00}"); + mdSb.Append($"](https://www.youtube.com/watch?v={videoId}&t={(int)ts.TotalSeconds}s) ").Append(text); + } + + return (rawSb.ToString().Trim(), mdSb.ToString().TrimEnd()); + } + + public static string NormalizeCaption(string text) + { + if (string.IsNullOrWhiteSpace(text)) return ""; + // \s+ matches every ordinary single space, so Regex.Replace returns a new string for nearly + // every caption even when nothing changes. Only run it when the text is not already normal. + if (NeedsNormalizing(text)) text = Whitespace().Replace(text, " ").Trim(); + return text.Contains(" ") ? text.Replace(" ", " ") : text; + } + + static bool NeedsNormalizing(ReadOnlySpan t) + { + if (char.IsWhiteSpace(t[0]) || char.IsWhiteSpace(t[^1])) return true; + for (int i = 1; i < t.Length; i++) + { + char c = t[i]; + if (c == ' ') + { + if (t[i - 1] == ' ') return true; + } + else if (char.IsWhiteSpace(c)) + { + return true; + } + } + return false; + } + + static readonly byte[] NewLine = Encoding.UTF8.GetBytes(Environment.NewLine); + + static readonly JsonSerializerOptions Indented = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + static readonly JsonSerializerOptions Compact = new() { WriteIndented = false, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + /// Reflection-based serialiser, written as UTF-8 to the stream with no intermediate string. + public static void Write(Stream stdout, Output output, bool compact) + { + JsonSerializer.Serialize(stdout, output, compact ? Compact : Indented); + stdout.Write(NewLine); + stdout.Flush(); + } + + static readonly OutputContext IndentedContext = new(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + static readonly OutputContext CompactContext = new(new JsonSerializerOptions { WriteIndented = false, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + + /// Source-generated metadata: trim and AOT safe, and skips reflection warm-up. + public static void WriteSourceGen(Stream stdout, Output output, bool compact) + { + JsonSerializer.Serialize(stdout, output, (compact ? CompactContext : IndentedContext).Output); + stdout.Write(NewLine); + stdout.Flush(); + } +} diff --git a/benchmarks/Ytx.Benchmarks/Subjects/Tier2.cs b/benchmarks/Ytx.Benchmarks/Subjects/Tier2.cs new file mode 100644 index 0000000..a729151 --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/Subjects/Tier2.cs @@ -0,0 +1,198 @@ +using System.Buffers; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace Ytx.Benchmarks.Subjects; + +/// +/// Tier 2: pooled buffers, hand-written digit formatting and a chunked Utf8JsonWriter. +/// Kept as a measuring stick for "how far can this go". The write-up recommends against shipping it: +/// it saves about 3 ms more than Tier 1 on a 5-hour video and is much harder to keep correct. +/// +public static class Tier2 +{ + /// A growable pooled char buffer (a minimal ValueStringBuilder that can live on the heap). + public sealed class CharBuffer : IDisposable + { + char[] _array; + int _length; + + public CharBuffer(int capacity) => _array = ArrayPool.Shared.Rent(capacity); + + public int Length => _length; + public ReadOnlySpan Span => _array.AsSpan(0, _length); + + Span Reserve(int needed) + { + if (_length + needed > _array.Length) + { + var bigger = ArrayPool.Shared.Rent(Math.Max(_array.Length * 2, _length + needed)); + _array.AsSpan(0, _length).CopyTo(bigger); + ArrayPool.Shared.Return(_array); + _array = bigger; + } + return _array.AsSpan(_length); + } + + public void Append(char c) + { + Reserve(1)[0] = c; + _length++; + } + + public void Append(ReadOnlySpan s) + { + s.CopyTo(Reserve(s.Length)); + _length += s.Length; + } + + public void AppendTwoDigits(int v) + { + var d = Reserve(2); + d[0] = (char)('0' + v / 10); + d[1] = (char)('0' + v % 10); + _length += 2; + } + + public void AppendInt(int v) + { + v.TryFormat(Reserve(11), out int written); + _length += written; + } + + // Matches the "00" format specifier: at least two digits, more if needed (hours above 99). + public void AppendPadded(int v) + { + if (v < 100) AppendTwoDigits(v); + else AppendInt(v); + } + + public void Dispose() + { + ArrayPool.Shared.Return(_array); + _array = []; + } + } + + // Collapse runs of whitespace to one space and trim both ends, writing into dst. Returns the length. + static int Normalize(ReadOnlySpan src, Span dst) + { + int n = 0; + bool pendingSpace = false; + foreach (var c in src) + { + if (char.IsWhiteSpace(c)) + { + pendingSpace = n > 0; + continue; + } + if (pendingSpace) + { + dst[n++] = ' '; + pendingSpace = false; + } + dst[n++] = c; + } + return n; + } + + public static (CharBuffer Raw, CharBuffer Markdown) Format(string videoId, IReadOnlyList captions) + { + int estimate = 0; + foreach (var c in captions) estimate += c.Text.Length; + + var raw = new CharBuffer(estimate + captions.Count); + var md = new CharBuffer(estimate + captions.Count * (60 + videoId.Length)); + char[] scratch = ArrayPool.Shared.Rent(256); + var newLine = Environment.NewLine.AsSpan(); + + foreach (var caption in captions) + { + var src = caption.Text.AsSpan(); + if (src.Length > scratch.Length) + { + ArrayPool.Shared.Return(scratch); + scratch = ArrayPool.Shared.Rent(src.Length); + } + + ReadOnlySpan text = scratch.AsSpan(0, Normalize(src, scratch)); + // Trap: v1.0.7 replaces   after collapsing whitespace, so "a   b" keeps three spaces. + // Fall back to the legacy code for that rare case instead of re-implementing the quirk. + if (text.IndexOf(" ") >= 0) text = Baseline.NormalizeCaption(caption.Text); + if (text.IsWhiteSpace()) continue; + + if (raw.Length > 0) + { + raw.Append(' '); + md.Append(newLine); + } + raw.Append(text); + + var ts = caption.Offset; + int h = (int)ts.TotalHours; + md.Append("- ["); + if (h > 0) + { + md.AppendPadded(h); + md.Append(':'); + } + md.AppendTwoDigits(ts.Minutes); + md.Append(':'); + md.AppendTwoDigits(ts.Seconds); + md.Append("](https://www.youtube.com/watch?v="); + md.Append(videoId); + md.Append("&t="); + md.AppendInt((int)ts.TotalSeconds); + md.Append("s) "); + md.Append(text); + } + + ArrayPool.Shared.Return(scratch); + return (raw, md); + } + + static readonly JsonWriterOptions IndentedWriter = new() { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + static readonly JsonWriterOptions CompactWriter = new() { Indented = false, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + static readonly byte[] NewLine = Encoding.UTF8.GetBytes(Environment.NewLine); + + // Writes a large string in 8K-char segments so the writer's buffer stays small. + static void WriteChunked(Utf8JsonWriter w, string name, ReadOnlySpan value) + { + w.WritePropertyName(name); + if (value.IsEmpty) + { + w.WriteStringValue(value); + return; + } + + const int Chunk = 8192; + while (value.Length > 0) + { + int n = Math.Min(Chunk, value.Length); + // Trap: splitting a surrogate pair across segments changes the escaped output. + if (n < value.Length && char.IsHighSurrogate(value[n - 1])) n--; + w.WriteStringValueSegment(value[..n], isFinalSegment: n == value.Length); + value = value[n..]; + w.Flush(); + } + } + + public static void Write(Stream stdout, string url, string title, string description, + ReadOnlySpan raw, ReadOnlySpan markdown, bool compact) + { + using (var w = new Utf8JsonWriter(stdout, compact ? CompactWriter : IndentedWriter)) + { + w.WriteStartObject(); + w.WriteString("url", url); + w.WriteString("title", title); + w.WriteString("description", description); + w.Flush(); + WriteChunked(w, "transcriptRaw", raw.Trim()); + WriteChunked(w, "transcript", markdown.TrimEnd()); + w.WriteEndObject(); + } + stdout.Write(NewLine); + stdout.Flush(); + } +} diff --git a/benchmarks/Ytx.Benchmarks/Synthetic.cs b/benchmarks/Ytx.Benchmarks/Synthetic.cs new file mode 100644 index 0000000..099cca5 --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/Synthetic.cs @@ -0,0 +1,104 @@ +using System.Text; + +namespace Ytx.Benchmarks; + +/// +/// Generates caption tracks shaped like real YouTube auto-generated captions, so the benchmarks run +/// offline and the repository holds no third-party transcript text. +/// +/// Shape measured from two real auto-caption tracks (17 min and 5 h 15 min): +/// - Cues alternate between a text line and a cue whose text is a single "\n" (rolling captions). +/// About half of all cues are that whitespace-only filler. +/// - Text cues average 34-37 characters, about 7 words of about 4.3 characters, with no extra spaces. +/// - A text cue starts about 10 ms after the previous filler cue. Durations are about 3-8 s. +/// The words are nonsense built from a fixed syllable list, so the output is deterministic for a seed. +/// +public static class Synthetic +{ + public sealed record Preset(string Name, string Id, int Cues, TimeSpan Length); + + // Cue counts and lengths match the two videos used in the original measurements. + public static readonly Preset[] Presets = + [ + new("short", "synthShort1", 924, TimeSpan.FromSeconds(1004)), + new("long", "synthLong01", 16284, TimeSpan.FromSeconds(18940)), + ]; + + static readonly string[] Syllables = + [ + "ka", "lo", "ri", "ten", "mar", "so", "vel", "an", "di", "po", "ques", "tra", "mi", "nor", "el", + "ba", "sun", "ti", "ro", "fen", "da", "lu", "ger", "o", "is", "pa", "the", "wen", "at", "cy", + ]; + + public static Dataset Generate(Preset p, int seed = 42) + { + var rng = new Random(seed); + var caps = new List(p.Cues); + double step = p.Length.TotalSeconds / Math.Max(1, p.Cues / 2); + double t = 0.16; + + for (int i = 0; i < p.Cues; i++) + { + if (i % 2 == 0) + { + caps.Add(new Cap(Line(rng), TimeSpan.FromSeconds(t), TimeSpan.FromSeconds(3 + rng.NextDouble() * 5))); + } + else + { + // Rolling-caption filler: whitespace only, normalised away by every variant. + double filler = t + step * (0.95 + rng.NextDouble() * 0.1); + caps.Add(new Cap("\n", TimeSpan.FromSeconds(filler), TimeSpan.FromSeconds(1.5 + rng.NextDouble() * 3))); + t = filler + 0.01; + } + } + + var description = new StringBuilder(); + for (int i = 0; i < 12; i++) description.Append(Line(rng)).Append(i % 3 == 2 ? "\n\n" : " "); + // Characters that exercise the relaxed encoder: emoji (surrogate pairs), accents, dashes, quotes. + description.Append("\U0001F916 caf\u00E9 \u2014 \"quoted\" & https://example.com/?a=1&b=2"); + + return new Dataset( + Id: p.Id, + Url: $"https://www.youtube.com/watch?v={p.Id}", + Title: $"Synthetic {p.Name} caption track ({p.Cues} cues)", + Description: description.ToString(), + Captions: caps.ToArray()); + } + + static string Line(Random rng) + { + var sb = new StringBuilder(40); + int words = 5 + rng.Next(5); + for (int w = 0; w < words; w++) + { + if (w > 0) sb.Append(' '); + int syllables = 1 + rng.Next(3); + for (int s = 0; s < syllables; s++) sb.Append(Syllables[rng.Next(Syllables.Length)]); + } + return sb.ToString(); + } + + public static int WriteAll(string dataDir, int seed) + { + var dir = Path.Combine(dataDir, "synthetic"); + foreach (var p in Presets) + { + var path = Path.Combine(dir, p.Name + ".json"); + var d = Generate(p, seed); + d.Save(path); + int text = d.Captions.Count(c => !string.IsNullOrWhiteSpace(c.Text)); + Console.WriteLine($"wrote {path} cues={d.Captions.Length} text-cues={text} last={d.Captions[^1].Offset:hh\\:mm\\:ss}"); + } + return 0; + } + + /// + /// Creates the default synthetic files if any are missing. Only touches the default data folder: + /// a folder passed with --data or YTX_BENCH_DATA is used exactly as given. + /// + public static void EnsureExists(string dataDir) + { + if (DataDir.IsDefault && !Presets.All(p => File.Exists(Path.Combine(dataDir, "synthetic", p.Name + ".json")))) + WriteAll(dataDir, 42); + } +} diff --git a/benchmarks/Ytx.Benchmarks/Variants.cs b/benchmarks/Ytx.Benchmarks/Variants.cs new file mode 100644 index 0000000..83cb40b --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/Variants.cs @@ -0,0 +1,67 @@ +using Ytx.Benchmarks.Subjects; + +namespace Ytx.Benchmarks; + +/// Runs one variant end to end: format captions, build the output, serialise, write. +public static class Variants +{ + public const string Baseline = "baseline"; + public const string Tier1 = "tier1"; + public const string Tier1SourceGen = "tier1sg"; + public const string Tier2 = "tier2"; + + public static readonly string[] All = [Baseline, Tier1, Tier1SourceGen, Tier2]; + + public static void Run(string variant, Dataset d, bool compact, Stream sink) + { + switch (variant) + { + case Baseline: + { + var (raw, md) = Subjects.Baseline.Format(d.Id, d.Captions); + Subjects.Baseline.Write(sink, NewOutput(d, raw, md), compact); + break; + } + case Tier1: + { + var (raw, md) = Subjects.Tier1.Format(d.Id, d.Captions); + Subjects.Tier1.Write(sink, NewOutput(d, raw, md), compact); + break; + } + case Tier1SourceGen: + { + var (raw, md) = Subjects.Tier1.Format(d.Id, d.Captions); + Subjects.Tier1.WriteSourceGen(sink, NewOutput(d, raw, md), compact); + break; + } + case Tier2: + { + var (raw, md) = Subjects.Tier2.Format(d.Id, d.Captions); + using (raw) + using (md) + { + Subjects.Tier2.Write(sink, d.Url, d.Title, d.Description, raw.Span, md.Span, compact); + } + break; + } + default: + throw new ArgumentException($"Unknown variant '{variant}'. Expected one of: {string.Join(", ", All)}."); + } + } + + public static byte[] RunToBytes(string variant, Dataset d, bool compact) + { + using var ms = new MemoryStream(); + Run(variant, d, compact, ms); + return ms.ToArray(); + } + + static Output NewOutput(Dataset d, string raw, string md) => new() + { + url = d.Url, + title = d.Title, + description = d.Description, + transcriptRaw = raw, + transcript = md, + }; +} diff --git a/benchmarks/Ytx.Benchmarks/Ytx.Benchmarks.csproj b/benchmarks/Ytx.Benchmarks/Ytx.Benchmarks.csproj new file mode 100644 index 0000000..e01c1f0 --- /dev/null +++ b/benchmarks/Ytx.Benchmarks/Ytx.Benchmarks.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + Ytx.Benchmarks + false + false + + false + + + + + + + + diff --git a/docs/investigations/2026-10-native-aot-and-allocations.md b/docs/investigations/2026-10-native-aot-and-allocations.md new file mode 100644 index 0000000..605ff2a --- /dev/null +++ b/docs/investigations/2026-10-native-aot-and-allocations.md @@ -0,0 +1,627 @@ +# NativeAOT and allocations in ytx + +- **Date:** October 2026 +- **Version studied:** ytx v1.0.7 (commit `07d0e12`) +- **Status:** exploratory, not yet decided +- **Tracking issue:** [#4](https://github.com/solrevdev/solrevdev.ytx/issues/4). Related review: [#3](https://github.com/solrevdev/solrevdev.ytx/issues/3). + +## The questions + +ytx is a small .NET global tool. It takes a YouTube URL, fetches the video's metadata and captions +with [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode), and prints one JSON document. +People run it by hand, but more and more it is run by agents and scripts, often many times in a row. + +Two questions came up: + +1. **Can ytx ship as a NativeAOT binary, and is it worth it?** A native binary needs no .NET runtime + and should start faster. +2. **Is ytx wasting time or memory in its own code?** It builds large strings for long transcripts. + Would cutting allocations make it noticeably faster? + +## Short answers + +- **NativeAOT works after one small code change.** The JSON code must use a source-generated + `JsonSerializerContext`. With that, the AOT binary produced output byte-identical to v1.0.7. +- **AOT starts in about 9 ms instead of about 38 ms.** It uses about 6x less CPU per real call and + half the peak memory. It needs no .NET runtime. +- **A real call is about 90% network.** One person running ytx once will not notice AOT. Agents and + batch jobs that run it hundreds of times will, mostly through CPU and memory. +- **Cutting allocations is not worth chasing for speed.** ytx's own code is about 2% of a run's time + and about 18% of its allocations. Two small, readable changes halve that cost. A full + zero-allocation rewrite is measurable in a benchmark but invisible to users. + +## Method + +### Environment + +| Item | Value | +|---|---| +| Machine | Apple M1, 8 cores, macOS 27.0.1 (build 26A434) | +| .NET SDKs | 10.0.401 and 8.0.425 | +| .NET runtime | 10.0.12 | +| YoutubeExplode | 6.5.6 (as shipped in v1.0.7). 6.6.2 was also checked for allocations. | +| BenchmarkDotNet | 0.15.2, default job, workstation GC | +| Linux | `mcr.microsoft.com/dotnet/sdk:10.0-noble-aot` and `ubuntu:24.04` in Docker on the same Mac (arm64) | + +### Test videos + +- **`aCi6BhrvjhM`:** 17 minutes, 924 caption cues. Used for most network runs. +- **`NYFGCESmikA`:** Lex Fridman #501, 5 hours 15 minutes, 16,284 caption cues, 1.1 MB of JSON output. + Used to stress the formatting code. + +Both are auto-generated caption tracks. About half of their cues are filler whose text is a single +newline, which matters for the normalisation results below. + +### What was measured, and how + +- **Deployment shapes.** ytx was published six ways for `net10.0` and `osx-arm64` (see the table in + [Results](#results-net100-osx-arm64)). Each was timed as a separate process. +- **Startup.** 30 runs after 3 warm-up runs, with no network: `--help`, `--version`, and stdin JSON + input that fails ID validation before any network call. +- **Network runs.** 8 rounds against `aCi6BhrvjhM`. Each round runs every build once, in turn, so + network drift affects all builds equally. This was done twice. +- **Resource use.** `/usr/bin/time -l` on one `--metadata-only` call per build, for peak memory and + instructions retired. +- **Phases.** An instrumented copy of v1.0.7 printed the time and bytes allocated in each phase + (`GC.GetTotalAllocatedBytes`). +- **Micro-benchmarks.** BenchmarkDotNet on the formatting and output code, replaying captured + captions so the network is excluded. Every variant was checked for byte-identical output. +- **Cold runs.** Each formatting variant also ran once per fresh process, 5 times, because a CLI + pays JIT and warm-up costs that BenchmarkDotNet hides. + +### Limits + +- One machine. Windows and osx-x64 binaries were built but never run. linux-arm64 ran in Docker. +- Network numbers come from one home connection in the UK. Their spread is large. Compare medians and + CPU time, not means. +- Nothing was committed or pushed while measuring. + +## Part 1: NativeAOT + +### What breaks today + +Publishing v1.0.7 with `-p:PublishAot=true` succeeds, but with four warnings, all in `Program.cs`. +YoutubeExplode and AngleSharp produce none. That was checked with `TrimmerSingleWarn=false`, which +lists warnings per call site instead of one line per assembly. YoutubeExplode is marked `IsTrimmable`. + +The build-time analyzer warnings, verbatim: + +```text +src/Ytx/Program.cs(88,33): warning IL2026: Using member 'System.Text.Json.JsonSerializer.Deserialize(String, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved. [src/Ytx/Ytx.csproj::TargetFramework=net10.0] +src/Ytx/Program.cs(88,33): warning IL3050: Using member 'System.Text.Json.JsonSerializer.Deserialize(String, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications. [src/Ytx/Ytx.csproj::TargetFramework=net10.0] +src/Ytx/Program.cs(176,24): warning IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved. [src/Ytx/Ytx.csproj::TargetFramework=net10.0] +src/Ytx/Program.cs(176,24): warning IL3050: Using member 'System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications. [src/Ytx/Ytx.csproj::TargetFramework=net10.0] +``` + +The ILCompiler repeats them while generating native code: + +```text +src/Ytx/Program.cs(88): Trim analysis warning IL2026: Program.
d__1.MoveNext(): Using member 'System.Text.Json.JsonSerializer.Deserialize(String,JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. ... +src/Ytx/Program.cs(88): AOT analysis warning IL3050: Program.
d__1.MoveNext(): Using member 'System.Text.Json.JsonSerializer.Deserialize(String,JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. ... +src/Ytx/Program.cs(176): Trim analysis warning IL2026: Program.
d__1.MoveNext(): Using member 'System.Text.Json.JsonSerializer.Serialize(Output,JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. ... +src/Ytx/Program.cs(176): AOT analysis warning IL3050: Program.
d__1.MoveNext(): Using member 'System.Text.Json.JsonSerializer.Serialize(Output,JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. ... +``` + +Line 88 reads JSON from stdin. Line 176 writes the output. The binary that comes out of this build +looks fine at first. `--help`, `--version` and argument errors all work. It even fetches from +YouTube. Then it fails when it writes the JSON: + +```text +Error: Reflection-based serialization has been disabled for this application. Either use the source generator APIs or explicitly configure the 'JsonSerializerOptions.TypeInfoResolver' property. +``` + +For URL arguments, with or without `--metadata-only`, ytx catches this, prints it and exits 1, +after spending a full network round trip. (Issue #4 says `--metadata-only` exited 0. A re-check on +8 Oct 2026 with the same build exited 1 with nothing on stdout.) + +The stdin JSON path is worse. It is outside the `try` block, so it crashes with an unhandled +exception and aborts (exit 134 on macOS): + +```text +Unhandled exception. System.InvalidOperationException: Reflection-based serialization has been disabled for this application. Either use the source generator APIs or explicitly configure the 'JsonSerializerOptions.TypeInfoResolver' property. + at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_JsonSerializerIsReflectionDisabled() + 0x30 + at System.Text.Json.JsonSerializerOptions.ConfigureForJsonSerializer() + 0x10 +``` + +This is why the CI smoke test pipes JSON into the binary. A test of `--version` alone would pass. + +### The fix + +The fix is about 10 lines. Add a `JsonSerializerContext` with `[JsonSerializable]` for `Input` and +`Output`, and pass its type info to the existing serialiser calls. After that change: + +- There are no trim or AOT warnings. +- All 26 tests pass. +- Output is byte-identical to v1.0.7 for a full run, for stdin input, and for the error and + exit-code paths. +- The ordinary, non-AOT build gets about 6 ms faster on the stdin path, because it skips + reflection-based serialiser setup. + +The change works on net8, net9 and net10. It is worth merging whatever happens with AOT. It is being +done separately, together with streaming the output and the Tier 1 changes from Part 2. + +### Results (net10.0, osx-arm64) + +**Build types:** + +- **Framework-dependent** needs a .NET runtime installed. This is what the NuGet tool ships. +- **ReadyToRun (R2R)** adds precompiled code to cut JIT time at startup. +- **Self-contained, single file, trimmed** bundles a trimmed runtime into one file. +- **NativeAOT** compiles everything to one native executable with no JIT. + +**Columns:** startup columns are means of 30 runs. Network columns are medians of 8 rounds, as wall +time / CPU time (user + sys). Peak RSS is the largest seen across all rounds. + +| Build | Size (binary / tgz) | `--version` | JSON path | `--metadata-only` wall / CPU | Full run wall / CPU | Peak RSS (metadata / full) | +|---|---|---|---|---|---|---| +| Installed tool shim 1.0.7 | runtime + 1.3 MB | 38.0 ms | 55.8 ms | 1052 / 303 ms | 1497 / 416 ms | 111 / 132 MB | +| v1.0.7 net8, framework-dependent | runtime + 1.3 MB | 37.9 | 77.9 | 1049 / 355 | 1568 / 486 | 110 / 126 | +| v1.0.7 net10, framework-dependent | 1.3 / 0.5 MB | 37.8 | 55.6 | 1018 / 298 | 1709 / 400 | 111 / 132 | +| Framework-dependent + source-gen JSON | 1.3 / 0.5 MB | 37.7 | 49.9 | 1024 / 293 | 1385 / 389 | 111 / 131 | +| Framework-dependent + R2R | 3.5 / 1.2 MB | 35.7 | 47.3 | 1126 / 229 | 1439 / 323 | 109 / 127 | +| Self-contained, single file, trimmed | 18.6 / 7.7 MB | 51.9 | 107.6 | 1324 / 589 | 2046 / 742 | 78 / 90 | +| Self-contained, single file, trimmed + R2R | 35.1 / 13.7 MB | 27.4 | 33.7 | 908 / 142 | 1481 / 208 | 87 / 97 | +| **NativeAOT** | **10.3 / 4.6 MB** | **8.6** | **8.8** | **784 / 47** | **1408 / 73** | **53 / 61** | +| NativeAOT, size-optimised | 9.3 / 4.3 MB | 8.6 | 8.7 | 852 / 47 | 1307 / 105 | 53 / 61 | + +All rows except the first three include the source-generated JSON fix. + +`/usr/bin/time -l` on one `--metadata-only` call shows the same pattern: + +| Build | Wall | User + sys CPU | Max RSS | Instructions retired | Peak memory footprint | +|---|---:|---:|---:|---:|---:| +| Framework-dependent (v1.0.7, net10) | 1.12 s | 0.35 s | 116 MB | 2.54 billion | 85 MB | +| Self-contained, trimmed + R2R | 1.17 s | 0.20 s | 85 MB | 1.17 billion | 64 MB | +| NativeAOT | 0.85 s | 0.04 s | 54 MB | 0.47 billion | 39 MB | + +**Reading the results:** + +- **AOT wins on every startup path.** `--help`, `--version` and bad input all take about 9 ms, + against 38โ€“56 ms for the shipped tool. Peak memory at startup is about 11 MB against 42 MB. +- **AOT uses about 6x less CPU on real calls.** 47โ€“73 ms against 300โ€“400 ms. That CPU is JIT + compilation and type loading, which the runtime pays on every process start. +- **AOT halves peak memory.** 53โ€“61 MB against 111โ€“132 MB. +- **Wall time on real calls is mostly network.** On the AOT `--metadata-only` call, about 780 ms of + wall time breaks down as about 9 ms of startup, about 47 ms of CPU, and network for the rest. +- **Avoid trimmed self-contained without R2R.** It was the worst build here. Trimming drops the + framework's precompiled code, so everything is JIT-compiled at run time. +- **Skip the size-optimised AOT build.** It saves 1 MB. `UseSystemResourceKeys` replaces exception + messages with resource keys, which would make ytx's `Error: {ex.Message}` output worse. +- **The net8 build is slower on the JSON path** (78 ms against 56 ms). Newer runtimes start + System.Text.Json faster. + +### Raw startup results + +Means, medians and spread in milliseconds, 30 rounds after 3 warm-ups. Labels: `orig-*` is v1.0.7, +`fdd-srcgen` is framework-dependent with source-gen JSON, `sc-trim` is self-contained single-file +trimmed. + +| Build | `--help` mean / med / sd | `--version` mean / med / sd | stdin JSON mean / med / sd | Peak RSS (`--version`) | +|---|---|---|---|---| +| tool-shim | 38.0 / 36.8 / 4.4 | 38.0 / 37.6 / 1.7 | 55.8 / 55.6 / 1.1 | 42.3 MB | +| orig-net8 | 38.1 / 37.4 / 2.1 | 37.9 / 37.7 / 0.7 | 77.9 / 77.5 / 1.6 | 38.5 MB | +| orig-net10 | 37.4 / 37.0 / 1.8 | 37.8 / 37.5 / 0.7 | 55.6 / 55.5 / 0.9 | 42.3 MB | +| fdd-srcgen | 37.5 / 37.2 / 1.8 | 37.7 / 37.5 / 0.9 | 49.9 / 49.5 / 1.6 | 42.4 MB | +| fdd-r2r | 35.6 / 35.5 / 1.0 | 35.7 / 35.6 / 0.5 | 47.3 / 47.1 / 1.2 | 42.1 MB | +| sc-trim | 47.2 / 46.7 / 1.6 | 51.9 / 51.6 / 1.0 | 107.6 / 106.6 / 2.5 | 20.0 MB | +| sc-trim-r2r | 27.6 / 27.2 / 1.9 | 27.4 / 27.1 / 1.0 | 33.7 / 33.6 / 0.8 | 36.1 MB | +| aot | 8.4 / 8.3 / 0.4 | 8.6 / 8.5 / 0.2 | 8.8 / 8.7 / 0.3 | 11.5 MB | +| aot-size | 8.6 / 8.3 / 1.0 | 8.6 / 8.6 / 0.3 | 8.7 / 8.7 / 0.2 | 11.3 MB | + +All stdin runs exited 2, as expected. A later re-run of `scripts/aot/run-bench.sh startup` on the +same machine measured 7.3 ms for AOT and 33.4 ms for framework-dependent `--help`. Absolute numbers +move by a few milliseconds between sessions. The ratio does not. + +### Raw network results + +Two separate runs of 8 rounds each against `aCi6BhrvjhM`. Wall time in milliseconds. + +**Run 2** (the one in the summary table, with CPU time): + +| Build | `--metadata-only` med / mean / sd | CPU med | Full run med / mean / max | CPU med | +|---|---|---:|---|---:| +| tool-shim | 1052 / 1093 / 109 | 303 | 1497 / 1550 / 1911 | 416 | +| orig-net8 | 1049 / 1051 / 177 | 355 | 1568 / 2847 / 11340 | 486 | +| orig-net10 | 1018 / 1235 / 555 | 298 | 1709 / 1791 / 2287 | 400 | +| fdd-srcgen | 1024 / 1028 / 118 | 293 | 1385 / 1665 / 2967 | 389 | +| fdd-r2r | 1126 / 1066 / 141 | 229 | 1439 / 3900 / 21306 | 323 | +| sc-trim | 1324 / 1280 / 146 | 589 | 2046 / 3641 / 11910 | 742 | +| sc-trim-r2r | 908 / 985 / 206 | 142 | 1481 / 3800 / 20229 | 208 | +| aot | 784 / 793 / 166 | 47 | 1408 / 3795 / 11345 | 73 | +| aot-size | 852 / 788 / 166 | 47 | 1307 / 1535 / 3284 | 105 | + +**Run 1** (no CPU column): + +| Build | `--metadata-only` med / mean | Full run med / mean / max | +|---|---|---| +| tool-shim | 1142 / 1098 | 1491 / 1634 / 2617 | +| orig-net8 | 1218 / 1207 | 1525 / 1883 / 4623 | +| orig-net10 | 1109 / 1196 | 1426 / 1420 / 1700 | +| fdd-srcgen | 992 / 1063 | 1501 / 1492 / 1665 | +| fdd-r2r | 1015 / 1107 | 1796 / 2275 / 5140 | +| sc-trim | 1468 / 1555 | 3386 / 4544 / 12546 | +| sc-trim-r2r | 913 / 915 | 1410 / 2133 / 7425 | +| aot | 932 / 933 | 1656 / 2646 / 9437 | +| aot-size | 903 / 1095 | 1144 / 2043 / 7182 | + +Look at the maximums. Single calls took 10โ€“21 seconds for every kind of build, against a typical +1.5 seconds. That is the "random slow calls" problem from #3, somewhere in YoutubeExplode or the +HTTP stack. It swamps every startup difference, which is why the summary uses medians and CPU time. + +### Platform findings + +**Linux.** + +- linux-arm64 AOT was built in `mcr.microsoft.com/dotnet/sdk:10.0-noble-aot`. The binary is 11.5 MB, + plus an 18 MB `.dbg` symbol file. It works end to end. +- It links only `libc` and `libm`, but it loads OpenSSL at run time for HTTPS. A bare `ubuntu:24.04` + image fails with `The SSL connection could not be established, see inner exception.` until + `libssl3t64` and `ca-certificates` are installed. +- A binary built on Ubuntu 24.04 (noble) needs `GLIBC_2.34` or newer, so `debian:11` cannot run it. + The workflow builds on Ubuntu 22.04 to reach more systems and prints the highest glibc symbol + version the binary needs. +- With `InvariantGlobalization`, ICU (`libicu`) is not needed. + +**macOS.** + +- osx-x64 cross-compiles from an arm64 Mac with no extra setup. Xcode ships both toolchains. +- The x64 binary could not be run locally because the test machine has no Rosetta. CI runs it on + GitHub's Intel runner instead. +- The binaries are only ad-hoc signed. That is fine for Homebrew or `curl`, which do not set the + quarantine flag. A browser download is quarantined and Gatekeeper blocks it. Notarising needs an + Apple Developer ID. + +**Windows.** win-x64 needs a Windows runner, because NativeAOT cannot cross-compile between operating +systems. It is untested so far. + +**Debug symbols.** By default the publish folder contains a `.dSYM` (about 30 MB on macOS), `.dbg` +(Linux) or `.pdb` (Windows). `CopyOutputSymbolsToPublishDirectory=false` removes it. This was verified +on osx-arm64: the publish folder holds only the executable. + +### The csproj change + +```xml + + + true + + + + + true + false + +``` + +The conditional group leaves the NuGet tool package alone. With it applied to v1.0.7, all 26 tests +pass and `dotnet pack` produces a package with the same files and sizes. + +Publish command: + +```bash +dotnet publish src/Ytx -c Release -f net10.0 -r -p:PublishAot=true +``` + +### Packaging options + +**Option A, recommended now: GitHub Release assets plus package managers.** + +- Keep `solrevdev.ytx` on NuGet exactly as it is. It still targets net8, net9 and net10. +- Attach a NativeAOT archive per platform, plus `SHA256SUMS`, to each GitHub Release. +- Point a Homebrew tap formula, a Scoop manifest and possibly a winget manifest at those assets. +- See `packaging/README.md` for the templates, the secrets each channel needs, and the name clash + with [koguchic/ytx](https://github.com/koguchic/ytx), an unrelated Rust CLI that also installs `ytx`. + +**Option B, maybe at 2.0: a RID-specific .NET tool.** + +.NET 10 can pack a tool per runtime identifier, with NativeAOT inside and a framework-dependent +fallback. It was tested with a separate project that compiles the same `Program.cs`: + +```xml +true +osx-arm64;osx-x64;linux-x64;linux-arm64;win-x64;any +``` + +- `dotnet pack` builds a 2 KB pointer package. +- `dotnet pack -r osx-arm64` builds an 11 MB AOT package for that RID. +- `dotnet pack -r any -p:PublishAot=false` builds a 0.5 MB framework-dependent fallback. +- Installing with SDK 10 links straight to the native binary, with no shim. `--version` then takes + 7.3 ms, against 33.5 ms for the current shim. + +It has real costs: + +- **It breaks users on the .NET 8 SDK.** Installing fails with: + + ```text + The settings file in the tool's NuGet package is invalid: Format version is higher than supported. This tool may not be supported in this SDK version. Update your SDK. + ``` + +- Each RID package must be built on its own OS. Cross-architecture works, cross-OS does not. +- The RID packages must be pushed before the pointer package. +- The net8 and net9 targets become pointless. + +So option B cannot ship as a minor release. + +### CI shape + +`.github/workflows/native.yml` builds on five runners: + +| RID | Runner | Notes | +|---|---|---| +| osx-arm64 | `macos-latest` | arm64 (M1) | +| osx-x64 | `macos-latest` | Cross-architecture build, then run on `macos-15-intel` | +| linux-x64 | `ubuntu-22.04` | Older glibc floor | +| linux-arm64 | `ubuntu-22.04-arm` | Native arm64 runner | +| win-x64 | `windows-latest` | | + +Runner availability was checked against GitHub's documentation on 8 Oct 2026: + +- `ubuntu-22.04-arm` is available for public repositories. +- `macos-13` no longer exists. It was retired in December 2025. `macos-15-intel` is the Intel + replacement. GitHub has said Intel macOS runners end when the macOS 15 image retires, announced for + fall 2027. After that, osx-x64 can still be cross-compiled but not tested on GitHub. + +Each binary is smoke-tested offline (`scripts/aot/smoke-test.sh`): `--version` must print the +release version, `--help` must print usage, and bad input must exit 2 on both the argument and stdin +JSON paths. The stdin JSON check is the one that catches the reflection problem above. + +One trap: `publish.yml` creates releases with `GITHUB_TOKEN`, and events caused by `GITHUB_TOKEN` +do not start other workflows. So `on: release: published` will not fire for automated releases. +`native.yml` therefore also accepts a `tag` input, and `publish.yml` can dispatch it with +`gh workflow run native.yml -f tag="$TAG"`, which is allowed. + +### Is AOT worth it? + +- **For a person running ytx once: no.** It saves about 0.2 s on a 1โ€“2 s call that waits on the network. +- **For agents and batch jobs: yes.** + - About 6x less CPU per call: 47โ€“73 ms against 300โ€“400 ms. + - Half the memory: 53โ€“61 MB against 111โ€“132 MB. + - Fast failure paths: `--help` and bad input drop from 38โ€“56 ms to about 9 ms. +- **For distribution: yes.** No .NET runtime is needed, which helps Homebrew, containers and CI. + +## Part 2: Allocations and CPU + +### Where the time and memory go + +An instrumented copy of v1.0.7 timed each phase on .NET 10 with workstation GC. + +**5 hour 15 minute video (16,284 cues):** + +| Phase | Time | Allocated | Owner | +|---|---|---|---| +| `Videos.GetAsync` (watch page) | 980โ€“1,370 ms | 43 MB | YoutubeExplode + network | +| Caption manifest | 170โ€“310 ms | 1.2โ€“1.6 MB | YoutubeExplode + network | +| Caption track fetch and XML parse | 610โ€“790 ms (spikes to 10 s) | 106โ€“109 MB | YoutubeExplode + network | +| ytx: build transcript | 28 ms | 7.9 MB | ytx | +| ytx: serialise and write | 15 ms | 26.3 MB | ytx | +| **Total** | **2.1โ€“2.2 s** | **186 MB** (22 gen0 and 4 gen2 collections) | | + +**17 minute video (924 cues), one run:** + +| Phase | Time | Allocated | +|---|---:|---:| +| Watch page | 1,189 ms | 30.8 MB | +| Caption manifest | 280 ms | 3.5 MB | +| Caption track | 140 ms | 6.4 MB | +| ytx: build transcript | 3.2 ms | 0.5 MB | +| ytx: serialise and write | 14.0 ms | 1.3 MB | +| **Total** | **1,627 ms** | **42.5 MB** (3 gen0, 2 gen1, 2 gen2) | + +About 98% of a run is network and YoutubeExplode. Perfect code on ytx's side would save 20โ€“25 ms out +of about 2 s. Network jitter alone moves the same caption fetch between 0.5 s and 10 s. + +YoutubeExplode 6.6.2 allocates about the same: 39โ€“44 MB for the watch page and about 97 MB for the +captions. Its release notes since 6.5.6 mention no performance work. It is still worth upgrading for +the client fixes in 6.5.7 and 6.6.2 and the HTML parser concurrency fix in 6.6.1, and because 6.5.6 +pulls in AngleSharp 1.3.0, which has a moderate advisory (NU1902). + +### What the current code does + +- **`NormalizeCaption` allocates for almost every caption.** The pattern `\s+` matches every single + space, so `Regex.Replace` returns a new string even when nothing changes. The static + `Regex.Replace` also looks up the regex cache on every call. +- **Serialising to a string costs 26 MB on a cold 5-hour run.** `JsonSerializer.Serialize` to a + string sizes its buffer for the worst-case escaping of the whole value, about 6 bytes per character + of a 1.1 million character string. It then produces a 2.2 MB UTF-16 string, which + `Console.WriteLine` encodes to UTF-8 again. +- **Each caption creates four temporary strings:** the timestamp, the link, the formatted line and + the normalised text. The `StringBuilder` grows in chunks, and `ToString().Trim()` copies the + result once more. +- **A new `JsonSerializerOptions` is built on every run,** and reflection-based setup is a real part + of the 13โ€“14 ms cold serialise time. The source-generated context from Part 1 fixes this. + +### Benchmark subjects + +- **Baseline** is the v1.0.7 code, copied verbatim. +- **Tier 1** is three readable changes, shown below. +- **Tier 2** is a pooled-buffer rewrite with hand-written digit formatting and a chunked + `Utf8JsonWriter`. + +Every variant was checked for byte-identical output against Baseline, in indented and compact mode, +on both videos, plus edge cases: ` `, NBSP, U+2028, U+0085, U+3000, emoji, and offsets over +100 hours. The checker is the `verify` command in `benchmarks/Ytx.Benchmarks`. + +### BenchmarkDotNet results + +Apple M1, .NET 10.0.12, BenchmarkDotNet 0.15.2, network excluded by replaying captured captions. + +| Method | Video | Mean | Allocated | Alloc ratio | +|---|---|---:|---:|---:| +| Baseline_Normalize (16k cues) | 5 h | 3,408 ยตs | 754,614 B | 1.00 | +| Tier1_Normalize | 5 h | **581 ยตs** | **904 B** | 0.001 | +| Baseline_Format | 5 h | 5,208 ยตs | 8,236,196 B | 0.78 | +| Tier1_Format | 5 h | 1,778 ยตs | 4,471,553 B | 0.43 | +| Tier2_Format | 5 h | 1,232 ยตs | 128 B | 0.00 | +| **Baseline_All** (format, serialise, write) | 5 h | **7,947 ยตs** | **10,506,442 B** | 1.00 | +| Tier1_All | 5 h | 3,974 ยตs | 4,469,011 B | 0.43 | +| Tier1SourceGen_All | 5 h | 3,923 ยตs | 4,469,010 B | 0.43 | +| Tier2_All | 5 h | 3,440 ยตs | 58,224 B | 0.006 | +| Baseline_All | 17 min | 420 ยตs | 626,002 B | 1.00 | +| Baseline_Format | 17 min | 292 ยตs | 486,113 B | 0.78 | +| Tier1_All | 17 min | 171 ยตs | 282,016 B | 0.45 | +| Tier1_Format | 17 min | 80 ยตs | 281,799 B | 0.45 | +| Tier1SourceGen_All | 17 min | 171 ยตs | 282,014 B | 0.45 | +| Tier2_Format | 17 min | 53 ยตs | 128 B | 0.00 | +| Tier2_All | 17 min | 149 ยตs | 48,024 B | 0.08 | + +The `_Format` alloc ratios are relative to `Baseline_All` for the same video, because BenchmarkDotNet +groups them together. + +### Cold runs + +BenchmarkDotNet measures warm, steady-state code. A CLI runs once, cold, and pays for JIT +compilation and serialiser warm-up. So each variant also ran once in a fresh process, 5 times, on the +5-hour data with no network: + +| Variant | Wall time | Allocated | +|---|---:|---:| +| Baseline | 42โ€“43 ms | 34.2 MB | +| Tier 1 (reflection JSON) | 29 ms | 28.6 MB | +| Tier 1 + source-gen JSON | 23 ms | 28.5 MB | +| Tier 2 | 20 ms | 5.3 MB | + +The synthetic dataset in `benchmarks/Ytx.Benchmarks` reproduces this closely: 46, 33, 25 and 23 ms. + +### Recommended changes (Tier 1) + +**1. Skip the regex when a caption is already clean.** Normalisation drops from 754 KB to under +1 KB and gets 6x faster. + +```csharp +[GeneratedRegex(@"\s+")] private static partial Regex Whitespace(); + +internal static string NormalizeCaption(string text) +{ + if (string.IsNullOrWhiteSpace(text)) return ""; + // \s+ matches every ordinary space, so the regex would allocate for nearly every caption. + if (NeedsNormalizing(text)) text = Whitespace().Replace(text, " ").Trim(); + return text.Contains(" ") ? text.Replace(" ", " ") : text; +} + +static bool NeedsNormalizing(ReadOnlySpan t) +{ + if (char.IsWhiteSpace(t[0]) || char.IsWhiteSpace(t[^1])) return true; + for (int i = 1; i < t.Length; i++) + if (t[i] == ' ' ? t[i - 1] == ' ' : char.IsWhiteSpace(t[i])) return true; + return false; +} +``` + +**2. Format numbers straight into the `StringBuilder`.** The interpolated-string handler for +`StringBuilder.Append` formats in place, which removes the temporary strings per caption. + +```csharp +if (rawSb.Length > 0) { rawSb.Append(' '); mdSb.Append(Environment.NewLine); } +rawSb.Append(text); +var ts = caption.Offset; int h = (int)ts.TotalHours; +if (h > 0) mdSb.Append($"- [{h:00}:{ts.Minutes:00}:{ts.Seconds:00}"); +else mdSb.Append($"- [{ts.Minutes:00}:{ts.Seconds:00}"); +mdSb.Append($"](https://www.youtube.com/watch?v={videoId}&t={(int)ts.TotalSeconds}s) ").Append(text); +``` + +**3. Serialise straight to stdout with the source-generated context.** This is shared with the AOT fix. + +```csharp +using var stdout = Console.OpenStandardOutput(); +JsonSerializer.Serialize(stdout, output, (compact ? Compact : Indented).Output); +stdout.Write(NewLineBytes); +``` + +Together these are about 30 lines. They stay readable and keep the output byte-identical. They halve +ytx's own CPU time and allocations and save about 20 ms on a cold 5-hour run. + +A detail for whoever implements this: v1.0.7 separates Markdown lines with `AppendLine` and ends the +output with `Console.WriteLine`. Both use `Environment.NewLine`, so on Windows the transcript lines +are joined with `\r\n` inside the JSON string, and the document ends with `\r\n`. Writing a literal +`\n` instead is arguably better, but it changes Windows output. Decide on purpose. + +### Not worth doing + +- **Tier 2.** It cuts allocations by 99% but saves only about 3 ms more than Tier 1 on a 5-hour video, + which is invisible next to 2 s of network. It is also harder to keep correct. It has to avoid + splitting surrogate pairs across JSON segments, copy the exact trim behaviour, and fall back to the + old code for ` ` because v1.0.7 replaces it after collapsing whitespace. +- **Reducing YoutubeExplode's allocations** (about 150 MB on the 5-hour video). That would mean + forking it or bypassing its parsing. GC pauses here cost a few milliseconds. + +## What is worth doing, and what is vanity + +| Change | Effort | User-visible effect | Verdict | +|---|---|---|---| +| Source-generated `JsonSerializerContext` | ~10 lines | Unblocks AOT. ~6 ms faster stdin path everywhere. | Do it | +| Tier 1 formatting and streaming output | ~30 lines | ~20 ms on a cold 5-hour run. Halves ytx's own allocations. | Do it, with a golden-output test | +| `IsAotCompatible=true` | 1 line | None directly. Catches AOT regressions at build time. | Do it | +| NativeAOT release assets | One workflow | 6x less CPU, half the memory, no runtime. Matters for agents, CI and Homebrew users. | Worth it, decision pending | +| Homebrew tap and Scoop bucket | Two repos, one PAT | Easier install without .NET | Worth it once assets exist | +| winget | Manual first PR, then a PAT | Reaches default Windows users | Later | +| RID-specific .NET tool (option B) | csproj and release rework | 7 ms startup via `dotnet tool` | Only at 2.0, if at all | +| Size-optimised AOT | 3 properties | Saves 1 MB, worse error messages | Vanity | +| Tier 2 zero-allocation rewrite | ~150 lines | ~3 ms more than Tier 1 | Vanity | +| Cutting YoutubeExplode's allocations | A fork | A few ms of GC | Vanity | + +The bigger wins for users are not in this document. The caption track bugs in #3 return the wrong +transcript with exit 0. The 10โ€“21 second outliers seen in the network runs cost more than every +startup saving here combined. Both deserve attention before shaving milliseconds. + +## Decision status + +**Exploratory, not yet decided.** + +- The source-gen JSON change, Tier 1 and `IsAotCompatible` are in + [#5](https://github.com/solrevdev/solrevdev.ytx/pull/5) (`perf/source-gen-json`), with + golden-output tests. It keeps `Environment.NewLine`, so Windows output is unchanged. +- The AOT work sits on top of that branch in `feat/native-aot`: the csproj change, `native.yml`, + the scripts in `scripts/aot/`, the benchmark project and the packaging templates. Nothing + publishes yet. +- On that branch, the osx-arm64 AOT binary passes `scripts/aot/smoke-test.sh`. A live run on + `aCi6BhrvjhM` produced output byte-identical to the framework-dependent build, in 1.58 s wall time + with 0.08 s of user CPU. +- Open questions: + - Is the Homebrew and Scoop audience big enough to justify a tap, a bucket and a PAT? + - Should `publish.yml` dispatch `native.yml` automatically, or should native assets stay manual + at first? + - Is a 2.0 with a RID-specific tool package wanted, given it drops .NET 8 SDK users? + +## How to reproduce + +Everything below runs from the repository root on macOS or Linux with the .NET 10 SDK and Python 3. + +**Deployment shapes and timings** (see `scripts/aot/README.md`): + +```bash +# Publish all variants for this machine (output in artifacts/aot/, which is gitignored) +scripts/aot/publish-variants.sh + +# Optional: the v1.0.7 baseline from a worktree +git worktree add ../ytx-v1.0.7 v1.0.7 +REPO=../ytx-v1.0.7 OUT=$PWD/artifacts/aot PREFIX=v1.0.7- scripts/aot/publish-variants.sh fdd + +# Measure. Startup needs no network; the other two call YouTube. +EXTRA="tool-shim=$HOME/.dotnet/tools/ytx" scripts/aot/run-bench.sh startup +scripts/aot/run-bench.sh network +scripts/aot/run-bench.sh rusage + +# Offline smoke test of one binary +scripts/aot/smoke-test.sh artifacts/aot/aot/Ytx +``` + +**Formatting benchmarks** (see `benchmarks/Ytx.Benchmarks/README.md`): + +```bash +cd benchmarks/Ytx.Benchmarks +dotnet run -c Release -- verify # byte-identical check on synthetic data +dotnet run -c Release -- capture NYFGCESmikA # optional: real captions, kept out of git +dotnet run -c Release -- --filter '*' # full BenchmarkDotNet run +``` + +The repository holds no real caption data. The synthetic datasets have the same cue counts and shape +as the two videos above, and give results within a few percent of them. + +**Linux in Docker** (arm64 on an Apple Silicon Mac): + +```bash +docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:10.0-noble-aot \ + dotnet publish src/Ytx -c Release -f net10.0 -r linux-arm64 -p:PublishAot=true -o /src/artifacts/linux-arm64 +docker run --rm -v "$PWD/artifacts/linux-arm64":/app ubuntu:24.04 /app/Ytx --version +# A full run needs: apt-get update && apt-get install -y libssl3t64 ca-certificates +``` diff --git a/docs/investigations/README.md b/docs/investigations/README.md new file mode 100644 index 0000000..164376b --- /dev/null +++ b/docs/investigations/README.md @@ -0,0 +1,15 @@ +# Investigations + +Write-ups of reviews and of performance and design experiments: the question, how it was measured, the results and +what was decided. Each one says whether its recommendations have been acted on. + +| Date | Topic | Status | +|---|---|---| +| October 2026 | [NativeAOT and allocations](2026-10-native-aot-and-allocations.md) | Source generation shipped in 1.0.8; native binaries ship with PR #6 | +| October 2026 | [Review of v1.0.7 to the 1.2.0 release](2026-10-ytx-review-to-1.1.0.md) | Done: issue #3 closed by 1.1.0, chapters in 1.2.0. Source for a blog post | + +Supporting code lives next to the code it measures: + +- [`benchmarks/Ytx.Benchmarks/`](../../benchmarks/Ytx.Benchmarks/): BenchmarkDotNet project and output verifier. +- [`scripts/aot/`](../../scripts/aot/): publish, timing and smoke-test scripts for native builds. +- [`packaging/`](../../packaging/): Homebrew and Scoop templates for native binaries. diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..de48649 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,122 @@ +# Packaging templates for native ytx binaries + +**Status: templates only.** `native.yml` attaches NativeAOT archives and a `SHA256SUMS` file to each +GitHub Release, and the main README documents installing them by hand. The Homebrew formula and Scoop +manifest below are not published yet. The NuGet tool (`dotnet tool install -g solrevdev.ytx`) is +unchanged and stays the primary install route. + +## Release assets these templates expect + +For release `vX.Y.Z`, `native.yml` uploads: + +| Asset | Contents | +|---|---| +| `ytx-X.Y.Z-osx-arm64.tar.gz` | `ytx`, `README.md` | +| `ytx-X.Y.Z-osx-x64.tar.gz` | `ytx`, `README.md` | +| `ytx-X.Y.Z-linux-x64.tar.gz` | `ytx`, `README.md` (built on Ubuntu 22.04 for a lower glibc floor) | +| `ytx-X.Y.Z-linux-arm64.tar.gz` | `ytx`, `README.md` | +| `ytx-X.Y.Z-win-x64.zip` | `ytx.exe`, `README.md` | +| `SHA256SUMS` | `sha256sum` output for every archive | + +The workflow attaches assets when a release is published by a person, or when it is dispatched with +`tag=vX.Y.Z`. A release that `publish.yml` creates with `GITHUB_TOKEN` does not trigger it on its own, +so `publish.yml` dispatches it with the new tag as its last step. See the comment at the top of +`.github/workflows/native.yml`. + +## Name clash + +[koguchic/ytx](https://github.com/koguchic/ytx) is an unrelated Rust CLI, installed with +`cargo install ytx-cli`, that also installs a command called `ytx`. On 8 Oct 2026 neither project +had a formula in homebrew-core, and koguchic/ytx has no tap or Scoop bucket. + +- Always document the fully qualified names: `brew install solrevdev/tap/ytx` and + `scoop install solrevdev/ytx`. +- If ytx is ever submitted to homebrew-core or the Scoop main bucket, expect a naming discussion. + A fallback name such as `solrevdev-ytx` would keep the `ytx` command but not the short formula name. +- Both tools put `ytx` on `PATH`. Users who install both get whichever comes first. + +## Homebrew tap + +Template: [`homebrew/ytx.rb`](homebrew/ytx.rb). + +1. Create the public repository `solrevdev/homebrew-tap` (the `homebrew-` prefix is required). +2. Copy the template to `Formula/ytx.rb` and fill in the version and four `sha256` values from + `SHA256SUMS`. +3. Check it: `brew install --build-from-source solrevdev/tap/ytx`, `brew test ytx` and + `brew audit --strict --online solrevdev/tap/ytx`. +4. Users install with `brew tap solrevdev/tap` then `brew install ytx`, or in one step with + `brew install solrevdev/tap/ytx`. + +Notes: + +- The binaries are only ad-hoc signed. Homebrew downloads with `curl`, so macOS does not quarantine + them and Gatekeeper does not block them. A browser download of the same archive is quarantined. + Avoiding that needs an Apple Developer ID and notarisation. +- On Linux, the binary loads the system OpenSSL 3 (`libssl.so.3`) at run time and needs CA + certificates. Desktop distributions have both. A bare `ubuntu:24.04` container fails with + "The SSL connection could not be established" until `libssl3t64` and `ca-certificates` are installed. +- `InvariantGlobalization` is on for AOT builds, so ICU is not needed. + +### Automating the tap update + +After `native.yml` uploads the assets, a job can rewrite the formula and push it to the tap: + +- Read `SHA256SUMS` from the release, substitute the placeholders in this template and commit + `Formula/ytx.rb` to `solrevdev/homebrew-tap`. +- **Secret needed:** `HOMEBREW_TAP_TOKEN`, a fine-grained personal access token limited to the + `solrevdev/homebrew-tap` repository with **Contents: read and write**. The workflow's own + `GITHUB_TOKEN` cannot push to another repository. +- Alternatives to a PAT: a GitHub App installed on the tap repository (short-lived tokens via + `actions/create-github-app-token`), or a deploy key with write access stored as a secret. +- `brew bump-formula-pr` also works, but it opens a pull request and is aimed at homebrew-core. + +## Scoop bucket + +Template: [`scoop/ytx.json`](scoop/ytx.json). + +1. Create the public repository `solrevdev/scoop-bucket`. The + [ScoopInstaller/BucketTemplate](https://github.com/ScoopInstaller/BucketTemplate) includes an + "Excavator" workflow that runs `checkver` and `autoupdate` on a schedule. +2. Copy the template to `bucket/ytx.json`, fill in the version and the `win-x64` hash. +3. Users install with `scoop bucket add solrevdev https://github.com/solrevdev/scoop-bucket` + then `scoop install solrevdev/ytx`. + +`checkver` reads the latest GitHub Release. `autoupdate` builds the new URL and takes the hash from +`SHA256SUMS` by matching the archive name. With Excavator running inside the bucket repository, the +bucket updates itself using that repository's own `GITHUB_TOKEN`. **No cross-repository secret is +needed.** The trade-off is a delay of up to the Excavator schedule (every 4 hours in the template). + +Only `win-x64` is built today. Add a `"arm64"` block if `win-arm64` is added to the matrix. + +## winget + +winget is the default package manager on Windows 10 and 11, so it reaches more users than Scoop. + +- Package identifier: `solrevdev.ytx`. Use the zip with `InstallerType: zip`, + `NestedInstallerType: portable` and `PortableCommandAlias: ytx`. +- The first version must be submitted as a pull request to + [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs), for example with + `wingetcreate new `. It goes through automated validation and moderator review. +- Later versions can be automated with `wingetcreate update solrevdev.ytx --version X.Y.Z --urls --submit`, + with the token in the `WINGET_CREATE_GITHUB_TOKEN` environment variable (the `--token` flag can + end up in logs), or with a community action such as winget-releaser. +- **Secret needed:** a personal access token that can fork `microsoft/winget-pkgs` and open pull + requests from that fork. `wingetcreate` documents a classic token with the `public_repo` scope. +- Unsigned portable executables are accepted, but SmartScreen may warn on first run. + +## Secrets summary + +| Channel | Secret | Scope | +|---|---|---| +| GitHub Release assets | none | `GITHUB_TOKEN` with `contents: write` in `native.yml` | +| Homebrew tap push | `HOMEBREW_TAP_TOKEN` (or a GitHub App) | Contents: read and write on `solrevdev/homebrew-tap` only | +| Scoop bucket | none if Excavator runs in the bucket | the bucket's own `GITHUB_TOKEN` | +| winget | `WINGET_TOKEN` | classic PAT, `public_repo`, to fork and open PRs on `microsoft/winget-pkgs` | +| Dispatch `native.yml` from `publish.yml` | none | add `actions: write` to the publishing job | + +## Later option: a RID-specific .NET tool + +.NET 10 can pack a tool as one package per runtime identifier, with NativeAOT binaries inside and a +framework-dependent `any` fallback. `dotnet tool install` then links straight to the native binary. +This was tested locally (see the investigation write-up) but it breaks installs on the .NET 8 and 9 +SDKs, so it belongs in a 2.0 release, if at all. diff --git a/packaging/homebrew/ytx.rb b/packaging/homebrew/ytx.rb new file mode 100644 index 0000000..ed6b4a9 --- /dev/null +++ b/packaging/homebrew/ytx.rb @@ -0,0 +1,46 @@ +# Template for Formula/ytx.rb in the solrevdev/homebrew-tap repository. +# Install with: brew install solrevdev/tap/ytx +# +# Replace the @@...@@ placeholders from the release's SHA256SUMS (see packaging/README.md). +# Unrelated project with the same command name: koguchic/ytx (Rust, `cargo install ytx-cli`). +# The fully qualified tap name avoids a formula clash, but both install a `ytx` command. +class Ytx < Formula + desc "Extract YouTube title, description and transcript (raw + Markdown) as JSON" + homepage "https://github.com/solrevdev/solrevdev.ytx" + version "@@VERSION@@" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/solrevdev/solrevdev.ytx/releases/download/v#{version}/ytx-#{version}-osx-arm64.tar.gz" + sha256 "@@SHA256_OSX_ARM64@@" + end + on_intel do + url "https://github.com/solrevdev/solrevdev.ytx/releases/download/v#{version}/ytx-#{version}-osx-x64.tar.gz" + sha256 "@@SHA256_OSX_X64@@" + end + end + + on_linux do + on_arm do + url "https://github.com/solrevdev/solrevdev.ytx/releases/download/v#{version}/ytx-#{version}-linux-arm64.tar.gz" + sha256 "@@SHA256_LINUX_ARM64@@" + end + on_intel do + url "https://github.com/solrevdev/solrevdev.ytx/releases/download/v#{version}/ytx-#{version}-linux-x64.tar.gz" + sha256 "@@SHA256_LINUX_X64@@" + end + # The .NET HTTP stack loads the system OpenSSL 3 (libssl.so.3) at run time and needs CA certificates. + # Most desktop distributions have both. A minimal container may not. + end + + def install + bin.install "ytx" + end + + test do + assert_match "ytx #{version}", shell_output("#{bin}/ytx --version") + # Offline usage error: invalid input must exit 2 without touching the network. + shell_output("#{bin}/ytx 'not a video!!' 2>&1", 2) + end +end diff --git a/packaging/scoop/ytx.json b/packaging/scoop/ytx.json new file mode 100644 index 0000000..b1a7309 --- /dev/null +++ b/packaging/scoop/ytx.json @@ -0,0 +1,28 @@ +{ + "version": "@@VERSION@@", + "description": "Extract YouTube title, description and transcript (raw + Markdown) as JSON.", + "homepage": "https://github.com/solrevdev/solrevdev.ytx", + "license": "MIT", + "notes": "Not related to koguchic/ytx (Rust, cargo install ytx-cli), which also installs a 'ytx' command.", + "architecture": { + "64bit": { + "url": "https://github.com/solrevdev/solrevdev.ytx/releases/download/v@@VERSION@@/ytx-@@VERSION@@-win-x64.zip", + "hash": "@@SHA256_WIN_X64@@" + } + }, + "bin": "ytx.exe", + "checkver": { + "github": "https://github.com/solrevdev/solrevdev.ytx" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$version/ytx-$version-win-x64.zip" + } + }, + "hash": { + "url": "$baseurl/SHA256SUMS", + "regex": "$sha256\\s+\\*?$basename" + } + } +} diff --git a/scripts/aot/README.md b/scripts/aot/README.md new file mode 100644 index 0000000..48edefd --- /dev/null +++ b/scripts/aot/README.md @@ -0,0 +1,81 @@ +# NativeAOT and deployment-shape scripts + +These scripts publish ytx in several deployment shapes and measure their size, startup time, CPU +time and memory. They produced the numbers in +[`docs/investigations/2026-10-native-aot-and-allocations.md`](../../docs/investigations/2026-10-native-aot-and-allocations.md). + +They need bash, the .NET 10 SDK and Python 3. The benchmark scripts run on macOS and Linux (they use +`os.wait4`). `smoke-test.sh` also runs in Git Bash on Windows, which is how CI uses it. + +| Script | What it does | +|---|---| +| `publish-variants.sh` | Publishes ytx as framework-dependent, ReadyToRun, trimmed self-contained and NativeAOT builds | +| `run-bench.sh` | Times every published binary: startup paths, real network calls, or `/usr/bin/time` resource use | +| `bench_rr.py` | Round-robin process timer used by `run-bench.sh` | +| `smoke-test.sh` | Offline checks for one binary: `--version`, `--help` and usage-error exit codes | + +All output goes to `artifacts/aot/` by default, which `.gitignore` already excludes. + +## Publish the variants + +```bash +scripts/aot/publish-variants.sh # all variants for this machine's RID +scripts/aot/publish-variants.sh aot fdd # just these two +RID=osx-x64 scripts/aot/publish-variants.sh aot # cross-architecture (same OS only) +``` + +| Variant | Meaning | +|---|---| +| `fdd` | Framework-dependent. Needs a .NET runtime. What the NuGet tool ships today. | +| `fdd-r2r` | Framework-dependent with ReadyToRun precompiled code | +| `sc-trim` | Self-contained single file, trimmed, invariant globalisation | +| `sc-trim-r2r` | As above plus ReadyToRun, uncompressed | +| `aot` | NativeAOT | +| `aot-size` | NativeAOT optimised for size, without stack trace data or resource strings | + +Settings: `REPO` (source tree, default the git root), `OUT` (default `$REPO/artifacts/aot`), +`RID` (default this machine), `TFM` (default `net10.0`) and `PREFIX` (label prefix). + +To compare against a released version, publish it from a worktree into the same `OUT` with a prefix: + +```bash +git worktree add ../ytx-v1.0.7 v1.0.7 +REPO=../ytx-v1.0.7 OUT=$PWD/artifacts/aot PREFIX=v1.0.7- scripts/aot/publish-variants.sh fdd +``` + +On v1.0.7 the `aot` and `aot-size` variants publish with four IL2026/IL3050 warnings and the binary +fails at run time on any JSON path. That is the reflection-based System.Text.Json problem the +write-up describes. A source-generated `JsonSerializerContext` fixes it. + +## Measure + +```bash +scripts/aot/run-bench.sh startup # no network: --help, --version, stdin JSON error path (30 rounds) +scripts/aot/run-bench.sh network # real YouTube calls: --metadata-only and a full run (8 rounds) +scripts/aot/run-bench.sh rusage # /usr/bin/time on one --metadata-only call per binary +``` + +Settings: `OUT`, `VIDEO` (default `aCi6BhrvjhM`), `ROUNDS` (network mode, default 8) and `EXTRA` +for binaries outside `OUT`, for example the installed tool shim: + +```bash +EXTRA="tool-shim=$HOME/.dotnet/tools/ytx" scripts/aot/run-bench.sh startup +``` + +How to read the results: + +- Every binary runs once per round, in turn, so network drift affects them all equally. +- Compare **medians** and **CPU time**. Network runs vary by seconds, and one slow call skews the mean. +- `peakRSS` is the largest resident set size seen across all rounds. +- An `exit=[-6]` (or 134) on the stdin path means the binary aborted. On an AOT build that is the + reflection-disabled JSON error. + +## Smoke test one binary + +```bash +scripts/aot/smoke-test.sh artifacts/aot/aot/Ytx 1.0.7 +``` + +It needs no network. It checks that `--version` prints the expected version, `--help` prints usage, +and that bad input exits with code 2, including through the stdin JSON path. `native.yml` runs it +on every platform. diff --git a/scripts/aot/bench_rr.py b/scripts/aot/bench_rr.py new file mode 100755 index 0000000..5f42907 --- /dev/null +++ b/scripts/aot/bench_rr.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Round-robin process benchmark. + +Usage: bench_rr.py ROUNDS WARMUP label=/path/to/binary [label=/path ...] -- [args for every binary] + +Every binary runs once per round, in turn, so network drift and thermal changes hit all of them +equally. Reports wall time, CPU time (user + sys) and peak RSS for each binary. + +Set BENCH_STDIN to pipe that text to each process (for the stdin JSON path). +POSIX only (macOS and Linux): it uses os.wait4 for per-process resource usage. +""" +import os +import statistics +import subprocess +import sys +import time + +# ru_maxrss is bytes on macOS but kilobytes on Linux. +RSS_TO_MB = 1 / 1048576 if sys.platform == "darwin" else 1 / 1024 + + +def run_once(binary, args, stdin_data): + start = time.perf_counter() + p = subprocess.Popen( + [binary] + args, + stdin=subprocess.PIPE if stdin_data else subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if stdin_data: + p.stdin.write(stdin_data.encode()) + p.stdin.close() + _, status, ru = os.wait4(p.pid, 0) + wall = (time.perf_counter() - start) * 1000 + cpu = (ru.ru_utime + ru.ru_stime) * 1000 + return wall, cpu, ru.ru_maxrss * RSS_TO_MB, os.waitstatus_to_exitcode(status) + + +def main(): + if "--" not in sys.argv or len(sys.argv) < 5: + sys.exit(__doc__) + rounds, warmup = int(sys.argv[1]), int(sys.argv[2]) + sep = sys.argv.index("--") + variants = [v.split("=", 1) for v in sys.argv[3:sep]] + args = sys.argv[sep + 1:] + stdin_data = os.environ.get("BENCH_STDIN") + + for label, binary in variants: + if not os.access(binary, os.X_OK): + sys.exit(f"{label}: not an executable file: {binary}") + + for _ in range(warmup): + for _, binary in variants: + run_once(binary, args, stdin_data) + + results = {label: [] for label, _ in variants} + for _ in range(rounds): + for label, binary in variants: + results[label].append(run_once(binary, args, stdin_data)) + + print(f"args: {' '.join(args) or '(none)'} stdin={stdin_data!r} rounds={rounds} warmup={warmup}") + for label, rs in results.items(): + wall = [r[0] for r in rs] + cpu = [r[1] for r in rs] + print( + f" {label:<22} med={statistics.median(wall):7.1f} mean={statistics.mean(wall):7.1f} " + f"min={min(wall):7.1f} max={max(wall):7.1f} sd={statistics.pstdev(wall):6.1f} ms " + f"cpu med={statistics.median(cpu):7.1f} ms peakRSS={max(r[2] for r in rs):6.1f} MB " + f"exit={sorted({r[3] for r in rs})}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/aot/publish-variants.sh b/scripts/aot/publish-variants.sh new file mode 100755 index 0000000..b1c6f82 --- /dev/null +++ b/scripts/aot/publish-variants.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Publishes ytx in several deployment shapes so their size and startup can be compared. +# +# Usage: scripts/aot/publish-variants.sh [variant...] +# Variants: fdd fdd-r2r sc-trim sc-trim-r2r aot aot-size (default: all) +# +# Environment: +# REPO repository root to build from (default: the git root of this script) +# OUT output folder, one subfolder/variant (default: $REPO/artifacts/aot) +# RID runtime identifier (default: detected from this machine) +# TFM target framework (default: net10.0) +# PREFIX label prefix for the subfolders (default: empty, e.g. "v1.0.7-") +# +# Each variant is published to $OUT// with its log in $OUT/logs/. +# Trim and AOT warnings are printed after each publish. They are expected on v1.0.7 (IL2026/IL3050). +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO=${REPO:-$(git -C "$script_dir" rev-parse --show-toplevel)} +OUT=${OUT:-$REPO/artifacts/aot} +TFM=${TFM:-net10.0} +PREFIX=${PREFIX:-} +PROJECT=$REPO/src/Ytx + +detect_rid() { + local os arch + case "$(uname -s)" in + Darwin) os=osx ;; + Linux) os=linux ;; + MINGW* | MSYS* | CYGWIN*) os=win ;; + *) echo "Unsupported OS: $(uname -s)" >&2; return 1 ;; + esac + case "$(uname -m)" in + arm64 | aarch64) arch=arm64 ;; + x86_64 | amd64) arch=x64 ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; return 1 ;; + esac + echo "$os-$arch" +} +RID=${RID:-$(detect_rid)} + +publish() { + local name=$1; shift + local dest=$OUT/$PREFIX$name log=$OUT/logs/$PREFIX$name.log + rm -rf "$dest" + echo "== $PREFIX$name ($RID, $TFM)" + # NU1902 is the known AngleSharp advisory from YoutubeExplode 6.5.6 and is not about publishing. + if ! dotnet publish "$PROJECT" -c Release -f "$TFM" -o "$dest" "$@" 2>&1 \ + | grep -v NU1902 | sed "s|$REPO/||g" > "$log"; then + echo " publish failed, see $log" >&2 + return 1 + fi + grep -E "warning (IL|NETSDK)|error" "$log" | sort -u || true + du -sh "$dest" | sed 's/^/ size: /' +} + +mkdir -p "$OUT/logs" +variants=("$@") +[[ ${#variants[@]} -eq 0 ]] && variants=(fdd fdd-r2r sc-trim sc-trim-r2r aot aot-size) + +for v in "${variants[@]}"; do + case $v in + fdd) publish fdd --no-self-contained ;; + fdd-r2r) publish fdd-r2r --no-self-contained -r "$RID" -p:PublishReadyToRun=true ;; + sc-trim) publish sc-trim --self-contained -r "$RID" -p:PublishSingleFile=true -p:PublishTrimmed=true \ + -p:InvariantGlobalization=true ;; + sc-trim-r2r) publish sc-trim-r2r --self-contained -r "$RID" -p:PublishSingleFile=true -p:PublishTrimmed=true \ + -p:InvariantGlobalization=true -p:PublishReadyToRun=true -p:EnableCompressionInSingleFile=false ;; + # TrimmerSingleWarn=false lists warnings per call site instead of one line per assembly. + aot) publish aot -r "$RID" -p:PublishAot=true -p:InvariantGlobalization=true -p:TrimmerSingleWarn=false ;; + aot-size) publish aot-size -r "$RID" -p:PublishAot=true -p:InvariantGlobalization=true \ + -p:OptimizationPreference=Size -p:UseSystemResourceKeys=true -p:StackTraceSupport=false ;; + *) echo "Unknown variant: $v" >&2; exit 2 ;; + esac +done diff --git a/scripts/aot/run-bench.sh b/scripts/aot/run-bench.sh new file mode 100755 index 0000000..4db68d7 --- /dev/null +++ b/scripts/aot/run-bench.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Benchmarks the binaries produced by publish-variants.sh. +# +# Usage: scripts/aot/run-bench.sh startup|network|rusage +# startup --help, --version and the stdin JSON error path. No network. 30 rounds after 3 warm-ups. +# network --metadata-only and a full run against $VIDEO. $ROUNDS rounds, 1 warm-up. +# rusage one --metadata-only run per binary under /usr/bin/time (peak RSS, instructions on macOS). +# +# Environment: +# REPO repository root (default: the git root of this script) +# OUT folder publish-variants.sh wrote to (default: $REPO/artifacts/aot) +# VIDEO video ID for network runs (default: aCi6BhrvjhM, a 17-minute video) +# ROUNDS rounds for network mode (default: 8) +# EXTRA extra "label=/path/to/binary" pairs, space-separated, e.g. the installed tool shim: +# EXTRA="tool-shim=$HOME/.dotnet/tools/ytx" +# +# Every binary found under $OUT/*/Ytx (or Ytx.exe) is included, labelled by its folder name. +# Results are printed and saved to $OUT/results/.txt. +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO=${REPO:-$(git -C "$script_dir" rev-parse --show-toplevel)} +OUT=${OUT:-$REPO/artifacts/aot} +VIDEO=${VIDEO:-aCi6BhrvjhM} +ROUNDS=${ROUNDS:-8} +mode=${1:-} + +variants=() +for bin in "$OUT"/*/Ytx "$OUT"/*/Ytx.exe; do + [[ -x $bin ]] && variants+=("$(basename "$(dirname "$bin")")=$bin") +done +# shellcheck disable=SC2206 # EXTRA is a deliberate space-separated list. +[[ -n ${EXTRA:-} ]] && variants+=($EXTRA) +if [[ ${#variants[@]} -eq 0 ]]; then + echo "No binaries under $OUT. Run scripts/aot/publish-variants.sh first." >&2 + exit 1 +fi + +mkdir -p "$OUT/results" +bench() { python3 "$script_dir/bench_rr.py" "$@"; } + +case $mode in + startup) + { + bench 30 3 "${variants[@]}" -- --help + bench 30 3 "${variants[@]}" -- --version + # Fails ID validation before any network call, so it times the stdin and JSON parse path. Exit 2. + BENCH_STDIN='{"url":"not a video!!"}' bench 30 3 "${variants[@]}" -- + } | tee "$OUT/results/startup.txt" + ;; + network) + { + bench "$ROUNDS" 1 "${variants[@]}" -- --metadata-only "$VIDEO" + bench "$ROUNDS" 1 "${variants[@]}" -- "$VIDEO" + } | tee "$OUT/results/network.txt" + ;; + rusage) + if [[ $(uname -s) == Darwin ]]; then time_flag=-l; else time_flag=-v; fi + { + for pair in "${variants[@]}"; do + echo "== ${pair%%=*}" + /usr/bin/time "$time_flag" "${pair#*=}" --metadata-only --compact "$VIDEO" 2>&1 >/dev/null \ + | grep -E "real|maximum resident|Maximum resident|instructions retired|peak memory|Elapsed" || true + done + } | tee "$OUT/results/rusage.txt" + ;; + *) + sed -n '2,20p' "$0" + exit 2 + ;; +esac diff --git a/scripts/aot/smoke-test.sh b/scripts/aot/smoke-test.sh new file mode 100755 index 0000000..d2662ad --- /dev/null +++ b/scripts/aot/smoke-test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Offline smoke test for a ytx binary. Needs no network and no .NET runtime. +# +# Usage: scripts/aot/smoke-test.sh /path/to/ytx [expected-version] +# +# Checks --version, --help, the usage-error exit codes (including the stdin JSON path) and a +# refused proxy connection. +# That path deserialises JSON, so it fails if a NativeAOT build still uses reflection-based +# System.Text.Json (the binary aborts instead of exiting 2). +set -uo pipefail + +bin=${1:?usage: smoke-test.sh /path/to/ytx [expected-version]} +expected_version=${2:-} +failures=0 + +pass() { echo "ok $1"; } +fail() { echo "FAIL $1" >&2; failures=$((failures + 1)); } + +expect_exit() { + local want=$1 desc=$2; shift 2 + local got + "$@" >/dev/null 2>&1 /dev/null 2>&1 + got=$? + if [[ $got -eq $want ]]; then pass "$desc (exit $got)"; else fail "$desc: expected exit $want, got $got"; fi +} + +if [[ ! -x $bin ]]; then + echo "Not an executable file: $bin" >&2 + exit 1 +fi + +if version_out=$("$bin" --version &1); then + pass "--version prints '$version_out'" +else + fail "--version exited non-zero: $version_out" +fi +if [[ -n $expected_version ]]; then + # tr strips the CR that Windows console output adds. + if [[ $(printf '%s' "$version_out" | tr -d '\r') == "ytx $expected_version" ]]; then + pass "--version matches $expected_version" + else + fail "--version: expected 'ytx $expected_version', got '$version_out'" + fi +fi + +if "$bin" --help /dev/null | grep -q "Usage:"; then pass "--help shows usage"; else fail "--help did not show usage"; fi + +expect_exit 2 "unknown option" "$bin" --definitely-not-an-option +expect_exit 2 "invalid URL argument" "$bin" "not a video!!" +expect_exit 2 "--help with --version" "$bin" --help --version +expect_exit 2 "no URL and empty stdin" "$bin" +expect_stdin_exit 2 "stdin JSON with invalid URL" '{"url":"not a video!!"}' +expect_stdin_exit 2 "stdin malformed JSON" '{"url":' +expect_exit 2 "unknown --format" "$bin" --format xml dQw4w9WgXcQ +expect_exit 2 "--format with --metadata-only" "$bin" --format srt --metadata-only dQw4w9WgXcQ +expect_exit 2 "--list-languages with --segments" "$bin" --list-languages --segments dQw4w9WgXcQ +expect_exit 2 "--timeout 0" "$bin" --timeout 0 dQw4w9WgXcQ +expect_exit 2 "--proxy without a scheme" "$bin" --proxy host:8080 dQw4w9WgXcQ +# Port 9 (discard) refuses connections, so this runs the HttpClient, proxy and error paths offline. +expect_exit 1 "unreachable --proxy" "$bin" --timeout 20 --proxy http://127.0.0.1:9 dQw4w9WgXcQ + +if [[ $failures -gt 0 ]]; then + echo "$failures smoke test(s) failed." >&2 + exit 1 +fi +echo "All smoke tests passed." diff --git a/src/Ytx/Ytx.csproj b/src/Ytx/Ytx.csproj index 8c7ee76..252e33a 100644 --- a/src/Ytx/Ytx.csproj +++ b/src/Ytx/Ytx.csproj @@ -35,6 +35,17 @@ ../../nupkg + + + + true + + false + +