Skip to content

CI: shorten the Linux test leg: CMTK0003's sweep repeats its compilation per row, and the EF Core query sweeps run their rows one after another #251

Closed

Description

@CaffeinatedCoder

Why

After #250 (#245), CI's Linux test leg is the end of every run: a 12m 36s job and a 9m 28s test step in #250's run (41187518150). Windows took 8m 15s. The leg is no longer stuck behind one slow theory. Two projects finish within 40 s of each other, sharing the runner's four cores:

Project (Linux, run 41187518150) Wall Biggest classes (rows, summed row time)
CodoMetis.TypeKit.Analyzers.Tests 531 s DroppedTaskVariantTests (1,837 rows, 1,792 s)
CodoMetis.TypeKit.EntityFrameworkCore.Tests 494 s SqlServerQueryVariantTests (12,275 rows, 396 s), QueryTranslationVariantTests (12,275 rows, 357 s)

Two things keep them there:

  • CMTK0003's sweep does the same work in every row. Each row builds a new compilation of the same DroppedTaskShapes.Declarations and references. Under row parallelism the rows now wait on each other for CPU: summed row time went from 763 s to 1,792 s.
  • The EF Core sweeps run their rows one after another, as DroppedTaskVariantTests did before test: run the analyzer sweeps' rows in parallel, and leave the pragma pass to each rule's focused tests #250. Each is one class. SqlServerQueryVariantTests is also in SqlServerCollection, around one shared container. Whether a row only reads from a seeded database, and so could run beside another, is not yet known.

Outcome of the profile (31 Oct)

CMTK0003: no change. A row's cost is its own file, and reusing the compilation saves nothing (the profile comment has the numbers). EF Core: no change to how rows run either. Parallel rows were 2.2x faster locally, where the database containers had cores of their own, and bought almost nothing on a 4-core CI runner (the CI comment has the numbers), so #252 keeps only the fix for the race they exposed in the SQL Server fixture. The Linux leg is bound by its four cores; what is left is more cores, by splitting the Linux test step across two jobs. The remaining items below are kept as the record of what was asked.

Proposal

Measure first, then change only what the measurement supports:

  1. Profile one row of each sweep. For CMTK0003, how much of a row is the compilation, the analyzer and the verifier, and how much is shared across rows. For EF Core, how much of a row is building the context, translating the query, running it on the database and running it in memory.
  2. CMTK0003: if most of a row is work it shares with other rows, compile the declarations once (for example a compilation reused for each call shape) and keep one row per case, so a failure still names its call and form. If the harness rules that out, say so here.
  3. EF Core: if a row only reads, run the rows of each sweep in parallel (parallelMode: all for the project, with DisableParallelization on anything that writes or shares a mutable fixture). If rows write, report what they write and whether a seeded, read-only database per collection would serve.
  4. Each change goes through verify-the-guard: a seeded defect each sweep catches before still fails a row after.

Done when

The profile is in this issue. Every change it supports is merged, with the Linux test step's before and after from CI in this issue. Each sweep still catches its seed. Rows are neither dropped nor merged into one test, and each still reports on its own.

Activity

  1. added this to the 2.3.1 milestone on Oct 31, 2026
  2. added
    testsAdds or changes tests only (tests, probes, harnesses); no library code
    on Oct 31, 2026
  3. CaffeinatedCoder commented on Oct 31, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Profile, measured locally on 31 Oct (Apple silicon, 16 cores), on release/2.3.1 at c8fb57c.

    CMTK0003 (DroppedTaskVariantTests), one row in ten (184 rows), run one at a time:

    Path ms/row
    The verifier, as the sweep runs it 92
    A new compilation per row: parse 18.6, compile 17-18, analyze 28.5 64-65
    One compilation of the declarations, each row's tree added 63
    The declarations compiled once and referenced as metadata 64

    Declarations.cs is 14 lines. A row's cost is its own file, which holds the call in its form through every pass-through and in every use, so rows share nothing worth compiling once. Item 2 is answered with no: reuse saves nothing. The only reducible part is the verifier's own work, about 28 of 92 ms. Removing it means replacing the verifier with a matcher of our own, which could pass silently, so I am not proposing it.

    EF Core, the whole CodoMetis.TypeKit.EntityFrameworkCore.Tests project at 4 threads, as on a CI runner:

    Mode Wall CPU (user) Result
    Default (collections) 123 s 119 s 25,497 tests, all pass
    -parallelMode all 55 s 121 s 3 fail

    The project waits on SQLite, SQL Server and PostgreSQL more than it computes: the same CPU in less than half the time. The three failures are SqlServerRoundTripTests racing itself ("There is already an object named 'Customers'"). Its tests share the database typekit_round_trips and each creates the same tables.

    Who writes what, read from the source:

    • Both query sweeps only read. The SQLite databases are seeded once per model behind a Lazy and read under a lock per connection, which A_seeded_database_read_by_many_cases_at_once_answers_each_alike already holds (EF variant tests: parallel cases share one SqliteConnection and now and then fail with 'database is locked' #148). SQL Server gives each model a schema of its own. PostgreSQL is translation only, with no server.
    • SqlServerKeyVariantTests gives each row a schema of its own (Interlocked).
    • Every SQLite class holds its connection in an instance field, so each test has its own database.
    • SqlServerRoundTripTests (7 tests, one database) and PostgreSqlRoundTripTests (9 tests, several writing to the fixture's typekit database) share state between their own tests.

    Next, on test/linux-leg-sweeps-2-3-1: parallelMode: all for the EF Core tests, with the two round-trip classes opted out ([TestClass(DisableParallelization = true)]). Then several runs in parallel mode, a seed showing that an opted-out class racing is caught, and the CI before and after.

  4. CaffeinatedCoder commented on Oct 31, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Implemented on test/linux-leg-sweeps-2-3-1 (not pushed yet):

    • xunit.runner.json with "parallelMode": "all" for the EF Core tests.
    • SqlServerRoundTripTests opts out ([TestClass(DisableParallelization = true)]). Seeded by removing the opt-out: 5 of its 7 tests fail ("There is already an object named 'Customers'").
    • PostgreSqlRoundTripTests needs no opt-out, and my earlier reason for one was wrong. Every read is scoped to the id of the row its test saved, and the other tests each use a database of their own. Without an opt-out it passed 3 runs of 3.
    • A latent race in the SQL Server fixture, which the whole suite found: SqlServer.DatabaseAsync used ConcurrentDictionary.GetOrAdd(name, CreateAsync), which can run the factory more than once for callers that arrive together. The first rows of a sweep then dropped and created typekit_queries under each other (11,832 failures in that run). It is now a Lazy<Task<string>>, the pattern the query sweeps already use. The new guard SqlServerFixtureTests.A_database_asked_for_by_many_callers_at_once_is_created_once asks for one database from 32 callers at once: it fails 3 of 3 runs with the plain GetOrAdd and passes with the Lazy. The other GetOrAdd calls in the tests (the AspNetCore document caches) have no side effects.

    The EF Core project locally, through dotnet test at 16 threads: 44-46 s in each of 3 runs, all 24,258 passing. Before: 123 s in the default mode at 4 threads. The whole solution passes: 35,337 passed (one more, the guard), 1,248 skipped as before. The CI before and after comes from the PR.

  5. CaffeinatedCoder commented on Oct 31, 2026

    @CaffeinatedCoder
    OwnerAuthor

    CI on #252 (run 41191607858) against #250's run (41187518150). One run each, so one sample of runner hardware each:

    #250 #252
    Test step, Linux 9m 28s 9m 17s
    Test job, Linux (the run's tail) 12m 36s 12m 12s
    EF Core project, Linux 494 s 443 s
    Analyzer project, Linux 531 s 468 s
    Test step, Windows 5m 17s 7m 11s
    EF Core project, Windows 261 s 168 s
    Analyzer project, Windows 315 s 411 s
    Conventions project, Windows 278 s 374 s

    On CI, parallel EF rows bought almost nothing. The local measurement misled: the Mac's Docker had 16 cores, so the database containers ran beside the tests. On a 4-core runner, SQL Server, PostgreSQL and the tests share the same four cores. What looked like waiting became CPU on the same machine. The rows' summed time rose (QueryTranslationVariantTests 357 s to 893 s, SqlServerQueryVariantTests 396 s to 553 s) while the project's wall time moved by 51 s. On Windows (no containers) EF Core itself got faster (261 s to 168 s), but the projects beside it got slower by about as much.

    So the Linux leg is bound by four cores. Only less CPU work or more cores shorten it. The lever left that keeps every row is to split the Linux test step across two jobs: each repeats the ~2.5 minute checkout and build, and gets four cores of its own.

  6. CaffeinatedCoder commented on Oct 31, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Done in #252, merged into release/2.3.1 as abe2763. Closes does not close an issue from a release branch, so this is closed by hand. Neither sweep changes how its rows run (the outcome above). #252 keeps the fix for the SQL Server fixture race that parallel rows exposed. What is left for the Linux leg, more cores, is #253.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    testsAdds or changes tests only (tests, probes, harnesses); no library code

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions