Skip to content

Add .NET 10 / ASP.NET Core 10 implementation and perf-lab integration#497

Open
cdhermann wants to merge 4 commits intoquarkusio:mainfrom
cdhermann:main
Open

Add .NET 10 / ASP.NET Core 10 implementation and perf-lab integration#497
cdhermann wants to merge 4 commits intoquarkusio:mainfrom
cdhermann:main

Conversation

@cdhermann
Copy link
Copy Markdown

Summary

Adds a .NET 10 implementation (dotnet10/) equivalent to the existing Quarkus 3 and Spring Boot
implementations, along with full perf-lab and CI integration.

Application (dotnet10/)

Same domain model, endpoints, and behaviour as the Quarkus implementation:

Concept Quarkus 3 dotnet10
HTTP framework RESTEasy (JAX-RS) ASP.NET Core Minimal API
ORM Hibernate ORM Panache EF Core + Npgsql
Health checks SmallRye Health AddHealthChecks().AddDbContextCheck<T>()
Metrics Micrometer → OTel OTLP OTel WithMetrics → OTLP
Traces SmallRye OTel, 10% sampling OTel TraceIdRatioBasedSampler(0.1)
JSON Jackson System.Text.Json (source-generated, AOT-safe)
HTTP port 8080 8080

The app emits a startup log matching the perf-lab logFileStartedRegex pattern
(.*dotnet10.+started in.*).

perf-lab integration

  • scripts/perf-lab/main.ymldotnet10 entry in RUNTIMES and RUNTIMECMDS;
    updateScript: skip-version-update prevents abort when qDup's update-runtime-version runs.
    runCmd uses env KEY=value prefix so that time-to-1st-request.sh's exec $RUN_CMD
    correctly word-splits env-var assignments vs. the binary.
  • scripts/perf-lab/run-benchmarks.shdotnet10 in ALLOWED_RUNTIMES / DEFAULT_RUNTIMES;
    xmx_to_dotnet_gc_limit() converts --jvm-memory (e.g. -Xmx512m) to a hex
    DOTNET_GCHeapHardLimit so memory constraints match the JVM exactly.
  • scripts/perf-lab/helpers/dotnet.yml / requirements.ymlensure-dotnet requirement check.
  • Runtime constraints are equivalent to the JVM runtimes:
    • CPU pinning via taskset (APP_CMD_PREFIX)
    • DOTNET_GCHeapHardLimit computed from --jvm-memory
    • DOTNET_ProcessorCount from --cpus-app
    • DOTNET_gcServer=1

CI (.github/workflows/main.yml)

New dotnet-build-test job that builds the app and runs unit tests (E2E tests excluded
pending a database service container — see open items).

Scripts

  • scripts/stress.sh and scripts/1strequest.sh — detect dotnet binaries and set the correct
    runtime env vars. 1strequest.sh also gains a --no-purge flag to skip sudo purge/
    drop_caches for local dev use without sudo.
  • scripts/remote-setup.sh — one-command preparation of a bare Linux benchmark host
    (SSH key install, passwordless sudo, environment verification).

Docs

  • DOTNET-PORTING.md — Quarkus→dotnet concept mapping table, implementation status,
    native AOT blocker rationale, and checklist for adding future dotnet versions.
  • README.md — dotnet10 entry, remote benchmark workflow, script examples.

Benchmark results while end-to-end testing

on a Hetzner CPX62 VPS, 16-core / 30 GB, 512 MB heap limit

./run-benchmarks.sh \
  --runtimes 'dotnet10' \
  --repo-url https://github.com/cdhermann/spring-quarkus-perf-comparison.git \
  --host quarkus-dotnet-performance-comparison \
  --user root \
  --cpus-app 0-3 \
  --cpus-db 4-6 \
  --cpus-otel 7-9 \
  --cpus-load-gen 10-12 \
  --cpus-monitoring 13 \
  --cpus-first-request 10

and

./run-benchmarks.sh \
  --runtimes 'quarkus3-jvm' \
  --repo-url https://github.com/cdhermann/spring-quarkus-perf-comparison.git \
  --host quarkus-dotnet-performance-comparison \
  --user root \
  --quarkus-version 3.34.5 \
  --cpus-app 0-3 \
  --cpus-db 4-6 \
  --cpus-otel 7-9 \
  --cpus-load-gen 10-12 \
  --cpus-monitoring 13 \
  --cpus-first-request 10
Metric dotnet10 Quarkus 3 JVM
Build time 2.2s 9.0s
Startup (TTFR) 1711ms 2578ms
RSS at startup 69 MiB 275 MiB
RSS after 1st request 129 MiB 292 MiB
RSS under load 225 MiB 772 MiB
Throughput 6582 tps 18176 tps
Throughput density 29.3 tps/MiB 23.9 tps/MiB
Errors 0 0

Open items (not blocking)

  • E2E tests require a running database. Unit tests pass in CI. E2E tests are excluded
    with --filter "FullyQualifiedName!~Dotnet10.Tests.E2e" until either Testcontainers or a
    GitHub Actions service container is wired up.
  • Dependabot NuGet monitoring. .github/dependabot.yml currently only covers Maven.
    A follow-up can add a nuget entry pointing at dotnet10/.
  • dotnet-native is not feasible yet. EF Core Native AOT support is still experimental
    upstream (dotnet/efcore#29840).
    A dotnet10-native runtime will be added once that stabilises.
  • java -version calls in perf-lab are misleading for dotnet runtimes — harmless noise
    since Java is always present (qDup is a Java tool), but could be made conditional on
    ${{RUNTIME.type}} != dotnet in a follow-up.

AI disclosure: Much of this implementation was developed with Claude (Anthropic).
The code, script changes, and benchmark methodology have been tested end-to-end, but
human review is warmly welcomed — particularly on the perf-lab integration and runtime
constraint parity between JVM and .NET.

Adds a dotnet10/ implementation equivalent to the existing Quarkus 3 and
Spring Boot implementations, with full perf-lab, CI, and script integration.

Application (dotnet10/):
- ASP.NET Core 10 Minimal API, EF Core + Npgsql, same domain model and
  endpoints as the Quarkus implementation
- OpenTelemetry traces (10% sampling) + metrics via OTLP, matching Quarkus
- System.Text.Json with source-generated context (AOT-safe)
- Health check via AddHealthChecks().AddDbContextCheck<T>()
- Startup log matching perf-lab logFileStartedRegex pattern
- Unit tests (xUnit) and E2E tests (Testcontainers, excluded from CI pending
  a database service container)

perf-lab integration:
- main.yml: dotnet10 RUNTIMECMD with updateScript: skip-version-update;
  runCmd uses env KEY=value prefix so exec $RUN_CMD correctly handles
  env-var assignments vs. the binary name
- run-benchmarks.sh: dotnet10 in ALLOWED_RUNTIMES/DEFAULT_RUNTIMES;
  xmx_to_dotnet_gc_limit() converts --jvm-memory to hex DOTNET_GCHeapHardLimit
  so memory constraints are equivalent to the JVM -Xmx flag
- Runtime constraints mirror JVM: CPU pinning via taskset (APP_CMD_PREFIX),
  DOTNET_GCHeapHardLimit from --jvm-memory, DOTNET_ProcessorCount from
  --cpus-app, DOTNET_gcServer=1
- helpers/dotnet.yml + requirements.yml: ensure-dotnet requirement check
- scripts/time-to-1st-request.sh: fixed exec $RUN_CMD word-splitting for
  env-var-prefixed commands via env binary prefix

CI (.github/workflows/main.yml):
- New dotnet-build-test job (builds + unit tests; E2E excluded)
- Java 21 setup included because Hyperfoil (used by the test harness) requires it

Scripts:
- stress.sh and 1strequest.sh detect dotnet binaries and set correct runtime
  env vars; 1strequest.sh gains --no-purge flag for local dev use
- scripts/remote-setup.sh: one-command preparation of a bare Linux benchmark host

Docs:
- DOTNET-PORTING.md: Quarkus->dotnet concept mapping, implementation status,
  native AOT blocker rationale, checklist for adding future dotnet versions
- README.md: dotnet10 entry, remote benchmark workflow, script examples

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@edeandrea
Copy link
Copy Markdown
Collaborator

Thank you @cdhermann for this!

We need to discuss internally how we want to handle this. I'm on my way out on PTO for the next week. I just didn't want to let it hang without any feedback at all.

@cdhermann
Copy link
Copy Markdown
Author

We need to discuss internally how we want to handle this.

Thanks, @edeandrea, for the quick feedback and for taking this into internal discussions.

I think it’s great that you're considering it. If it makes it into the test suite, that would be awesome. However, I completely understand if it doesn't—maintaining parity between the .NET and JVM versions as they evolve is a non-negligible amount of work.

What I’d be most interested in, though, is a 'head-to-head' comparison in a real lab environment just to see who currently has the edge in terms of throughput and resource consumption.

cdhermann and others added 3 commits April 24, 2026 10:21
Detects gcc and zlib dev headers on the remote host independently.
If either is missing, reads /etc/os-release to identify the distro
and installs the appropriate batteries-included packages:
  - Debian/Ubuntu/Mint/Pop: apt-get update + build-essential zlib1g-dev
  - Fedora: gcc gcc-c++ make zlib-devel
  - RHEL/CentOS/AlmaLinux/Rocky: dnf groupinstall 'Development Tools' + zlib-devel
  - openSUSE/SLES: zypper install gcc gcc-c++ make zlib-devel
  - Arch/Manjaro: pacman -Sy base-devel
  - Unknown: falls back on ID_LIKE, then warns with manual instructions

Prevents silent GraalVM native-image build failures on fresh Ubuntu
servers where gcc and zlib dev headers are not pre-installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…h-log

Native Quarkus binaries start in ~80ms, which is fast enough that the
async watch-log script can fire LOG_REGEX_REACHED before the main thread
has registered its wait-for — causing qDup to hang indefinitely.

Adding a 1s sleep between ctrlC and the signal gives the main thread
time to register wait-for regardless of how fast the app starts.
JVM runtimes (~2.5s startup) were never affected by this race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The run-load-test phase was starting the app first, then setting up
watch-log. By the time tail -f started, the app had already been
running for several seconds and had written its "started" message to
the log file. tail -f replays existing file content on open, so the
regex fired instantly — causing the main thread to skip past wait-for
LOG_REGEX_REACHED and attempt to collect load results before wrk
had run at all.

Fix: move the watch-log invocation to before the app start. The log
file is already created empty by `touch`, so tail -f on an empty file
follows only new writes. The app's "started" line is always new
content from tail's perspective.

Also remove the sleep: 1s from the watch-log callback. The sleep was
added as a workaround for a different race condition but does not
actually block in a watch→then handler (qDup executes watch callbacks
asynchronously without honouring sleep semantics there).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@edeandrea
Copy link
Copy Markdown
Collaborator

edeandrea commented Apr 29, 2026

Hi @cdhermann !

We wanted to again thank you for all of this great work! To be honest we're really flattered that someone would take what we've done to this next level!

That being said, I don't think it is something that we will merge, mostly because we lack the dotnet experience to properly review this and also to maintain it going forward. We'd like to leave this PR open for discoverability in case others are interested in it - it really is good work.

What I’d be most interested in, though, is a 'head-to-head' comparison in a real lab environment just to see who currently has the edge in terms of throughput and resource consumption.

Unfortunately, due to security concerns, we can't run something in our lab environment that we haven't reviewed thoroughly, and as mentioned, we just don't have the dotnet experience to give this a proper review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants