feat: fromHexInto() api (#8275)

**Motivation**

- improve the time to deserialize hex, especially for getBlobsV2() where
each blob is 131kb
- if the benchmark is correct, we can expect the time to deserialize
blob hex from `332.6842 ms/op` to more or less 2ms
- will apply it for getBlobsV2() in the next PR by preallocate some
memory and reuse it for all slots

**Description**

- implement `fromHexInto()` using `String.charCodeAt()` for browser and
use that for NodeJs as well
- the Buffer/NodeJS implementation is too bad that I only maintain it in
the benchmark

**Test result on a regular lodestar node**
- `browser fromHexInto(blob)` is 1000x faster than `browser
fromHex(blob)` and >100x faster than `nodejs fromHexInto(blob) `

```
packages/utils/test/perf/bytes.test.ts
  bytes utils
    ✔ nodejs block root to RootHex using toHex                             2817687 ops/s    354.9010 ns/op        -       1324 runs  0.897 s
    ✔ nodejs block root to RootHex using toRootHex                         4369044 ops/s    228.8830 ns/op        -       1793 runs  0.902 s
    ✔ nodejs fromhex(blob)                                                3.005854 ops/s    332.6842 ms/op        -         10 runs   4.18 s
    ✔ nodejs fromHexInto(blob)                                            3.617654 ops/s    276.4222 ms/op        -         10 runs   3.36 s
    ✔ browser block root to RootHex using the deprecated toHexString       1656696 ops/s    603.6110 ns/op        -        963 runs   1.34 s
    ✔ browser block root to RootHex using toHex                            2060611 ops/s    485.2930 ns/op        -        424 runs  0.812 s
    ✔ browser block root to RootHex using toRootHex                        2320476 ops/s    430.9460 ns/op        -        889 runs  0.841 s
    ✔ browser fromHexInto(blob)                                           503.7166 ops/s    1.985243 ms/op        -         10 runs   21.9 s
    ✔ browser fromHex(blob)                                              0.5095370 ops/s    1.962566  s/op        -         10 runs   21.7 s
```

---------

Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com>
This commit is contained in:
twoeths
2025-08-27 20:20:15 +07:00
committed by GitHub
parent 14a6f737fd
commit afdf325ebf
5 changed files with 136 additions and 6 deletions

View File

@@ -60,12 +60,29 @@ export function fromHex(hex: string): Uint8Array {
const byteLen = hex.length / 2;
const bytes = new Uint8Array(byteLen);
for (let i = 0; i < byteLen; i++) {
const byte = parseInt(hex.slice(i * 2, (i + 1) * 2), 16);
bytes[i] = byte;
const byte2i = charCodeToByte(hex.charCodeAt(i * 2));
const byte2i1 = charCodeToByte(hex.charCodeAt(i * 2 + 1));
bytes[i] = (byte2i << 4) | byte2i1;
}
return bytes;
}
export function fromHexInto(hex: string, buffer: Uint8Array): void {
if (hex.startsWith("0x")) {
hex = hex.slice(2);
}
if (hex.length !== buffer.length * 2) {
throw new Error(`hex string length ${hex.length} must be exactly double the buffer length ${buffer.length}`);
}
for (let i = 0; i < buffer.length; i++) {
const byte2i = charCodeToByte(hex.charCodeAt(i * 2));
const byte2i1 = charCodeToByte(hex.charCodeAt(i * 2 + 1));
buffer[i] = (byte2i << 4) | byte2i1;
}
}
/**
* Populate charCodes from bytes. Note that charCodes index 0 and 1 ("0x") are not populated.
*/
@@ -85,3 +102,22 @@ function bytesIntoCharCodes(bytes: Uint8Array, charCodes: number[]): void {
charCodes[2 + 2 * i + 1] = second < 10 ? second + 48 : second + 87;
}
}
function charCodeToByte(charCode: number): number {
// "a".charCodeAt(0) = 97, "f".charCodeAt(0) = 102 => delta = 87
if (charCode >= 97 && charCode <= 102) {
return charCode - 87;
}
// "A".charCodeAt(0) = 65, "F".charCodeAt(0) = 70 => delta = 55
if (charCode >= 65 && charCode <= 70) {
return charCode - 55;
}
// "0".charCodeAt(0) = 48, "9".charCodeAt(0) = 57 => delta = 48
if (charCode >= 48 && charCode <= 57) {
return charCode - 48;
}
throw new Error(`Invalid hex character code: ${charCode}`);
}

View File

@@ -1,5 +1,6 @@
import {
fromHex as browserFromHex,
fromHexInto as browserFromHexInto,
toHex as browserToHex,
toPubkeyHex as browserToPubkeyHex,
toRootHex as browserToRootHex,
@@ -15,6 +16,8 @@ let toHex = browserToHex;
let toRootHex = browserToRootHex;
let toPubkeyHex = browserToPubkeyHex;
let fromHex = browserFromHex;
// there is no fromHexInto for NodeJs as the performance of browserFromHexInto is >100x faster
const fromHexInto = browserFromHexInto;
if (typeof Buffer !== "undefined") {
toHex = nodeToHex;
@@ -23,4 +26,4 @@ if (typeof Buffer !== "undefined") {
fromHex = nodeFromHex;
}
export {toHex, toRootHex, toPubkeyHex, fromHex};
export {toHex, toRootHex, toPubkeyHex, fromHex, fromHexInto};

View File

@@ -1,3 +1,5 @@
import {fromHexInto as _fromHexInto} from "./browser.js";
export function toHex(buffer: Uint8Array | Parameters<typeof Buffer.from>[0]): string {
if (Buffer.isBuffer(buffer)) {
return "0x" + buffer.toString("hex");
@@ -59,3 +61,5 @@ export function fromHex(hex: string): Uint8Array {
const b = Buffer.from(hex, "hex");
return new Uint8Array(b.buffer, b.byteOffset, b.length);
}
/// the performance of fromHexInto using a preallocated buffer is very bad compared to browser so I moved it to the benchmark

View File

@@ -1,11 +1,23 @@
import {bench, describe} from "@chainsafe/benchmark";
import {toHexString} from "../../src/bytes.js";
import {toHex as browserToHex, toRootHex as browserToRootHex} from "../../src/bytes/browser.js";
import {toHex, toRootHex} from "../../src/bytes/nodejs.js";
import {
fromHex as browserFromHex,
fromHexInto as browserFromHexInto,
toHex as browserToHex,
toRootHex as browserToRootHex,
} from "../../src/bytes/browser.js";
import {fromHex, toHex, toRootHex} from "../../src/bytes/nodejs.js";
describe("bytes utils", () => {
const runsFactor = 1000;
const blockRoot = new Uint8Array(Array.from({length: 32}, (_, i) => i));
// FIELD_ELEMENTS_PER_BLOB * BYTES_PER_FIELD_ELEMENT = 4096 * 32 = 131072
const BLOB_LEN = 131072;
const blob = new Uint8Array(BLOB_LEN);
for (let i = 0; i < blob.length; i++) {
blob[i] = i % 256;
}
const blobHex = toHex(blob);
bench({
id: "nodejs block root to RootHex using toHex",
@@ -27,6 +39,25 @@ describe("bytes utils", () => {
runsFactor,
});
bench({
id: "nodejs fromhex(blob)",
fn: () => {
for (let i = 0; i < runsFactor; i++) {
fromHex(blobHex);
}
},
});
const buffer = Buffer.alloc(BLOB_LEN);
bench({
id: "nodejs fromHexInto(blob)",
fn: () => {
for (let i = 0; i < runsFactor; i++) {
nodeJsFromHexInto(blobHex, buffer);
}
},
});
bench({
id: "browser block root to RootHex using the deprecated toHexString",
fn: () => {
@@ -56,4 +87,41 @@ describe("bytes utils", () => {
},
runsFactor,
});
const buf = new Uint8Array(BLOB_LEN);
bench({
id: "browser fromHexInto(blob)",
fn: () => {
for (let i = 0; i < runsFactor; i++) {
browserFromHexInto(blobHex, buf);
}
},
runsFactor,
});
bench({
id: "browser fromHex(blob)",
fn: () => {
for (let i = 0; i < runsFactor; i++) {
browserFromHex(blobHex);
}
},
});
});
/**
* this function is so slow compared to browser's implementation we only maintain it here to compare performance
* - nodejs fromHexInto(blob) 3.562495 ops/s 280.7022 ms/op - 10 runs 3.50 s
* - browser fromHexInto(blob) 535.0952 ops/s 1.868826 ms/op - 10 runs 20.8 s
*/
function nodeJsFromHexInto(hex: string, buffer: Buffer): void {
if (hex.startsWith("0x")) {
hex = hex.slice(2);
}
if (hex.length !== buffer.length * 2) {
throw new Error(`hex string length ${hex.length} must be exactly double the buffer length ${buffer.length}`);
}
buffer.write(hex, "hex");
}

View File

@@ -3,6 +3,7 @@ import {
bytesToInt,
formatBytes,
fromHex,
fromHexInto,
intToBytes,
toHex,
toHexString,
@@ -108,16 +109,26 @@ describe("toPubkeyHex", () => {
}
});
describe("fromHex", () => {
describe("fromHex and fromHexInto", () => {
const testCases: {input: string; output: Buffer | Uint8Array}[] = [
{
input: "0x48656c6c6f2c20576f726c6421",
output: new Uint8Array([72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33]),
},
{
// same but with upper case
input: "0x48656C6C6F2C20576F726C6421",
output: new Uint8Array([72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33]),
},
{
input: "48656c6c6f2c20576f726c6421",
output: new Uint8Array([72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33]),
},
{
// same but with upper case
input: "48656C6C6F2C20576F726C6421",
output: new Uint8Array([72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33]),
},
{input: "0x", output: new Uint8Array([])},
];
@@ -126,6 +137,14 @@ describe("fromHex", () => {
expect(fromHex(input)).toEqual(output);
});
}
for (const {input, output} of testCases) {
it(`should convert hex string ${input} into provided buffer`, () => {
const buffer = new Uint8Array(output.length);
fromHexInto(input, buffer);
expect(toHex(buffer)).toBe(toHex(output));
});
}
});
describe("toHexString", () => {