Nanomath resolution process is all WET... #40

Open
opened 2025-12-13 08:32:38 +00:00 by glen · 3 comments
Owner

... where WET = Write Everything Twice. You can see this very acutely in the recent pull request #39 in the code for cbrt in complex/arithmetic. The final function that is returned is quite reasonable and compact, but just above it, there is a slew of .resolve calls that essentially completely reiterates the computation, just at the level of types rather than values. Essentially every .resolve call mirrors a step in the computation, just with the types of the arguments that will be used, and then the resulting type is used as the argument type for the next step in the computation.

At this point, we are so close that I will go ahead and implement polynomialRoot per #30 and benchmark this iteration directly parallel to Pocomath (the most recent design iteration that made it all the way to a benchmark) and to mathjs 15. I think it is likely we will see a speedup, conceivably even slightly better than Pocomath.

But I don't think the nanomath scheme is scalable. The resolution section prior to the implementation amounts to a heavy load of boilerplate that makes writing the implementations feel very much of a chore. I think to extend this basic approach to the size of mathjs 15, we need to automate this resolution process somehow -- as I write the type selection code, it feels very automatable.

On the other hand, I don't see an obvious way in JavaScript to select out and resolve just all of the functions that the eventual implementation will need. Four concepts come to mind, but all have their drawbacks/uncertainties.

A) Something like automatic differentiation. In other words, the actual implementation has a "math" object injected into it, and at resolution time, it's called with a special version of this object that serves just to record dependencies and select resolutions for each of the calls. Then when the implementation is actually used, it's passed its own tailored "math" object in which every method that's actually needed just "magically" has the right definition, no type detection and dispatch needed . The big drawback here is that for a function with multiple code paths (because of conditionals, etc), it's not at all clear how that resolution-time call could get possibly get info on function calls in paths not taken..

B) A variation on A in which again, each implementation has a "math" object passed to it which it uses to get dependencies -- but in fact, it also has its own private little instance of the math object that lasts just the same length of time as it does. At first there's nothing in the math object, But each time it's used for resolution and it does the type detection and resolution, it asks th "real" math objet to do that, and the associated mini-math object remembers the instantiated function and where it occurred, so that next time it doesn't need to do that type detection/dispatch, it just directly calls the one in its mini-math. The main bit unclear here is how to distinguish different calls of the same function with different actual argument types so that the mini-math can serve up a different implementation of the dependency. I also think that all these computed calls will be slower than the nanomath scheme where all the selection is done all at once so that the functions can just be compiled into the implementation as JavaScript creates the function object. So this would likely be an intermediate-speed solution between nanomath and mathjs 15.

C) Shift as much of the implementations as possible to mathjs expression language, where we have a full syntax tree and can do this sort of static analysis directly. Certainly cbrt can be defined from more primitive operations via mathjs expressions. For the primitive ones or others that need JavaScript, just grin and bear the nanomath resolution scheme -- it's not bad at all to deal with for the simpler functions, which is why I didn't really become conceerned with this issue until now. The biggest concern with this approach is that it may invite many extensions and complications to the mathjs expression language to be able to implement the large mathjs library, which could get unwieldy.

D) Specify the implementation in JavaScript, but as a string, and parse it to an AST form, and just do static analysis on that AST, and inject all of the dependencies needed into the context in which the string is finally converted into an actual JavaScript function. This approach should in theory work and yield good performance, but it faces the hurdle that full static analysis of JavaScript code is a tall order, We would basically be writing our own JavaScript compiler in JavaScript.

I am wide open to any other options or thoughts on which of A - D we might best be able to actually pull off

... where WET = Write Everything Twice. You can see this very acutely in the recent pull request #39 in the code for cbrt in complex/arithmetic. The final function that is returned is quite reasonable and compact, but just above it, there is a slew of `.resolve` calls that essentially completely reiterates the computation, just at the level of types rather than values. Essentially every .resolve call mirrors a step in the computation, just with the types of the arguments that will be used, and then the resulting type is used as the argument type for the next step in the computation. At this point, we are so close that I will go ahead and implement polynomialRoot per #30 and benchmark this iteration directly parallel to Pocomath (the most recent design iteration that made it all the way to a benchmark) and to mathjs 15. I think it is likely we will see a speedup, conceivably even slightly better than Pocomath. But I don't think the nanomath scheme is scalable. The resolution section prior to the implementation amounts to a heavy load of boilerplate that makes writing the implementations feel very much of a chore. I think to extend this basic approach to the size of mathjs 15, we need to automate this resolution process somehow -- as I write the type selection code, it feels very automatable. On the other hand, I don't see an obvious way in JavaScript to select out and resolve just all of the functions that the eventual implementation will need. Four concepts come to mind, but all have their drawbacks/uncertainties. A) Something like automatic differentiation. In other words, the actual implementation has a "math" object injected into it, and at resolution time, it's called with a special version of this object that serves just to record dependencies and select resolutions for each of the calls. Then when the implementation is actually used, it's passed its own tailored "math" object in which every method that's actually needed just "magically" has the right definition, no type detection and dispatch needed . The big drawback here is that for a function with multiple code paths (because of conditionals, etc), it's not at all clear how that resolution-time call could get possibly get info on function calls in paths not taken.. B) A variation on A in which again, each implementation has a "math" object passed to it which it uses to get dependencies -- but in fact, it also has its own private little instance of the math object that lasts just the same length of time as it does. At first there's nothing in the math object, But each time it's used for resolution and it does the type detection and resolution, it asks th "real" math objet to do that, and the associated mini-math object remembers the instantiated function and where it occurred, so that next time it doesn't need to do that type detection/dispatch, it just directly calls the one in its mini-math. The main bit unclear here is how to distinguish different calls of the same function with different actual argument types so that the mini-math can serve up a different implementation of the dependency. I also think that all these computed calls will be slower than the nanomath scheme where all the selection is done all at once so that the functions can just be compiled into the implementation as JavaScript creates the function object. So this would likely be an intermediate-speed solution between nanomath and mathjs 15. C) Shift as much of the implementations as possible to mathjs expression language, where we have a full syntax tree and can do this sort of static analysis directly. Certainly cbrt can be defined from more primitive operations via mathjs expressions. For the primitive ones or others that need JavaScript, just grin and bear the nanomath resolution scheme -- it's not bad at all to deal with for the simpler functions, which is why I didn't really become conceerned with this issue until now. The biggest concern with this approach is that it may invite many extensions and complications to the mathjs expression language to be able to implement the large mathjs library, which could get unwieldy. D) Specify the implementation in JavaScript, but as a string, and parse it to an AST form, and just do static analysis on that AST, and inject all of the dependencies needed into the context in which the string is finally converted into an actual JavaScript function. This approach should in theory work and yield good performance, but it faces the hurdle that full static analysis of JavaScript code is a tall order, We would basically be writing our own JavaScript compiler in JavaScript. I am wide open to any other options or thoughts on which of A - D we might best be able to actually pull off
Author
Owner

I have thought a bit more about B, which can basically be thought of as caching the type detection/function resolution results for the dependencies of any implementation so that they are done only once. Other than looking up in the cache and then calling being slower than pre-computing the resolved function and compiling it into place like nanomath is doing now, it would work perfectly for dependencies that are only called with one actual-argument signature. But often that's not the case, and then the individual call sites must be distinguished. So B splits into two options about how to do that:

B.1) In the guts of the implementation, the implementor must decorate different instances of any dependency that is called in different places where it has different signatures, something like:

const myImp = math => (myarg1, myarg2) => {
   return math.calledOnce(
     math.site(1).repeatedCall(myarg1),
     math.site(2).repeatedCall(myarg2))
}

where "calledOnce" and "repeatedCall" are just stand-ins for math methods, like "multiply" and "negate," say. There would be various possibilities for the exact location and syntax of the site annotations, and the "site" values could be arbitrary labels, and there could and perhaps should be an annotation that says "don't even try to cache this call, it's going to be called with different argument types at this exact call site". Note that if there are two calls that the implementor knows will always be called with the same signature, the implementor can label them as the same "site" -- so maybe "flavor" would be a better term than "site".

B.2) We use Error.captureStackTrace() to autodetect the different call sites. It is non-standard but available in all major browsers and JavaScript runtimes except deno, including bun. It can't tell that two different sites will be sure to have the same signature, but that's pretty minor, it will just cache the same thing twice. There may still be a need to have a decoration that says "don't cache this site" if it is going to be called at that point with different types (but generally that doesn't happen much). The biggest concern here is that including a call to captureStackTrace will slow down the dependency calls even more, so it's not clear that this would even be faster than the current mathjs 15 always-re-dispatch strategy.

So we will have to decide if it is worth experimenting with either (B.1) or (B.2)...

I have thought a bit more about B, which can basically be thought of as caching the type detection/function resolution results for the dependencies of any implementation so that they are done only once. Other than looking up in the cache and then calling being slower than pre-computing the resolved function and compiling it into place like nanomath is doing now, it would work perfectly for dependencies that are only called with one actual-argument signature. But often that's not the case, and then the individual call sites must be distinguished. So B splits into two options about how to do that: B.1) In the guts of the implementation, the implementor must decorate different instances of any dependency that is called in different places where it has different signatures, something like: ``` const myImp = math => (myarg1, myarg2) => { return math.calledOnce( math.site(1).repeatedCall(myarg1), math.site(2).repeatedCall(myarg2)) } ``` where "calledOnce" and "repeatedCall" are just stand-ins for math methods, like "multiply" and "negate," say. There would be various possibilities for the exact location and syntax of the site annotations, and the "site" values could be arbitrary labels, and there could and perhaps should be an annotation that says "don't even try to cache this call, it's going to be called with different argument types at this exact call site". Note that if there are two calls that the implementor knows will always be called with the same signature, the implementor can label them as the same "site" -- so maybe "flavor" would be a better term than "site". B.2) We use Error.captureStackTrace() to autodetect the different call sites. It is non-standard but available in all major browsers and JavaScript runtimes except `deno`, including `bun`. It can't tell that two different sites will be sure to have the same signature, but that's pretty minor, it will just cache the same thing twice. There may still be a need to have a decoration that says "don't cache this site" if it is going to be called at that point with different types (but generally that doesn't happen much). The biggest concern here is that including a call to captureStackTrace will slow down the dependency calls even more, so it's not clear that this would even be faster than the current mathjs 15 always-re-dispatch strategy. So we will have to decide if it is worth experimenting with either (B.1) or (B.2)...
Author
Owner

As to the other alternatives, I don't currently see any way to actually make (A) work, and I have essentially no enthusiasm for (D). On the other hand, (C) seems doable, and Jos has been interested for some time in making the mathjs language a more full-fledged programming language with loops and flow-control constructs. The biggest worry is that the compiled versions of complicated mathjs-expression-language functions may still be noticeably slower than the same function written directly in JavaScript. So again, we would have to test carefully to see what kind of performance would come out the other end.

As to the other alternatives, I don't currently see any way to actually make (A) work, and I have essentially no enthusiasm for (D). On the other hand, (C) seems doable, and Jos has been interested for some time in making the mathjs language a more full-fledged programming language with loops and flow-control constructs. The biggest worry is that the compiled versions of complicated mathjs-expression-language functions may still be noticeably slower than the same function written directly in JavaScript. So again, we would have to test carefully to see what kind of performance would come out the other end.
Author
Owner

So to summarize, at the moment I don't see a way around the laborious pre-resolution in nanomath that seems both workable and likely to deliver the same speedup. Three options (B.1), (B.2), or (C) seem as though they could be worth experimenting with to see the tradeoff between ease-of-implementation and resulting performance. I just don't want the quest for a mathjs core replacement to be never-ending. One point is that any of these three options can be done in a hybrid way with nanomath -- in other words, if we implement them, they could be used for bigger/more compute-intensive functions where the resolution-time may not be the major factor, and then any function that becomes a performance bottleneck could always be re-implemented in the current nanomath style to squeeze out some wasted time there.

So a potential strategy is to proceed with nanomath for now, and as we get to the points where the implementation pain of doing all the resolution calls before the real body of the method becomes too great, we can layer on either or both of (C) (use mathjs expression language internally) and/or one or the other of (B.1/B.2) (resolution caching schemes) without disrupting any existing implementations.

So I guess that's my plan for now, but definitely would love other thoughts/guidance.

So to summarize, at the moment I don't see a way around the laborious pre-resolution in nanomath that seems both workable and likely to deliver the same speedup. Three options (B.1), (B.2), or (C) seem as though they could be worth experimenting with to see the tradeoff between ease-of-implementation and resulting performance. I just don't want the quest for a mathjs core replacement to be never-ending. One point is that any of these three options can be done in a hybrid way with nanomath -- in other words, if we implement them, they could be used for bigger/more compute-intensive functions where the resolution-time may not be the major factor, and then any function that becomes a performance bottleneck could always be re-implemented in the current nanomath style to squeeze out some wasted time there. So a potential strategy is to proceed with nanomath for now, and as we get to the points where the implementation pain of doing all the resolution calls before the real body of the method becomes too great, we can layer on either or both of (C) (use mathjs expression language internally) and/or one or the other of (B.1/B.2) (resolution caching schemes) without disrupting any existing implementations. So I guess that's my plan for now, but definitely would love other thoughts/guidance.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: StudioInfinity/nanomath#40
No description provided.