Split route match clauses into sub-modules to parallelize compile-time type-checking#6740
Closed
rafaels88 wants to merge 1 commit into
Closed
Conversation
85c7e2e to
4a53edf
Compare
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>
4a53edf to
a34a239
Compare
Author
I've done this test on my local (1.7.23 is the phoenix version I'm still using on prod): |
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!!! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:GET has ~2× the routes of POST but ~5× the type-check time. Because all match clauses (and the
&Controller.action/aritycompile-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:
Router.Match{N}(bounded at 250 routes per module). The parent's__match_route__/3delegates across them.&Controller.action/arity) go intoRouter.Checks{N}sub-modules (bounded at 400). These exist only to be type-checked, so they parallelize cleanly.__pipeline__/1.Each sub-module is verified in its own
check_modulepass, 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:
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:
__match_route__/3walks the chunk modules with a dynamicmodule.match(...)call. This is load-bearing, not a shortcut: a static per-verb jump table (def __match_route__("GET", ...)callingMatch0.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 opaquemodule.match(...)), never static calls.__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.