Skip to content

feat(cborlite): a light CBOR reader. - #3935

Draft
RafaelGranza wants to merge 2 commits into
perf/cbor/primitivesfrom
perf/cbor/encoder
Draft

feat(cborlite): a light CBOR reader.#3935
RafaelGranza wants to merge 2 commits into
perf/cbor/primitivesfrom
perf/cbor/encoder

Conversation

@RafaelGranza

@RafaelGranza RafaelGranza commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

cborlite has two main goals:

  • To only see a byte once. No revisiting.
  • To fill the fields you ask for and skip the rest.

Skipping is cheap. No hidden byte checks. Less CPU usage.

This unmarshaler is not exhaustive. It declines a shape it does not know.
A caller can fall back to the generic decoder. Add support when a shape needs it.

It does not check that the bytes are well formed, so it is for bytes this node wrote and read back. Do not use it for external inputs.

To unmarshal a new type, implement PrefixUnmarshaler.

Comparing to fxmacker.

fxmacker needs us to unmarshal ALL keys inside a SBOR object. Which created our partial objects. With cborlite we can actaully skip the keys we don't want.

fxmacker traverses every byte at least twice, to check if it's well formed than to decode. In sama cases, for types that don't say its size, fxmacker traverse the bytes a third time to count the byte offset.
cborlite only visits a byte ONCE.

Order to review

  • decode.go
  • cache.go
  • readers_kind.go
  • plan.go

The other files' order is less relevant.

Speedup

How to measure here. But see following PRs using cborlite.

cborlite coverage by bucket

Tested every populated bucket in a synced mainnet node with deprecated states. Tested on a random 10% population for each bucket larger than 100k. Tested all entries in the smaller ones.

For the buckets of new state, I did a static analysis of the types.

We have 2 unsupported types, and they are not accepted gracefully, with descriptive errors.
The caller can call the generic encoder afterwards. They are mapped in TODOS.

# Bucket Bucket Tested Decoded with
0 StateTrie Specialized Codec
1 Peer p2p Specialized Codec
2 ContractClassHash Specialized Codec
3 ContractStorage Specialized Codec
4 Class cborlite
5 ContractNonce Specialized Codec
6 ChainHeight Specialized Codec
7 BlockHeaderNumbersByHash Specialized Codec
8 BlockHeadersByNumber cborlite
9 TransactionBlockNumbersAndIndicesByHash Specialized Codec
10 TransactionsByBlockNumberAndIndex folded to 40 cborlite
11 ReceiptsByBlockNumberAndIndex folded to 40 cborlite
12 StateUpdatesByBlockNumber cborlite
13 ClassesTrie Specialized Codec
14 DeprecatedContractStorageHistory Specialized Codec
15 DeprecatedContractNonceHistory Specialized Codec
16 DeprecatedContractClassHashHistory Specialized Codec
17 ContractDeploymentHeight Specialized Codec
18 L1Height cborlite
19 DeprecatedSchemaVersion Specialized Codec
20 Unused retired
21 BlockCommitments cborlite
22 Temporary scratch Specialized Codec
23 DeprecatedSchemaIntermediateState Specialized Codec
24 L1HandlerTxnHashByMsgHash Specialized Codec
25 MempoolHead transient Specialized Codec
26 MempoolTail transient Specialized Codec
27 MempoolLength transient Specialized Codec
28 MempoolNode transient ⚠️ Don't know yet ⚠️
29 ClassTrie new-state Specialized Codec
30 ContractTrieContract new-state Specialized Codec
31 ContractTrieStorage new-state Specialized Codec
32 Contract new-state Specialized Codec
33 StateHashToTrieRoots new-state (dead)
34 StateID new-state Specialized Codec
35 PersistedStateID new-state Specialized Codec
36 TrieJournal cborlite
37 AggregatedBloomFilters cborlite
38 RunningEventFilter cborlite
39 ClassCasmHashMetadata Specialized Codec
40 BlockTransactions (mixed — fan-out ↓)
40 ↳ BlockTransactionsIndexes ⚠️ Decay to Generic ⚠️
40 ↳ Transaction (Invoke / L1Handler / Deploy / Declare) cborlite
40 ↳ DeployAccountTransaction ⚠️ Decay to Generic ⚠️
40 ↳ TransactionReceipt cborlite
41 SchemaMetadata cborlite
42 SchemaIntermediateState scratch Specialized Codec
43 ContractClassHashHistory new-state Specialized Codec
44 ContractNonceHistory new-state Specialized Codec
45 ContractStorageHistory new-state Specialized Codec

*Specialized Codec means it has its own methods to unmarshal itself, like felts and tries do.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @RafaelGranza's task in 9m 27s —— View job


Review: feat(cborlite): a light CBOR decoder

  • Read PR diff against origin/perf/cbor/primitives
  • Review decode.go, cache.go, readers_kind.go, plan.go
  • Review felt fast paths + encoder/registry wiring
  • Review test coverage
  • Post inline findings + summary

Nice piece of work — the design is coherent, the tests are unusually well-commented, and several sharp edges (presize amplification, a lying PrefixUnmarshaler, the plan-cycle guard, map value reuse, named byte types) are already covered by tests that explain why they exist. I checked the bounds arithmetic end to end (Head/ArrayHeader/MapHeader/bytesPayload all validate against the remaining buffer, and every reader returns consumed ≤ len(data)), so I found no path to a slice-out-of-range. I also audited reflect.Value addressability on every route into target.Addr() and target.Set — all reachable targets are addressable — and the buildMutex / sync.Map split, which is sound: plans/buildingPlans are only touched under the lock, and no build path re-enters cachedReader, so there's no reentrancy deadlock.

Findings below, all posted inline.

Important

  1. Plan cache poisoning on a failed build inside a type cycleplan.go:72. A plan handed out from buildingPlans is still half-populated; if the outer build later fails, the inner plan is already published in plans holding a reader bound to the abandoned incomplete one. Result: Unmarshal into the inner type succeeds while silently dropping fields, which is precisely the invariant TestNoRegisteredTransactionDecodesWrong guards. Full repro in the comment. Fix is small — make plan publication transactional in cachedReader.

  2. cbor.Unmarshaler is not recognised, and it fails silently rather than decliningreaders.go:20-36. A type with a custom MarshalCBOR but no UnmarshalCBORPrefix gets read structurally, and an all-unknown-keys map decodes to a zero value with no error. The two such types in the repo (core.TransactionVersion, core.discardedCBOR) are safe by coincidence, not construction. Refusing to build a structural reader for them turns a future silent corruption into a build error.

  3. No recursion depth limit on the read pathplan.go:115. Skip has maxSkipDepth = 1024; plan.readerpointerReaderplan.reader has nothing. A Go stack overflow is a fatal error, not recoverable, so corrupt bytes for a recursive type (core.SegmentLengths, per the test's own comment) kill the node instead of returning errShape.

  4. errShape is unexported, so the documented fallback can't be implemented preciselydecode.go:30. Callers must fall back on any error, so a genuine defect degrades to the generic decoder permanently and silently — "only the clock says anything", as registry_test.go puts it.

  5. Per-item reflection on the interface pathreaders_kind.go:302-319. Every interface item pays reflect.PointerTo(...).Implements(...) plus two more sync.Map lookups, all of which are a pure function of (tag, interfaceType, strict). Memoising per tag is straightforward and matters given the package's premise.

Nits

  1. arrayReader/readByteArray are the only container readers without readNullInto, and a non-byte array is written in place rather than published on success — readers_kind.go:222.
  2. TestSliceDecodingDoesNotTrustTheCountForSizing asserts on process-wide cumulative runtime.MemStats.TotalAlloc, so it will flake once anything in the package uses t.Parallel()readers_kind_test.go:413-426.
  3. RegisterType returning a tag makes the unsynchronised tagNum global part of the public contract — encoder.go:40-51.

Also checked, no issue found

  • felt.Felt.UnmarshalCBORPrefix / decodeSlicePrefix — reject without touching the destination on every failure path, and decodeLimbs' 0x84 requirement matches what both Felt.MarshalCBOR and fxamacker's [4]uint64 encoding of fp.Element write, so there's no round-trip gap. Slice.UnmarshalCBOR's refactor to out + consumed == len(data) preserves the old semantics exactly, including null → nil.
  • readBigInt on the null path — reflect.ValueOf of a typed-nil *big.Int is a valid Value, so target.Set does not panic.
  • readByteSlice's SetBytes on a named byte slice ([]namedByte) — reflect checks the element kind, not the exact type, so no panic; the test covers it.
  • big.Int by value falls out as "no exported fields to read" rather than decoding garbage.
  • core/feltencoder/cborlite introduces no import cycle (cborlite is stdlib-only).
  • mapReader's SetZero per pair is load-bearing for map[K]*T and map[K][]T, and is tested.

Verification

go build and go vet ./encoder/... ./core/felt/... are clean. I was not able to run the test suitego test was denied by this job's tool permissions — so findings 1 and 3 are from code reading, not an executed repro. Finding 1's trace is mechanical enough that I'm confident in it, but a quick local go test -race ./encoder/cborlite/ with the A/B cycle case would confirm it in a minute.
• branch perf/cbor/encoder

@RafaelGranza RafaelGranza self-assigned this Aug 13, 2026
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.15789% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.81%. Comparing base (948ce43) to head (e3e936b).

Files with missing lines Patch % Lines
encoder/cborlite/readers_kind.go 87.97% 11 Missing and 11 partials ⚠️
encoder/cborlite/plan.go 83.95% 8 Missing and 5 partials ⚠️
encoder/cborlite/readers.go 89.18% 2 Missing and 2 partials ⚠️
encoder/cborlite/cache.go 84.21% 2 Missing and 1 partial ⚠️
encoder/cborlite/decode.go 92.00% 1 Missing and 1 partial ⚠️
encoder/encoder.go 80.00% 1 Missing ⚠️
Additional details and impacted files
@@                   Coverage Diff                    @@
##           perf/cbor/primitives    #3935      +/-   ##
========================================================
+ Coverage                 74.71%   74.81%   +0.09%     
========================================================
  Files                       463      469       +6     
  Lines                     41066    41433     +367     
========================================================
+ Hits                      30683    30997     +314     
- Misses                     8284     8313      +29     
- Partials                   2099     2123      +24     
Flag Coverage Δ
jsonv2 77.42% <100.00%> (+0.07%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread encoder/cborlite/plan.go Outdated
Comment on lines +302 to +319
tag, consumed, ok := Tag(data)
if !ok {
return 0, errShape
}

concrete, known := tagTypes.Load(tag)
if !known {
return 0, errShape
}
concreteType := concrete.(reflect.Type)
if !reflect.PointerTo(concreteType).Implements(interfaceType) {
return 0, errShape
}

read, err := cachedReader(concreteType, strict)
if err != nil {
return 0, fmt.Errorf("tag %d (%s): %w", tag, concreteType, err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Per-item reflection work on the interface path, which is the hottest path this PR targets.

For every interface-typed item decoded (i.e. every transaction, every trie node) this does:

  • tagTypes.Load(tag) — a sync.Map lookup,
  • reflect.PointerTo(concreteType) — internally another sync.Map lookup in reflect's ptr-type cache,
  • .Implements(interfaceType) — a method-set walk,
  • cachedReader(concreteType, strict) — a third sync.Map lookup with a cacheKey struct boxed into any.

All four results are a pure function of (tag, interfaceType, strict), which are all fixed once the reader is built. Resolving them once per tag and memoising into a sync.Map (or a copy-on-write map[uint64]reader) owned by this closure would cut the steady-state cost to a single lookup, and would also make the "declines a shape it does not know" path cheap.

Given the package's whole premise is "visit a byte once, less CPU", it seems worth not paying Implements per item.

Fix this →

Comment thread encoder/cborlite/decode.go Outdated
Comment thread encoder/cborlite/plan.go
Comment on lines +20 to +36
func specialTypeReader(valueType reflect.Type) (reader, bool) {
// Implements UnmarshalCBORPrefix
if reflect.PointerTo(valueType).Implements(prefixUnmarshalerType) {
return readPrefixUnmarshaler, true
}

// Implements UnmarshalBinary
if reflect.PointerTo(valueType).Implements(binaryUnmarshalerType) {
return readBinaryUnmarshaler, true
}

if valueType == bigIntType {
return readBigInt, true
}

return nil, false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cbor.Unmarshaler is not recognised, and the consequence is a silent wrong read rather than a decline.

specialTypeReader honours PrefixUnmarshaler and encoding.BinaryUnmarshaler, but not fxamacker's cbor.Unmarshaler — which is the interface every custom-encoded type in this repo currently implements. A type whose MarshalCBOR writes a shape unrelated to its struct layout gets read structurally here, and in non-strict mode an all-unknown-keys map decodes to a zero value with no error.

Today the two such types happen to be safe by coincidence rather than by construction:

  • core.TransactionVersion (core/transaction.go:199) marshals as a felt, and since it's a defined type over fp.Element its Go kind is [4]uint64 — so arrayReader happens to read the same bytes.
  • core.discardedCBOR is struct{}, so plan.reader demands a map header and errors out.

Neither is guaranteed. The moment someone adds a MarshalCBOR to a struct with exported fields, or changes TransactionVersion's encoding, this decoder starts returning zero values without complaining — and Unmarshal has no fallback at the field level, so the caller never learns.

Given the package's own invariant ("it must never report success and hand back something else", registry_test.go:50), the safe default is to refuse to build a structural reader for a type where reflect.PointerTo(t) implements cbor.Unmarshaler but not PrefixUnmarshaler. That turns a future silent corruption into a build error that says "add an UnmarshalCBORPrefix", which is exactly the pattern felt.Felt and felt.Slice follow in this PR.

Fix this →

Comment thread encoder/cborlite/readers_kind.go
Comment thread encoder/cborlite/readers_kind_test.go Outdated
Comment thread encoder/encoder.go
@RafaelGranza
RafaelGranza marked this pull request as draft August 13, 2026 05:49
@RafaelGranza RafaelGranza changed the title feat(cborlite): a light CBOR decoder. feat(cborlite): a light CBOR reader. Aug 13, 2026
Reflection over a struct becomes a plan of readers, built once per type and
cached. RegisterType now returns the tag it assigned, so the registry teaches
both decoders the same tag to type mapping from one list.
A felt packs into an array of limbs, and walking that generically costs a call
per limb. Without this the new decoder is slower than the generic one on a
class: 396 against 334 us.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant