Skip to content

Conversation

diegommm
Copy link
Contributor

@diegommm diegommm commented Aug 29, 2025

Improvements to the Type System:

  • Aggressively cache struct, methods, and functions information.
  • Make the type checker reusable.
  • Make all methods of Nature work on *Nature instead. This avoid excessive memory copying.
  • Moved many type-checking from checker to nature because this prevents too much memory copying.
  • Optimize and cache many type checks.
  • Remove dead code (run staticcheck and golangci-lint).

Details for the future:

  • Please, keep passing Nature as value whenever possible. I made the methods work on the pointer because it avoids excessive copying that I saw in the profiling.
  • The use of *Cache is mandatory only for complex operations. In ast and parser it's ok to not use it because there we don't need anything special.
goos: linux
goarch: amd64
pkg: github.com/expr-lang/expr/checker
cpu: 13th Gen Intel(R) Core(TM) i7-13700Hbench-results-old.txtbench-results-new.txt        │
                                            │        sec/opsec/op     vs baseChecker/name=function_calls-20                        16.927µ ±  3%   4.444µ ± 9%  -73.75% (p=0.000 n=20)
Checker/name=unary_and_binary_operations-20            9.088µ ± 10%   4.634µ ± 5%  -49.01% (p=0.000 n=20)
Checker/name=deep_struct_access-20                     23.12µ ±  7%   13.25µ ± 8%  -42.69% (p=0.000 n=20)
geomean                                                15.26µ         6.486µ       -57.51%bench-results-old.txtbench-results-new.txt        │
                                            │         B/opB/op     vs baseChecker/name=function_calls-20                         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=20) ¹
Checker/name=unary_and_binary_operations-20            0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=20) ¹
Checker/name=deep_struct_access-20                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=20) ¹
geomean                                                           ²               +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomeanbench-results-old.txtbench-results-new.txt        │
                                            │       allocs/opallocs/op   vs baseChecker/name=function_calls-20                         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=20) ¹
Checker/name=unary_and_binary_operations-20            0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=20) ¹
Checker/name=deep_struct_access-20                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=20) ¹
geomean                                                           ²               +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

@diegommm diegommm changed the title [WIP] Improve Performance of the Type System Improve Performance of the Type System Aug 31, 2025
@diegommm diegommm marked this pull request as ready for review August 31, 2025 08:18
@antonmedv
Copy link
Member

Awesome, it's really interesting to see the results. I think this will be very beneficial for users who use Eval path and do not precompile expressions.

Type reflect.Type // Type of the value. If nil, then value is unknown.
Kind reflect.Kind // Kind of the value.

cache *Cache
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this cache is shared, let's do not set it inside the nature itself, but pass it from the top, from the checker as function arguments.

We already have this problem and we have to set a nature cache, for example, in the following line:

		base := n.Node.Nature()
		base.SetCache(c) // AST doesn't cache nature info
		base = base.Deref()

Let's not set cash inside an agent. But pass in to all functions what may use it:

		base := n.Node.Nature()
		base = base.Deref(c) // As the deref can modify the cache.

Copy link
Contributor Author

@diegommm diegommm Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used your approach in the first version, but it was very difficult to use.
I tried two ways:

  • Modify the signature of the methods to pass a *Cache as first argument, which can be nil.
  • Call SetCache every time I needed it to ensure it was used.

The problems I found:

  • There is no reason to not use the cache, so I think it would be ok to have it be a part.
  • The logic to account for cache not being set every time creates a lot of complexity. Remember this happens for every method. I have to look for a suitable alternative in every case. This makes it hard to follow and verify every case.

The solution I came to:

  • The cache is not agent, it is also functional. It is integral to Nature. AST only creates raw Nature, but then in order to have type information we use the cache.
  • The cache is a proxy but it also makes it easier to centralize all the logic. This makes it easier to implement.
  • The cache should be local to a conf.Config. This is because that's the best way to reuse it throughout the lifetime of a program being compiled. Starting with the type checker, all following compilation steps need it.
  • In the early stages when we are parsing the program we are just building the AST so we only care about creating the basics. For example, we don't know yet what type is returned at this point. But then we bind the AST to a cache then it means we are finally interested in type information details.
  • I think "bind" is a better word so I changed from SetCache to Bind to express this relationship.
  • The cache is not only meant for the type checker. I only changed mainly the type checker because it's the core, but it is also meant to be used by the optimizer, patchers, and the compiler. I also saw that we could probably use it in the vm and in the runtime because this would avoid some costly reflection checks.
  • I didn't go into more usage of it because I made many changes already. I would like to see if these changes are ok and then take a look at the other packages.

We already have this problem and we have to set a nature cache, for example, in the following line:

So as I explain above, this is not a problem. In AST we don't care about type information, we only know about raw elements. So when we need more information we need to bind it with Bind to a cache.

@@ -34,6 +34,7 @@ type Config struct {
Functions FunctionsTable
Builtins FunctionsTable
Disabled map[string]bool // disabled builtins
NtCache nature.Cache
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, if we can pass it via the function inside the compiler, from the top it will be easier and we don't need to set it here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the other comment.

@antonmedv
Copy link
Member

benchstat old.txt new.txt 
goos: darwin
goarch: arm64
pkg: github.com/expr-lang/expr/checker
cpu: Apple M2
                                           │   old.txt    │               new.txt               │
                                           │    sec/op    │   sec/op     vs base                │
Checker/name=function_calls-8                18.788µ ± 3%   7.443µ ± 0%  -60.38% (p=0.000 n=10)
Checker/name=unary_and_binary_operations-8   10.010µ ± 0%   7.997µ ± 2%  -20.11% (p=0.000 n=10)
Checker/name=deep_struct_access-8             30.04µ ± 0%   21.69µ ± 5%  -27.80% (p=0.000 n=10)
geomean                                       17.81µ        10.89µ       -38.86%

                                           │    old.txt     │                  new.txt                  │
                                           │      B/op      │     B/op      vs base                     │
Checker/name=function_calls-8                  0.000 ± 0%       0.000 ± 0%         ~ (p=1.000 n=10) ¹
Checker/name=unary_and_binary_operations-8     0.000 ± 0%       0.000 ± 0%         ~ (p=1.000 n=10) ¹
Checker/name=deep_struct_access-8            1.242Ki ± 0%     0.000Ki ± 0%  -100.00% (p=0.000 n=10)
geomean                                                   ²                 ?                       ² ³
¹ all samples are equal
² summaries must be >0 to compute geomean
³ ratios must be >0 to compute geomean

                                           │   old.txt    │                 new.txt                 │
                                           │  allocs/op   │ allocs/op   vs base                     │
Checker/name=function_calls-8                0.000 ± 0%     0.000 ± 0%         ~ (p=1.000 n=10) ¹
Checker/name=unary_and_binary_operations-8   0.000 ± 0%     0.000 ± 0%         ~ (p=1.000 n=10) ¹
Checker/name=deep_struct_access-8            159.0 ± 0%       0.0 ± 0%  -100.00% (p=0.000 n=10)
geomean                                                 ²               ?                       ² ³
¹ all samples are equal
² summaries must be >0 to compute geomean
³ ratios must be >0 to compute geomean

Nice -38.86% improvments. 👍🏻

@diegommm
Copy link
Contributor Author

Nice -38.86% improvments. 👍🏻

I updated the benchmarks, now it's ~58%!

Awesome, it's really interesting to see the results. I think this will be very beneficial for users who use Eval path and do not precompile expressions.

This would also be useful in tight loops when we need to compile many expressions, for example when a service initializes.

// and lexer do not need to use the cache because they don't need any service
// from the Nature type, they only describe. However, when receiving a Nature
// from one of those packages, the cache must be set immediately.
type Cache struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use global cache instance under sync.RWLock? Will it make core more easy to maintain?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought of different alternatives but they seem very complicated. I will try passing Cache from the top as you suggested above.


cache *Cache
*Optional
*FuncData
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also write the comment on Why? do we need separate *Optional and *FuncData so reader will understand it.


*structData

// map-only data
Fields map[string]Nature // Fields of map type.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we avoiding map in methodset , why this map?

if n.Type != nil {
n.Type = deref.Type(n.Type)
func (c *Cache) getStruct(t reflect.Type) Nature {
if c != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can c be nil here? Looks like redundent if.

// Untyped nil is assignable to any interface, but implements only the empty interface.
if nt.IsAny() {
switch nt.Kind {
case reflect.Pointer, reflect.Interface:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking for reflect.Interfece is not sufficient. We need to nt.IsAny():

func (n Nature) IsAny() bool {
	return n.Kind() == reflect.Interface && n.NumMethods() == 0

return nt
}

func (n *Nature) Bind(c *Cache) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hard to follow logic. Too many ifs and branching. Why do we need to write cache on bind op?

Alos if Nature relies on Cache, lets make it impossible to create nature without the Cache?

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