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

Cache evaluated trigger specs #1540

Merged
merged 5 commits into from
Dec 14, 2023
Merged

Conversation

Telroshan
Copy link
Collaborator

When specifying a hx-trigger attribute, getTriggerSpecs tokenizes & parses the trigger spec string everytime it's called.
This isn't an issue for most cases, but it performs poorly when operating on a large set of elements.

  • In this JSFiddle, using the current state of the lib (open the developer console to see the logged execution times)
    I get in average the following:
    GetTriggerSpecs: 140 ms, called 2001 times
  • In this JSFiddle with the system I'm proposing here, I now get in average the following:
    GetTriggerSpecs: 3 ms, called 2001 times

The example code here is simply letting htmx initialize 2 000 divs, that have the exact same hx-trigger specified, which is in this example hx-trigger="click[!ctrlKey&&!shiftKey&&!altKey]

This example isn't taken out of nowhere, it's actually a real use case I have on Zorro, where a board could totally have 2 000 tasks (and more) at once. I'm using this exact hx-trigger specifier for every task that opens a details tab for the clicked task (excluding shift/alt/ctrl clicks that have selection behaviours and shouldn't trigger a request).

The suggested change here helps cutting down initial page load times, which contributes to user experience.

Given that there aren't that many possible hx-trigger specifications (there is technically a defined number of available events & modifiers), I think the added memory footprint of storing an extra copy is negligible, thus I suggest this system of caching the evaluated triggers to skip the tokenization & parsing whenever possible.

Didn't break any test when I ran them locally but let me know if I missed something

@alexpetros alexpetros added the enhancement New feature or request label Jul 17, 2023
@Telroshan Telroshan added performance ready for review Issues that are ready to be considered for merging and removed enhancement New feature or request labels Sep 20, 2023
src/htmx.js Outdated Show resolved Hide resolved
@alexpetros alexpetros changed the title [Performance] Proposal - cache evaluated trigger specs Cache evaluated trigger specs Sep 26, 2023
@alexpetros
Copy link
Collaborator

I've looked at this a couple times and while I understand your use-case and want to support it, it feels like a pretty heavy implementation for a reasonably niche use-case. I kind of dislike the forEach too, which adds a lot of complexity to what's happening here.

Can we achieve similar performance using the hx-trigger string as a key and the specs as the value, rather than caching each of the specs individually? Obviously this wouldn't work as well if you have incredibly granular permutations of trigger specs, but I'm expecting that even in your case you probably don't.

@Telroshan
Copy link
Collaborator Author

Yeah it's clearly a niche use case as you only start to encounter performance problems with hundreds/thousands of them

a pretty heavy implementation

Keep in mind though that it runs faster than re-evaluating the specs, so it's actually less heavy than doing the logic everytime

the specs as the value

I'll try again on that, that's what I wanted to do initially but encountered reference issues (didn't dig enough in the trigger specs system to understand what was going on exactly), thus this foreach to "clone" the specs and make a new array reference (as the spread operator isn't IE 11 compatible, I had to go for a manual loop to copy each item 1 by 1)

Obviously this wouldn't work as well if you have incredibly granular permutations of trigger specs

The code in this PR is already caching the specs by associating an array of trigger specs to a given hx-trigger string, so the key you suggest is already the one I'm using, and the values as well, but the way that I retrieve the values is what feels heavy there

I'll dig more into this and hopefully come up with a way to avoid having to copy the array, though I'd expect I would have to copy it at some other place in the code, we'll see how it goes

@Telroshan Telroshan added under discussion Issues that are being considered but require further alignment and removed ready for review Issues that are ready to be considered for merging labels Sep 26, 2023
@Telroshan
Copy link
Collaborator Author

@alexpetros following up on this: I have removed the array copies, don't know if I had hallucinated back then but there's no reference issues anymore by simply reusing the same trigger specs array, which alleviated the code.
Don't know if you had read my latest comment above btw, let me know how you feel about it

Copy link
Collaborator

@alexpetros alexpetros left a comment

Choose a reason for hiding this comment

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

Yes, this looks much simpler! I added one comment that I think will help improve the readability of this, because there's a lot of reassigning going on that I think we can mitigate.

src/htmx.js Outdated
shouldEvaluateTrigger = false
}
}
if (shouldEvaluateTrigger) {
Copy link
Collaborator

@alexpetros alexpetros Nov 17, 2023

Choose a reason for hiding this comment

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

Can you pull this into a separate function called parseAndCacheTrigger? That will let you write lines 1250-1258 as:

if (shouldEvaluateTrigger) {
  triggerSpecs = triggerSpecsCache[explicitTrigger] || parseAndCacheTrigger(explicitTrigger)
}

which is smaller and I think communicates the intent better than reassigning the if condition

@alexpetros
Copy link
Collaborator

Regrettably that makes the diff a little harder to follow on account of the indent change, but I do think the resulting code is much better. Okie dokie!

@alexpetros alexpetros added ready for review Issues that are ready to be considered for merging and removed under discussion Issues that are being considered but require further alignment labels Nov 22, 2023
@1cg
Copy link
Contributor

1cg commented Nov 30, 2023

@Telroshan is it possible to eliminate the whitespace changes here to make the actual change clearer and then submit a whitespace fix in a separate PR?

@Telroshan
Copy link
Collaborator Author

Done @1cg

@1cg
Copy link
Contributor

1cg commented Nov 30, 2023

OK, what I don't like about this is, afaict, this is an unbounded cache, so it'll eat up more and more memory over time. I don't mind a bounded cache, although I'd want to see it help a lot in a real world situation before I agreed to it.

remember, there are two hard problems in computer science:

  • naming
  • cache invalidation
  • off-by-one errors

@Telroshan
Copy link
Collaborator Author

@1cg it'll only eat up more memory if you define a different hx-trigger syntax. There aren't that many different syntaxes you can define with this attribute, but you surely can define a thousands attributes with the same value

I'd want to see it help a lot in a real world situation before I agreed to it.

As I mentioned in the description, it helped me on Zorro with thousands of tasks! Feel free to reject if if you don't like it though, but I'll definitely be using it on my end 😆

@1cg
Copy link
Contributor

1cg commented Nov 30, 2023

How do you feel about bounding the size of the cache? Is there a LRU Map implementation in JavaScript? (We could do random eviction assuming processing the elts is in order so it's likely to hit a bunch of the same specs over and over)

@Telroshan
Copy link
Collaborator Author

If you're worried about the cache, then do you think the following would work?

  • Store an additional counter in the cached specs, increment this by 1 everytime the same spec is reused
  • Decrement it by 1 on node cleanup, and if the counter hits 0, remove the entry to dereference the pointer

@alexpetros
Copy link
Collaborator

^ That seems somewhat over-engineered. What if just put the cache behind a config option that defaults to false? The vast majority of our users will never need it, but a handful of intensive apps (zorro, contexte) will definitely be glad it exists.

@Telroshan
Copy link
Collaborator Author

Btw, logging the cache size (number of entries + the length of the cache converted to a JSON string) after running the whole test suite gives the following

61 cached specs => 3318 bytes of JSON

So basically 3 KB of memory overhead when running the whole test suite

What if just put the cache behind a config option that defaults to false

I don't know how I feel about this tbh, I meant this PR as a nice under-the-hood improvement, in that case I think it'd be better to implement some cache max size & random eviction as @1cg suggested if you're worried about the cache size over time, rather than make this a config option that users should be aware of.

I'll update the code with the former approach when I get the chance and let you know so we can discuss it!

@Telroshan
Copy link
Collaborator Author

Btw which maximum cache size do you have in mind @1cg, starting from which we should start evicting older (or random) entries?

@alexpetros
Copy link
Collaborator

Well my point is that the vast majority of users won't have to be aware of the caching option. Adding a config that lets you fine-tune performance at the cost of a little memory seems like a very standard way of handling that problem—and far less involved than implementing random eviction that has to work 100% correctly for every user.

@1cg
Copy link
Contributor

1cg commented Dec 3, 2023

what if we punt on this a bit and move triggerSpecCache to htmx.config.triggerSpecCache

logic would be if(htmx.config.triggerSpecCache) consult the cache

default would be null

cache size management could be done on the side by people who want to turn it on then, we kinda wipe our hands of it

@1cg
Copy link
Contributor

1cg commented Dec 6, 2023

@Telroshan what do you think about the idea above?

If we check htmx.config.triggerSpecCache and use it like your original change, people can stick an empty object in there and just cache everything, or implement a proxy and do whatever they want for LRU behavior. Punts that complexity for us.

wdyt?

@alexpetros
Copy link
Collaborator

If you want to do a compromise solution, wouldn't it make more sense to define some kind of functional interface (like cacheGet() and cachePut())? That way users actually have the flexibility to define their own caching system, if they so choose. Letting them manage the cache themselves, but the cache has to be an object, feels like a middle-ground between two solutions that doesn't solve either side's problem.

@Telroshan
Copy link
Collaborator Author

Telroshan commented Dec 7, 2023

TL;DR: Skip ahead and just read the suggestion part below

Yeah I'm not sure if I know where I stand on this matter... I agree with @alexpetros that just exposing a property is going to be very limiting in terms of custom caching system as you're bound to using that object key-value pair retrieval syntax 😕 So if you wanted to define your own system and implement some cache eviction, you'd have to rely on some other stuff to detect "when to clear the cache" or at least check its size

On the other hand, I find it too "heavy" to have to define 2 interfaces when you (by you I actually mean myself and my own usecase here) just want to have a cache that never clears and are fine with it.

Again, cache size after running the whole test suite is 3 KB, a very likely-to-be overestimated number as it's the length of the encoded JSON which is probably heavier than the actual cache's memory footprint. In my case, I really don't care about that footprint and I'd rather have a cache that always grows and never shrinks as it fits my usecase.

So conceptually, I agree with @alexpetros that a more robust system would allow more customized systems and benefit everyone interested in this feature. On the other hand, I don't have any intent to clear my own cache and I'm not fond of a too "annoying to configure" approach when I just want a dumb "store everything and never evict" cache. So in my specific situation, as a htmx user I'd rather have @1cg 's suggestion that is easy to set up.

Suggestion

Would you accept to take the typeof approach where both suggestions could coexist?
We could define a htmx.config.triggerSpecCache property that must be an object, but check with a typeof === function if it defines a get or set properties (which must be functions) ? If it does, call those functions, otherwise, use the cache as a plain key-value pairs object as currently in this PR?

This would allow the easy configuration of triggerSpecsCache: {} and everything gets stored, as well as more complex approaches with custom get/set methods

We would document this ofc

Let me know!

@Telroshan
Copy link
Collaborator Author

Actually let me push what I have in mind so you can directly see what it looks like (easily revertable if you don't like it ofc)

@1cg
Copy link
Contributor

1cg commented Dec 7, 2023

i think we could do the simple case, where we keep the current code but move the cache object to htmx.config.triggerSpecCache and then document how to use a Proxy object if you want something more sophisticated:

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy

to avoid adding another layer of complexity to the system.

Then people can just jam a plain javascript object in there if they just want unbounded caching, or implement a proxy if they want something more sophisticated.

(Trying to keep the # of new concepts to a minimum)

@Telroshan
Copy link
Collaborator Author

Oh I didn't know about proxies, that seems like the perfect fit to keep a simple syntax within the core lib indeed!

@Telroshan
Copy link
Collaborator Author

Telroshan commented Dec 7, 2023

Like this @1cg ? If accepted, I'll add documentation as there's now a new configuration property (and tests to make sure the cache is used if defined)

@alexpetros
Copy link
Collaborator

Oh cool, I also didn't know about proxy objects. That satisfies my concerns as well.

src/htmx.js Outdated Show resolved Hide resolved
@1cg
Copy link
Contributor

1cg commented Dec 7, 2023

sure

@Telroshan
Copy link
Collaborator Author

I'll add docs & tests in the next couple days!

@1cg 1cg merged commit da54605 into bigskysoftware:dev Dec 14, 2023
1 check passed
1cg pushed a commit that referenced this pull request Dec 14, 2023
* Fix parseAndCacheTrigger indentation as discussed in #1540

#1540 (comment)

* Trigger specs cache documentation + tests
@Telroshan Telroshan mentioned this pull request Jul 13, 2024
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
performance ready for review Issues that are ready to be considered for merging
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants