Skip to content

Split route match clauses into sub-modules to parallelize compile-time type-checking#6740

Closed
rafaels88 wants to merge 1 commit into
phoenixframework:jv-router-splitfrom
rafaels88:router-split-submodules
Closed

Split route match clauses into sub-modules to parallelize compile-time type-checking#6740
rafaels88 wants to merge 1 commit into
phoenixframework:jv-router-splitfrom
rafaels88:router-split-submodules

Conversation

@rafaels88

@rafaels88 rafaels88 commented Jun 30, 2026

Copy link
Copy Markdown

Context

This builds on #6739 (jv-router-split), which splits routes by verb during compilation. That change reorganizes the match clauses into per-verb functions within the router module, improving runtime dispatch.

While benchmarking #6739 against a large production Phoenix application (~4,100 routes across ~1,350 controllers) on Elixir 1.20, I found that the new set-theoretic type checker's "group pass check" phase did not improve: the router module still took ~420s to type-check, essentially unchanged from before #6739.

Root cause

The set-theoretic type checker's unit of work is the module (Module.ParallelChecker.check_module/3), and per-module verification cost is strongly super-linear in the number of route clauses. Splitting the same app's routes into separate modules by verb and measuring each in isolation:

module routes type-check
GET 1,972 65.1s
POST 1,070 12.3s
PUT 449 1.7s
PATCH 404 1.3s
DELETE 241 0.65s
single module, all verbs 4,145 450s

GET has ~2× the routes of POST but ~5× the type-check time. Because all match clauses (and the &Controller.action/arity compile-time checks) live in one module, the checker processes them as a single serial pass whose cost grows super-linearly.

Splitting into per-verb functions within the same module (as #6739 does) doesn't move this number, because the module is the granularity the checker operates on — not the function.

Change

Emit the generated route artifacts into sub-modules instead of in-module functions:

  • Match clauses go into per-verb-chunked sub-modules Router.Match{N} (bounded at 250 routes per module). The parent's __match_route__/3 delegates across them.
  • The compile-time action references (&Controller.action/arity) go into Router.Checks{N} sub-modules (bounded at 400). These exist only to be type-checked, so they parallelize cleanly.
  • Pipelines stay in the parent. The match clauses can't capture the parent's private pipeline functions, so each clause carries a pipeline index that the parent resolves back to a capture via a generated __pipeline__/1.

Each sub-module is verified in its own check_module pass, so the work both parallelizes across cores and — because of the super-linearity — drops in total CPU.

Results

Same app, same Elixir 1.20.1, only the codegen differs:

phase #6739 (per-verb functions) this change (sub-modules)
group pass check (whole app) 422.0s 17.4s
full compilation cycle 537.0s 93.6s
router module compile 450.7s 17.5s

The 17.4s whole-app type-check is below this app's pre-1.20 baseline (~86s), so this recovers the full 1.20 type-checker cost on large routers.

Correctness verified: the route table is identical (all 4,145 routes preserved), and dispatch semantics are unchanged — verb disambiguation, path params, multi-pipeline resolution, wildcard-any-method matching, and no-match fallthrough all check out. The existing Phoenix router/routing/verified-routes suites pass (195 tests). No new compiler warnings.

Caveats / open questions

This is a proof-of-concept demonstrating the mechanism, not necessarily merge-ready:

  • Dispatch is dynamic by design. __match_route__/3 walks the chunk modules with a dynamic module.match(...) call. This is load-bearing, not a shortcut: a static per-verb jump table (def __match_route__("GET", ...) calling Match0.match(...) directly) makes the type checker re-aggregate every route's return type back into the router module — I measured ~198s that way, i.e. it defeats the split. So the win requires the dispatch to stay opaque to the checker. The trade-off is a per-request walk of the chunk list; if that cost matters, verb-keying can be reintroduced as dynamic per-verb module lists (still an opaque module.match(...)), never static calls.
  • Chunk sizes (250 / 400) are arbitrary knobs and should be tuned, or made configurable.
  • __verify_route__ and __forward__ were left whole in the parent; they weren't a bottleneck on this app.

Happy to iterate on the dispatch structure if the direction is interesting.

@rafaels88 rafaels88 force-pushed the router-split-submodules branch from 85c7e2e to 4a53edf Compare June 30, 2026 09:55
@josevalim

Copy link
Copy Markdown
Member

The slow type checking times have already been fixed on Elixir v1.21, so I don't believe we should further change the router based on an Elixir version that has been fixed. Can you see meaningful improvements in main as well?

On Elixir 1.20 the set-theoretic type checker's per-module cost is
super-linear in route count, and phoenixframework#6739's per-verb functions live in one
module, so the type-check phase is unchanged on large routers. Emit the
match clauses (chunked) and the &Controller.action checks into sub-modules
so each is verified in its own parallel module pass. Dispatch into the
sub-modules is kept dynamic on purpose: a static call would make the type
checker re-aggregate every route's return type into the router module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rafaels88 rafaels88 force-pushed the router-split-submodules branch from 4a53edf to a34a239 Compare June 30, 2026 10:10
@rafaels88

rafaels88 commented Jun 30, 2026

Copy link
Copy Markdown
Author

The slow type checking times have already been fixed on Elixir v1.21, so I don't believe we should further change the router based on an Elixir version that has been fixed. Can you see meaningful improvements in main as well?

I've done this test on my local (1.7.23 is the phoenix version I'm still using on prod):

┌─────┬─────────────────┬────────┬───────────────┬──────────────────┬────────────────┬───────────────────┐
│  #  │     Phoenix     │ Elixir │ compile cycle │ type-check (gpc) │ router compile │ router type-check │
├─────┼─────────────────┼────────┼───────────────┼──────────────────┼────────────────┼───────────────────┤
│ C4  │ 1.7.23          │ 1.20.1 │           511 │              385 │            430 │             387.1 │
├─────┼─────────────────┼────────┼───────────────┼──────────────────┼────────────────┼───────────────────┤
│ C2  │ PR 6740         │ 1.20.1 │          93.9 │             16.6 │           17.8 │              0.72 │
├─────┼─────────────────┼────────┼───────────────┼──────────────────┼────────────────┼───────────────────┤
│ C1  │ 1.7.23          │ main   │           158 │             15.2 │           50.7 │              1.12 │
├─────┼─────────────────┼────────┼───────────────┼──────────────────┼────────────────┼───────────────────┤
│ C3  │ PR 6740         │ main   │          97.5 │             17.4 │           18.2 │              0.84 │
└─────┴─────────────────┴────────┴───────────────┴──────────────────┴────────────────┴───────────────────┘

@josevalim

Copy link
Copy Markdown
Member

The number you see on this branch are pretty close to the ones I see on #6937, so I think that one is likely enough! Please let me know if that's not the case! Thank you for all measurements!!!

@josevalim josevalim closed this Jun 30, 2026
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