From f4080172dec4b3d7c98aa50993837851db2fa3df Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Tue, 7 Apr 2026 01:00:30 +0100 Subject: [PATCH] aspnet-iouring --- frameworks/aspnet-minimal-iouring/AppData.cs | 108 +++++++++++ frameworks/aspnet-minimal-iouring/Dockerfile | 39 ++++ frameworks/aspnet-minimal-iouring/Handlers.cs | 171 ++++++++++++++++++ frameworks/aspnet-minimal-iouring/Models.cs | 45 +++++ frameworks/aspnet-minimal-iouring/Program.cs | 78 ++++++++ .../aspnet-minimal-iouring.csproj | 12 ++ frameworks/aspnet-minimal-iouring/meta.json | 24 +++ 7 files changed, 477 insertions(+) create mode 100644 frameworks/aspnet-minimal-iouring/AppData.cs create mode 100644 frameworks/aspnet-minimal-iouring/Dockerfile create mode 100644 frameworks/aspnet-minimal-iouring/Handlers.cs create mode 100644 frameworks/aspnet-minimal-iouring/Models.cs create mode 100644 frameworks/aspnet-minimal-iouring/Program.cs create mode 100644 frameworks/aspnet-minimal-iouring/aspnet-minimal-iouring.csproj create mode 100644 frameworks/aspnet-minimal-iouring/meta.json diff --git a/frameworks/aspnet-minimal-iouring/AppData.cs b/frameworks/aspnet-minimal-iouring/AppData.cs new file mode 100644 index 00000000..cdb0a9fb --- /dev/null +++ b/frameworks/aspnet-minimal-iouring/AppData.cs @@ -0,0 +1,108 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Data.Sqlite; +using Npgsql; + +static class AppData +{ + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static List? DatasetItems; + public static byte[]? LargeJsonResponse; + + public static SqlitePool? DbPool; + public static NpgsqlDataSource? PgDataSource; + + public static void Load() + { + LoadDataset(); + LoadLargeDataset(); + OpenDatabase(); + OpenPgPool(); + } + + static void LoadDataset() + { + var path = Environment.GetEnvironmentVariable("DATASET_PATH") ?? "/data/dataset.json"; + if (!File.Exists(path)) return; + DatasetItems = JsonSerializer.Deserialize>(File.ReadAllText(path), JsonOptions); + } + + static void LoadLargeDataset() + { + var path = "/data/dataset-large.json"; + if (!File.Exists(path)) return; + var items = JsonSerializer.Deserialize>(File.ReadAllText(path), JsonOptions); + if (items == null) return; + + var processed = new List(items.Count); + foreach (var item in items) + { + processed.Add(new ProcessedItem + { + Id = item.Id, Name = item.Name, Category = item.Category, + Price = item.Price, Quantity = item.Quantity, Active = item.Active, + Tags = item.Tags, Rating = item.Rating, + Total = Math.Round(item.Price * item.Quantity, 2) + }); + } + LargeJsonResponse = JsonSerializer.SerializeToUtf8Bytes( + new { items = processed, count = processed.Count }, JsonOptions); + } + + static void OpenPgPool() + { + var dbUrl = Environment.GetEnvironmentVariable("DATABASE_URL"); + if (string.IsNullOrEmpty(dbUrl)) return; + try + { + // Parse postgres:// URI into Npgsql connection string + var uri = new Uri(dbUrl); + var userInfo = uri.UserInfo.Split(':'); + var connStr = $"Host={uri.Host};Port={uri.Port};Username={userInfo[0]};Password={userInfo[1]};Database={uri.AbsolutePath.TrimStart('/')};Maximum Pool Size=256;Minimum Pool Size=64;Multiplexing=true;No Reset On Close=true;Max Auto Prepare=4;Auto Prepare Min Usages=1"; + var builder = new NpgsqlDataSourceBuilder(connStr); + PgDataSource = builder.Build(); + } + catch { } + } + + static void OpenDatabase() + { + var path = "/data/benchmark.db"; + if (!File.Exists(path)) return; + DbPool = new SqlitePool($"Data Source={path};Mode=ReadOnly", Environment.ProcessorCount); + } +} + +sealed class SqlitePool +{ + private readonly ConcurrentBag _connections = new(); + + public SqlitePool(string connectionString, int size) + { + for (int i = 0; i < size; i++) + { + var conn = new SqliteConnection(connectionString); + conn.Open(); + using var pragma = conn.CreateCommand(); + pragma.CommandText = "PRAGMA mmap_size=268435456"; + pragma.ExecuteNonQuery(); + _connections.Add(conn); + } + } + + public SqliteConnection Rent() + { + SqliteConnection? conn; + var spin = new SpinWait(); + while (!_connections.TryTake(out conn)) + spin.SpinOnce(); + return conn; + } + + public void Return(SqliteConnection conn) => _connections.Add(conn); +} diff --git a/frameworks/aspnet-minimal-iouring/Dockerfile b/frameworks/aspnet-minimal-iouring/Dockerfile new file mode 100644 index 00000000..b6c147c3 --- /dev/null +++ b/frameworks/aspnet-minimal-iouring/Dockerfile @@ -0,0 +1,39 @@ +# Stage 1: Build the custom .NET runtime with io_uring socket engine +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime-build +RUN apt-get update && apt-get install -y --no-install-recommends \ + git cmake clang libicu-dev libssl-dev libkrb5-dev zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /runtime +RUN git clone --depth 1 --branch io_uring https://github.com/benaadams/runtime.git . \ + && ./build.sh -subset clr+libs -c Release -bl + +# Stage 2: Build the app using the official SDK +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS app-build +WORKDIR /app +COPY *.csproj ./ +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o out + +# Stage 3: Final image — patch the runtime with io_uring binaries +FROM mcr.microsoft.com/dotnet/aspnet:10.0 + +# Install libmsquic for HTTP/3 +ADD https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb /packages-microsoft-prod.deb +RUN dpkg -i packages-microsoft-prod.deb && rm packages-microsoft-prod.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends libmsquic \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Overlay custom runtime libraries (io_uring socket engine) +COPY --from=runtime-build /runtime/artifacts/bin/runtime/net10.0-linux-Release-x64/ /usr/share/dotnet/shared/Microsoft.NETCore.App/10.0.0-preview.*/ + +WORKDIR /app +COPY --from=app-build /app/out . +EXPOSE 8080 8443/tcp 8443/udp + +# Enable io_uring socket engine +ENV DOTNET_SYSTEM_NET_SOCKETS_IO_URING=1 + +ENTRYPOINT ["dotnet", "aspnet-minimal-iouring.dll"] diff --git a/frameworks/aspnet-minimal-iouring/Handlers.cs b/frameworks/aspnet-minimal-iouring/Handlers.cs new file mode 100644 index 00000000..42c8f5e7 --- /dev/null +++ b/frameworks/aspnet-minimal-iouring/Handlers.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using System.Buffers; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http.HttpResults; + + +[JsonSerializable(typeof(ResponseDto))] +[JsonSerializable(typeof(ResponseDto))] +[JsonSerializable(typeof(DbResponseItemDto))] +[JsonSerializable(typeof(ProcessedItem))] +[JsonSerializable(typeof(RatingInfo))] +[JsonSerializable(typeof(List))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +partial class AppJsonContext : JsonSerializerContext { } + +static class Handlers +{ + public static int Sum(int a, int b) => a + b; + + public static async ValueTask SumBody(int a, int b, HttpRequest req) + { + using var reader = new StreamReader(req.Body); + return a + b + int.Parse(await reader.ReadToEndAsync()); + } + + public static string Text() => "ok"; + + public static async ValueTask Upload(HttpRequest req) + { + long size = 0; + var buffer = ArrayPool.Shared.Rent(65536); + try + { + int read; + while ((read = await req.Body.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0) + { + size += read; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return size.ToString(); + } + + public static Results>, ProblemHttpResult> Json() + { + var source = AppData.DatasetItems; + if (source == null) + return TypedResults.Problem("Dataset not loaded"); + + int count = source.Count; + + var items = new ProcessedItem[count]; + + for (int i = 0; i < count; i++) + { + var item = source[i]; + items[i] = new ProcessedItem + { + Id = item.Id, + Name = item.Name, + Category = item.Category, + Price = item.Price, + Quantity = item.Quantity, + Active = item.Active, + Tags = item.Tags, + Rating = item.Rating, + Total = Math.Round(item.Price * item.Quantity, 2) + }; + } + + return TypedResults.Json(new ResponseDto(items, count), AppJsonContext.Default.ResponseDtoProcessedItem); + } + + public static Results Compression() + { + if (AppData.LargeJsonResponse == null) + return TypedResults.Problem("Dataset not loaded"); + + return TypedResults.Bytes(AppData.LargeJsonResponse, "application/json"); + } + + public static Results>, ProblemHttpResult> Database(HttpRequest req) + { + if (AppData.DbPool == null) + return TypedResults.Problem("DB not available"); + + double min = 10, max = 50; + // Optimize query lookups + var query = req.Query; + if (query.TryGetValue("min", out var minStr) && double.TryParse(minStr, out var pmin)) min = pmin; + if (query.TryGetValue("max", out var maxStr) && double.TryParse(maxStr, out var pmax)) max = pmax; + + var conn = AppData.DbPool.Rent(); + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN @min AND @max LIMIT 50"; + + cmd.Parameters.AddWithValue("@min", min); + cmd.Parameters.AddWithValue("@max", max); + + using var reader = cmd.ExecuteReader(); + + var items = new List(50); + + while (reader.Read()) + { + items.Add(new DbResponseItemDto + { + Id = reader.GetInt32(0), + Name = reader.GetString(1), + Category = reader.GetString(2), + Price = reader.GetDouble(3), + Quantity = reader.GetInt32(4), + Active = reader.GetInt32(5) == 1, + Tags = JsonSerializer.Deserialize(reader.GetString(6), AppJsonContext.Default.ListString)!, + Rating = new RatingInfo { Score = reader.GetDouble(7), Count = reader.GetInt32(8) }, + }); + } + + return TypedResults.Json(new ResponseDto(items, items.Count), AppJsonContext.Default.ResponseDtoDbResponseItemDto); + } + finally + { + AppData.DbPool.Return(conn); + } + } + + public static async Task>, ProblemHttpResult>> AsyncDatabase(HttpRequest req) + { + if (AppData.PgDataSource == null) + return TypedResults.Problem("DB not available"); + + // Query Parsing + double min = 10, max = 50; + var query = req.Query; + if (query.TryGetValue("min", out var minVal) && double.TryParse(minVal, out var pmin)) min = pmin; + if (query.TryGetValue("max", out var maxVal) && double.TryParse(maxVal, out var pmax)) max = pmax; + + await using var cmd = AppData.PgDataSource.CreateCommand( + "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN $1 AND $2 LIMIT 50"); + + cmd.Parameters.AddWithValue(min); + cmd.Parameters.AddWithValue(max); + + await using var reader = await cmd.ExecuteReaderAsync(); + + var items = new List(50); + + while (await reader.ReadAsync()) + { + items.Add(new DbResponseItemDto + { + Id = reader.GetInt32(0), + Name = reader.GetString(1), + Category = reader.GetString(2), + Price = reader.GetDouble(3), + Quantity = reader.GetInt32(4), + Active = reader.GetBoolean(5), + Tags = JsonSerializer.Deserialize(reader.GetString(6), AppJsonContext.Default.ListString)!, + Rating = new RatingInfo { Score = reader.GetDouble(7), Count = reader.GetInt32(8) } + }); + } + + return TypedResults.Json(new ResponseDto(items, items.Count), AppJsonContext.Default.ResponseDtoDbResponseItemDto); + } +} \ No newline at end of file diff --git a/frameworks/aspnet-minimal-iouring/Models.cs b/frameworks/aspnet-minimal-iouring/Models.cs new file mode 100644 index 00000000..9eab38b5 --- /dev/null +++ b/frameworks/aspnet-minimal-iouring/Models.cs @@ -0,0 +1,45 @@ +sealed record ResponseDto(IReadOnlyList Items, int Count); + + +sealed class DbResponseItemDto +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public double Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = []; + public RatingInfo Rating { get; set; } = new(); +} + +sealed class DatasetItem +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public double Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = []; + public RatingInfo Rating { get; set; } = new(); +} + +sealed class ProcessedItem +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public double Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = []; + public RatingInfo Rating { get; set; } = new(); + public double Total { get; set; } +} + +sealed class RatingInfo +{ + public double Score { get; set; } + public int Count { get; set; } +} diff --git a/frameworks/aspnet-minimal-iouring/Program.cs b/frameworks/aspnet-minimal-iouring/Program.cs new file mode 100644 index 00000000..365388c7 --- /dev/null +++ b/frameworks/aspnet-minimal-iouring/Program.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography.X509Certificates; + +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.FileProviders; + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); + +var certPath = Environment.GetEnvironmentVariable("TLS_CERT") ?? "/certs/server.crt"; +var keyPath = Environment.GetEnvironmentVariable("TLS_KEY") ?? "/certs/server.key"; +var hasCert = File.Exists(certPath) && File.Exists(keyPath); + +builder.WebHost.ConfigureKestrel(options => +{ + options.Limits.Http2.MaxStreamsPerConnection = 256; + options.Limits.Http2.InitialConnectionWindowSize = 2 * 1024 * 1024; + options.Limits.Http2.InitialStreamWindowSize = 1024 * 1024; + + options.ListenAnyIP(8080, lo => + { + lo.Protocols = HttpProtocols.Http1; + }); + + if (hasCert) + { + options.ListenAnyIP(8443, lo => + { + lo.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + lo.UseHttps(X509Certificate2.CreateFromPemFile(certPath, keyPath)); + }); + } +}); + +builder.Services.AddResponseCompression(); + +var app = builder.Build(); + +app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/compression"), subApp => +{ + subApp.UseResponseCompression(); +}); + +app.Use((ctx, next) => +{ + ctx.Response.Headers.Server = "aspnet-minimal"; + return next(); +}); + +AppData.Load(); + +app.MapGet("/pipeline", Handlers.Text); + +app.MapGet("/baseline11", Handlers.Sum); +app.MapPost("/baseline11", Handlers.SumBody); +app.MapGet("/baseline2", Handlers.Sum); + +app.MapPost("/upload", Handlers.Upload); +app.MapGet("/json", Handlers.Json); +app.MapGet("/compression", Handlers.Compression); +app.MapGet("/db", Handlers.Database); +app.MapGet("/async-db", Handlers.AsyncDatabase); + +if (Directory.Exists("/data/static")) +{ + var typeProvider = new FileExtensionContentTypeProvider(); + + typeProvider.Mappings[".js"] = "application/javascript"; + + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider("/data/static"), + ContentTypeProvider = typeProvider, + RequestPath = "/static" + }); +} + +app.Run(); diff --git a/frameworks/aspnet-minimal-iouring/aspnet-minimal-iouring.csproj b/frameworks/aspnet-minimal-iouring/aspnet-minimal-iouring.csproj new file mode 100644 index 00000000..2fbebb32 --- /dev/null +++ b/frameworks/aspnet-minimal-iouring/aspnet-minimal-iouring.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + true + + + + + + diff --git a/frameworks/aspnet-minimal-iouring/meta.json b/frameworks/aspnet-minimal-iouring/meta.json new file mode 100644 index 00000000..2e632709 --- /dev/null +++ b/frameworks/aspnet-minimal-iouring/meta.json @@ -0,0 +1,24 @@ +{ + "display_name": "aspnet-minimal-iouring", + "language": "C#", + "type": "tuned", + "engine": "io_uring", + "description": "ASP.NET Core minimal API with experimental io_uring socket engine (dotnet/runtime#124374). Replaces epoll with io_uring for socket I/O on Linux 6.1+.", + "repo": "https://github.com/benaadams/runtime/tree/io_uring", + "enabled": false, + "tests": [ + "baseline", + "pipelined", + "limited-conn", + "json", + "upload", + "compression", + "noisy", + "static", + "tcp-frag", + "async-db", + "sync-db", + "baseline-h2", + "static-h2" + ] +}