Skip to content

IGNITE-27606 Replace GridUnsafe with byte-array VarHandle views in PartitionUpdateCountersMessage - #13422

Merged
anton-vinogradov merged 1 commit into
apache:masterfrom
anton-vinogradov:ignite-27606
Aug 3, 2026
Merged

IGNITE-27606 Replace GridUnsafe with byte-array VarHandle views in PartitionUpdateCountersMessage#13422
anton-vinogradov merged 1 commit into
apache:masterfrom
anton-vinogradov:ignite-27606

Conversation

@anton-vinogradov

Copy link
Copy Markdown
Contributor

IGNITE-27606

PartitionUpdateCountersMessage keeps its counters in one byte[], packed by hand as 20-byte items (partition, initial counter, updates count). The packing used GridUnsafe. This replaces it with byte-array VarHandle views.

The wire form does not change

The bytes stay the same on every little-endian machine, which is every platform Ignite runs on today. The new test testWireLayoutIsLittleEndian asserts the layout byte by byte, and it passes against the code before this change as well.

Two problems fixed along the way

A write past the end of the array. ensureSpace grew the array by a factor of 1.33, and that can be less than what was asked for: 1.33 of a one-item array is 26 bytes, while two items need 40. GridUnsafe does not check bounds, so add then wrote outside the array and the value was silently lost. The new test testAddPastInitialSize fails against the old code with expected:<100> but was:<0>. It never fires in production because both callers size the message exactly (IgniteTxHandler, IgniteTxLocalAdapter), so the growth path is never taken. ensureSpace now takes Math.max of the request and the growth.

The byte order followed the host. GridUnsafe.getInt/getLong over a byte[] use the native order, so a node wrote the counters in its own order and the receiver read them in its own. In a cluster with mixed endianness this corrupts the counters. The VarHandle views pin little-endian, so the wire form no longer depends on the architecture.

VarHandle also brings bounds checking, which is what turns the first problem from a silent bad value into an exception.

Checks

  • new PartitionUpdateCountersMessageTest, 5 cases - green; against the code before this change 1 fails (the bug above) and 4 pass (the wire form is unchanged);
  • IgniteCoreMessagesSerializationTest - green;
  • GridCachePartitionsUpdateCountersAndSizeTest - green, 4 of 4;
  • checkstyle with -Pcheckstyle - no violations.

🤖 Generated with Claude Code

@anton-vinogradov

anton-vinogradov commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

/runall


🚀 RunAll queuedbuild 9243737 · live progress & verdict: Ignite PR Checker. The verdict lands here when the run finishes.
🏁 Run finished — the verdict comment has the full story.

@anton-vinogradov

anton-vinogradov commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Ignite PR Checker verdict · RunAll build 9243737 · 147 suites ran, 0 reused

🔍 1 suite(s) ran fewer tests than on master (tests that never ran can't fail):

  • Thin Client: Java: 57 tests vs 439 on master (−87%)

✅ No test blockers otherwise; 17 pre-existing/flaky filtered out.

♻️ Settled after 1 auto re-run wave(s): #1 — 2 blocker + 1 broken suite(s).

…rtitionUpdateCountersMessage

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@anton-vinogradov

anton-vinogradov commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Measured: is there a performance drop from GridUnsafe to VarHandle?

Short answer: VarHandle costs a bounds check that Unsafe skipped, so it is slower where Unsafe was already fast, and much faster where it was not. All numbers below are measured on aarch64 hardware. I have not measured on x86; see the caveat at the end before reading the first table as an x86 result.

JMH, JDK 17 (Corretto), Apple aarch64, 1 thread, AverageTime, ns/op, lower is better. items is the number of partitions in one message.

The unaligned-access branch - the one x86 always takes. Forced here with -DIGNITE_MEMORY_UNALIGNED_ACCESS=true, so GridUnsafe calls UNSAFE.getInt/getLong directly instead of assembling the value byte by byte. Same code branch as x86, but still running on aarch64:

benchmark items GridUnsafe VarHandle change
read 4 2.426 3.553 +46%
read 32 18.698 25.881 +38%
read 1024 1073.1 1118.5 +4%
write 4 2.006 3.500 +74%
write 32 12.960 19.668 +52%
write 1024 420.0 605.6 +44%

The percentages look big, the absolute numbers do not: a transaction touches a handful of partitions, so the whole message write goes from 2.0 ns to 3.5 ns. The extra work is the bounds check that Unsafe skips - the same check that turns the out-of-bounds write described in the description from a silently lost value into an exception.

The byte-by-byte branch - what aarch64 takes today, with no property set:

benchmark items GridUnsafe VarHandle change
read 4 9.270 3.444 −63%
read 32 71.793 25.490 −65%
read 1024 2366.0 1097.4 −54%
write 4 10.486 3.642 −65%
write 32 84.026 19.274 −77%
write 1024 2669.7 593.3 −78%

The reason is GridUnsafe#unaligned(): it returns true only for i386, x86, amd64 and x86_64, and otherwise falls back to the IGNITE_MEMORY_UNALIGNED_ACCESS property, which is false by default. So on aarch64 every getInt/getLong/putInt/putLong over a byte[] is assembled byte by byte. The VarHandle view does not have that split and is 2 to 4.5 times faster there.

Caveats

The first table is not an x86 measurement. Both runs are on aarch64. The property forces the same code branch x86 takes, which is why the comparison is meaningful at all, but the hardware underneath is not x86. The cost of an unaligned load, store forwarding and a well-predicted branch all differ between x86 and Apple silicon, so the direction should carry over - VarHandle adds a bounds check, so it cannot come out ahead when both sides compile to a real unaligned load - while the size of the gap should not be assumed to. Someone with an x86 box is welcome to rerun it; the source is below.

A microbenchmark of three field accesses is easy to over-read. At items=4 a fair share of the number is loop overhead. Read the tables for the order of magnitude, not the exact percent.

Benchmark source (not part of this PR)

Put it in modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/misc/, build with -Pbenchmarks, and run with the project's --add-opens list, otherwise GridUnsafe fails to initialize and only the VarHandle benchmarks run.

@State(Scope.Thread)
@Fork(1)
@Threads(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class JmhCounterPackingBenchmark {
    private static final int ITEM_SIZE = 4 + 8 + 8;

    private static final VarHandle INT_VIEW = MethodHandles.byteArrayViewVarHandle(int[].class, ByteOrder.LITTLE_ENDIAN);

    private static final VarHandle LONG_VIEW = MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN);

    @Param({"4", "32", "1024"})
    private int items;

    private byte[] data;

    @Setup
    public void setup() {
        data = new byte[items * ITEM_SIZE];

        for (int i = 0; i < items; i++)
            addVarHandle(i, i, i * 10L, i * 100L);
    }

    private void addUnsafe(int idx, int part, long init, long updatesCnt) {
        long off = GridUnsafe.BYTE_ARR_OFF + (long)idx * ITEM_SIZE;

        GridUnsafe.putInt(data, off, part); off += 4;
        GridUnsafe.putLong(data, off, init); off += 8;
        GridUnsafe.putLong(data, off, updatesCnt);
    }

    private void addVarHandle(int idx, int part, long init, long updatesCnt) {
        int off = idx * ITEM_SIZE;

        INT_VIEW.set(data, off, part);
        LONG_VIEW.set(data, off + 4, init);
        LONG_VIEW.set(data, off + 12, updatesCnt);
    }

    @Benchmark
    public byte[] writeUnsafe() {
        for (int i = 0; i < items; i++)
            addUnsafe(i, i, i * 10L, i * 100L);

        return data;
    }

    @Benchmark
    public byte[] writeVarHandle() {
        for (int i = 0; i < items; i++)
            addVarHandle(i, i, i * 10L, i * 100L);

        return data;
    }

    @Benchmark
    public long readUnsafe() {
        long res = 0;

        for (int i = 0; i < items; i++) {
            long off = GridUnsafe.BYTE_ARR_OFF + (long)i * ITEM_SIZE;

            res += GridUnsafe.getInt(data, off);
            res += GridUnsafe.getLong(data, off + 4);
            res += GridUnsafe.getLong(data, off + 12);
        }

        return res;
    }

    @Benchmark
    public long readVarHandle() {
        long res = 0;

        for (int i = 0; i < items; i++) {
            int off = i * ITEM_SIZE;

            res += (int)INT_VIEW.get(data, off);
            res += (long)LONG_VIEW.get(data, off + 4);
            res += (long)LONG_VIEW.get(data, off + 12);
        }

        return res;
    }
}

@anton-vinogradov
anton-vinogradov merged commit 152157d into apache:master Aug 3, 2026
7 checks passed
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Possible compatibility issues. Please, check rolling upgrade cases

This PR modifies protected classes (with Order annotation).
Changes to these classes can break rolling upgrade compatibility.

Affected files:

  • modules/core/src/main/java/org/apache/ignite/internal/processors/cache/distributed/dht/PartitionUpdateCountersMessage.java

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants