Cinder: Instagram's performance-oriented fork of CPython
github.com
github.com
Python has emphasized readability for the reference implementation vs. the practical benefits of better performance for everyone, and the community is really hurting for it.
Please CPython, upstream this or something like it. There reaches a point where this whole zeal about readability becomes idealistic and hurts real-world pragmatic goals. It's hurting the language and we can do better.
Python can be ahead in the performance race. We just need to get real.
Part of the reason Instagram can get away with this is they likely have almost complete control of their dependencies and the like. But the changes they decide to make would not just work for everything.
That being said I think we could get a lot of neat PEPs from this.
EDIT: this is just my opinion , but in a world where we have type annotations, JITs feel like a massive step back. Stuff like mypyc could get us way further into high performance stuff (and no black box hand waving for perf things).
But JIT also works on untyped or incompletely typed code.
With hidden classes and such it likely wouldn't matter much. You'd need them anyway for full dynamism
We haven’t gone the hidden classes route so far because it’s simpler to just look at attributes assigned in __init__ and annotated on the class and lock those into slots. If you’re writing typed Python you probably don’t do a lot of tacking extra ad hoc attributes onto instances, since type checkers don’t like that either.
There was a piece by v8 devs a while back where they ended up turning down JIT aggressiveness cuz it ended up making normal web pages slower (except for like Google Docs).
This is just me backseat-BDFLing “why do the hard thing when we could do the easy thing” tho
I have seen a very large codebase in a dynamic language at [redacted] that has been mostly converted to use a sophisticated type system. Certain core things have not been converted so far, though, but just marked with a lot of "proceed with caution" and "TODO" red flags. Making them typesafe in any conceivable way would break compatibility for large subsystems.
Sometimes a rewrite is the only way out of such a situation, but very few can afford it.
Starting a project in a highly dynamic language is a conscious choice with well-known properties. The second part is interesting, however. Essentially you are saying that gains from development velocity do not offset losses from technical debt, which is a strange observation. I would say it sort of hints at time to MVP being irrelevant business metric (startup runway/survivability aside) at best. This is contrary to typical startup truths.
But sometimes they don't. Case in point: YouTube, Instagram. Both of them are slowly migrating more and more Python code to different languages, but the amount of Python is still large, and AFAIK neither has plans to eschew it completely.
Time to MVP is a very relevant business metric. But the same thing that speeds you up in the beginning slows you down later on. If you architect your code past MVP to help replace the implementations of critical paths, it may help you down the line. (Micro)service architecture is one way to do that.
Our approach to “int fits in a machine word” in Static Python is to require machine ints to be explicitly annotated as such, and then you opt in to limited size ints with overflow etc, in exchange for getting really fast arithmetic.
Being compatible with the rest of the Python ecosystem is the main reason why Cinder is built on top of CPython. Although yes, some features are indeed very experimental.
> in a world where we have type annotations, JITs feel like a massive step back. Stuff like mypyc could get us way further into high performance stuff
Ah, but that introduces a separate compilation step, which may not be tolerable in every situation.
Yes, but developers don't have to ever interact with it.
> Also, look at how cython work
Cython works by adding a separate build step. Changing a Cython module requires you to recompile it, which is avoided with a JIT.
FWIW, I think we could probably buy ourselves a lot of latitude to optimize CPython by designating a much smaller API surface (like h.py) and then optimizations largely won’t have to worry about breaking compatibility with C-extensions (which seems to be the biggest reason CPython is unoptimized).
But in general I’ve lost faith in the maintainers’ leadership to drive through this kind of change (or similarly, to fix package management), so I’ve moved on to greener pastures (Go for the most part, with some Rust here and there) and everything is just so easy nowadays compared to my ~15 years as a Python developer.
For big monoliths (like ours at IG), the server start-up can take more than 10sec, which is already super high for a "edit -> refresh" workflow. Introducing a Cython like compilation step is really a major drawback for every single developer.
For smaller projects, Cython works extremely well (and we do use it for places where we need to interface with C/C++).
Then skip it for your dev builds.
The ones they removed are not commonly used at all.
Maybe there could be a subset of Python that removes the hyper dynamic parts so it can be compiled to something faster. Maybe I'm just describing Go.
What you’re describing is essentially Static Python (mentioned in the readme)
I don't know if this is the case. Python's entire value proposition is that it is "executable pseudocode" with an extremely low barrier to entry - so somebody like a scientist or business analyst can solve problems without a deep understanding of computer science. Those goals are always going to run counter to performance, and python would have to re-invent itself at a foundational level to actually compete on performance with languages which were optimized for it from the ground up. Python's already winning in a lot of super relevant ways from playing to its strengths, and I don't think it makes sense to compromise those strengths to reach middle-of-the-pack performance.
If you ask me, if the python community really wanted to advance their interests, the thing to focus on would be dependency management and project encapsulation. If I could have a ux like npm or cargo for python, I would surely be tempted to use it more outside of jupyter. But it would not be because of performance ;)
1. near-universal adoption
2. "just works" experience: i.e. I can clone any random git repo, and run `npm start|build|whatever` and it's always going to work, without having to know anything about my environment or fiddling with configurations.
Does poetry do that?
From my experience, with repos that have Poetry set up, I do get a "it just works" experience that I've previously been lacking when working with python. AFAIK there is no "scripts" section (for e.g. "poetry start" like "npm start") in the config file, or at least no one is using them. You'll have to take a quick peek into the README, but the same is true for npm projects, as the script verbs aren't really standardized.
There is a scripts section in pyproject.toml, which is leveraged by “poetry run”. This is different than for npm, though, since these aren’t dev-environment scripts but the executable scripts that are installed with the project
I'm pretty happy using Poetry, and agree with your initial point, partially with the second. There can be some edges with packages involving "anything" binary (to be fair, that is to be expected), and a big issue (for me) is locking the resolution of some libraries, like boto (since botocloud has hundreds of patch releases and the resolver can get pretty crazy unless you play a bit of manual bisection). But this only hits you on poetry lock or poetry add when developing, and only in some cases, I think it's a fair price to pay for a reproducible build.
I don't recall it having custom commands but in my opinion this isn't the job of a package manager.
If you really want a "just works" experience when dealing with C extensions, you should freeze your development environment one layer up (e.g. VMs, vagrant images, what have you) so that you can always successfully install and compile your C extensions because the underlying system is also kept under version control. And this is independent of whether or not you are working on a Poetry or an NPM based project.
It's not that hard. I suspect that you're just used to simpler conditions.
2 - Pretty much yeah
Python has historically refused to implement many optimizations because they reduce compiler readability.
LOL'd hard at that.
What kind of scientist would write "import numpy as np" on their pseudocode? Or multiply matrices with the "@" operator? From the point of view of a scientist, Matlab/Octave, or even Fortran, is executable pseudocode. Numerical stuff in Python seems like an ugly kludge.
Not only to newcomers. I've been using python+numpy for more than 10 years, nearly every day, and it despairs me as much as the first day, if not more. I relish the few moments that I get to write numeric algorithms in any other language.
I suspect one of the reasons packaging is so bad is because for each node on the dependency tree, you need to download and execute setup.py in order to discover the node’s direct dependencies, and to get the transitive dependencies you must download and execute the direct dependencies’ setup.py filles. Since these files aren’t idempotent, we can’t reliably cache the results (these scripts could return different sets of dependencies based on the environment or even the current time or anything else). This at least seems to be the reason all reproducible package managers are slow (by which I mean 30+ minutes to resolve dependency versions for a small non-toy project even if the resolved dependencies at their property versions are already cache to disk).
I don't think Python's language readability is the problem.
The problem is CPython's core team shooting down any performance improvement for the sake of keeping the compiler source code readable.
These days Julia is a very clear counterexample to this sort of claim, I don't think this is true at all.
Also doens't Julia make pretty heavy use of dynamic dispatch, or am I mistaken about that?
Some packages still take a while to load, e.g. for me to load the plotting pacakge and produce the first plot takes about 8 seconds (all subsequent plots in that session are fast). This is down from like 30 seconds last year.
One can also bundle heft packages like Plots.jl into their julia system image so they don't have to recompile all that machinery every time they restart julia.
The "readable pseudocode" kind of code is exactly the sort of code in Julia that you almost always expect to be compiled down to native code quite efficiently. The kind of pseudocode I usually see is either straightforward loops iterating over something where the compiler can infer all the necessary information, or calls to library functions where somebody else has already made sure it's good. I use this a lot in my own code, and, like I said, I don't think there is a tradeoff.
The true underpinnings of readability is language malleability, nothing more and nothing less. The whole "dynamic languages are automatically more readable" impression is a misleading consequence of the much more general claim "Malleable languages are more readable", with a malleability = dynamicism substitution.
Malleability is key because readability is really just languages being as close as possible to the problems they are used to solve. This means one of two things
1)Either the language come preequipped with the concepts and semantics of the problem domain built into their fabric, this is the DSL approach (this will always fail if you aspire to be a "general purpose language", you simply can't hope to match the sheer number of contexts people want to use your language in. One radical conclusion is to abandon the "general purpose language" myth absolutely, and just make all application development language-building, and focus on building the tools to make language building easy.),
2)Or you make the language malleable and stretchy enough that a tiny handful of the rare programmer-domainExperts breed can construct the whole domain inside the langauge, _with_ _the_ _language_ (no transpiler, preprocessor, etc..., this would require the rare programmer-expert to be an even rarer programmer-expert-languageHacker), then cover up all the low level machinery with syntactic elements that mirror the domain vocabulary.
That's it. Any langauge that allows you to do the above is a malleable language. Any malleable language is a candidate for being a readable langauge (and a horribly unreadable langauge, if you gave in to irresponsible abstractions). Dynamic languages are malleable because they give you extremely powerful hooks into their semantics, they are very... well, dynamic. The details differ, the two poster childs are python and ruby, they have a grab bag of features that range from operator overloading in python, free form syntax in ruby, and extreme dynamic dispatch and resolution rules in both. The last feature is a common theme in all dynamic languages, and it happens to be a performance killer.But you absolutely don't have to be dynamic to be malleable, lisps have been doing it since forever, and though lisps are traditionally dynamic, it's macros that make them stretchy, not dynamicism. And there are loads of other mechanims that can make a language malleable without making it unpredictable. It can be as simple as scala allowing unicode in it's source, and as complicated as haskell making lazy evaluation the default and now control structures are just a library. Any abstraction with a "meta" flavor, with hooks extending into the language environment and doing various things depending on how you wield them, makes the language more malleable.
Malleablity is allowing the language's source to take different shapes and semantics according to the whimes of the programmer, performance comes from massaging the source till it fits comfortably into machine semantics without much runtime shenanigans. They are absolutely not in tension, making the code do everything at runtime is just the easy way out.
For those unfamiliar:
https://code.google.com/archive/p/unladen-swallow/
Python is falling behind fast. Look at how well PHP has improved in performance and how Ruby is doing lately.
This resistance is biting back.
It's easy to forget that CPython is not that optimised generally, but as soon as you need to deal with algorithms it is lacking often.
I think even that the fact there are so many C implementations in libraries shows that the lack of optimisation for the sake of clean compiler code manifests as a readability / complexity issues elsewhere.
"Not that optimised" is actually an understatement. In my experience, CPU-heavy code typically becomes 10-100x faster if you port it from Python to a language like C (without even trying to be clever or using SIMD assembly).
It is so simple it is definitely worth trying.
I did it for Bloom tree implementation, and got to within around 3x of the C code it replaced.
I don’t know here you are picking this from. What’s hurting performance is not readability, it is the flexibility of what you can do (hence: strict modules/static python in Cinder) which means it is really hard to perform optimizations as no assumptions hold unless you turn down the more permissive language features.
Another one is backwards compatibility, and I might get some flak from people still complaining about it more than 10 years after python 3.0, but things are still 99.9% compatible with decades-old code in python 3.9, and you can’t throw that away.
Finally, the numbers of people dedicated to improving CPython performance in an upstreamable fashion is ridiculously low compared to other languages, and especially compared to the number of businesses using it. There are quite few people working on performance, but not many of them appear to be doing it in the open and with the goal of upstreaming it in a non-breaking manner (kudos to the instagram engineers, I guess).
Yeah because Guido and the maintainers have proclaimed multiple times that they prefer a simple implementation for teachability, over performance minded design. That’s where this entire complaint is coming from.
Python's language readability is NOT the problem I allude to here.
The problem is CPython's core team shooting down any performance improvement for the sake of keeping the compiler source code readable.
Steps to Reproduce Install docker from docker.com
docker run -t -i fedora:32 bash
git clone https://github.com/facebookincubator/cinder.git
yum install zlib-devel openssl-devel
./configure
make
make altinstall
This error is occurs when you do ./configure --enable-optimizations this version of python complies it either way. >> Objects/accu.o
Parser/listnode.c: In function ‘list1node’:
Parser/listnode.c:66:1: error: ‘/cinder/Parser/listnode.gcda’ profile count data file not
found [-Werror=missing-profile]
66 | }
| ^
If you get this error it is because you used --enable-optimizations.github.com/brianherman https://www.linkedin.com/in/brian-herman-092919208/
Edit: Removing github flavored markdown Edit: Answering own question Edit: More formatting
I completely agree with this, as a dev I want to see warnings but I don't always want to have everything perfect from the start. The famous example here is -Wunused-arg. I DON'T want to fight this during development, when I often have prototyping stages where functions remain unimplemented intentionally. I very much want to avoid merging this into master though, so CI gating with -Werror it is.
Nice. Looking forward to seeing that happen over the next few months.
As an aside it is great that the Cinder team is specifically calling out that Cinder is not intended to be used outside of FB. Many people have been burned by lack of community around hhvm.
Yes you basically need to sit down with an expert to learn this stuff. It's famously under-documented and extremely hard to learn how it's done in practice on your own.
Note: Never worked on v8, just liked the information here.
SOM for example has many implementations with different approaches to compilation http://som-st.github.io.
But, if we could make python a lot faster at compiler level, it doesn't break the dev experience and lengthen dev on-boarding time. This helps the team productivity as a whole. I hope the CPython community is able to leverage few things from Cinder. This would benefit the entire python community.
tests/expected/*.go has generally human readable code.
1. The computations are distributed enough that there is no one hot loop
2. Go is shit at integrating with other software without IPC, ipc could be infeasible or have too much overhead, it also complicates building / bundling / shipping a lot
1. In distributed computing, there are usually minimal communication because nodes communication is costly, the bottleneck can still be the hot loop on each nodes.
2. When optimizing the hot loop only, it can be quickly written in Cython or C, since the scope is usually a lot smaller, so no need to use Go.
I didn't mean distributed computing, I meant distributed through the program. Not all programs actually have a hot loop.
> When optimizing the hot loop only, it can be quickly written in Cython or C, since the scope is usually a lot smaller, so no need to use Go.
That assumes proficiency in C, and it still complicates building / bundling / shipping a lot, the first bit of native code into a Python codebase is a huge investment and a significant step back in convenience in many ways.
Step 1: "Who needs Threads? Just async/await all the things!"
Step 2: Build complex logic to untangle your nonsensical use of async/await and force things to run synchronously in the backend without the user knowing. See? A 5% CPU optimization right there!
Addressing your snark, I assume this optimization is targeted at places where you have a single "await" expression in the source that might call different functions at runtime, only some of which need to await.
...and those are stackless coroutines under AOT compiled conditions, so it only saves a malloc.
Here's the BPO issue [1] that tracks the upstreaming attempt.
[0] https://github.com/facebookincubator/cinder/blob/9de726349eb...
> Cinder is not polished or documented for anyone else's use. We don't have the capacity to support Cinder as an independent open-source project
I'm quite surprised by that. I understand that FB makes tons of money so I can't believe they can't have the capacity... Maybe they don't have the will (which I perfectly understand, it's just the wording)
Anyway, it's great they share.
Our team is smaller than you might think! You also left off the last half of that sentence, which is quite important: "nor any desire for it to become an alternative to CPython."
We'd love to see the ideas and implementations in Cinder end up in upstream CPython in some form or another. It'll make our lives easier in that we won't have to maintain as much forked code, and hopefully everyone else will benefit from a faster CPython.
I thought so but the other half of my brain was screaming for "FB has so much power, how come they don't invest more in open source".
But well, that's economics at play I guess. I'm an idealist after all... Thx for replying !
The most interesting aspect really is that both companies have run in to the limits of performance with dynamically typed languages and had to implement their own language runtimes, AOT compilers, and JIT's.
Static typing is the best way forward at scale.
I think what they are looking for is to reduce the amount of machines they need. Which in the end is more money in their pockets. Making the website faster for the end user is a welcome side effect.
nothing is documented in facebook. There are no advantages to doing documentation in your career.
I my experience "Language X is slow and we could save Y switching to Z" is always a false promise. You can pick parts of the system that are costing a lot and port them to other language/frameworks to capture the bulk of savings while keeping your developers happy working on familiar code. Or if you'e big enough like Facebook, you can go see why X is slow and if it is possible to improve at the compiler level. Never disrupt the developers flow (Twitter did this with their Scala craze back in the day)
That turns pretty quickly into "developers being unhappy that they have to maintain legacy code written for the old language/platform," though, especially with any sort of employee turnover.
Refactor slowly over time, turn a ball of mud into cleanly defined domain boundaries, parallelize the tests, etc. etc.
Sometimes the choice is also made when you already have to pay a transitional cost - at a previous employer we had to rewrite a bunch of code to move from an old version of PHP to a newer one, and a strong argument was made that at that point we should just adopt a better language since we already had to pay that cost. I think eventually a lot of stuff transitioned to Haskell as part of that process, even if other stuff stayed in PHP.
This $4000 in savings actually allowed them to hire an additional full time developer to maintain their site. So, your logic only works for certain use cases, not all.
The moment it was done, we just switched nameservers. It had no impact on client's workflow or day to day life.
edit: Adding link with info about Cloudflare's APO product https://support.cloudflare.com/hc/en-us/articles/36004982231...
gotta use aws
Of course I don't have all the context, but, I cannot imagine any circumstance when rewriting some server side logic in elixir would be useful from a PoV of cost savings..
By RAW numbers, GoLang or something else maybe faster, for sure, but it will definitely extend the time to develop something like Wordpress because its frameworks I've tried (Eg. Gorilla) need more effort compared to using something like Phoenix that I use. Plus, a lot of devs were already onto Ruby, it's easier to onboard them into Elixir than GoLang.
> Of course I don't have all the context, but, I cannot imagine any circumstance when rewriting some server side logic in elixir would be useful from a PoV of cost savings..
I think you may not have worked with a high traffic site, just by swapping languages alone you get insane cost savings for that scale because it quickly adds up. Even if your new language is just 0.1 second faster per user, for a million users, it adds up really fast. As I mentioned, just by this switch alone we brought down the cost to 1/4th.
I believe GoLang or something else may have drastically reduced further, but it has the same downsides of Elixir (Harder to find talent for, learning curve). I'm a pure Elixir consultant, so I chose it. My client is more than happy with this outcome.
Their whole culture elevated performance and they handled a lot of traffic with relatively small amounts of hardware that they documented. I've got some familiarity with a Rails app where the devs seemed astonished at some of the details in their blogs about running SO.
Although Dr Chris Seaton mentioned it is not his goal to have Shopify Rails running on top of TurffleRuby. Not sure if he meant it was short term or long term. I think Dr Stefan Marr will be joining him soon, and Dr Maxime Chevalier-Boisvert is leading a team working on YJIT in Shopify. ( Did Shopify just hire all known Dr. working on Ruby performance )
Lots of progress has been made on CRuby. The official optcarrot benchmark (realistic-ish interpreter bound) is ~6x faster on Ruby 2.6 than Ruby 1.8.
CRuby performance running Rails improved almost as much. Zendesk got a 2-3x improvement from 1.8 to 1.9 and 1.9 to 2.6 doubles it again.
The thing that keeps software interesting for me is that there are almost no absolute rules. So I'll agree that complete rewrites are usually a mistake and performance problems are often not in the language.
But I can't agree that rewrites are always a mistake. esbuild is a recent good example of how much difference switching to a faster language can make.
A ton of work goes into making sure the Python code isn't slow, and there are complicated C++ services built as workarounds for the slowness of python.
Twitter did this with their Scala craze, but Twitter struggled to scale it's Ruby app. Twitter didn't have nearly as deep pockets to write a performant alternative Ruby VM.
Edit: I was wrong, Fog Creek did have a "CityDesk" product at one point, but Wasabi was for on-prem FogBugz[1].
[1] https://instagram-engineering.com/python-at-scale-strict-mod...
Yet many of the languages that evolved as "scripting languages" threw much of that knowledge out. In the middle of the 90s we knew how to make fast dynamic languages. Maybe not as crazy dynamic as something like ruby, but not far off. Or at least, they had the sense to codify it as a separate thing (CLOS in common lisp comes to mind).
Something like SBCL is more dynamic than you need(TM), yet produces code that is often at least an order of magnitude faster than python.
A better reason for rewriting however is to migrate to a more maintainable language with focus on readability from one which focus on expressiveness (and hence favored for rapid development) as the project matures.
There are some perf hits, as well as all the improvements.
Most of the hits are due to the default “JIT everything” behavior which is really bad if there are a lot of rarely called functions, but would be easy to fix. This is discussed in the README.
Edit: found them at https://github.com/facebookincubator/cinder/blob/cinder/3.8/...
It may seem like a lot of work, but making Python faster isn't exactly trivial either.
Also, I love Python, but using it for huge projects like this sounds dubious at best. I think the benefits of static typing really show when projects grow large.
Good to see more work on this front! It's seemed crazy that Python can be this popular but still not be nearly as fast as it could be.
Regular models, views, ORM stuff can be fairly generic. Did they need to change Django to better benefit from Cinder?
Edit: intriguing… https://github.com/facebookincubator/cinder/blob/f60897df9f6...
tldr: very old version with tons of custom stuff.
In some ways a bit cheeky that they took the 'dump it and see' approach rather than offering to work to upstream it, since it's kinda outsourcing the work of maintaining the performance improvements to the Python core devs rather than offering to put some of Facebook's considerable resources towards doing it themselves.
They could've kept these developments behind closed doors and nobody would mind it.
They also offered to provide support if anyone wanted to contact them. That is top-class behaviour from instagram, I personally couldn't expect more.
> What more could we expect from them to do?
> That is top-class behaviour from instagram
To be considered top-class, I would expect them to go the extra mile and contribute to upstream. As many other companies have done over the past and continue to do.
If you're going that mile, then my hat is off to you and the team, that's how it's supposed to be done! :)
>Short answer: no.
That's refreshingly honest
Completely agree, and Instagram did it, and they have been reaping the benefits for it. Now they've open-sourced the runtime, so we can all benefit now too.
correction: the documentation does mention the lock: https://github.com/facebookincubator/cinder/blob/f60897df9f6...
That's unchanged from the upstream CPython repo.
Did anyone find the benchmarks
Is this supported? Short answer: no.
We've made Cinder publicly available in order to facilitate conversation about potentially upstreaming some of this work to CPython and to reduce duplication of effort among people working on CPython performance.
Cinder is not polished or documented for anyone else's use. We don't have the capacity to support Cinder as an independent open-source project, nor any desire for it to become an alternative to CPython.
Facebook is basically just open-sourcing their changes to CPython, in order to make it easier to work with core CPython developers. For a company of Facebook's size, migrating to PyPy would probably be have been a much bigger endeavor than simply forking CPython. Since the goal of this open-sourcing is to improve CPython rather than to attract people to a competitor, I would interpret this work as "Facebook investing in CPython" rather than "Facebook investing in a CPython alternative".
Is it that hard to google a name before choosing?
Aside from faster CPython, I'd love to see a PIP that standardizes module naming convention & inheriting+extending existing modules, rather than completely reinventing the wheel and making a totally new module just to add a single feature that some other module lacked. That plus the new regex extensions and Python will be almost as useful as Perl. :-)
Its also much nicer to reason about a function outputting a block of html code that represents some object like a toolbar. Than to have python code in the html doing doing for loops with html code inside.
Easier, and shorter to write a function that changes the output. Then to battle the templating language, or write and support 8 different views.
But that's also a template - just your own kind rather than framework's.
I guess it's hard to really understand the tradeoffs here without concrete examples... which is not great for HN either.
Sometimes I do common things like stored markdown->html by injecting filters into the template context. So {{ comment | markdown }}.
For completely different presentations, you can have separate templates returned by the same endpoint. But it sounds like that actually should just be a separate endpoint altogether. You could use template inheritance for code reuse, but you can also walk into over complicating that if those start diverging over time.
Handling screen variety is best done with CSS (and JS when you have to do something hacky). The HTML shouldn't need to change for that.
Even back to JSPs or early PHP, I learned the hard way to avoid sticking business logic in my "templates" or sticking HTML in my code. Either will work, but maintaining it sucks. And if it is a successful project and you need to change the design in year 3, it'll easily result in a nearly complete rewrite.
I know the cool thing to do now is even just expose the data via REST or GraphQL and do a SPA, but I don't do frontend work enough to get over that learning curve.