@nite-framework/nite-zk-profiler 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/dist/analyze.d.ts +16 -0
- package/dist/analyze.js +18 -0
- package/dist/budget.d.ts +33 -0
- package/dist/budget.js +77 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +130 -0
- package/dist/compile.d.ts +16 -0
- package/dist/compile.js +38 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +20 -0
- package/dist/measure.d.ts +23 -0
- package/dist/measure.js +67 -0
- package/dist/report.d.ts +9 -0
- package/dist/report.js +74 -0
- package/dist/toolchain.d.ts +24 -0
- package/dist/toolchain.js +101 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 codeBigInt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
# nite-zk-profiler
|
|
2
|
+
|
|
3
|
+
See what a Compact circuit costs to prove, while you are still writing it.
|
|
4
|
+
|
|
5
|
+
No proving keys. No real proof. A small contract reports in under a second, and a thirteen circuit production contract in about thirty, nearly all of which is the Compact compiler itself. Measuring the same contract by generating proving keys takes far longer.
|
|
6
|
+
|
|
7
|
+
> Every number and command output in this document was captured from a real toolchain run on Compact 0.31.1, not invented. See [Verified mechanism](#verified-mechanism) for the raw evidence.
|
|
8
|
+
|
|
9
|
+
## The problem
|
|
10
|
+
|
|
11
|
+
Proving cost does not rise smoothly with circuit size. It rises in steps.
|
|
12
|
+
|
|
13
|
+
Every circuit is assigned a `k`. The prover works over `2^k` rows, and everything you pay for, proving time and peak memory, is set by `k`. Each step up in `k` roughly doubles the bill.
|
|
14
|
+
|
|
15
|
+
The trap is that **`k` is not a function of how much code you wrote.** It is set by which ledger operations and witness calls your circuit performs, and by the shape of the data they touch. Measured on real circuits:
|
|
16
|
+
|
|
17
|
+
| circuit | what it does | rows | k | relative cost |
|
|
18
|
+
| --- | --- | --- | --- | --- |
|
|
19
|
+
| `bump` | one Counter increment | 24 | 5 | 1x |
|
|
20
|
+
| `manyIncrements` | twelve Counter increments | 24 | 7 | 4x |
|
|
21
|
+
| `justAdd` | Counter increment with a cast | 191 | 8 | 8x |
|
|
22
|
+
| `justCompare` | one Boolean write from one comparison | 119 | 9 | 16x |
|
|
23
|
+
| `balanceOf` | one Map lookup | 305 | 9 | 16x |
|
|
24
|
+
| `register` | Map insert plus Counter increment | 368 | 9 | 16x |
|
|
25
|
+
| `insert32` | one MerkleTree insert | 2299 | 13 | 256x |
|
|
26
|
+
| `prove32` | one MerkleTree path check, depth 32 | 3551 | 13 | 256x |
|
|
27
|
+
|
|
28
|
+
Read that table twice. `manyIncrements` does twelve times the work of `bump` in exactly the same 24 rows, and still costs four times as much to prove. `justCompare` writes a single boolean and costs sixteen times `bump`. `justAdd` uses more rows than `justCompare` and costs half as much. Touching a MerkleTree at all costs 256 times a counter increment.
|
|
29
|
+
|
|
30
|
+
None of this is visible in the source. None of it is proportional to lines of code. And today the only way to find out is to generate proving keys and time a real proof, which is far too slow to sit in an edit loop. So the expensive choice gets made early, silently, and shows up much later as a user facing performance problem.
|
|
31
|
+
|
|
32
|
+
This tool makes `k` visible while you are still choosing your data model.
|
|
33
|
+
|
|
34
|
+
## What the tool does
|
|
35
|
+
|
|
36
|
+
It reports the cost class of every circuit in your contract.
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
$ nite-zk profile Sample.compact
|
|
40
|
+
|
|
41
|
+
circuit rows k capacity cost
|
|
42
|
+
bump 24 5 32 1x
|
|
43
|
+
balanceOf 305 9 512 16x
|
|
44
|
+
register 368 9 512 16x
|
|
45
|
+
insert32 2299 13 8192 256x
|
|
46
|
+
|
|
47
|
+
4 circuits, toolchain 0.31.1, zkir 2.1.0, 0.4s
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`k` is the number that matters. `cost` is `2^k` expressed relative to the cheapest circuit in the contract, so you can see at a glance which circuits dominate your proving budget.
|
|
51
|
+
|
|
52
|
+
It also supports a committed cost budget, so an unintended increase fails CI instead of shipping:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
$ nite-zk check
|
|
56
|
+
|
|
57
|
+
transferFunds k 16 budget 15 over by 1, about 2x
|
|
58
|
+
proveMembership k 16 budget 16 at budget
|
|
59
|
+
setConfig k 9 budget 9 at budget
|
|
60
|
+
|
|
61
|
+
FAIL: 1 circuit over budget
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The budget is a ceiling you declare, not a snapshot of the last run. Raising `k` on purpose means raising the ceiling in the same commit, where a reviewer sees it.
|
|
65
|
+
|
|
66
|
+
## Scope
|
|
67
|
+
|
|
68
|
+
In scope for this release:
|
|
69
|
+
|
|
70
|
+
- Per circuit `rows`, `k`, capacity, and relative cost
|
|
71
|
+
- `zk-budget.json` declared cost ceilings: write them, and check against them
|
|
72
|
+
- Nonzero exit when a circuit exceeds its declared `maxK`, so it drops into CI as one step
|
|
73
|
+
- Multi file contracts
|
|
74
|
+
- A measured cost reference for Compact constructs, so a developer reading a high `k` knows what to do about it
|
|
75
|
+
|
|
76
|
+
Explicitly out of scope:
|
|
77
|
+
|
|
78
|
+
- Per line attribution by ablation. Proposed and deliberately cut. It may be proposed separately later.
|
|
79
|
+
- The VS Code extension. The tool is built so an extension could consume its JSON output later, but that is not part of this work.
|
|
80
|
+
|
|
81
|
+
## Architecture
|
|
82
|
+
|
|
83
|
+
The tool is a thin, honest wrapper around two programs that already ship with the Compact toolchain. It does not model proving cost itself, it does not reimplement any part of the compiler, and it does not guess. It runs the shipped tools and reads what they report.
|
|
84
|
+
|
|
85
|
+
### Pipeline
|
|
86
|
+
|
|
87
|
+
```mermaid
|
|
88
|
+
flowchart TD
|
|
89
|
+
A["Compact source file"] --> B["1. Resolve toolchain"]
|
|
90
|
+
B --> C["2. compact compile --skip-zk"]
|
|
91
|
+
C --> D["out/zkir/*.zkir<br/>one file per exported circuit"]
|
|
92
|
+
D --> E["3. zkir mock-compile-many"]
|
|
93
|
+
E --> F["4. Parse rows and k per circuit"]
|
|
94
|
+
F --> G{"Mode"}
|
|
95
|
+
G -->|"profile"| H["Print table, or JSON"]
|
|
96
|
+
G -->|"save"| I["Write zk-budget.json"]
|
|
97
|
+
G -->|"check"| J["Compare against baseline"]
|
|
98
|
+
J --> K{"Any circuit at a higher k?"}
|
|
99
|
+
K -->|"no"| L["exit 0"]
|
|
100
|
+
K -->|"yes"| M["Print diff, exit 1"]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Each stage is described below.
|
|
104
|
+
|
|
105
|
+
### Stage 1: resolve the toolchain
|
|
106
|
+
|
|
107
|
+
This is the most important stage in the tool, and the condition that is easiest to get wrong.
|
|
108
|
+
|
|
109
|
+
`zkir` is not on your `PATH`. It ships inside each installed toolchain version:
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
~/.compact/versions/0.31.1/x86_64-unknown-linux-musl/
|
|
113
|
+
zkir (midnight-zkir 2.1.0, reads IR version 2.0)
|
|
114
|
+
zkir-v3 (midnight-zkir-v3 3.0.0, reads IR version 3.0)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Two binaries, identical command line interfaces, incompatible IR formats. If the profiler ever picks a `zkir` from a different toolchain than the compiler that produced the IR, the results are meaningless or the run breaks outright.
|
|
118
|
+
|
|
119
|
+
So the binary is always resolved from the version directory of the compiler that actually ran:
|
|
120
|
+
|
|
121
|
+
```mermaid
|
|
122
|
+
flowchart TD
|
|
123
|
+
A["Start"] --> B["Determine compiler version<br/>(+VERSION arg, or the configured default)"]
|
|
124
|
+
B --> C["Resolve artifact root<br/>COMPACT_DIRECTORY, else ~/.compact"]
|
|
125
|
+
C --> D["versions/VERSION/TARGET/"]
|
|
126
|
+
D --> E{"Version supported?"}
|
|
127
|
+
E -->|"no"| F["Error: unsupported toolchain,<br/>name the version and what is supported"]
|
|
128
|
+
E -->|"yes"| G["Select the zkir in that same directory"]
|
|
129
|
+
G --> H["Confirm with zkir --version"]
|
|
130
|
+
H --> I["Use it for every mock-compile in this run"]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Three rules follow, and they are load bearing:
|
|
134
|
+
|
|
135
|
+
1. **Never resolve `zkir` from `PATH`.** Only from the version directory.
|
|
136
|
+
2. **Never fall back to another version.** If the paired binary is missing, that is an error, not a reason to reach for a neighbour.
|
|
137
|
+
3. **Refuse unknown versions up front,** naming the version found and the versions supported, rather than producing a number that looks plausible and is wrong.
|
|
138
|
+
|
|
139
|
+
The failure this prevents is real and it is quiet. Running the v3 binary against v2 IR does not fail cleanly at the start:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
$ zkir-v3 mock-compile-many out/zkir
|
|
143
|
+
Mock compiling 2 circuits:
|
|
144
|
+
circuit "balanceOf"Error: Unhandled version: 2.0
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
It prints the beginning of a normal, successful looking report, then dies partway through the first circuit. A parser that reads line by line without checking the exit code would report a partial result as a complete one. The tool checks the exit code on every invocation and treats a truncated report as a hard failure.
|
|
148
|
+
|
|
149
|
+
### Stage 2: compile without proving keys
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
compact compile --skip-zk <source> <outdir>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`--skip-zk` is what makes this fast enough to sit in an edit loop. It produces the intermediate representation and skips proving key generation entirely, which is the slow part.
|
|
156
|
+
|
|
157
|
+
The output directory looks like this:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
out/
|
|
161
|
+
zkir/
|
|
162
|
+
balanceOf.zkir <- one file per exported circuit
|
|
163
|
+
register.zkir
|
|
164
|
+
compiler/
|
|
165
|
+
contract-info.json
|
|
166
|
+
contract/
|
|
167
|
+
index.js, index.d.ts, index.js.map
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Only `out/zkir/` matters here. The tool compiles into a temporary directory by default so it never disturbs your project's own build output.
|
|
171
|
+
|
|
172
|
+
Two failure modes get explicit handling:
|
|
173
|
+
|
|
174
|
+
- **A contract with no provable circuits emits no `out/zkir/` directory at all.** Circuits that touch neither the ledger nor a witness compile to nothing and need no proof. Running `zkir` at that point produces a bare `Error: No such file or directory`, which explains nothing. The tool detects the empty case first and reports that the contract has no provable circuits.
|
|
175
|
+
- **A compile error exits 255** through the launcher, with diagnostics on stdout. Those are surfaced as is rather than being reworded, since the compiler's own messages are better than anything the tool would invent.
|
|
176
|
+
|
|
177
|
+
### Stage 3: measure
|
|
178
|
+
|
|
179
|
+
```text
|
|
180
|
+
zkir mock-compile-many out/zkir
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
One invocation covers every circuit, and it reports circuit names directly:
|
|
184
|
+
|
|
185
|
+
```text
|
|
186
|
+
Mock compiling 2 circuits:
|
|
187
|
+
circuit "balanceOf" (k=9, rows=305)
|
|
188
|
+
circuit "register" (k=9, rows=368)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Two details drive the implementation:
|
|
192
|
+
|
|
193
|
+
- **This is written to stderr, not stdout.** Reading stdout gives you an empty string and a report with zero circuits. That is a silent wrong answer rather than a crash, so it is worth stating plainly.
|
|
194
|
+
- **`mock-compile-many` is preferred over per file `mock-compile`.** The single file form reports the full file path instead of the circuit name, which would leave the tool recovering names from filenames. The batch form gives clean names and costs one process instead of one per circuit.
|
|
195
|
+
|
|
196
|
+
### Stage 4: analyse
|
|
197
|
+
|
|
198
|
+
`rows` and `k` are both read from the `zkir` output. **`k` is never computed locally, because it cannot be.**
|
|
199
|
+
|
|
200
|
+
This is the central design constraint of the tool, and it is worth being precise about. It would be natural to assume `k = ceil(log2(rows))` and derive everything from a row count. The measurements say otherwise:
|
|
201
|
+
|
|
202
|
+
- `bump` and `manyIncrements` both use **24 rows**, and report **k=5** and **k=7**.
|
|
203
|
+
- `justCompare` uses **119 rows** at **k=9**, while `justAdd` uses **191 rows** at **k=8**. More rows, lower `k`.
|
|
204
|
+
|
|
205
|
+
So any formula mapping rows to `k` is wrong, and a tool built on one would report confident, incorrect costs. `k` comes from `zkir` or it does not come at all.
|
|
206
|
+
|
|
207
|
+
The same evidence rules out a headroom metric. There is no honest way to say "this circuit has N rows left before its cost doubles," because rows are not what pushes a circuit to the next `k`. Reporting one would be inventing a number.
|
|
208
|
+
|
|
209
|
+
The only derived value is capacity:
|
|
210
|
+
|
|
211
|
+
```text
|
|
212
|
+
capacity = 2^k
|
|
213
|
+
relative cost = 2^(k - lowest k in this contract)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`k` is stable and deterministic per circuit. The same circuit reports the same `k` across reruns, and is unaffected by which other circuits share the contract, which is what makes it sound to use as a committed budget.
|
|
217
|
+
|
|
218
|
+
### Stage 5: report or gate
|
|
219
|
+
|
|
220
|
+
Three modes over the same measurement.
|
|
221
|
+
|
|
222
|
+
```mermaid
|
|
223
|
+
flowchart LR
|
|
224
|
+
A["Measurements"] --> B["profile"]
|
|
225
|
+
A --> C["save"]
|
|
226
|
+
A --> D["check"]
|
|
227
|
+
B --> E["Human table, or --json<br/>exit 0"]
|
|
228
|
+
C --> F["zk-budget.json<br/>committed to the repo"]
|
|
229
|
+
D --> G["Compare to declared budget"]
|
|
230
|
+
F -.->|"read back"| G
|
|
231
|
+
G --> H["exit 0 or exit 1"]
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**The gate compares against a declared budget, not against the last run.** This distinction is the whole design.
|
|
235
|
+
|
|
236
|
+
A circuit's `k` going up is not automatically a bug. Adding a MerkleTree for stronger privacy, or an extra commitment for replay protection, raises `k` and is supposed to. A tool that fails the build every time cost rises for a good reason gets switched off within a week, and then it protects nothing.
|
|
237
|
+
|
|
238
|
+
So `zk-budget.json` records the cost you have consciously accepted:
|
|
239
|
+
|
|
240
|
+
```json
|
|
241
|
+
{
|
|
242
|
+
"toolchain": "0.31.x",
|
|
243
|
+
"circuits": {
|
|
244
|
+
"proveMembership": { "maxK": 16 },
|
|
245
|
+
"transferFunds": { "maxK": 15 },
|
|
246
|
+
"setConfig": { "maxK": 9 }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`check` fails only when a circuit exceeds its declared `maxK`. Raising a ceiling on purpose is a one line edit committed alongside the change that caused it:
|
|
252
|
+
|
|
253
|
+
```diff
|
|
254
|
+
- "transferFunds": { "maxK": 15 }
|
|
255
|
+
+ "transferFunds": { "maxK": 16 }
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
That line is the point. It turns a silent 2x proving cost increase into something a reviewer sees and approves, without ever blocking a deliberate design decision. The gate is an acknowledgement mechanism, not a prohibition.
|
|
259
|
+
|
|
260
|
+
Behavior in the remaining cases:
|
|
261
|
+
|
|
262
|
+
- **Circuit under budget:** pass, and report the slack so an over generous ceiling is visible.
|
|
263
|
+
- **New circuit not in the budget:** pass with a warning by default, fail under `--strict` so mature projects can require every circuit to be declared.
|
|
264
|
+
- **Circuit removed from the contract:** pass, with a note that the budget entry is stale.
|
|
265
|
+
- **Row growth within the same `k`:** reported, never fails. Rows do not predict `k`, so a row threshold would fire on changes that cost nothing and stay silent on changes that cost sixteen times more.
|
|
266
|
+
|
|
267
|
+
## Cost model
|
|
268
|
+
|
|
269
|
+
Every construct below was measured by compiling one factor at a time into an otherwise fixed contract and reading the row delta. Marginal cost per occurrence, on Compact 0.31.1:
|
|
270
|
+
|
|
271
|
+
| construct | rows each |
|
|
272
|
+
| --- | --- |
|
|
273
|
+
| `persistentHash<Vector<n, _>>` | `1956 + 1930 * ceil(n/2)`, plus 33 when `n` is odd |
|
|
274
|
+
| `persistentCommit<S>` where `S` has `f` fields | same as `persistentHash` of width `f + 1` |
|
|
275
|
+
| `MerkleTree.insert` | 1996 |
|
|
276
|
+
| `witness` call | 258 |
|
|
277
|
+
| `transientHash<Vector<n, _>>` | 22 per element |
|
|
278
|
+
| `if / else` block | 31 |
|
|
279
|
+
| `if` block | 19 |
|
|
280
|
+
| `assert` on a ledger value | 13 |
|
|
281
|
+
| ledger read, `Bytes<32>` | 12 |
|
|
282
|
+
| `assert` on a public value | 11 |
|
|
283
|
+
| ternary | 10 |
|
|
284
|
+
| `Map.member`, `Set.member` | 1 |
|
|
285
|
+
| `Map.insert`, `Set.insert`, ledger write | 0 |
|
|
286
|
+
|
|
287
|
+
The spread is four orders of magnitude. One `persistentHash` of four inputs costs more than five hundred asserts.
|
|
288
|
+
|
|
289
|
+
Validated outside the synthetic probes: removing a single `persistentHash<Vector<4, Bytes<32>>>` from a 26687 row circuit dropped it to 20862, a delta of 5825 rows. The table predicts 5817. That is 0.14% error.
|
|
290
|
+
|
|
291
|
+
### Choosing between alternatives
|
|
292
|
+
|
|
293
|
+
The cheap option is often the wrong option. Each lever below is stated with the constraint first, because every one of them has a case where taking it is a correctness or upgrade safety bug rather than a saving.
|
|
294
|
+
|
|
295
|
+
#### Transient versus persistent hashing
|
|
296
|
+
|
|
297
|
+
This is the largest cost difference in Compact, and the most dangerous to apply blindly.
|
|
298
|
+
|
|
299
|
+
| primitive | rows, 2 inputs | returns | guarantee |
|
|
300
|
+
| --- | --- | --- | --- |
|
|
301
|
+
| `persistentHash` | 3886 | `Bytes<32>` | stable across upgrades, except on devnet |
|
|
302
|
+
| `transientHash` | 44 | `Field` | **not** guaranteed stable across upgrades |
|
|
303
|
+
|
|
304
|
+
The runtime documentation is explicit about which to use, and it is a semantic rule, not a performance one:
|
|
305
|
+
|
|
306
|
+
- `persistentHash` and `persistentCommit` "should be used to derive state data, and not for consistency checks where avoidable."
|
|
307
|
+
- `transientHash` and `transientCommit` "should not be used to derive state data, but can be used for consistency checks."
|
|
308
|
+
|
|
309
|
+
**The pitfall.** Anything written into ledger state, or compared against a value that outlives the transaction, is state data and must use the persistent form. A replay protection key inserted into a `Set<Bytes<32>>` and checked on a later transaction is state data. Swapping it to `transientHash` makes the contract cheaper and breaks it on the next toolchain upgrade, silently, with no compile error and no failing test until the upgrade lands.
|
|
310
|
+
|
|
311
|
+
**The trap that looks like a workaround.** `upgradeFromTransient` converts a `Field` back to `Bytes<32>` for 361 rows, so `transientHash` plus `upgradeFromTransient` costs 405 rows against `persistentHash`'s 3886, a tempting 9.6x. Do not read this as a cheap persistent hash. The function upgrades the *representation*, not the *stability guarantee*, and the value is still derived from a hash the documentation says may change between upgrades. The type system will not stop you. Treat this pattern as unsafe for state data unless Midnight documents otherwise.
|
|
312
|
+
|
|
313
|
+
**A lever that may be safe, not yet demonstrated end to end.** `degradeToTransient` measured at **zero rows** in isolation, and `transientHash` at 44 rows against `persistentHash`'s 3886. That suggests the documented split is also the cheap one: derive state with `persistentHash` once, then degrade for free and run in circuit consistency checks transiently.
|
|
314
|
+
|
|
315
|
+
Stated precisely, because the distinction matters: what is measured is the cost of each primitive on its own. What is **not** measured is a real circuit rewritten this way and confirmed to produce equivalent results for fewer rows. Treat the pattern as a hypothesis with good supporting numbers, and measure your own circuit before and after rather than trusting the arithmetic.
|
|
316
|
+
|
|
317
|
+
Two further constraints on the persistent forms: they throw at runtime on data containing `Opaque` types, and `persistentCommit` requires an opening of exactly 32 bytes.
|
|
318
|
+
|
|
319
|
+
#### Hash arity
|
|
320
|
+
|
|
321
|
+
Cost steps every two inputs, so widening an even width hash by one is nearly free (33 rows) while widening an odd one is not (1897 rows).
|
|
322
|
+
|
|
323
|
+
**The pitfall.** Arity is not a free tuning knob. The number and order of inputs define what the hash commits to, so changing arity changes every value it derives. If those values are in ledger state, that is a breaking state migration, not an optimization. And dropping an input to land on a cheaper parity can destroy domain separation and open a collision between two things that were previously distinct. Only spend this when you are adding a genuinely useful binding and the current width happens to be even.
|
|
324
|
+
|
|
325
|
+
#### Combining hashes
|
|
326
|
+
|
|
327
|
+
Four `persistentHash<Vector<2>>` calls cost 15544 rows. One `persistentHash<Vector<8>>` over the same eight values costs 9676, a 38% saving.
|
|
328
|
+
|
|
329
|
+
**The pitfall.** This only applies when the intermediate hashes are not individually needed. If each narrow hash is separately stored, separately compared, or separately disclosed, merging them changes the contract's semantics. Merging is safe with respect to collisions here only because `Vector<n, Bytes<32>>` is fixed width and positional, so there is no concatenation ambiguity. Do not generalise the trick to variable length or attacker influenced inputs.
|
|
330
|
+
|
|
331
|
+
#### Ledger reads and writes
|
|
332
|
+
|
|
333
|
+
Writes, `Map.insert` and `Set.insert` all measured at **zero** marginal rows, because they are recorded as public ledger operations rather than proven in circuit. Reads cost 12 rows each, and reading the same field twice pays twice.
|
|
334
|
+
|
|
335
|
+
**The pitfall, and it is a big one.** Zero *proving* rows does not mean free. This tool measures one cost dimension. Ledger writes still consume on chain state, transaction size, and fees, and a developer who reads "writes are free" here and starts writing liberally to state will pay for it somewhere this tool does not look. The measurement is narrow and true; the conclusion "so write more" does not follow.
|
|
336
|
+
|
|
337
|
+
Hoisting a repeated read into a local is a real saving, but confirm it is a safe rewrite in your circuit before applying it, since caching a value changes behavior if anything between the two reads could alter what the second one would have seen.
|
|
338
|
+
|
|
339
|
+
#### Control flow
|
|
340
|
+
|
|
341
|
+
A ternary costs 10 rows and an `if / else` costs 31. Ternary is genuinely three times cheaper, and both are noise next to a single hash call. Restructuring branches while a `persistentHash` sits in the same circuit is optimising the wrong thing by a factor of roughly four hundred.
|
|
342
|
+
|
|
343
|
+
> These figures describe Compact 0.31.1 and will move with the toolchain. They are measured, reproducible documentation, not something the tool computes at runtime. Automated attribution of cost back to source constructs is the ablation feature that was cut from this release.
|
|
344
|
+
|
|
345
|
+
### Multi file contracts
|
|
346
|
+
|
|
347
|
+
Supported, and requiring no extra configuration. Relative imports resolve against the importing file's directory, so the compiler walks the import graph itself from a single entry point:
|
|
348
|
+
|
|
349
|
+
```text
|
|
350
|
+
src/
|
|
351
|
+
Main.compact <- entry point, the file you profile
|
|
352
|
+
lib/
|
|
353
|
+
Math.compact <- import "./lib/Math";
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Two things follow from how the compiler handles this:
|
|
357
|
+
|
|
358
|
+
- **An imported file must contain a single `module` definition and nothing else.** A `pragma` or top level `import` in a module file fails with `does not contain a (single) module defintion`.
|
|
359
|
+
- **Only the entry contract's exported circuits produce ZKIR.** An imported helper is inlined into whichever circuit calls it, so its cost is attributed to the circuit that actually pays for it. That is the correct behavior for a profiler: you see the true cost of each entry point, helpers included.
|
|
360
|
+
|
|
361
|
+
## Supported toolchain versions
|
|
362
|
+
|
|
363
|
+
| Compact toolchain | zkir | Status |
|
|
364
|
+
| --- | --- | --- |
|
|
365
|
+
| 0.31.x | 2.1.0 (IR 2.0) | Supported, verified on 0.31.1 |
|
|
366
|
+
| 0.33.x | not yet released | Committed to support when it lands |
|
|
367
|
+
|
|
368
|
+
Anything outside this range is rejected with a clear error naming the version found. The tool will not produce a number it cannot stand behind.
|
|
369
|
+
|
|
370
|
+
## Command line surface
|
|
371
|
+
|
|
372
|
+
```text
|
|
373
|
+
nite-zk profile <source> Report rows, k and relative cost per circuit
|
|
374
|
+
nite-zk save <source> Write zk-budget.json from current measurements
|
|
375
|
+
nite-zk check [<source>] Measure and compare against zk-budget.json
|
|
376
|
+
|
|
377
|
+
--json Machine readable output
|
|
378
|
+
--out <dir> Compile into a specific directory
|
|
379
|
+
--budget <file> Use a different baseline path
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Designed to work as a single CI step:
|
|
383
|
+
|
|
384
|
+
```yaml
|
|
385
|
+
- run: npx @nite-framework/nite-zk-profiler check src/Main.compact
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`profile` and `check` exit 0 on success and 1 on failure, so no wrapper script is needed. `check` fails only when a circuit exceeds its declared `maxK`, or, under `--strict`, when a circuit is missing from the budget entirely.
|
|
389
|
+
|
|
390
|
+
## Installing
|
|
391
|
+
|
|
392
|
+
```text
|
|
393
|
+
npm install --save-dev @nite-framework/nite-zk-profiler
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The published command is `nite-zk` regardless of the package name. The package ships as ES modules and runs on Node 20 or newer, and on Bun. It expects a supported Compact toolchain already installed, since it drives `compact` and the `zkir` that ships beside it rather than bundling either.
|
|
397
|
+
|
|
398
|
+
## Development
|
|
399
|
+
|
|
400
|
+
```text
|
|
401
|
+
npm install
|
|
402
|
+
npm test # unit tests, plus integration tests against the real toolchain
|
|
403
|
+
npm run typecheck
|
|
404
|
+
npm run build
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Tests are TypeScript and run directly through Node's native type stripping, so development needs Node 22.18 or newer. The published package is plain JavaScript and runs on Node 20.
|
|
408
|
+
|
|
409
|
+
The integration tests skip themselves when no supported compiler is present, so the suite still runs on a machine without one.
|
|
410
|
+
|
|
411
|
+
## Verified mechanism
|
|
412
|
+
|
|
413
|
+
Captured on Compact toolchain 0.31.1, language version 0.23.0, before any code was written.
|
|
414
|
+
|
|
415
|
+
Toolchain behavior:
|
|
416
|
+
|
|
417
|
+
- `compact compile --skip-zk` emits `out/zkir/<circuit>.zkir`, one file per exported circuit.
|
|
418
|
+
- `zkir mock-compile-many <dir>` reports `(k=N, rows=N)` per named circuit, **on stderr**.
|
|
419
|
+
- `zkir` reports `midnight-zkir 2.1.0`; `zkir-v3` reports `midnight-zkir-v3 3.0.0-rc.1`.
|
|
420
|
+
- v3 against v2 IR fails with `Error: Unhandled version: 2.0`, exit 1, after emitting partial output.
|
|
421
|
+
- A contract with no ledger or witness usage emits no `out/zkir/` directory at all.
|
|
422
|
+
- Compile errors exit 255 through the launcher.
|
|
423
|
+
- `mock-compile-many` over two circuits completes in roughly 50ms.
|
|
424
|
+
|
|
425
|
+
Cost model:
|
|
426
|
+
|
|
427
|
+
- `k` is not derivable from `rows`. Equal row counts produce different `k`; higher row counts produce lower `k`.
|
|
428
|
+
- `k` is deterministic per circuit, and unaffected by the other circuits in the same contract.
|
|
429
|
+
- `rows` sets a floor on `k` but does not determine it. `k` is at least `ceil(log2(rows))` across every circuit measured, and sometimes higher, so rows are the lever you pull while `k` is the price you pay.
|
|
430
|
+
- MerkleTree depth changes `rows` by roughly 31 per level (2807, 3055, 3551 rows at depths 8, 16, 32) without changing `k` across that range. The `k` class was already set by the tree operation itself.
|
|
431
|
+
- Every figure in the cost model table was produced by one factor at a time ablation against a fixed contract, at occurrence counts of 1, 2 and 4, and was linear in all cases.
|
|
432
|
+
|
|
433
|
+
Across the contracts used to develop this tool, circuits ranged from k=9 to k=17, a 256x spread in proving cost, and in every case the expensive circuits were expensive because of hashing rather than size.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Measurement } from "./measure.ts";
|
|
2
|
+
export interface CircuitCost extends Measurement {
|
|
3
|
+
/** Rows the prover works over: 2^k. */
|
|
4
|
+
capacity: number;
|
|
5
|
+
/** Cost relative to the cheapest circuit in this contract. */
|
|
6
|
+
relativeCost: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Derive the reportable numbers.
|
|
10
|
+
*
|
|
11
|
+
* `k` is read from zkir and never recomputed. It is not a function of `rows`:
|
|
12
|
+
* equal row counts have been observed at different `k`, and higher row counts
|
|
13
|
+
* at lower `k`. Any local formula would be confidently wrong, so the only
|
|
14
|
+
* derived values here are the ones that follow from `k` itself.
|
|
15
|
+
*/
|
|
16
|
+
export declare function analyze(measurements: Measurement[]): CircuitCost[];
|
package/dist/analyze.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the reportable numbers.
|
|
3
|
+
*
|
|
4
|
+
* `k` is read from zkir and never recomputed. It is not a function of `rows`:
|
|
5
|
+
* equal row counts have been observed at different `k`, and higher row counts
|
|
6
|
+
* at lower `k`. Any local formula would be confidently wrong, so the only
|
|
7
|
+
* derived values here are the ones that follow from `k` itself.
|
|
8
|
+
*/
|
|
9
|
+
export function analyze(measurements) {
|
|
10
|
+
if (measurements.length === 0)
|
|
11
|
+
return [];
|
|
12
|
+
const lowestK = Math.min(...measurements.map((m) => m.k));
|
|
13
|
+
return measurements.map((m) => ({
|
|
14
|
+
...m,
|
|
15
|
+
capacity: 2 ** m.k,
|
|
16
|
+
relativeCost: 2 ** (m.k - lowestK),
|
|
17
|
+
}));
|
|
18
|
+
}
|
package/dist/budget.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CircuitCost } from "./analyze.ts";
|
|
2
|
+
export declare const DEFAULT_BUDGET_PATH = "zk-budget.json";
|
|
3
|
+
export interface Budget {
|
|
4
|
+
/** Toolchain line the ceilings were established against. */
|
|
5
|
+
toolchain: string;
|
|
6
|
+
circuits: Record<string, {
|
|
7
|
+
maxK: number;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
export type Status = "under" | "at" | "over" | "undeclared" | "stale";
|
|
11
|
+
export interface CheckRow {
|
|
12
|
+
circuit: string;
|
|
13
|
+
status: Status;
|
|
14
|
+
k?: number;
|
|
15
|
+
maxK?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface CheckResult {
|
|
18
|
+
rows: CheckRow[];
|
|
19
|
+
failed: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Build a budget that grants every circuit exactly what it currently costs. */
|
|
22
|
+
export declare function budgetFrom(costs: CircuitCost[], toolchainLine: string): Budget;
|
|
23
|
+
export declare function writeBudget(path: string, budget: Budget): void;
|
|
24
|
+
export declare function readBudget(path: string): Budget;
|
|
25
|
+
/**
|
|
26
|
+
* Compare measurements against declared ceilings.
|
|
27
|
+
*
|
|
28
|
+
* The gate is a ceiling, not a diff against the last run. A circuit's `k` rising
|
|
29
|
+
* is often intentional, so only exceeding a ceiling the project has committed to
|
|
30
|
+
* is a failure. Raising a ceiling deliberately is a one line change a reviewer
|
|
31
|
+
* sees, which is the point.
|
|
32
|
+
*/
|
|
33
|
+
export declare function check(costs: CircuitCost[], budget: Budget, strict: boolean): CheckResult;
|
package/dist/budget.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { ProfilerError } from "./errors.js";
|
|
3
|
+
export const DEFAULT_BUDGET_PATH = "zk-budget.json";
|
|
4
|
+
/** Build a budget that grants every circuit exactly what it currently costs. */
|
|
5
|
+
export function budgetFrom(costs, toolchainLine) {
|
|
6
|
+
const circuits = {};
|
|
7
|
+
for (const c of [...costs].sort((a, b) => a.circuit.localeCompare(b.circuit))) {
|
|
8
|
+
circuits[c.circuit] = { maxK: c.k };
|
|
9
|
+
}
|
|
10
|
+
return { toolchain: toolchainLine, circuits };
|
|
11
|
+
}
|
|
12
|
+
export function writeBudget(path, budget) {
|
|
13
|
+
writeFileSync(path, `${JSON.stringify(budget, null, 2)}\n`, "utf8");
|
|
14
|
+
}
|
|
15
|
+
export function readBudget(path) {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = readFileSync(path, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new ProfilerError(`No budget file at ${path}`, "Create one from the current measurements with `nite-zk save <source>`.");
|
|
22
|
+
}
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
throw new ProfilerError(`Could not parse ${path}`, String(e));
|
|
29
|
+
}
|
|
30
|
+
const budget = parsed;
|
|
31
|
+
if (!budget || typeof budget !== "object" || typeof budget.circuits !== "object") {
|
|
32
|
+
throw new ProfilerError(`Malformed budget file: ${path}`, 'Expected {"toolchain": "...", "circuits": {"name": {"maxK": N}}}.');
|
|
33
|
+
}
|
|
34
|
+
for (const [name, entry] of Object.entries(budget.circuits)) {
|
|
35
|
+
if (!entry || typeof entry.maxK !== "number" || !Number.isInteger(entry.maxK)) {
|
|
36
|
+
throw new ProfilerError(`Malformed budget entry for "${name}" in ${path}`, 'Each circuit needs an integer "maxK".');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return budget;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Compare measurements against declared ceilings.
|
|
43
|
+
*
|
|
44
|
+
* The gate is a ceiling, not a diff against the last run. A circuit's `k` rising
|
|
45
|
+
* is often intentional, so only exceeding a ceiling the project has committed to
|
|
46
|
+
* is a failure. Raising a ceiling deliberately is a one line change a reviewer
|
|
47
|
+
* sees, which is the point.
|
|
48
|
+
*/
|
|
49
|
+
export function check(costs, budget, strict) {
|
|
50
|
+
const rows = [];
|
|
51
|
+
let failed = false;
|
|
52
|
+
for (const cost of costs) {
|
|
53
|
+
const declared = budget.circuits[cost.circuit];
|
|
54
|
+
if (!declared) {
|
|
55
|
+
rows.push({ circuit: cost.circuit, status: "undeclared", k: cost.k });
|
|
56
|
+
if (strict)
|
|
57
|
+
failed = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
let status = "under";
|
|
61
|
+
if (cost.k > declared.maxK) {
|
|
62
|
+
status = "over";
|
|
63
|
+
failed = true;
|
|
64
|
+
}
|
|
65
|
+
else if (cost.k === declared.maxK) {
|
|
66
|
+
status = "at";
|
|
67
|
+
}
|
|
68
|
+
rows.push({ circuit: cost.circuit, status, k: cost.k, maxK: declared.maxK });
|
|
69
|
+
}
|
|
70
|
+
const measured = new Set(costs.map((c) => c.circuit));
|
|
71
|
+
for (const name of Object.keys(budget.circuits)) {
|
|
72
|
+
if (!measured.has(name)) {
|
|
73
|
+
rows.push({ circuit: name, status: "stale", maxK: budget.circuits[name].maxK });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { rows, failed };
|
|
77
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
interface Options {
|
|
3
|
+
command?: string;
|
|
4
|
+
source?: string;
|
|
5
|
+
json: boolean;
|
|
6
|
+
strict: boolean;
|
|
7
|
+
out?: string;
|
|
8
|
+
budget: string;
|
|
9
|
+
versionArg?: string;
|
|
10
|
+
help: boolean;
|
|
11
|
+
version: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function parseArgs(argv: string[]): Options;
|
|
14
|
+
export declare function run(argv: string[]): number;
|
|
15
|
+
export declare function main(argv: string[]): number;
|
|
16
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { analyze } from "./analyze.js";
|
|
3
|
+
import { DEFAULT_BUDGET_PATH, budgetFrom, check, readBudget, writeBudget, } from "./budget.js";
|
|
4
|
+
import { compileSkipZk } from "./compile.js";
|
|
5
|
+
import { ProfilerError } from "./errors.js";
|
|
6
|
+
import { measure } from "./measure.js";
|
|
7
|
+
import { checkJson, formatCheck, formatProfile, profileJson } from "./report.js";
|
|
8
|
+
import { SUPPORTED_RANGES, resolveToolchain } from "./toolchain.js";
|
|
9
|
+
const USAGE = `nite-zk - see what a Compact circuit costs to prove
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
nite-zk profile <source> Report rows, k and relative cost per circuit
|
|
13
|
+
nite-zk save <source> Write zk-budget.json from current measurements
|
|
14
|
+
nite-zk check <source> Measure and compare against zk-budget.json
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--json Machine readable output
|
|
18
|
+
--out <dir> Compile into a specific directory (kept)
|
|
19
|
+
--budget <file> Budget path (default: ${DEFAULT_BUDGET_PATH})
|
|
20
|
+
--strict check: fail on circuits missing from the budget
|
|
21
|
+
+VERSION Pin the Compact toolchain, e.g. +0.31.1
|
|
22
|
+
-h, --help Show this message
|
|
23
|
+
-v, --version Show the tool version
|
|
24
|
+
|
|
25
|
+
Supported Compact toolchains: ${SUPPORTED_RANGES.join(", ")}
|
|
26
|
+
`;
|
|
27
|
+
export function parseArgs(argv) {
|
|
28
|
+
const opts = {
|
|
29
|
+
json: false,
|
|
30
|
+
strict: false,
|
|
31
|
+
budget: DEFAULT_BUDGET_PATH,
|
|
32
|
+
help: false,
|
|
33
|
+
version: false,
|
|
34
|
+
};
|
|
35
|
+
for (let i = 0; i < argv.length; i++) {
|
|
36
|
+
const arg = argv[i];
|
|
37
|
+
if (arg === "--json")
|
|
38
|
+
opts.json = true;
|
|
39
|
+
else if (arg === "--strict")
|
|
40
|
+
opts.strict = true;
|
|
41
|
+
else if (arg === "-h" || arg === "--help")
|
|
42
|
+
opts.help = true;
|
|
43
|
+
else if (arg === "-v" || arg === "--version")
|
|
44
|
+
opts.version = true;
|
|
45
|
+
else if (arg === "--out")
|
|
46
|
+
opts.out = argv[++i];
|
|
47
|
+
else if (arg === "--budget")
|
|
48
|
+
opts.budget = argv[++i] ?? DEFAULT_BUDGET_PATH;
|
|
49
|
+
else if (arg.startsWith("+"))
|
|
50
|
+
opts.versionArg = arg;
|
|
51
|
+
else if (!opts.command)
|
|
52
|
+
opts.command = arg;
|
|
53
|
+
else if (!opts.source)
|
|
54
|
+
opts.source = arg;
|
|
55
|
+
}
|
|
56
|
+
return opts;
|
|
57
|
+
}
|
|
58
|
+
/** Compile and measure. Shared by all three commands. */
|
|
59
|
+
function profileSource(source, opts) {
|
|
60
|
+
// Monotonic, so a wall clock adjustment mid run cannot produce a negative
|
|
61
|
+
// or wildly wrong duration.
|
|
62
|
+
const started = performance.now();
|
|
63
|
+
const toolchain = resolveToolchain(opts.versionArg);
|
|
64
|
+
const compiled = compileSkipZk(source, toolchain, opts.out);
|
|
65
|
+
try {
|
|
66
|
+
const costs = analyze(measure(compiled.zkirDir, toolchain, source));
|
|
67
|
+
return { costs, toolchain, elapsedMs: performance.now() - started };
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
compiled.cleanup();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function run(argv) {
|
|
74
|
+
const opts = parseArgs(argv);
|
|
75
|
+
if (opts.help || !opts.command) {
|
|
76
|
+
process.stdout.write(USAGE);
|
|
77
|
+
return opts.command ? 0 : 1;
|
|
78
|
+
}
|
|
79
|
+
if (opts.version) {
|
|
80
|
+
process.stdout.write("nite-zk-profiler 0.1.0\n");
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
if (!["profile", "save", "check"].includes(opts.command)) {
|
|
84
|
+
process.stderr.write(`Unknown command: ${opts.command}\n\n${USAGE}`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
if (!opts.source) {
|
|
88
|
+
process.stderr.write(`${opts.command} needs a source file\n\n${USAGE}`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
const { costs, toolchain, elapsedMs } = profileSource(opts.source, opts);
|
|
92
|
+
if (opts.command === "profile") {
|
|
93
|
+
process.stdout.write(opts.json
|
|
94
|
+
? `${profileJson(costs, toolchain)}\n`
|
|
95
|
+
: formatProfile(costs, toolchain, elapsedMs));
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (opts.command === "save") {
|
|
99
|
+
// Record the supported line rather than the exact patch version, so a
|
|
100
|
+
// routine toolchain bump inside 0.31.x does not invalidate the budget.
|
|
101
|
+
const budget = budgetFrom(costs, `${toolchain.version.split(".").slice(0, 2).join(".")}.x`);
|
|
102
|
+
writeBudget(opts.budget, budget);
|
|
103
|
+
process.stdout.write(`Wrote ${opts.budget} with ${costs.length} circuit${costs.length === 1 ? "" : "s"}\n`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const result = check(costs, readBudget(opts.budget), opts.strict);
|
|
107
|
+
process.stdout.write(opts.json ? `${checkJson(result)}\n` : formatCheck(result));
|
|
108
|
+
return result.failed ? 1 : 0;
|
|
109
|
+
}
|
|
110
|
+
export function main(argv) {
|
|
111
|
+
try {
|
|
112
|
+
return run(argv);
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
if (e instanceof ProfilerError) {
|
|
116
|
+
process.stderr.write(`\nerror: ${e.message}\n`);
|
|
117
|
+
if (e.details)
|
|
118
|
+
process.stderr.write(`\n${e.details}\n`);
|
|
119
|
+
process.stderr.write("\n");
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Only self-execute as a CLI, so the module stays importable from tests.
|
|
126
|
+
const invokedDirectly = process.argv[1] !== undefined &&
|
|
127
|
+
/(?:^|[\\/])(?:cli\.(?:ts|js)|nite-zk)$/.test(process.argv[1]);
|
|
128
|
+
if (invokedDirectly) {
|
|
129
|
+
process.exit(main(process.argv.slice(2)));
|
|
130
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Toolchain } from "./toolchain.ts";
|
|
2
|
+
export interface CompileResult {
|
|
3
|
+
/** Directory the compiler wrote into. */
|
|
4
|
+
outDir: string;
|
|
5
|
+
/** Directory holding `<circuit>.zkir`, whether or not it exists. */
|
|
6
|
+
zkirDir: string;
|
|
7
|
+
/** Remove the output directory, when this tool created a temporary one. */
|
|
8
|
+
cleanup: () => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Compile without proving keys.
|
|
12
|
+
*
|
|
13
|
+
* `--skip-zk` is what makes profiling fast enough to sit in an edit loop: it
|
|
14
|
+
* emits the IR and skips key generation, which is the slow part.
|
|
15
|
+
*/
|
|
16
|
+
export declare function compileSkipZk(source: string, toolchain: Toolchain, outDir?: string): CompileResult;
|
package/dist/compile.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ProfilerError } from "./errors.js";
|
|
6
|
+
/**
|
|
7
|
+
* Compile without proving keys.
|
|
8
|
+
*
|
|
9
|
+
* `--skip-zk` is what makes profiling fast enough to sit in an edit loop: it
|
|
10
|
+
* emits the IR and skips key generation, which is the slow part.
|
|
11
|
+
*/
|
|
12
|
+
export function compileSkipZk(source, toolchain, outDir) {
|
|
13
|
+
const temporary = outDir === undefined;
|
|
14
|
+
const target = temporary
|
|
15
|
+
? mkdtempSync(join(tmpdir(), "nite-zk-"))
|
|
16
|
+
: resolve(outDir);
|
|
17
|
+
const args = ["compile"];
|
|
18
|
+
if (toolchain.versionArg)
|
|
19
|
+
args.push(toolchain.versionArg);
|
|
20
|
+
args.push("--skip-zk", resolve(source), target);
|
|
21
|
+
const res = spawnSync("compact", args, { encoding: "utf8" });
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
if (temporary)
|
|
24
|
+
rmSync(target, { recursive: true, force: true });
|
|
25
|
+
};
|
|
26
|
+
if (res.error) {
|
|
27
|
+
cleanup();
|
|
28
|
+
throw new ProfilerError("Could not run `compact compile`", String(res.error));
|
|
29
|
+
}
|
|
30
|
+
if (res.status !== 0) {
|
|
31
|
+
// The compiler's own diagnostics are better than anything worth inventing
|
|
32
|
+
// here, so they are passed through unchanged.
|
|
33
|
+
const diagnostics = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
|
|
34
|
+
cleanup();
|
|
35
|
+
throw new ProfilerError(`Compilation failed for ${source}`, diagnostics || `compact exited with status ${res.status}`);
|
|
36
|
+
}
|
|
37
|
+
return { outDir: target, zkirDir: join(target, "zkir"), cleanup };
|
|
38
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors that represent a clear, actionable problem for the user.
|
|
3
|
+
* The CLI prints these without a stack trace; anything else is a real crash.
|
|
4
|
+
*/
|
|
5
|
+
export declare class ProfilerError extends Error {
|
|
6
|
+
readonly details?: string;
|
|
7
|
+
constructor(message: string, details?: string);
|
|
8
|
+
}
|
|
9
|
+
/** The contract compiled fine but contains nothing that needs a proof. */
|
|
10
|
+
export declare class NoProvableCircuitsError extends ProfilerError {
|
|
11
|
+
constructor(source: string);
|
|
12
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors that represent a clear, actionable problem for the user.
|
|
3
|
+
* The CLI prints these without a stack trace; anything else is a real crash.
|
|
4
|
+
*/
|
|
5
|
+
export class ProfilerError extends Error {
|
|
6
|
+
details;
|
|
7
|
+
constructor(message, details) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "ProfilerError";
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** The contract compiled fine but contains nothing that needs a proof. */
|
|
14
|
+
export class NoProvableCircuitsError extends ProfilerError {
|
|
15
|
+
constructor(source) {
|
|
16
|
+
super(`No provable circuits in ${source}`, "The contract compiled, but emitted no ZKIR. Circuits that touch neither\n" +
|
|
17
|
+
"ledger state nor a witness need no proof, so there is nothing to measure.");
|
|
18
|
+
this.name = "NoProvableCircuitsError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Toolchain } from "./toolchain.ts";
|
|
2
|
+
export interface Measurement {
|
|
3
|
+
circuit: string;
|
|
4
|
+
/** Constraint rows, as reported by zkir. */
|
|
5
|
+
rows: number;
|
|
6
|
+
/** Proving domain exponent, as reported by zkir. Never computed here. */
|
|
7
|
+
k: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse a `zkir mock-compile-many` report.
|
|
11
|
+
*
|
|
12
|
+
* Exported for testing, because the failure this guards against is a partial
|
|
13
|
+
* report that reads as a successful one.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseReport(text: string): Measurement[];
|
|
16
|
+
/**
|
|
17
|
+
* Measure every circuit in one `zkir` invocation.
|
|
18
|
+
*
|
|
19
|
+
* `mock-compile-many` is used rather than per file `mock-compile` because the
|
|
20
|
+
* single file form reports the file path instead of the circuit name, which
|
|
21
|
+
* would leave circuit names to be recovered from filenames.
|
|
22
|
+
*/
|
|
23
|
+
export declare function measure(zkirDir: string, toolchain: Toolchain, source: string): Measurement[];
|
package/dist/measure.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { NoProvableCircuitsError, ProfilerError } from "./errors.js";
|
|
4
|
+
/** ` circuit "name" (k=9, rows=305)` */
|
|
5
|
+
const CIRCUIT_LINE = /^\s*circuit\s+"([^"]+)"\s+\(k=(\d+),\s*rows=(\d+)\)\s*$/;
|
|
6
|
+
/** `Mock compiling 2 circuits:` */
|
|
7
|
+
const HEADER_LINE = /^Mock compiling (\d+) circuits?:/m;
|
|
8
|
+
/**
|
|
9
|
+
* Parse a `zkir mock-compile-many` report.
|
|
10
|
+
*
|
|
11
|
+
* Exported for testing, because the failure this guards against is a partial
|
|
12
|
+
* report that reads as a successful one.
|
|
13
|
+
*/
|
|
14
|
+
export function parseReport(text) {
|
|
15
|
+
const measurements = [];
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
const match = line.match(CIRCUIT_LINE);
|
|
18
|
+
if (match) {
|
|
19
|
+
measurements.push({
|
|
20
|
+
circuit: match[1],
|
|
21
|
+
k: Number(match[2]),
|
|
22
|
+
rows: Number(match[3]),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const header = text.match(HEADER_LINE);
|
|
27
|
+
if (!header) {
|
|
28
|
+
throw new ProfilerError("Could not parse the zkir report", `Expected a "Mock compiling N circuits:" header. Got:\n${text.trim() || "(no output)"}`);
|
|
29
|
+
}
|
|
30
|
+
// A truncated run prints the header and some circuits before dying, so the
|
|
31
|
+
// declared count is checked against what was actually parsed.
|
|
32
|
+
const declared = Number(header[1]);
|
|
33
|
+
if (declared !== measurements.length) {
|
|
34
|
+
throw new ProfilerError("Truncated zkir report", `zkir said it was compiling ${declared} circuits but only ${measurements.length} were reported.\n` +
|
|
35
|
+
"This usually means the run failed partway through. Refusing to report partial results.");
|
|
36
|
+
}
|
|
37
|
+
return measurements;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Measure every circuit in one `zkir` invocation.
|
|
41
|
+
*
|
|
42
|
+
* `mock-compile-many` is used rather than per file `mock-compile` because the
|
|
43
|
+
* single file form reports the file path instead of the circuit name, which
|
|
44
|
+
* would leave circuit names to be recovered from filenames.
|
|
45
|
+
*/
|
|
46
|
+
export function measure(zkirDir, toolchain, source) {
|
|
47
|
+
if (!existsSync(zkirDir)) {
|
|
48
|
+
throw new NoProvableCircuitsError(source);
|
|
49
|
+
}
|
|
50
|
+
const res = spawnSync(toolchain.zkirPath, ["mock-compile-many", zkirDir], {
|
|
51
|
+
encoding: "utf8",
|
|
52
|
+
});
|
|
53
|
+
if (res.error) {
|
|
54
|
+
throw new ProfilerError(`Could not run ${toolchain.zkirPath}`, String(res.error));
|
|
55
|
+
}
|
|
56
|
+
// zkir writes its report to stderr. Reading stdout yields an empty string and
|
|
57
|
+
// a report of zero circuits, which is a silent wrong answer rather than a crash.
|
|
58
|
+
const output = `${res.stderr ?? ""}${res.stdout ?? ""}`;
|
|
59
|
+
if (res.status !== 0) {
|
|
60
|
+
throw new ProfilerError("zkir mock-compile-many failed", output.trim() || `zkir exited with status ${res.status}`);
|
|
61
|
+
}
|
|
62
|
+
const measurements = parseReport(output);
|
|
63
|
+
if (measurements.length === 0) {
|
|
64
|
+
throw new NoProvableCircuitsError(source);
|
|
65
|
+
}
|
|
66
|
+
return measurements.sort((a, b) => a.circuit.localeCompare(b.circuit));
|
|
67
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CircuitCost } from "./analyze.ts";
|
|
2
|
+
import type { CheckResult } from "./budget.ts";
|
|
3
|
+
import type { Toolchain } from "./toolchain.ts";
|
|
4
|
+
/** Human readable per circuit cost table. */
|
|
5
|
+
export declare function formatProfile(costs: CircuitCost[], toolchain: Toolchain, elapsedMs: number): string;
|
|
6
|
+
/** Human readable budget comparison. */
|
|
7
|
+
export declare function formatCheck(result: CheckResult): string;
|
|
8
|
+
export declare function profileJson(costs: CircuitCost[], toolchain: Toolchain): string;
|
|
9
|
+
export declare function checkJson(result: CheckResult): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
function pad(text, width) {
|
|
2
|
+
return text.padEnd(width);
|
|
3
|
+
}
|
|
4
|
+
function padLeft(text, width) {
|
|
5
|
+
return text.padStart(width);
|
|
6
|
+
}
|
|
7
|
+
function costLabel(relative) {
|
|
8
|
+
return `${relative}x`;
|
|
9
|
+
}
|
|
10
|
+
/** Human readable per circuit cost table. */
|
|
11
|
+
export function formatProfile(costs, toolchain, elapsedMs) {
|
|
12
|
+
const nameWidth = Math.max(7, ...costs.map((c) => c.circuit.length));
|
|
13
|
+
const rowsWidth = Math.max(4, ...costs.map((c) => String(c.rows).length));
|
|
14
|
+
const capWidth = Math.max(8, ...costs.map((c) => String(c.capacity).length));
|
|
15
|
+
const costWidth = Math.max(4, ...costs.map((c) => costLabel(c.relativeCost).length));
|
|
16
|
+
const lines = [""];
|
|
17
|
+
lines.push(` ${pad("circuit", nameWidth)} ${padLeft("rows", rowsWidth)} ${padLeft("k", 3)} ` +
|
|
18
|
+
`${padLeft("capacity", capWidth)} ${padLeft("cost", costWidth)}`);
|
|
19
|
+
for (const c of costs) {
|
|
20
|
+
lines.push(` ${pad(c.circuit, nameWidth)} ${padLeft(String(c.rows), rowsWidth)} ` +
|
|
21
|
+
`${padLeft(String(c.k), 3)} ${padLeft(String(c.capacity), capWidth)} ` +
|
|
22
|
+
`${padLeft(costLabel(c.relativeCost), costWidth)}`);
|
|
23
|
+
}
|
|
24
|
+
const plural = costs.length === 1 ? "circuit" : "circuits";
|
|
25
|
+
lines.push("");
|
|
26
|
+
lines.push(` ${costs.length} ${plural}, toolchain ${toolchain.version}, ` +
|
|
27
|
+
`${toolchain.zkirVersion}, ${(elapsedMs / 1000).toFixed(1)}s`);
|
|
28
|
+
lines.push("");
|
|
29
|
+
return lines.join("\n");
|
|
30
|
+
}
|
|
31
|
+
const STATUS_NOTE = {
|
|
32
|
+
under: (r) => `under by ${(r.maxK ?? 0) - (r.k ?? 0)}`,
|
|
33
|
+
at: () => "at budget",
|
|
34
|
+
over: (r) => {
|
|
35
|
+
const by = (r.k ?? 0) - (r.maxK ?? 0);
|
|
36
|
+
return `over by ${by}, about ${2 ** by}x`;
|
|
37
|
+
},
|
|
38
|
+
undeclared: () => "not in budget",
|
|
39
|
+
stale: () => "no longer in contract",
|
|
40
|
+
};
|
|
41
|
+
/** Human readable budget comparison. */
|
|
42
|
+
export function formatCheck(result) {
|
|
43
|
+
const nameWidth = Math.max(7, ...result.rows.map((r) => r.circuit.length));
|
|
44
|
+
const lines = [""];
|
|
45
|
+
for (const row of result.rows) {
|
|
46
|
+
const k = row.k === undefined ? " -" : padLeft(String(row.k), 3);
|
|
47
|
+
const maxK = row.maxK === undefined ? " -" : padLeft(String(row.maxK), 3);
|
|
48
|
+
lines.push(` ${pad(row.circuit, nameWidth)} k ${k} budget ${maxK} ${STATUS_NOTE[row.status](row)}`);
|
|
49
|
+
}
|
|
50
|
+
const over = result.rows.filter((r) => r.status === "over").length;
|
|
51
|
+
const undeclared = result.rows.filter((r) => r.status === "undeclared").length;
|
|
52
|
+
lines.push("");
|
|
53
|
+
if (over > 0) {
|
|
54
|
+
lines.push(` FAIL: ${over} circuit${over === 1 ? "" : "s"} over budget`);
|
|
55
|
+
}
|
|
56
|
+
else if (result.failed) {
|
|
57
|
+
lines.push(` FAIL: ${undeclared} circuit${undeclared === 1 ? "" : "s"} not declared in the budget (--strict)`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
lines.push(" OK: every circuit within budget");
|
|
61
|
+
}
|
|
62
|
+
lines.push("");
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
export function profileJson(costs, toolchain) {
|
|
66
|
+
return JSON.stringify({
|
|
67
|
+
toolchain: toolchain.version,
|
|
68
|
+
zkir: toolchain.zkirVersion,
|
|
69
|
+
circuits: costs,
|
|
70
|
+
}, null, 2);
|
|
71
|
+
}
|
|
72
|
+
export function checkJson(result) {
|
|
73
|
+
return JSON.stringify(result, null, 2);
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Toolchain lines this tool has been verified against. */
|
|
2
|
+
export declare const SUPPORTED_RANGES: string[];
|
|
3
|
+
export interface Toolchain {
|
|
4
|
+
/** Compiler version, as reported by the compiler itself. */
|
|
5
|
+
version: string;
|
|
6
|
+
/** Absolute path to the `zkir` paired with that compiler. */
|
|
7
|
+
zkirPath: string;
|
|
8
|
+
/** Version string reported by that `zkir` binary. */
|
|
9
|
+
zkirVersion: string;
|
|
10
|
+
/** `+VERSION` selector to pass through to `compact`, when one was requested. */
|
|
11
|
+
versionArg?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Whether a compiler version is one this tool has been verified against. */
|
|
14
|
+
export declare function isSupported(version: string): boolean;
|
|
15
|
+
/** Root holding `versions/`, overridable exactly as the `compact` CLI does. */
|
|
16
|
+
export declare function artifactRoot(): string;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the compiler and the `zkir` that ships beside it.
|
|
19
|
+
*
|
|
20
|
+
* `zkir` is never taken from PATH and never borrowed from a neighbouring
|
|
21
|
+
* version. If the paired binary is missing that is an error, because a report
|
|
22
|
+
* built from a mismatched IR reader is wrong rather than merely incomplete.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveToolchain(versionArg?: string): Toolchain;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ProfilerError } from "./errors.js";
|
|
6
|
+
/** Toolchain lines this tool has been verified against. */
|
|
7
|
+
export const SUPPORTED_RANGES = ["0.31.x"];
|
|
8
|
+
/**
|
|
9
|
+
* The IR major version emitted by supported compilers. The toolchain ships a
|
|
10
|
+
* `zkir` (IR 2.0) and a `zkir-v3` (IR 3.0) side by side with identical CLIs and
|
|
11
|
+
* incompatible formats, so the major version is checked rather than assumed.
|
|
12
|
+
*/
|
|
13
|
+
const EXPECTED_ZKIR_MAJOR = 2;
|
|
14
|
+
/** Whether a compiler version is one this tool has been verified against. */
|
|
15
|
+
export function isSupported(version) {
|
|
16
|
+
return /^0\.31\./.test(version);
|
|
17
|
+
}
|
|
18
|
+
/** Root holding `versions/`, overridable exactly as the `compact` CLI does. */
|
|
19
|
+
export function artifactRoot() {
|
|
20
|
+
return process.env.COMPACT_DIRECTORY || join(homedir(), ".compact");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Ask the compiler which version it is. This honours `+VERSION` and any
|
|
24
|
+
* configured default, so the answer describes the compiler that will actually
|
|
25
|
+
* run rather than whatever happens to be installed.
|
|
26
|
+
*/
|
|
27
|
+
function detectCompilerVersion(versionArg) {
|
|
28
|
+
const args = ["compile"];
|
|
29
|
+
if (versionArg)
|
|
30
|
+
args.push(versionArg);
|
|
31
|
+
args.push("--version");
|
|
32
|
+
const res = spawnSync("compact", args, { encoding: "utf8" });
|
|
33
|
+
if (res.error) {
|
|
34
|
+
throw new ProfilerError("Could not run `compact`", "The Compact CLI was not found on your PATH.\n" +
|
|
35
|
+
"Install it, or see https://docs.midnight.network for setup.");
|
|
36
|
+
}
|
|
37
|
+
const out = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
|
|
38
|
+
const match = out.match(/^\s*(\d+\.\d+\.\d+)\s*$/m);
|
|
39
|
+
if (!match) {
|
|
40
|
+
const hint = versionArg
|
|
41
|
+
? `Is ${versionArg.slice(1)} installed? Check with \`compact list\`, ` +
|
|
42
|
+
`and install it with \`compact update ${versionArg.slice(1)}\`.`
|
|
43
|
+
: "No default compiler appears to be set. Set one with `compact update`.";
|
|
44
|
+
throw new ProfilerError("Could not determine the Compact compiler version", `${hint}\n\n\`compact compile ${versionArg ?? ""} --version\` returned:\n${out || "(no output)"}`);
|
|
45
|
+
}
|
|
46
|
+
return match[1];
|
|
47
|
+
}
|
|
48
|
+
/** Find the per-target directory inside a version that actually holds `zkir`. */
|
|
49
|
+
function findTargetDir(versionDir) {
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(versionDir);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new ProfilerError(`Toolchain directory not found: ${versionDir}`, "The compiler reported a version with no matching install directory.");
|
|
56
|
+
}
|
|
57
|
+
const hit = entries.find((e) => existsSync(join(versionDir, e, "zkir")));
|
|
58
|
+
if (!hit) {
|
|
59
|
+
throw new ProfilerError(`No \`zkir\` binary found under ${versionDir}`, "This toolchain version is installed but incomplete. Reinstall it with\n" +
|
|
60
|
+
"`compact update`, and note that a separately installed zkir is never used.");
|
|
61
|
+
}
|
|
62
|
+
return join(versionDir, hit);
|
|
63
|
+
}
|
|
64
|
+
/** Confirm the binary is the one we think it is, and that it reads IR 2.0. */
|
|
65
|
+
function checkZkir(zkirPath) {
|
|
66
|
+
const res = spawnSync(zkirPath, ["--version"], { encoding: "utf8" });
|
|
67
|
+
if (res.status !== 0) {
|
|
68
|
+
throw new ProfilerError(`Could not run ${zkirPath}`, (res.stderr ?? "").trim() || "The binary exists but did not execute.");
|
|
69
|
+
}
|
|
70
|
+
const reported = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
|
|
71
|
+
const match = reported.match(/midnight-zkir\s+(\d+)\.(\d+)\.\S+/);
|
|
72
|
+
if (!match) {
|
|
73
|
+
throw new ProfilerError(`Unrecognised zkir version string: ${reported}`, "Expected something like `midnight-zkir 2.1.0`.");
|
|
74
|
+
}
|
|
75
|
+
const major = Number(match[1]);
|
|
76
|
+
if (major !== EXPECTED_ZKIR_MAJOR) {
|
|
77
|
+
throw new ProfilerError(`Wrong zkir IR version: found ${reported}`, `Supported compilers emit IR ${EXPECTED_ZKIR_MAJOR}.0, but this binary reads IR ${major}.0.\n` +
|
|
78
|
+
"Mixing them produces a partial report that looks successful. Refusing to continue.");
|
|
79
|
+
}
|
|
80
|
+
return reported;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the compiler and the `zkir` that ships beside it.
|
|
84
|
+
*
|
|
85
|
+
* `zkir` is never taken from PATH and never borrowed from a neighbouring
|
|
86
|
+
* version. If the paired binary is missing that is an error, because a report
|
|
87
|
+
* built from a mismatched IR reader is wrong rather than merely incomplete.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveToolchain(versionArg) {
|
|
90
|
+
const version = detectCompilerVersion(versionArg);
|
|
91
|
+
if (!isSupported(version)) {
|
|
92
|
+
throw new ProfilerError(`Unsupported Compact toolchain: ${version}`, `This tool supports ${SUPPORTED_RANGES.join(", ")}.\n` +
|
|
93
|
+
"Toolchain lines change zkir internals, so rather than report a number it\n" +
|
|
94
|
+
"cannot stand behind, it stops here.\n" +
|
|
95
|
+
`Pin a supported compiler with: nite-zk profile +0.31.1 <source>`);
|
|
96
|
+
}
|
|
97
|
+
const versionDir = join(artifactRoot(), "versions", version);
|
|
98
|
+
const zkirPath = join(findTargetDir(versionDir), "zkir");
|
|
99
|
+
const zkirVersion = checkZkir(zkirPath);
|
|
100
|
+
return { version, zkirPath, zkirVersion, versionArg };
|
|
101
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nite-framework/nite-zk-profiler",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "See what a Compact circuit costs to prove, without generating proving keys",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nite-zk": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/cli.js",
|
|
10
|
+
"types": "./dist/cli.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"packageManager": "npm@11.10.1",
|
|
19
|
+
"keywords": [
|
|
20
|
+
"midnight",
|
|
21
|
+
"compact",
|
|
22
|
+
"zk",
|
|
23
|
+
"zero-knowledge",
|
|
24
|
+
"profiler",
|
|
25
|
+
"proving-cost",
|
|
26
|
+
"nite-zk-profiler"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/nite-framework/nite-zk-profiler.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/nite-framework/nite-zk-profiler#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/nite-framework/nite-zk-profiler/issues"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.build.json",
|
|
42
|
+
"postbuild": "node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"",
|
|
43
|
+
"test": "node --test",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^26.2.0",
|
|
49
|
+
"typescript": "^5.9.3"
|
|
50
|
+
}
|
|
51
|
+
}
|