In 1970, people probably thought that by 2026 humanity would be conquering the far reaches of space, but in 2026 we are struggling to get two programs on the same computer to communicate efficiently.
TL;DR: The "FFI Sync" communication method looks like the best approach for tsgo-based JS linters because it offers the strongest performance and DX tradeoff, while "IPC Sync" is too slow to be practical. FFI Sync also works great for both JS and native integrations.
Since the release of typescript-go, I did several independent attempts (both closed and open source) to measure the performance of different approaches to bridging it with a JS runtime. The ability to communicate with tsgo from JS is crucial because the ecosystem relies a lot on lint rules that are written primarily in JS.
This small research aims to gather all my previous explorations together in a reproducible, structured, documented manner.
The primary goal of this research is to explore tsgo-powered linting, so some tradeoffs that are critical for other use cases are fine here.
I hope that this research will be useful for other folks who are interested in webdev tooling, and linting in particular.
If you want to know why type-aware linting is so important, please read "Typed Linting: The Most Powerful TypeScript Linting Ever" by Josh Goldberg.
This research could be seen as a logical continuation of "Hybrid Linters: The Best of Both Worlds" (again by Josh Goldberg).
My personal goal is to find the best approach that would allow us to achive:
- Great speed
- Nice developer experience
- Lint rules and plugins in JS and native languages
I hope this will help to resolve tsgo integration uncertainty for Flint (1, 2) and Golar.
Description of some things may seem too obvious for more experienced folks, but I want this writing to be approachable for people who aren't that deep in linting but want to learn more about it.
First of all, let's recap sync vs. async interaction.
Sync communication means that the client is blocked while waiting for request completion, while async communication allows both client and server do their work simulatenously, since they're running in different OS threads.
Async communication may seem to be a preferred method, since it allows you to parallelize workload; however, some ecosystem tools (for example, ESLint) disallow async code execution.
Thus, such tools will have to either deal with either poor performance or worse DX (manual request batching).
Since we expect the client to be written in JS (or other language), the typescript-go server must be compiled into a standalone executable binary.
Our JS runtime needs to communicate with this binary somehow.
There are two approaches for interacting with it from JS: IPC and FFI.
How it works: we compile typescript-go into an executable binary (similar to @typescript/native-preview) and spawn it as a separate OS process.
The JS runtime interacts with it via stdio pipes or sockets/named pipes.
Drawbacks:
- it's slower due to OS syscalls, context switching, and serialization/deserialization
- plugins written in native languages (e.g., Rust) bottlenecked with IPC as well as JS
Advantages:
- a single binary (
tsgo) can be used for both CLI workflows (tsgo --noEmit) and IPC interactions (tsgo api)
Since the client and server processes are isolated, all communication requires serialization and deserialization. The larger the message, the slower the delivery.
IPC API on the client side can be either synchronous or asynchronous.
How it works: we compile typescript-go as a shared library and load it as a Node-API addon. The JS runtime interacts with it by calling exported JS functions.
Drawbacks:
- we have to compile a separate binary for this (we can't use typescript-go's binary)
- we can't load multiple Go addons into one process due to the Go runtime limitation
Advantages:
- communication with JS is much faster than IPC
- low-level languages can access tsgo memory without serialization/deserialization and inter-process communication overhead
Unstructured data, such as source file text, can be accessed by both JS and addon sides without copying because they can share the same memory. However, structured data, such as JS objects, must be created somewhere: either on the JS side by deserializing unstructured data from the addon, or on the addon side by creating JS objects via Node-API.
First, let's inspect the performance of transferring different sizes of messages via IPC.
We have two programs:
- Go echo server - reads the payload length and payload from stdin and responds with payload copy to stdout
- Node.js client - sends a given amount of bytes to the Go server and waits for a response
An await roundtrip(1024) call means that we transfer 1KiB once in each direction.
We can see that transferring 1B-64KiB takes ~25-50μs. The chart also grows almost linearly.
Since with FFI we don't need to copy large unstructured data, it doesn't make sense to benchmark it. Instead we will measure a time of a single JS runtime -> Node API -> Go call.
main.go exports a simple sum function and main.c exposes it to JS via Node-API.
Benchmark shows that an FFI call takes about 78ns to complete. That is ~348 times faster than a roundtrip of a single byte over IPC!
In our benchmarks, we will be using three files from microsoft/typescript:
- small (1.3 KiB) -
corePublic.ts - medium (527 KiB) -
parser.ts - large (3MiB) -
checker.ts
At first, out of interest, let's compare speed of parsing in Rust (Oxc) and in Go (typescript-go).
Oxc parser is a clear winner. Unfortunately, Go doesn't have the same low-level memory management as Rust does.
In order to partially solve this problem, typescript-go uses AST node pools for different node types that are encountered in source code more frequently than other.
Some AST nodes are allocated in node pools, while other are allocated on the heap.
As a consequence, heap allocations slow down parsing.
In contrast, Oxc parser uses a bump allocator, which allows placing the entire AST into one contiguous chunk of memory.
This helped the Oxc team to avoid AST serialization step completely before sending a binary AST to JS (Raw Transfer).
Since the AST in tsgo isn't located in a contiguous chunk of memory, it's required to encode it in order to transfer it to JS. This takes some time, but, as we will see later, it isn't that critical.
We will compare three different strategies for getting AST on the JS side:
- A (Binary encoding) - tsgo parses source text, builds an AST, encodes it in binary form, transfers it to JS, and JS decodes it to JS objects.
- B (Parsing in JS) - tsgo parses source text using Go parser, and builds an AST; JS parses source text using JS parser, and builds an AST.
- C (Materialization in addon) - this approach is similar to A with one difference: instead of encoding/decoding a binary AST, it converts an AST directly to JS objects using Node-API.
When I started writing this, approach A was slower than B, so I was considering whether it was worth maintaining a JS-based implementation copy of the Go-parser for the sake of performance. However, it turned out that there were two low hanging fruits (1, 2) in A.
Also, I'm including approach C, which is based on typescript-ffi, one of my past experiments, for the sake of completeness - to be sure that we're evaluating all possible approaches.
Now, let's compare their performance:
Luckily, A is a clear winner in terms of performance; it's ~1.3-1.7 times faster than B. The B would be a maintenance burden.
C is slower than B due to the lack of JIT. Creating tons of objects in an addon using Node-API is much slower than parsing source text from scratch in JS.
Both binary AST flattening and lazy AST deserialization are explained in great details in this cool article. Btw, it has influenced tsgo's binary AST structure as well.
Oxlint switched from lazy deserialization to eager deserialization in order to provide better compatibility with ESLint.
Deno lint uses lazy AST deserialization. It creates JS objects only for AST nodes touched by lint rules.
Out of curiosity, I decided to know the exact number of nodes that are touched by typescript-eslint and ESLint rules.
The chart below shows the exact spans of the tracing.ts file that were touched by the rules (excluding CFG and scope analysis).
@typescript-eslint/no-unused-vars and @typescript-eslint/no-deprecated are excluded because they touch many more nodes than the other nodes.
And here is a chart that shows the average number of AST nodes touched by rules when run on many files.
We will try to compare several different approaches to perform type-aware linting.
These different approaches will implement the no-unsafe-enum-comparison rule.
I chose this rule because it has relatively small amount of code (I don't want to port a big amount of code into three languages) and calls a lot of checker methods.
What this rule does:
- Locate all binary expressions (
===,!==, etc.) and switch-cases - Get types of two operands
- If a type (or a type consituent, in case of unions) is coming from an enum, get the type of the enum declaration
- If both operands are coming from the same enum, treat the usage as safe
- Otherwise, report an error
All rule implementations report same number of errors (222), so they have identical functionality.
The speed of rule implementations is measured on the famous checker.ts, which weights 3MiB.
First and most performant approach is to compile tsgo alongside the lint rules written in Go. This is the approach used by tsgolint and its forks (oxlint-tsgolint and Rslint).
The biggest upside of this approach is the absence of any communication overhead. Lint rules access checker methods directly without crossing the boundary of the Go runtime.
The downside is that there is no extensibility. We can't allow dynamically registering new rules. Go plugins can't be used here because they don't work on Windows.
You can check the rule source code here.
Unfortunately, crossing Go runtime boundaries is a bit expensive. CGO does have a tangible performance cost. While in the latest Go versions CGO call cost is lower, it still takes a few tens of nanoseconds.
process.dlopen allows loading .node addons with os.constants.dlopen.RTLD_GLOBAL.
This way, a plugin.node addon loaded after tsgo.node can access symbols defined by tsgo.node.
On Windows, we can use GetModuleHandleExW + GetProcAddress to access symbols exported by tsgo.node addon.
Low level languages that can read raw memory can skip the encoding/decoding step and read raw Go memory instead.
Even though tsgo's AST, types, and symbols contain several Go-specific types - such as Go strings, maps, slices, and interfaces - it's still more than possible to read and interpret them directly from memory.
The high-level picture looks like this.
tsgo.node uses the official TS parser to parse source text into the AST.
plugin.node calls tsgo.node through CGO to get the pointer to the AST head.
Once plugin.node has that pointer, it can access the AST directly without consulting tsgo.node.
The obvious downside of this approach is that now the internal representation of the AST on the Go side becomes a part of the public API. However, changes in AST shape are not that common.
The same technique can be used to access types and symbols from the checker.
plugin.node could call checker methods via CGO and get back a pointer on a type or symbol.
Nodes, types, and symbols have an interface-based, self-referential layout in memory (you can learn more about it in Jake Bailey's talk). Moreover, the same structs can be used to represent different AST nodes/type kinds.
There are two possible approaches to traverse such ASTs/types.
As an illustration, I will use the ForInOrOfStatement struct which is used to represent both ForInStatement and ForOfStatement nodes.
- Kind based switch: an AST visitor can hardcode (or codegen) the fact that nodes that have
ForInStatementorForOfStatementkind must be cast to theForInOrOfStatementlayout. - Go-type-hash-based switch: Go interfaces are basically two pointers, with first pointer being a pointer to the so-called "itab".
It holds information about a type that is being exposed as an interface.
We're interested in the third field of the itab:
hash. It holds a unique hash of a type.tsgo.nodecan self-discover these hashes dynamically and telladdon.nodeabout AST struct <-> hash mapping. Native rule will comparehashvalue from itab ofForInOrOfStatement'snodeDatainterface with the hash provided bytsgo.node. This will be similar to type switches in Go (node.(type))
To prevent situations where plugin.node tries to access the memory that was garbage-collected by the Go runtime (use-after-free), tsgo.node must tell the Go runtime that the pointers are in use by either pinning them with runtime.Pinner or by storing them in some storage that has a durable lifetime, such as a slice/map attached to the active TS project.
While native rules can be implemented in any low-level language (e.g., C++, Rust, Zig, etc.; ironically, we cannot write them in Go), I decided to choose Odin because there are currently no publicly awailable Odin-based Node-API addons, so this is a funny challenge.
You can check the rule source code here.
Synchronous checker call, synchronous linting.
It calls getTypeAtLocation exported by tsgo.node synchronously for every type.
The checker method is called synchronously during AST traversal.
Personally, this approach is my favourite, both because of its simplicity and DX. Forcing lint rule authors to use async everywhere is not cool, IMO.
You can check the rule source code here.
Synchronous checker call, asynchronous linting.
The rule traverses the AST and calls an asynchronous validation function for every AST node that can potentially contain a rule violation.
Once enough getTypeAtLocation requests are gathered (batchSize), the rule calls getTypeAtLocationBatch exported by tsgo.node.
It executes synchronously (JS is blocked) and returns a batch response.
You can check the rule source code here.
Asynchronous checker call, asynchronous linting.
The rule traverses the AST and calls an asynchronous validation function for every AST node that can potentially contain rule violation.
Once enough getTypeAtLocation requests are gathered (batchSize), rule calls getTypeAtLocationBatchAsync exported by tsgo.node.
It puts a request into a queue and returns to JS.
The queue is processed by a separate goroutine.
When all types for a request are resolved, the goroutine calls Node-API's thread-safe function in nonblocking mode and continues working on the request queue.
The JS callback passed in setOnBatchFinished is called with the response (resolved types).
You can check the rule source code here.
Synchronous checker call, synchronous linting.
It spawns the tsgo binary and communicates with it in blocking mode.
Every node type is requested separately.
This approach is intended to replace the current synchronous Strada (TypeScript 6.0) API for sync-only tools, such as ESLint (+ typescript-eslint).
You can check the rule source code here.
Asynchronous checker call, asynchronous linting.
It spawns tsgo binary and communicates with it in non-blocking mode.
Type requests are batched by chunks with batchSize.
You can check the rule source code here.
Both IPC Sync and IPC Async Batched approaches are currently used in the experimental @typescript/api.
For illustration purposes, we're comparing all runs with TypeScript 6.0 (Strada); it's written in JS.
You can check the rule source code here.
I tried to squeeze performance out of all implementations to make them as fast as possible and to make the comparison fair.
Because of this, I didn't use the official IPC package and modified several places in typescript-go.
A few notable differences with @typescript/api:
To perform a getTypeAtLocation(node) call, @typescript/api sends the tsgo server a handle (a string in the form <node.pos>.<node.end>.<node.kind>.<node.sourceFile.path>).
The server parses it, finds the corresponding source file for a given path, and iterates over the file AST to find a node whose pos and end match the requested one.
While this approach is safe, it's rather slow.
Instead, I modified the AST encoder to put the AST node pointer alongside other node fields. While it adds an extra 8 bytes to each encoded node, the AST is serialized/deserialized only once, while checker methods are called much frequently.
In my benchmarks, getTypeAtLocation accepts a pointer of a node and does (*ast.Node)(unsafe.Pointer(uintptr(POINTER))) in Go code.
This may seem unsafe because it casts an arbitrary memory address to a pointer to ast.Node; however, as long as *ast.SourceFile is pinned on the Go side, this cast is safe.
For async communication, @typescript/api uses vscode-jsonrpc which encodes every request/response into JSON.
JSON encoding is fine for occasional requests, but paying serialization/deserialization cost on hot requests can slow down execution a lot.
Also, serialization of a type handle (similarly to the AST node handle) can take a significant amount of CPU time.
So, to provide a fair comparison, I had to build a small custom IPC server that avoids these extra costs.
In sync mode, it uses a MessagePack protocol + SyncRpcChannel used by @typescript/api.
In async mode, it uses a similar custom KLV communication, with a custom client.
Types are cached on both client and server sides, so information about a type is transferred only once. All other consequent communications that involve a cached type only use its 32-bit id.
Since this is a PoC, I decided not to spend time on implementing serialization for unused Type and Symbol fields.
So the times shown by FFI and IPC implementations in this benchmark are slightly better than they will be in real-world usage.
Type and Symbol have a lot of fields.
I decided to encode only the type id, type pointer, type flags, type constituents (for unions and intersections), the type's symbol flags, and the pointer to the parent node of the value declaration of the type's symbol.
These are the only fields used by our lint rules.
FFI Sync and FFI Sync Batched take advantage of their synchronous communication and reuse one ArrayBuffer for all tsgo calls.
Instead of passing arguments as JS values and accepting response as JS values:
- A pointer to the underlying
ArrayBuffer's memory is passed to the Go side - The JS side writes a binary request into the buffer and calls Go through Node-API without any arguments
- The Go side reads the request from the buffer, performs computations, and writes the response back to the same buffer
I also tried to use busy waiting on the shared memory to avoid CGO call overhead, but it had the same performance as CGO, and in addition two CPU cores were always spinning at 100%.
It's hard to properly measure the performance of rules whose bottleneck is type-checking. The checker evaluates types lazily, i.e., the more requests a rule make, the higher the chance that some part of type-resolution is cached.
We will compare two different situations: hot and cold.
Hot means that we're measuring the speed of linting with all types cached, i.e., in setup we lint the file, and the lint run used in measurements is requesting already cached types. This will show us the performance of different approaches in the best-case scenario. It's impossible for these approaches to work faster (of course if I didn't miss something in the implementations). The bottleneck here is communication overhead.
Cold means that we're measuring the initial lint with an empty checker state. All types are resolved from scratch. The bottleneck is expected to be type-checking.
Before each benchmark run, we reset the TS program, populate checker with cached types (for the hot benchmark), and run GC (for both the JS and Go sides) to make the comparison as fair as possible.
Also, AST transfer and materialization are not considered in this benchmark. Only linting time is measured. This means that, in reality, FFI and IPC approaches will be slower, since they must pay the cost of AST transfer and materialization.
Rules request types for 488609 nodes.
You can find the benchmark code here.
Also, it's important to decide on a batch size for the FFI Sync Batched, FFI Async Batched, and IPC Async Batched approaches.
This shows us that the most optimal batch sizes are:
- 500 for FFI Sync Batched
- 500 for FFI Async Batched
- 1000 for IPC Async Batched
We will use them in all other benchmarks.
It's also worth mentioning that in some real-world scenarios the batch size can be smaller because sometimes checker calls depend on previous checker calls; this makes them hard to parallelize/batch.
IPC Sync takes an enormous amount of time to finish! In fact, it's ~105 times slower than Strada, ~22 times slower than IPC Async Batched, and ~351 times slower than Builtin!
Let's look at the same chart but without IPC Sync:
Native is ~1.8 times slower than Builtin. This is purely because of CGO overhead (~23ms).
Strada is only ~3.4 times slower than Builtin.
FFI Sync shows the best result among other JS tsgo-based approaches! It's only ~3.8 times slower than Builtin, ~2.2 times slower than Native, and ~1.1 times slower than Strada.
FFI Sync Batched is ~10.1 times slower than Builtin.
FFI Async Batched is ~1.4 times slower than FFI Sync Batched. I presume this is because the goroutine + thread-safe callback approach has slightly bigger overhead due to more complex scheduling.
IPC Async has the worst performance (not considering IPC Sync). It's ~16 times slower than Builtin, ~4.7 times slower than Strada, and ~4.1 times slower than FFI Sync.
IPC Sync has the worst performance again... It's ~13 times slower than Strada and ~48 times slower than Builtin.
Let's drop it from the comparison:
Native is now only ~1.1 times slower than Builtin! The only performance overhead is still CGO (again ~23ms!)
Strada is ~3.7 times slower than Builtin. This matches what the TS team said when they released tsgo (10x speedup = 3x from parallelization * 3x from using a native language). Since we're running Builtin in a single thread, we're seeing only the 3x gained from rewrite in a native language.
FFI Sync outperforms the other JS approaches again! It's ~1.4 times slower than Builtin, ~1.3 times slower than Native, and ~2.5 times faster than Strada!
FFI Sync Batched, FFI Async Batched, and IPC Async Batched fall into the same group. They're ~2.1-2.4 times slower than Builtin.
FFI Async Batched is the most performant among them.
The only surprizing part is that FFI Async Batched is faster than FFI Sync Batched in the cold benchmark, but slower in the hot one. Here is my attempt to understand this behavior.
Communication overhead for Sync Batched is lower than for Async Batched. But difference between hot and cold type-checking overhead outweights the difference between sync vs. async communication.
So, in the hot scenario, Sync Batched overhead + hot checker call is faster than the Async Batched overhead. In the cold scenario, Sync Batched overhead + cold checker call is slower than the Async Batched overhead, and, thanks to parallelization, FFI Async Batched is faster in cold scenario.
As I already mentioned, it's hard to measure the actual performance of linting without implementing several rules and running them. However, we can get a rough understanding of the top and bottom lines of performance in best- and worst-case scenarios. The real-world performance will be somewhere in between these ranges.
| Approach | Slowdown (the less the better) |
|---|---|
| Builtin | 1x |
| Native | 1.1-1.8x |
| FFI Sync | 1.4-3.8x |
| FFI Sync Batched | 2.2-10.1x |
| FFI Async Batched | 2.1-13.8x |
| IPC Sync | 48-349x |
| IPC Async Batched | 2.4-15.8x |
| Strada | 3.3-3.7x |
I won't say that FFI Sync is (1.4 + 3.8) / 2 = 2.6 times slower than Builtin, because it's impossible (as far as I can judge) to predict the time spent in the checker in real-world scenarios without actually implementing them. So, it's up to the reader to make these predictions.
Remember that this benchmark has several assumptions:
- AST transfer & materialization are not accounted for
- The binary size of an encoded type is much smaller than it will be in reality
- We're relying on a raw pointer dereference hack (not sure if the TS team will agree to incorporate this)
The only realistic conclusions that could be drawn from this are:
- FFI Sync is at least not slower than Strada, which is good news. Also, since Node-API addons can be accessed from multiple worker threads, FFI Sync can be easily parallelized (similar to tsgolint and typescript-go itself -- each thread gets its own dedicated checker).
- IPC Sync is a performance killer. There is no place for it in real-world usage.
- FFI Sync is by far the most performant approach among tsgo-based JS clients. Its DX is also the best of group.
- The difference between FFI Sync Batched, FFI Async Batched, IPC Async Batched is negligible. Considering the superior DX of IPC Async Batched (which uses a standalone binary executable), it looks like the most appealing approach among them.
- The Native approach is the only fast enough approach compared to the others.
Here is my ✨vibes-based✨ illustration of Speed vs. DX:
Well, it turns out that in a world where some people are talking about achieving AGI, it's still hard to establish effective communication between two programs running on the same computer...
Looks like FFI Sync is the best choice for linters. Additionally, it allows the use of plugins written in native compiled languages!
Regarding raw pointer dereferences and the troublesome versioning strategy, my take is: if it improves the performance for the end user, we should implement it.
In my opinion, the hypothetical lack of safety and an unconvenient versioning strategy can be dealt with if this would allow us to save time for the end users. Correctness and speed - this is what matters in the end!
No, we can't. TypeScript's checker has a very dynamic nature and even if we resolved types for every possible node, we would still have to communicate with the checker to dynamically perform certain operations on a type.
Examples:
getAwaitedType(type)- resolves the type of awaited Promise-like typeisTypeAssignableTo(source, target)- performs complex type relations checks
Also, see related issue.
The FFI approach was rejected by the TS team because the Go runtime doesn't support simultaneously loading several shared libraries (.so or .node built with go build -buildmode=c-shared) into one process.
While some programs may want to use different versions of tsgo in one JS program, this doesn't apply to linters. Why would someone want to lint their files with two different versions of tsgo? Someone would definitely want this, but the 99.99% of linter users don't need this.
What is more important for us is that by loading tsgo as a shared library, we would block loading of any other Go shared libraries for lint users. This would probably affect only Astro linting because its compiler currently is written in Go. However, there is an effort to rewriting it in Rust, so in the future, hopefully, typescript-go would be the only Go shared library that we would want to load.
The Go WASM target doesn't perform as well as native targets (1, 2). Therefore, compiling typescript-go to WASM for non-browser workflows (running locally on developers' machines and in CI) provides no benefits.
The WASM target can be useful for usecases like website linter playgrounds, where speed is not critical.
As an alternative to materializing the entire AST into JS objects, I thought it could be possible to visit the AST with a cursor.
Instead of creating a JS object and accessing its properties, we could access AST node properties directly from the underlying binary AST representation.
This way, we can save some time and memory.
const ast = getFileAST()
const sourceFileOffset = 0
const statementsCount = ast.sourceFile.statementsCount(sourceFileOffset)
for (let i = 0; i++; i < statementsCount) {
const statementOffset = ast.sourceFile.statementAt(sourceFileOffset, i)
const statementKind = ast.kind(statementOffset)
if (statementKind === SyntaxKind.Identifier) {
const text = ast.identifier.text(statementOffset)
const pos = ast.pos(statementOffest)
console.log(`found identifier with text ${text} at ${pos}`)
}
}But this is terrible DX, and creating JS objects for every AST node is not that slow after all.
In order to save time on communication, a linter could provide some sort of DSL, similar to ESQuery. The linter could implement a small VM that would evaluate a given DSL query without leaving Go runtime boundaries. It's hard to say how this DSL could possibly look like, but it could probably allow the following things:
- Querying AST nodes by their attributes/childs/parents/siblings
- Getting types/symbols of these AST nodes
- Accessing type constituents of types
- Requesting type operations on types
- Accessing AST nodes of symbols
While this could potentially work, it would require a significant amount of time to research and implement. My biggest concern is that it would be nearly impossible to debug. In order to debug it, rule authors would have to run linter with the Delve debugger and step through VM instructions to inspect the state of query execution.
Although, the linter could provide an online playground where a rule author can put their DSL query and execute it step by step, but this is a whole other story.
Did you know that tsgolint could have JS plugins support + ESLint compat from day one?
I wrote a 100% compliant TS AST to ESTree AST converter in Go, an ESLint config loader, and patched the goja (a JS engine written in pure Go) to reduce the number of allocations in Go interop.
tsgolint's JS integration was able to pass 100% of ESLint core tests!
However... I can't find screenshots, but I remember that it was several times slower than ESLint. This is why I abandoned this idea.
Another option is to embed one of the Lua interpreters written in Go, for example shopify/go-lua.
But Go is not the best language for writing performant interpreters, and it's also hard to debug rules that are evaluated inside a Go VM.
So this doesn't look like a feasible idea.
If you're using Nix, you can just do
nix develop .to enter the shell with all required packages.
Otherwise, you'll need:
- Node.js 24+
- Go 1.26+
- Odin
- GNU Make
- gnuplot
- rustc & Cargo
- GCC
I can't say whether it would work on macOS or not, but maybe. It most definitely wouldn't work as-is on Windows, though - so if you're on Windows, you need to run it under WSL.
Please make sure that you have submodules initialized:
git submodule update --init --depth 1Commands to run:
# install deps
make setup
# build native packages
make build
# run benchmarks and collect data
make collect
# render charts based on collected data
make render
# run setup, build, collect, and render at once
make

















