Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/benchmark-report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: .NET SDK benchmark report

on:
workflow_run:
workflows: ['.NET SDK benchmarks']
types: [completed]

permissions: {}

jobs:
report:
permissions:
contents: read
actions: read
pull-requests: write
uses: PostHog/.github/.github/workflows/sdk-benchmark-report.yml@36c59a0045926b2dc82566cafb96721a0e808673 # SDK benchmark reporting
with:
workflow-path: .github/workflows/benchmarks.yml
artifact-name: dotnet-benchmark-results
report-key: posthog-dotnet
sdk: posthog-dotnet
89 changes: 89 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: .NET SDK benchmarks

on: [pull_request]

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
benchmark:
runs-on: ubuntu-24.04
timeout-minutes: 12
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false

- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '9.0.109'

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12.12'

- name: Prepare pinned baseline
env:
TARGET_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
export BASE_WORKTREE="$RUNNER_TEMP/dotnet-benchmark-base"
export RESULTS="$RUNNER_TEMP/dotnet-benchmark-results"
printf 'BASE_WORKTREE=%s\nRESULTS=%s\n' "$BASE_WORKTREE" "$RESULTS" >> "$GITHUB_ENV"
BASE_SHA=$(git merge-base "$TARGET_SHA" "$HEAD_SHA")
git worktree add --detach "$BASE_WORKTREE" "$BASE_SHA"
mkdir -p "$RESULTS"
python -c 'import json, os, pathlib, sys; pathlib.Path(os.environ["RESULTS"], "revisions.json").write_text(json.dumps({"target": os.environ["TARGET_SHA"], "head": os.environ["HEAD_SHA"], "base": sys.argv[1]}, indent=2))' "$BASE_SHA"

- name: Test coordinator controls
run: python -B -m unittest discover -s benchmarks -p 'test_*.py'

- name: Build, test native controls and measure paired base and head
id: measure
timeout-minutes: 10
run: |
python -B benchmarks/run.py \
--base "$BASE_WORKTREE" \
--head "$GITHUB_WORKSPACE" \
--build-directory "$RUNNER_TEMP/dotnet-benchmark-build" \
--output "$RESULTS" \
--repetitions 6

- name: Render benchmark comparison
if: ${{ !cancelled() && (steps.measure.outcome == 'success' || steps.measure.outcome == 'failure') }}
uses: PostHog/.github/.github/actions/benchmark-report@9b34fde6b26df7d874b85d2de09904b4ab701234 # SDK benchmark reporting
with:
mode: render
source-status: ${{ steps.measure.outcome }}
base-results: ${{ runner.temp }}/dotnet-benchmark-results/base.json
head-results: ${{ runner.temp }}/dotnet-benchmark-results/head.json
output-directory: ${{ runner.temp }}/dotnet-benchmark-report

- name: Upload rendered comparison
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dotnet-benchmark-report
path: ${{ runner.temp }}/dotnet-benchmark-report
if-no-files-found: ignore
retention-days: 14

- name: Upload raw benchmark results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dotnet-benchmark-results
path: |
${{ runner.temp }}/dotnet-benchmark-results/base.json
${{ runner.temp }}/dotnet-benchmark-results/head.json
${{ runner.temp }}/dotnet-benchmark-results/revisions.json
if-no-files-found: warn
retention-days: 14
107 changes: 107 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,113 @@ The test projects target both `net8.0` and `netcoreapp3.1`. While .NET Core 3.1

This testing approach ensures broad compatibility without requiring users to install legacy runtimes in production.

## Performance benchmarks

Every opened, reopened, or updated pull request runs `.github/workflows/benchmarks.yml`.
It compares the merge base with the exact PR head, using the head's benchmark code for
both revisions. Results are informational: slow measurements do not fail the job, but
build, execution, fixture correctness, or delivery failures do. The separate trusted
`workflow_run` publisher posts a sticky PR comment once its workflow is on the default
branch. Reporting is provided by [PostHog/.github](https://github.com/PostHog/.github/blob/main/.github/workflows/sdk-benchmark-report.md).

The 20 workloads follow the shared [SDK benchmark specification](https://github.com/PostHog/.github/blob/main/.github/workflows/sdk-benchmark-specification.md)
(`suite.version=2`, `suite.specification=sdk-benchmarks/v1`). They cover nine single-flag
cases (16,000 evaluations each), all-flags at 10/100/1,000 definitions (1,600/160/160
evaluations), 1,600 load evaluations using at most eight ThreadPool tasks, 40 definition
reloads, and six capture workloads with 1,000 events each. Capture uses 32-byte and
4-KiB payloads for enqueue, steady delivery (explicit flush every 100), and burst
delivery (one final explicit flush). Enqueue has timing/allocation passes; both delivery
patterns have timing/allocation/memory passes. Single/all-flags have timing/allocation
passes; load and refresh have memory passes. Flag cases include percentage rollouts,
variants, properties, regexes, cohorts, early/late/no-match groups and inactive flags.
The suite exercises supported legacy getters; it does not measure the newer
`EvaluateFlagsAsync` snapshot API.

### Run locally

Install .NET SDK 9.0.109 (including runtime 9.0.8) and Python 3.12. The coordinator uses
only Python's standard library; all instrumentation and SDK workloads execute in C#.
Use a fresh build directory for each invocation:

```bash
python3 -B -m unittest discover -s benchmarks -p 'test_*.py'
RUN=$(mktemp -d)
python3 -B benchmarks/run.py \
--base /path/to/baseline-checkout \
--head "$PWD" \
--build-directory "$RUN/build" \
--output "$RUN/results" \
--repetitions 6
```

Use `--smoke --repetitions 1` for correctness checks across all 20 workloads: 160
single-flag evaluations, 16 all-flags evaluations, 32 load evaluations, four refreshes,
and 200 events per capture workload. Smoke is not repeatability evidence. Native
controls run automatically before pairs and check the inventory, fixtures, phase reset,
flush schedules, reload generations, deterministic checkpoints and failure detection.
For a same-revision noise control, point both `--base` and `--head` at the same checkout.
Run repeatability checks on an otherwise idle machine; concurrent validation is only
correctness evidence. Do not compare results from different hosts or languages.

The coordinator copies the same standalone benchmark project outside the checkout,
builds it separately against each revision's public SDK project with locked NuGet
restore, and fingerprints the loaded SDK assembly and dependency graph. It uses the
SDK's `net8.0` target on pinned .NET 9.0.8. Benchmark workers use workstation GC and
optimized, non-tiered JIT compilation to avoid tier transitions during measurements.
This is one explicit runtime configuration, not a claim about all .NET deployments.
Each workload/repetition/pass starts a fresh process; base/head order alternates.
Warmup is 1,024 single evaluations, 16 all/load evaluations, two refreshes, or 100
captures, followed by a 25-ms native settling delay and full GC. Warmup captures are
flushed and verified before counters and bounded sequence state reset. Every measured
flag workload starts with generation 0 reloaded and verified. Full flag correctness
sweeps occur outside measurement, including after the memory post-settling snapshot;
measured refreshes only reload definitions and check that each made a request.

### Measurement boundaries

- Elapsed time uses `Stopwatch`; CPU uses whole-process `TotalProcessorTime`. Values
are expressed in nanoseconds, but CPU-counter resolution is OS-dependent.
- Allocation volume uses `GC.GetTotalAllocatedBytes(precise: true)`, including managed
objects collected during the operation. It excludes native allocations and does not
provide exact object counts; counts are reported as unsupported, never zero.
- Idle and post-settling managed heap use `GC.GetTotalMemory(false)` after explicit
full collections. These are retained-heap estimates, not allocation volume.
- RSS checkpoints follow every 32 completed load evaluations, each refresh, or every
100 captures, including the final operation if needed. Steady checkpoints follow the
scheduled flush; burst checkpoints precede the final flush. `observed_workload_peak_rss`
is the maximum of before-work, checkpoints and after-work readings, not a continuous
maximum. `process_lifetime_peak_rss` (`PeakWorkingSet64`) includes startup and warmup.
`idle_process_rss`, `post_settling_process_rss` and `post_settling_heap_used` retain their
distinct scopes. No forced GC occurs within measured work or checkpoint callbacks;
full collections bracket memory measurement. GC heap bytes and RSS are not
interchangeable; releasing objects need not reduce RSS.
- Public `IHttpClientFactory` injection supplies offline definitions and acknowledges
batches. Measurements include in-process transport JSON parsing and validation, with
compression disabled through public options. This is not a network or remote
ingestion benchmark. No project credentials are required.
- Caller fixtures are constructed outside timing/allocation boundaries. Event `benchmark`
uses 16 rotating identities and separate per-event property dictionaries containing
ASCII `payload`, `plan=pro` and integer `sequence`, starting at zero for each phase.
The receiver checks exact payload, plan and sequence-to-identity mapping using bounded,
preallocated uniqueness state: balanced duplicate/loss fails; order is not required.
- Queue capacity (100,001), automatic flush count (100,000), and maximum count-based
batch size (100,000) exceed the workload; polling and flush intervals are one hour.
Native background consumers
and size-based transport behavior remain active. The patterns specify caller flushes,
not HTTP request counts. Enqueue measures synchronous `Capture` API return
(`completion_boundary=api_return`), excluding its later flush and verification;
process-wide CPU/allocation counters may include concurrent background work. Delivery
includes capture preparation, serialization and public `FlushAsync` completion
(`completion_boundary=completed_delivery`). Verification never adds a rescuing flush.

CI keeps the suite on one runner, with a 12-minute job deadline and a 10-minute
build/measurement step deadline to leave time for reporting and artifact upload.
Each worker has a 120-second deadline and a 512-MiB **managed heap** limit, not an RSS
limit. After two worker timeouts the coordinator finishes the current pair, saves
partial diagnostics and fails the run. A killed process is not automatically classified
as OOM. Raw `base.json` and `head.json` retain statuses, metadata, repetitions and memory
checkpoints; the PR report suppresses numeric comparisons when the source run failed.

## Public API changes

Public API is hard to change once it ships, so agree on it before writing the implementation. Our [SDK guidelines](https://posthog.com/handbook/engineering/sdks/guidelines) explain how we design it.
Expand Down
1 change: 1 addition & 0 deletions benchmarks/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__/
18 changes: 18 additions & 0 deletions benchmarks/Benchmark.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- The coordinator copies this project outside the checkout for each SDK revision. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RuntimeFrameworkVersion>9.0.8</RuntimeFrameworkVersion>
<RollForward>Disable</RollForward>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
<TieredCompilation>false</TieredCompilation>
<ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="$(PostHogProject)" />
</ItemGroup>
</Project>
Loading
Loading