Skip to content

chore: simplify arrayComposite offsets and skip them for fixed size elements - #556

Merged
wemeetagain merged 3 commits into
ChainSafe:masterfrom
lodekeeper:refactor/array-composite-end-offset
Oct 16, 2026
Merged

wemeetagain merged 3 commits into
ChainSafe:masterfrom
lodekeeper:refactor/array-composite-end-offset

Conversation

@lodekeeper

@lodekeeper lodekeeper commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

readOffsetsArrayComposite() returned one offset per element, so both deserialize loops in arrayComposite.ts had to special case the last element (i === length - 1 ? end : start + offsets[i + 1]). The comments next to them ("Capture length before pushing end offset", "offests include the last element end") describe an n + 1 layout that the code no longer had.

The fixed size branch also built an offsets array of i * fixedSize just so both element kinds could share one loop, while serialization already writes fixed size elements at offset + i * fixedSize directly.

This also lets #555 drop the withEndOffset flag it adds to readVariableOffsetsArrayComposite(), since that is the layout SerializedList needs.

Description

  • Fixed size elements are read at start + i * fixedSize in value_deserializeFromBytesArrayComposite(), tree_deserializeFromBytesArrayComposite() and ProgressiveListCompositeType.tree_deserializeFromBytes(), with no offsets array. The length checks (size % fixedSize, assertValidArrayLength()) and their errors are unchanged
  • readVariableOffsetsArrayComposite() now returns n + 1 offsets, with a trailing entry equal to end - start. An empty array returns [0]
  • assertValidArrayLength() still gets the element count (offsets.length - 1)
  • The variable size loops use endEl = start + offsets[i + 1] with no branch

The offset readers are module private and the deserialize functions above are their only callers. ProgressiveList keeps its own variable size offset reader. Validation and error order are unchanged. The trailing write happens after all offset checks.

Fixed size elements use plain number math, so they don't wrap at any size. Variable size offsets stay in a Uint32Array, except when the array is 2^32 bytes or larger. Then they go in a Float64Array so the trailing entry doesn't wrap, and the last element still ends at end as before. The SSZ spec caps the serialized size below 2^32, but the library accepted these inputs before this change.

Tests

  • pnpm test:unit: 1814 passed
  • pnpm test:spec-generic: 5065 passed
  • pnpm test:spec-static-minimal: 13284 passed, pnpm test:spec-static-mainnet: 540 passed
  • pnpm lint clean

Benchmarks

Local, node 24 (V8 13.6), at 08422c6. Medians of 6 interleaved master/branch rounds per case, with the side that runs first alternating each round. deserialize() = value, deserializeToView() = tree. Cases up to 128 elements come from the small file (3 to 6 s per case), the rest from the large file (1.5 to 4 s per case).

case value master value branch tree master tree branch
List[ByteList] 1 x 100B 0.91 us 0.90 us 2.83 us 2.73 us
List[ByteList] 4 x 100B 3.08 us 2.99 us 7.07 us 6.84 us
List[ByteList] 1000 x 1000B 1.10 ms 1.06 ms 3.71 ms 3.79 ms
List[ByteList] 10000 x 100B 9.14 ms 8.51 ms 24.66 ms 20.82 ms
List[ByteList] 100000 x 0B 8.46 ms 8.99 ms 34.71 ms 27.17 ms
ProgressiveList[ProgressiveByteList] 10000 x 100B 8.98 ms 8.72 ms - -
List[Container(fixed 40B)] 1 0.52 us 0.48 us 2.69 us 2.54 us
List[Container(fixed 40B)] 16 2.20 us 2.11 us 6.73 us 6.23 us
List[Container(fixed 40B)] 128 14.15 us 13.01 us 37.04 us 35.58 us
List[Container(fixed 40B)] 100000 23.81 ms 26.08 ms 50.51 ms 47.99 ms
ProgressiveList[Container(fixed 40B)] 16 2.50 us 2.19 us 5.85 us 5.19 us
ProgressiveList[Container(fixed 40B)] 100000 23.39 ms 22.52 ms 51.03 ms 45.75 ms
Vector[Bytes32, 4] 0.63 us 0.57 us 0.98 us 0.91 us
Vector[Bytes32, 8192] 453.48 us 413.21 us 1.15 ms 960.27 us

The fixed size cases are a few percent faster than master because nothing is allocated for offsets anymore. This also removes the n = 16 regression the first commit had (List[Container(fixed 40B)] 16 value: 2.16 us on master vs 2.89 us, from Uint32Array(17) crossing V8's 64 byte on heap limit). It's now 2.20 us on master vs 2.11 us. Variable size cases are unchanged within noise. On this machine the large cases vary by about 20% between rounds of the same code, so differences below that only show up in the small cases.

Bench file (not committed). The small cases used the same file with counts 1/4 (ByteList), 1/16/128 (Container) and Vector[Bytes32, 4], without the ProgressiveList cases
import {bench, describe} from "@chainsafe/benchmark";
import {
  ByteListType,
  ByteVectorType,
  CompositeType,
  ContainerType,
  ListCompositeType,
  ProgressiveByteListType,
  ProgressiveListCompositeType,
  UintNumberType,
  VectorCompositeType,
} from "../../../src/index.ts";

const bytes32 = new ByteVectorType(32);
const uint64 = new UintNumberType(8);
const byteList = new ByteListType(2 ** 30);
const fixedContainer = new ContainerType({root: bytes32, value: uint64}, {typeName: "Container(fixed 40B)"});

function filledBytes(len: number, seed: number): Uint8Array {
  const b = new Uint8Array(len);
  for (let i = 0; i < len; i++) b[i] = (seed + i) & 0xff;
  return b;
}

function byteLists(count: number, size: number): Uint8Array[] {
  return Array.from({length: count}, (_, i) => filledBytes(size, i));
}

describe("arrayComposite deserialize", () => {
  const cases: {id: string; type: CompositeType<unknown, unknown, unknown>; value: unknown; tree: boolean}[] = [];

  // variable size elements, readVariableOffsetsArrayComposite()
  for (const [count, size] of [
    [4, 100],
    [1_000, 1_000],
    [10_000, 100],
    [100_000, 0],
  ] as const) {
    cases.push({
      id: `List[ByteList] ${count} x ${size}B`,
      type: new ListCompositeType(byteList, 2 ** 20),
      value: byteLists(count, size),
      tree: true,
    });
  }
  cases.push({
    id: "ProgressiveList[ProgressiveByteList] 10000 x 100B",
    type: new ProgressiveListCompositeType(new ProgressiveByteListType()),
    value: byteLists(10_000, 100),
    tree: false,
  });

  // fixed size elements, start + i * fixedSize
  for (const count of [16, 100_000]) {
    cases.push({
      id: `List[Container(fixed 40B)] ${count}`,
      type: new ListCompositeType(fixedContainer, 2 ** 40),
      value: Array.from({length: count}, (_, i) => ({root: filledBytes(32, i), value: i})),
      tree: true,
    });
  }
  for (const count of [16, 100_000]) {
    cases.push({
      id: `ProgressiveList[Container(fixed 40B)] ${count}`,
      type: new ProgressiveListCompositeType(fixedContainer),
      value: Array.from({length: count}, (_, i) => ({root: filledBytes(32, i), value: i})),
      tree: true,
    });
  }
  cases.push({
    id: "Vector[Bytes32, 8192]",
    type: new VectorCompositeType(bytes32, 8192),
    value: Array.from({length: 8192}, (_, i) => filledBytes(32, i)),
    tree: true,
  });

  for (const {id, type, value, tree} of cases) {
    const bytes = type.serialize(value);

    bench({
      id: `${id} - value`,
      fn: () => {
        type.deserialize(bytes);
      },
    });

    if (tree) {
      bench({
        id: `${id} - tree`,
        fn: () => {
          type.deserializeToView(bytes);
        },
      });
    }
  }
});

🤖 Generated with AI assistance

readOffsetsArrayComposite() now returns n + 1 offsets with a trailing entry equal to the serialized size, so element i is always [offsets[i], offsets[i + 1]) and the deserialize loops no longer special case the last element.

🤖 Generated with AI assistance
@lodekeeper
lodekeeper requested a review from a team as a code owner October 16, 2026 14:20
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T14:25:16.638368Z 8ac6df0 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@lodekeeper lodekeeper changed the title refactor: include end offset in arrayComposite offsets chore: include end offset in arrayComposite offsets Oct 16, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ac6df088d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/ssz/src/type/arrayComposite.ts Outdated
Fixed size elements are now read at start + i * fixedSize in value_/tree_deserializeFromBytesArrayComposite and ProgressiveList tree_deserializeFromBytes, so offsets are only read and allocated for variable size elements.

🤖 Generated with AI assistance
@lodekeeper lodekeeper changed the title chore: include end offset in arrayComposite offsets chore: simplify arrayComposite offsets and skip them for fixed size elements Oct 16, 2026
@wemeetagain
wemeetagain merged commit b7f08f6 into ChainSafe:master Oct 16, 2026
13 checks passed
wemeetagain pushed a commit that referenced this pull request Oct 16, 2026
**Motivation**

#556 is merged and conflicts with this branch in
`readVariableOffsetsArrayComposite()`.

**Description**

Merges `master` into `te/serialized_list`:

- `readVariableOffsetsArrayComposite()` takes master's version and stays
exported. It always returns n + 1 offsets now, so `withEndOffset` is
gone. Offsets are a `Float64Array` when the list is 2^32 bytes or
larger, so the trailing entry doesn't wrap
- `SerializedListBaseType.value_deserializeFromBytes()` drops the `true`
argument
- `SerializedList.offsets` is `Uint32Array | Float64Array` to match. Its
doc comment no longer says that list types keep only n offsets

If you'd rather keep `offsets` a plain `Uint32Array`, the alternative is
to reject serialized lists of 2^32 bytes or more in
`SerializedListBaseType`. That would differ from `ListCompositeType`,
which accepts them.

**Tests**

- `pnpm test:unit`: 2405 passed
- `pnpm test:spec-generic`: 5065 passed
- `pnpm test:spec-static-minimal`: 13284 passed, `pnpm
test:spec-static-mainnet`: 540 passed
- `pnpm lint` clean

🤖 Generated with AI assistance

Co-authored-by: lodekeeper <lodekeeper@users.noreply.github.com>
wemeetagain added a commit that referenced this pull request Oct 16, 2026
PR #557 brought master's changes into this branch with a squash commit, so the PR comparison still includes the changes from #556. Record the actual merge with master to correct the comparison base.

The resulting tree is identical to ca973a6; preserve the exported readVariableOffsetsArrayComposite helper required by SerializedListType.
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.

2 participants