Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Router: PoC stack based backtracking #1770

Closed

Conversation

stffabi
Copy link
Contributor

@stffabi stffabi commented Feb 10, 2021

PoC regarding issue #1754

@codecov
Copy link

codecov bot commented Feb 10, 2021

Codecov Report

Merging #1770 (3e817bc) into master (932976d) will decrease coverage by 0.19%.
The diff coverage is 89.28%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #1770      +/-   ##
==========================================
- Coverage   89.74%   89.55%   -0.20%     
==========================================
  Files          32       32              
  Lines        2672     2661      -11     
==========================================
- Hits         2398     2383      -15     
- Misses        175      179       +4     
  Partials       99       99              
Impacted Files Coverage Δ
router.go 95.34% <89.28%> (-1.71%) ⬇️
middleware/slash.go 91.30% <0.00%> (+0.60%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 932976d...3e817bc. Read the comment docs.

@stffabi stffabi closed this Feb 10, 2021
@stffabi stffabi deleted the poc-router-stack-backtracking branch February 10, 2021 16:17
@stffabi stffabi restored the poc-router-stack-backtracking branch February 10, 2021 16:18
@stffabi stffabi reopened this Feb 10, 2021
@stffabi
Copy link
Contributor Author

stffabi commented Feb 10, 2021

I've further improved the PoC and was able to remove almost all corner cases in the algorithm, which have been introduced to fix routing issues.

@lammel
Copy link
Contributor

lammel commented Feb 11, 2021

@stffabi Thanks, very appreciated,

There is quite some performance penality involved which should be reviewed:

RouterStaticRoutes-2                      21.7µs ± 1%    23.1µs ± 2%   +6.09%  (p=0.000 n=7+8)
RouterStaticRoutesMisses-2                 705ns ± 4%    1090ns ± 6%  +54.65%  (p=0.000 n=8+8)
RouterGitHubAPI-2                         41.4µs ± 4%    43.6µs ± 6%   +5.25%  (p=0.007 n=8+8)
RouterGitHubAPIMisses-2                    860ns ± 4%    1186ns ± 4%  +37.91%  (p=0.000 n=8+8)
RouterParseAPI-2                          2.17µs ± 3%    2.89µs ± 3%  +33.34%  (p=0.000 n=7+8)
RouterParseAPIMisses-2                     488ns ± 5%     877ns ± 5%  +79.57%  (p=0.000 n=8+8)
RouterGooglePlusAPI-2                     1.41µs ± 2%    1.65µs ± 2%  +16.76%  (p=0.000 n=8+7)
RouterGooglePlusAPIMisses-2                725ns ± 3%    1037ns ± 3%  +43.03%  (p=0.000 n=8+7)
RouterParamsAndAnyAPI-2                   3.37µs ± 5%    3.79µs ± 4%  +12.58%  (p=0.000 n=8+8)

The above is from the automatic benchstat, so might be little off. But the numbers do indicate a performance impact that should be reviewed.
Maybe you can have a look at that.

@stffabi
Copy link
Contributor Author

stffabi commented Feb 11, 2021

@lammel You're very welcome.

Yes I know there are some performance penalties. But as already mentioned it is in a PoC state and my first priority was to have a working and correct algorithm before introducing any optimizations.
We have to make sure which test cases really could and should get on par with master. There might be cases were we could never reach the current performance anymore, because the current algorithm on master too early stops and responds with not found and doesn't find all routes.

One question regarding the base of the benchstat of a commit. What's the base of the automatic benchstat of a commit? Is the old version always the latest commit on the master branch from with the PR has been diverged? Is every commit on the PR compared with the divergence commit on master, or is the base always the previous commit?

I would be glad to invest some more time to improve the performance, if the echo community plans to have this merged. Unfortunately I had some bad experiences with other open source projects (not with echo 😄 ) lately, where I ended up with invested time and PRs that never got merged.

@aldas aldas mentioned this pull request Feb 11, 2021
@stffabi stffabi force-pushed the poc-router-stack-backtracking branch from 55a7287 to 3e817bc Compare February 11, 2021 15:20
@stffabi
Copy link
Contributor Author

stffabi commented Feb 12, 2021

I've improved the PoC, it now doesn't anymore store the backtrack information on the heap but on the goroutine's stack. As a consequence the maximum level of nested backtracking needs to be known beforehand during compile-time. Currently as this is PoC, the Find simply panics if the level is too deep, but we could easily check this during the registration of the routes. Therefore it would panic beforehand and not depending on the incoming request. But let's first focus on the performance.

I've started to experiment around with different values for the max backtracking depth. First started with 1 (which would be identical with the current implementation) and then I've successively increased it. Please note in order that all benchmark tests run and don't panic we need at least a max depth of 2.

Here are the results (benchmarks done on my local machine), delta is always compared to old (932976d)

name                           old time/op    new-1 time/op    delta    new-2 time/op    delta    new-3 time/op    delta    new-4 time/op    delta    new-5 time/op    delta    new-8 time/op    delta    new-12 time/op    delta
RouterStaticRoutes-8           10.6µs ± 5%       8.8µs ± 3%  -16.88%       8.7µs ± 1%  -17.24%       8.8µs ± 3%  -16.82%       8.8µs ± 1%  -16.85%       9.1µs ± 3%  -13.57%       9.1µs ± 2%  -13.49%        9.6µs ± 6%   -8.86%
RouterStaticRoutesMisses-8      394ns ± 2%       393ns ± 1%     ~          402ns ± 1%   +2.13%       405ns ± 2%   +2.93%       409ns ± 2%   +3.90%       421ns ± 1%   +6.82%       440ns ± 7%  +11.85%        461ns ± 2%  +17.02%
RouterGitHubAPI-8              23.5µs ± 6%                                19.4µs ± 1%  -17.63%      19.3µs ± 2%  -17.70%      19.5µs ± 1%  -17.17%      19.9µs ± 2%  -15.30%      20.0µs ± 2%  -14.70%       20.6µs ± 2%  -12.14%
RouterGitHubAPIMisses-8         502ns ± 6%                                 460ns ± 1%   -8.37%       464ns ± 3%   -7.43%       466ns ± 2%   -7.16%       487ns ± 6%   -3.01%       490ns ± 1%     ~           513ns ± 2%   +2.25%
RouterParseAPI-8               1.28µs ± 1%                                1.14µs ± 2%  -11.00%      1.14µs ± 1%  -11.10%      1.14µs ± 1%  -10.53%      1.17µs ± 2%   -8.90%      1.20µs ± 2%   -6.48%       1.23µs ± 1%   -3.68%
RouterParseAPIMisses-8          280ns ± 5%                                 278ns ± 3%     ~          281ns ± 4%     ~          281ns ± 2%     ~          291ns ± 2%   +3.70%       309ns ± 3%  +10.16%        329ns ± 3%  +17.17%
RouterGooglePlusAPI-8           827ns ± 5%                                 715ns ± 1%  -13.57%       722ns ± 2%  -12.69%       723ns ± 2%  -12.62%       735ns ± 0%  -11.11%       751ns ± 2%   -9.15%        769ns ± 2%   -7.02%
RouterGooglePlusAPIMisses-8     414ns ± 4%                                 368ns ± 2%  -11.29%       370ns ± 3%  -10.84%       373ns ± 2%  -10.05%       385ns ± 2%   -7.02%       402ns ± 3%   -2.95%        422ns ± 1%   +1.85%
RouterParamsAndAnyAPI-8        1.84µs ± 2%                                1.53µs ± 1%  -17.05%      1.54µs ± 1%  -16.39%      1.53µs ± 1%  -16.67%      1.57µs ± 1%  -14.97%      1.58µs ± 1%  -14.13%       1.62µs ± 2%  -12.13%

As one can see, the new algorithm with a max depth of 2 outperforms the current version in almost any benchmark. Furthermore one can see that by increasing the max depth the performance begins to degrade, especially for the Misses benchmarks. As increasing the max depth doesn't have any influence on the amount of processing our algorithm has to do (we only need a depth of 2 for successfully run the benchmarks), the performance penalty comes from the go runtime in order to allocate the goroutine's stack. So for increasing the max depth the go runtime needs to allocate more memory on the goroutine's stack, which is obviously clearly out of our control.

So what could we conclude from this: One has to balance between the max depth of backtracking echo could support and the performance penalty one is willing to pay for it.

I currently see no options to further improve this. First we have to store the backtracking information somewhere,
which is now already stored on the fastest memory section, the goroutine's stack. Second we need memory in order to store multiple levels, so memory needs to be allocated and we can't get any better than letting the go runtime allocating this memory for us on the goroutine's stack.

The only options I could see, but those options could make the code harder to maintain, are the following:

  • Having different implementations of Find with different max depths.
  • Chose the most suitable Find for the current max backtracking depth, as determined during route registration.
  • Maybe having a fallback to switch to a heap implementation if the current max backtracking depth exceeds a threshold.

This would allow echo to work with the best performance depending on the nested backtracking depth, that the user has registered on the router.

@lammel
Copy link
Contributor

lammel commented Feb 12, 2021

I would be glad to invest some more time to improve the performance, if the echo community plans to have this merged. Unfortunately I had some bad experiences with other open source projects (not with echo ) lately, where I ended up with invested time and PRs that never got merged.

Your work will be very valuable even if this PoC PR will not be merged. We can't do any promises on that, but even then this work will provide the foundation for other PRs to improve on that concepts! There are some other ideas around that could be efficient too (both in terms of maintainability and performance).

What we really want to avoid is having multiple implementations for the router or duplicate router functions.
Code maintainability is very important.

@stffabi
Copy link
Contributor Author

stffabi commented Feb 12, 2021

Another option that could possibly bring improvements would be to use tail recursion, but this would highly depend on the optimizations the go compiler does and might change with go versions. Which is another decision one would have to make if echo wants to go that path.

Your work will be very valuable even if this PoC PR will not be merged. We can't do any promises on that, but even then this work will provide the foundation for other PRs to improve on that concepts! There are some other ideas around that could be efficient too (both in terms of maintainability and performance).

I understand that, I have no problem if it's not my PR that gets merged 😄, as long as the issue gets fixed in a timely manner and I could help along that path. The problem in the past with other projects was that on the first creation of the PR I got feedback. I adjusted the PR with the demanded changes and then got never any feedback and the issues still persists in the other projects, now for more than 2 years.

If i could ask, what are those ideas? I would love to hear about them and possibly participate on that discussions. Unfortunately I couldn't found any public discussion about those. Maybe other Draft PRs for those ideas could be created to compare all of them and to compare the pros and cons of every idea.

@lammel
Copy link
Contributor

lammel commented Feb 12, 2021

Another option that could possibly bring improvements would be to use tail recursion, but this would highly depend on the optimizations the go compiler does and might change with go versions. Which is another decision one would have to make if echo wants to go that path.

Might be an option, at least we would participate in advancements of the go optimizer. But I'm also not sure this is the right way to go.

I understand that, I have no problem if it's not my PR that gets merged , as long as the issue gets fixed in a timely manner and I could help along that path. The problem in the past with other projects was that on the first creation of the PR I got feedback. I adjusted the PR with the demanded changes and then got never any feedback and the issues still persists in the other projects, now for more than 2 years.

Seems like recently the merges are pretty reasonable. Hope we can say that for this PR then too ;-)

If i could ask, what are those ideas? I would love to hear about them and possibly participate on that discussions. Unfortunately I couldn't found any public discussion about those. Maybe other Draft PRs for those ideas could be created to compare all of them and to compare the pros and cons of every idea.

We planned to open a public discussion about ideas for the router.

  • Path segmented radix tree, which would be a slight optimization to simplify param vs. static backtracking (split routes on /)
  • Discuss other ideas / options
  • Improve the router to handle methods (currently ignored in Router.Find)
  • Better handling of param names, to allow flexible naming

On of the topics for simplifcation of the router.Find function tackled by the PR actually.

@aldas
Copy link
Contributor

aldas commented Feb 12, 2021

I think @stffabi version is quite good fix. Performance hit on backtracking can not be avoided as by algoritm backtracking is done until suitable node is found up in tree to the right or we are back at root node and there are nowhere else to go.

@stffabi version is quite nice as it simplifies current implementation quite a bit and makes it more approachable. If that depth calculation is moved to route.Insert() and panic is remove (not found result would be fine by me - better than current) this change looks quite ok.

I tried to implement same algorithm but without stack (as I thought it would definitely mean allocating) and result are similar - misses (backtracking) is always performance hit.

My solution https://github.com/aldas/echo/blob/issue-1754_router_backtracking/router.go#L552

And comparison to

  • old.out - current echo Find() implementation
  • stffabi.out - @stffabi stack based implementation
  • new.out - mine (using direction for tree traversial)
x@x:~/code/echo$ benchstat old.out stffabi.out 
name                         old time/op    new time/op    delta
RouterStaticRoutes-6           16.0µs ± 1%    15.1µs ± 3%   -5.12%  (p=0.000 n=20+19)
RouterStaticRoutesMisses-6      515ns ± 1%     615ns ± 3%  +19.48%  (p=0.000 n=20+19)
RouterGitHubAPI-6              30.1µs ± 3%    33.0µs ±15%   +9.68%  (p=0.000 n=20+20)
RouterGitHubAPIMisses-6         621ns ± 0%     678ns ± 5%   +9.15%  (p=0.000 n=16+20)
RouterParseAPI-6               1.61µs ± 2%    1.55µs ± 3%   -3.64%  (p=0.000 n=20+18)
RouterParseAPIMisses-6          349ns ± 1%     404ns ± 1%  +15.81%  (p=0.000 n=20+17)
RouterGooglePlusAPI-6          1.04µs ± 0%    1.01µs ± 3%   -3.36%  (p=0.000 n=15+20)
RouterGooglePlusAPIMisses-6     516ns ± 1%     547ns ± 4%   +6.00%  (p=0.000 n=19+20)
RouterParamsAndAnyAPI-6        2.44µs ± 1%    2.34µs ± 6%   -4.31%  (p=0.000 n=20+20)

x@x:~/code/echo$ benchstat new.out stfabi.out 
name                         old time/op    new time/op    delta
RouterStaticRoutes-6           14.9µs ± 1%    15.1µs ± 3%   +1.77%  (p=0.000 n=18+19)
RouterStaticRoutesMisses-6      585ns ± 2%     615ns ± 3%   +5.20%  (p=0.000 n=20+19)
RouterGitHubAPI-6              31.2µs ± 1%    33.0µs ±15%   +5.69%  (p=0.033 n=20+20)
RouterGitHubAPIMisses-6         763ns ± 1%     678ns ± 5%  -11.15%  (p=0.000 n=20+20)
RouterParseAPI-6               1.38µs ± 3%    1.55µs ± 3%  +12.23%  (p=0.000 n=20+18)
RouterParseAPIMisses-6          394ns ± 1%     404ns ± 1%   +2.58%  (p=0.000 n=16+17)
RouterGooglePlusAPI-6           887ns ± 2%    1009ns ± 3%  +13.69%  (p=0.000 n=19+20)
RouterGooglePlusAPIMisses-6     616ns ± 0%     547ns ± 4%  -11.08%  (p=0.000 n=14+20)
RouterParamsAndAnyAPI-6        2.43µs ± 2%    2.34µs ± 6%   -3.89%  (p=0.000 n=20+20)

x@x:~/code/echo$ benchstat old.out new.out 
name                         old time/op    new time/op    delta
RouterStaticRoutes-6           16.0µs ± 1%    14.9µs ± 1%   -6.77%  (p=0.000 n=20+18)
RouterStaticRoutesMisses-6      515ns ± 1%     585ns ± 2%  +13.58%  (p=0.000 n=20+20)
RouterGitHubAPI-6              30.1µs ± 3%    31.2µs ± 1%   +3.78%  (p=0.000 n=20+20)
RouterGitHubAPIMisses-6         621ns ± 0%     763ns ± 1%  +22.86%  (p=0.000 n=16+20)
RouterParseAPI-6               1.61µs ± 2%    1.38µs ± 3%  -14.14%  (p=0.000 n=20+20)
RouterParseAPIMisses-6          349ns ± 1%     394ns ± 1%  +12.90%  (p=0.000 n=20+16)
RouterGooglePlusAPI-6          1.04µs ± 0%    0.89µs ± 2%  -14.99%  (p=0.000 n=15+19)
RouterGooglePlusAPIMisses-6     516ns ± 1%     616ns ± 0%  +19.20%  (p=0.000 n=19+14)
RouterParamsAndAnyAPI-6        2.44µs ± 1%    2.43µs ± 2%   -0.43%  (p=0.014 n=20+20)

also I think all implementation have atm 1 failing testcase. When backtracking from /:name/lastname dead end /:name should be the match has it has priority over /*

func TestRouterBacktrackingFromParamAny2(t *testing.T) {
	e := New()
	r := e.router

	r.Add(http.MethodGet, "/*", handlerHelper("case", 1))
	r.Add(http.MethodGet, "/:name", handlerHelper("case", 2))
	r.Add(http.MethodGet, "/:name/lastname", handlerHelper("case", 3))

	c := e.NewContext(nil, nil).(*context)
	
	r.Find(http.MethodGet, "/firstname/", c) // trailing slash confuses param kind matching algorithm
	c.handler(c)
	assert.Equal(t, "/:name", c.Get("path"))  // FIXME: currently matches `/*` which is low priority than `/:name` and therefore should not be matched
	assert.Equal(t, "firstname/", c.Param("*"))


	r.Find(http.MethodGet, "/firstname/test", c)
	c.handler(c)
	assert.Equal(t, "/:name", c.Get("path")) // FIXME:  currently matches `/*`
	assert.Equal(t, "firstname/test", c.Param("*"))
}

@aldas
Copy link
Contributor

aldas commented Feb 12, 2021

also it would probably help if instead of search value we hold index of search on path in stack

		state [backTrackingDepth]struct {
			nk kind
			nn *node
			searchLen int
			np int
		}

and in pop()

		search = path[last.searchLen:]
		searchLen = last.searchLen

Patch:
search_index.patch.txt

Result:

x@x:~/code/echo$ benchstat old.out stfabi_int.out 
name                         old time/op    new time/op    delta
RouterStaticRoutes-6           16.0µs ± 1%    16.1µs ± 6%     ~     (p=0.147 n=20+20)
RouterStaticRoutesMisses-6      515ns ± 1%     603ns ± 1%  +17.09%  (p=0.000 n=20+18)
RouterGitHubAPI-6              30.1µs ± 3%    29.2µs ± 1%   -2.94%  (p=0.000 n=20+20)
RouterGitHubAPIMisses-6         621ns ± 0%     681ns ± 5%   +9.68%  (p=0.000 n=16+20)
RouterParseAPI-6               1.61µs ± 2%    1.63µs ± 2%   +1.61%  (p=0.000 n=20+18)
RouterParseAPIMisses-6          349ns ± 1%     392ns ± 0%  +12.32%  (p=0.000 n=20+18)
RouterGooglePlusAPI-6          1.04µs ± 0%    1.05µs ± 2%   +0.96%  (p=0.001 n=15+20)
RouterGooglePlusAPIMisses-6     516ns ± 1%     526ns ± 1%   +1.85%  (p=0.000 n=19+19)
RouterParamsAndAnyAPI-6        2.44µs ± 1%    2.43µs ± 0%   -0.56%  (p=0.000 n=20+19)

@aldas
Copy link
Contributor

aldas commented Feb 15, 2021

@lammel I think we should accept this idea as a solution. Whatever the future holds for router this PR helps with backtracking and simplifies current solution. This will not prevent different paths and ideas for future.

Only noticeable change is for misses. As current implementation does not handle backtracking anyway in depth - I would assume that current users do not have routing trees that would be affected much. For those 404 cases - these are off the happy path anyway and are not that critical for execution.

Also - for example going from 3 microsecond (µs) to 4 microsecond for thing that is executed once per request should be treated differently that something that is executed multiple times per request (some changes seems to be even on nanosecond land). At least the context where the change happens should also be considered and why it happens - we support one feature (backtracking) fully now. Just looking at plain -+% should not be the only criteria

Lets get this PR cleaned up and merged and iterate on this in future if needed or when some finds he/she can do better.

@lammel
Copy link
Contributor

lammel commented Feb 15, 2021

Based on the benchmarks I've seen this looks promising anyway, especially cleaning up the "special cases" in the Find logic.

So I'm definitly in favour of cleaning up this PR an get it merged!

We still need to define the behaviur of the router for back tracking and add appropriate tests with a comment (even if it turns out to be the wrong decision in the future). I'm running out if time this week to check on that probably.

@stffabi
Copy link
Contributor Author

stffabi commented Feb 15, 2021

Also - for example going from 30 microsecond (µs) to 33 microsecond (+10%) for thing that is executed once per request should be treated differently that something that is executed multiple times per request (some changes seems to be even on nanosecond land). At least the context where the change happens should also be considered and why it happens - we support one feature (backtracking) fully now.

I think the current benchmarks are a little bit misleading, because the current benchmarks time reported as time/op is not really the time per request. It's per multiple requests, e.g. the mentioned 30 microsecond (µs) for the RouterGitHubAPI-6 benchmark seems to be the timing for 236 requests. As far as I understand the benchmarking framework of Go, op is defined as one iteration of the b.N loop, which is in the current implementation also a loop over multiple requests.

Wouldn't something like this be more accurate in benchmarkRouterRoutes?

// Find routes
c := e.pool.Get().(*context)
for i := 0; i < b.N; i++ {
  route := routesToFind[i%len(routesToFind)]
  r.Find(route.Method, route.Path, c)
}
e.pool.Put(c)

First we would have not included the time to get the context from the pool (which is not really part of the router) and we would have a more accurate time/op equals time/request. @aldas what do you think?

Benchmarking with this adjustments even shows much smaller absolute performance regression per request, on my local machine in the range of 1-2 nanosecond per request.

@aldas
Copy link
Contributor

aldas commented Feb 15, 2021

@stffabi has quite good test cases for backtracking - I found 2 problems with them in my implementation and that 1 problem that all implementations have after thinking/reviewing cases. We can extend test suite with own cases that we come up to.

I would even like to add PR on top of it taken from my own branch just for to convert test cases to table driven (already done in my branch) as it would help debugging cases so much - adding breakpoints and only executing certain case is such a time saver with breakpoints.
Also maybe renaming fields and adding comments explaining current algorithm more in details.

@stffabi
Copy link
Contributor Author

stffabi commented Feb 15, 2021

also it would probably help if instead of search value we hold index of search on path in stack

@aldas yeah that's a very nice idea, It also popped up on my list to give it try.

@lammel
Copy link
Contributor

lammel commented Feb 15, 2021

If @stffabi allowed for maintainers then @aldas can push right into this branch, you could work together right on this PR.
Guess that would be easier than managing to related PRs.
@aldas you just need to add the repo of @stffabi as an additional remote and push your changes there, they will show up right here. Just be sure that @stffabi knows about it first.

Otherwise we need to first merge this one and use the changes by @aldas in a follow-up PR.

@stffabi
Copy link
Contributor Author

stffabi commented Feb 15, 2021

@lammel @aldas I'm fine with "allow edit for maintainer", I think it's already allowed. I normally don't disable that for PRs.

@lammel
Copy link
Contributor

lammel commented Feb 15, 2021

@lammel @aldas I'm fine with "allow edit for maintainer", I think it's already allowed. I normally don't disable that for PRs.

Awesome, same for me. Than let's try it this way @aldas. Guess it's ok to add your changes for table driven tests too.

@aldas
Copy link
Contributor

aldas commented Feb 15, 2021

I think it would be easier for @stffabi (and understand what was changed when looking in history) if we finish this PR without me touching tests and renaming stuff and I'll add my PR after this is merged. These are just minor refinements are better of with their own history.

If @stffabi is willing to finish implementing/moving depth calculation out of .Find() I would say were are pretty much done.

@lammel
Copy link
Contributor

lammel commented Feb 15, 2021

Ok. If it's easier that way then may @stffabi could move the depth calculation to router.Add and just keep track on the maximum number of levels that need to be considered for backtracking. Find() would just use that information then, no need for any calculations in the hot path. (Hope I did not misunderstand what you said earlier and that is possible)

@stffabi
Copy link
Contributor Author

stffabi commented Feb 15, 2021

If @stffabi is willing to finish implementing/moving depth calculation out of .Find() I would say were are pretty much done.

@aldas Yes of course I would be happy to do that, I should be able to do this during the next few days.

@aldas, @lammel could we first write down what our final decisions are?

  1. Putting the backtracking information onto the Goroutine`s stack.
  2. Current max backtracking depth 10. Or what do you think?
  3. Panic during .Add() if the backtracking max depth has been exceeded.
  4. Find returns Not Found if max depth has been exceeded for some unforeseen reasons (which should not happen due to the panic during .Add().
  5. The idea of @aldas to remove the string from the backtracking information can be aded with another PR ontop of this.
  6. Should I change the benchmarks to reflect a better per request timing? As described above.

@stffabi
Copy link
Contributor Author

stffabi commented Feb 15, 2021

Ok. If it's easier that way then may @stffabi could move the depth calculation to router.Add and just keep track on the maximum number of levels that need to be considered for backtracking. Find() would just use that information then, no need for any calculations in the hot path. (Hope I did not misunderstand what you said earlier and that is possible)

Unfortunately that's not possible this way. If we want to put the backtracking information onto the Goroutine`s stack the max level we can support must be a compile time constant.

@aldas
Copy link
Contributor

aldas commented Feb 15, 2021

Ok, I did not know that it has to be set compile time.

well, we can not have always ideal solutions.

I would suggest then:

  • do not touch insert
  • replace panic with returning NotFound - this is similar what we do today
  • maybe increase depth constant to maybe 20-30 - just to be somewhere very-very unlikely place. If search field is replaced with int in stack then the size of that array is small/fixed also.
  • Add comment why it is so next i can do better person knows what he/she is dealing with and do not shoot himself/herself in the foot.

@lammel lammel added this to the v4.2.1 milestone Feb 15, 2021
@stffabi
Copy link
Contributor Author

stffabi commented Feb 15, 2021

Yes it's too bad we can't assign that dynamically.

I still have some concerns regarding the do not touch insert point. Wouldn't it be better to panic if we exceed the max depth during Router.Add() instead of just silently ignoring it? In such cases the user of echo just has the feeling that everything is well but some routes clearly doesn't work. That was also the point that I found it the most annoying when I've discovered this issue. I would be very happy if my registration just had paniced if I had exceeded the only backtracking level of the current implementation, instead of having the illusion everything is fine.

@aldas
Copy link
Contributor

aldas commented Feb 15, 2021

Panicing would be fatal to current users that 'rely' on current 'silent' behavior and are ok with 404. I'd add note in echo documentation about that behavior and maybe todo/fixme in this repo. There are actually other things/problems that route.Insert also ignores - ala settings different param names for different routes with same prefix.

If you have yet (over the days/weeks/months/years) noticed getting 404 so far then you do care about that route and handler. Panic would mean that you would not get to start server and to do thing that you care about.

I have mentioned in somewhere that actually in future (v5 maybe) when adding a route we should return an error. This would be one thing to return error

@stffabi
Copy link
Contributor Author

stffabi commented Feb 15, 2021

I see your argument, but if we want to argument that way we have to consider a lot more. We then can't just return 404 in the case we exceed the max backtracking depth of 20 or 30.
Let's assume a user is relying on getting 404, then he also relies exactly on the current behaviour of max backtracking of 1. This user also don't wants to see correct backtracking up to 20-30 and start seeing the 404 after 21 or 31.

If we see the current behaviour as a feature, then we would have to introduce some kind of feature flag (maybe an ENV variable like the Go-Team does with GODEBUG?) to either select the old or the new algorithm. Or we have to wait for v5 until we introduces the new backtracking algorithm and could panic on insert (or adjusting the interface to return an error during Router.Add()).

@lammel
Copy link
Contributor

lammel commented Feb 15, 2021

We definitely don't want a feature flags or environment variables for the router. This will just make testing and support harder.
A panic without a possibility for the developer to react on it (e.g. configure stacksize) is not a good option and should be avoided.

So for v4 we can define the behavior and go with a minimal solution (like this PR to fix unexpected not found).
Important notes can be added for the change of the undefined behavior and this is exceptable IMO.

Or we flag this PR also for v5 and can do breaking changes.

Still with the current requirement to define a compile time constant for backtracking depth we are vary limited it seems.
A developer has no chance to fix the situation on his end apart from hacking echo. So this might not be the best solution for us.

@stffabi
Copy link
Contributor Author

stffabi commented Feb 15, 2021

Well I think there are some philosophy questions which the maintainers need to clarify for themselves. Those are clearly out of my scope.

As for my part on this issue, you have a draft PR and are allowed to further improve or abandon it.

@aldas
Copy link
Contributor

aldas commented Feb 15, 2021

I think we are little bit overly dramatic about limitations of this solution.

But do not worry @stffabi stack-clousure solution can be turned into algorithmic backtracking. I have already implemented/proved https://github.com/aldas/echo/blob/issue-1754_router_backtracking/router.go#L552

so when rules from upwards traversal are as stated in #1754 (comment) we can calculate those in that popNext function instead of taking from stack. I'll try it out tomorrow. relax and give me 24h to report my findings. I'm quite positive that is can be done and fitted to this solution.

@aldas
Copy link
Contributor

aldas commented Feb 16, 2021

ok, I unable to push to @stffabi branch (probably doing something wrong) and i'll just post patch here (renamed to .txt as github does not allow uploading .patch files)

Complete change:
backtrack_by_calculating_next_node_instead_of_poping_from_stack.patch.txt

Briefly main change is 'pop' method:

	backtrackNode := func(fromKind kind) (nodeKind kind, valid bool) {
		previous := cn
		cn = previous.parent
		valid = cn != nil

		// next node type by priority
		switch previous.kind {
		case akind:
			nodeKind = skind
		default:
			nodeKind = previous.kind + 1
		}

		if fromKind == skind {
			// when backtracking is done from static kind block we did not change search so nothing to restore
			return
		}

		if previous.kind == skind {
			searchIndex -= len(previous.prefix)
			search = path[searchIndex:]
		} else {
			n--
			searchIndex -= len(pvalues[n])
			search = path[searchIndex:]
		}
		return
	}

and benchmark from my pc with current Echo implementation

go test -run="-" -bench="BenchmarkRouter.*" -count 20  > ~/code/echo/stffabi_nostack.out

...

x@x:~/code/echo$ benchstat old.out stffabi_nostack.out 
name                         old time/op    new time/op    delta
RouterStaticRoutes-6           16.0µs ± 1%    13.8µs ± 1%  -13.52%  (p=0.000 n=20+18)
RouterStaticRoutesMisses-6      515ns ± 1%     543ns ± 1%   +5.44%  (p=0.000 n=20+20)
RouterGitHubAPI-6              30.1µs ± 3%    27.1µs ± 2%   -9.79%  (p=0.000 n=20+19)
RouterGitHubAPIMisses-6         621ns ± 0%     642ns ± 0%   +3.32%  (p=0.000 n=16+18)
RouterParseAPI-6               1.61µs ± 2%    1.43µs ± 4%  -11.12%  (p=0.000 n=20+20)
RouterParseAPIMisses-6          349ns ± 1%     346ns ± 1%   -1.01%  (p=0.000 n=20+18)
RouterGooglePlusAPI-6          1.04µs ± 0%    0.93µs ± 1%  -11.32%  (p=0.000 n=15+18)
RouterGooglePlusAPIMisses-6     516ns ± 1%     503ns ± 0%   -2.67%  (p=0.000 n=19+18)
RouterParamsAndAnyAPI-6        2.44µs ± 1%    2.15µs ± 3%  -11.89%  (p=0.000 n=20+20)

@lammel
Copy link
Contributor

lammel commented Feb 16, 2021

Well I think there are some philosophy questions which the maintainers need to clarify for themselves. Those are clearly out of my scope.

Sorry, just wanted to help and state my* personal views* on what issues may arise with decisions like a hardcoded stacksize. I see no philosophical question though, just some discussions that need to be settled.

As for my part on this issue, you have a draft PR and are allowed to further improve or abandon it.

We are aiming for option number 1 👍🏼

@aldas
Copy link
Contributor

aldas commented Feb 17, 2021

@stffabi if you would apply that patch I think we are done or very close to be ready to merge.

@aldas aldas marked this pull request as ready for review February 24, 2021 21:37
@aldas
Copy link
Contributor

aldas commented Mar 2, 2021

I'll close this PR as this is now merged to master with #1791. @stffabi Thank you for your effort! That part is now much cleaner and more readable.

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.

3 participants