Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 46 additions & 32 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
@@ -1,62 +1,76 @@
name: CI
on:
- push
- pull_request
push:
branches:
- master
tags: ['*']
pull_request:
workflow_dispatch:
concurrency:
# Skip intermediate builds: always.
# Cancel intermediate builds: only if it is a pull request build.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }}
jobs:
test:
name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }}
name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ github.event_name }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
permissions: # needed to allow julia-actions/cache to proactively delete old caches that it has created
actions: write
contents: read
strategy:
fail-fast: false
matrix:
version:
- '1.0'
- '1.6'
- 'nightly'
- 'lts'
- '1'
- 'pre'
os:
- ubuntu-latest
- macOS-latest
arch:
- x64
- windows-latest
steps:
- uses: actions/checkout@v4
- uses: julia-actions/setup-julia@v2
with:
version: ${{ matrix.version }}
arch: ${{ matrix.arch }}
- uses: actions/cache@v4
env:
cache-name: cache-artifacts
with:
path: ~/.julia/artifacts
key: ${{ runner.os }}-test-${{ env.cache-name }}-${{ hashFiles('**/Project.toml') }}
restore-keys: |
${{ runner.os }}-test-${{ env.cache-name }}-
${{ runner.os }}-test-
${{ runner.os }}-
- uses: julia-actions/cache@v2
- uses: julia-actions/julia-buildpkg@v1
- uses: julia-actions/julia-runtest@v1
- uses: julia-actions/julia-processcoverage@v1
- uses: codecov/codecov-action@v5
with:
files: lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
docs:
name: Documentation
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
statuses: write
steps:
- uses: actions/checkout@v4
- uses: julia-actions/setup-julia@v2
with:
version: '1'
- run: |
julia --project=docs -e '
using Pkg
Pkg.develop(PackageSpec(path=pwd()))
Pkg.instantiate()'
- run: |
julia --project=docs -e '
using Documenter: DocMeta, doctest
using MsgPack
DocMeta.setdocmeta!(MsgPack, :DocTestSetup, :(using MsgPack); recursive=true)
doctest(MsgPack)'
- run: julia --project=docs docs/make.jl
- uses: julia-actions/cache@v2
- name: Configure doc environment
shell: julia --project=docs --color=yes {0}
run: |
using Pkg
Pkg.develop(PackageSpec(path=pwd()))
Pkg.instantiate()
- uses: julia-actions/julia-docdeploy@v1
env:
JULIA_PKG_SERVER: ""
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }}
- name: Run doctests
shell: julia --project=docs --color=yes {0}
run: |
using Documenter: DocMeta, doctest
using MsgPack
DocMeta.setdocmeta!(MsgPack, :DocTestSetup, :(using MsgPack); recursive=true)
doctest(MsgPack)
43 changes: 43 additions & 0 deletions .github/workflows/CompatHelper.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CompatHelper
on:
schedule:
- cron: 0 0 * * *
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
CompatHelper:
runs-on: ubuntu-latest
steps:
- name: Check if Julia is already available in the PATH
id: julia_in_path
run: which julia
continue-on-error: true
- name: Install Julia, but only if it is not already available in the PATH
uses: julia-actions/setup-julia@v2
with:
version: '1'
if: steps.julia_in_path.outcome != 'success'
- name: "Add the General registry via Git"
run: |
import Pkg
ENV["JULIA_PKG_SERVER"] = ""
Pkg.Registry.add("General")
shell: julia --color=yes {0}
- name: "Install CompatHelper"
run: |
import Pkg
name = "CompatHelper"
uuid = "aa819f21-2bde-4658-8897-bab36330d9b7"
version = "3"
Pkg.add(; name, uuid, version)
shell: julia --color=yes {0}
- name: "Run CompatHelper"
run: |
import CompatHelper
CompatHelper.main()
shell: julia --color=yes {0}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMPATHELPER_PRIV: ${{ secrets.DOCUMENTER_KEY }}
24 changes: 22 additions & 2 deletions .github/workflows/TagBot.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
name: TagBot
on:
schedule:
- cron: 0 * * * *
issue_comment:
types:
- created
workflow_dispatch:
inputs:
lookback:
default: '3'
permissions:
actions: read
checks: read
contents: write
deployments: read
issues: read
discussions: read
packages: read
pages: read
pull-requests: read
repository-projects: read
security-events: read
statuses: read
jobs:
TagBot:
if: github.event_name == 'workflow_dispatch' || github.actor == 'JuliaTagBot'
runs-on: ubuntu-latest
steps:
- uses: JuliaRegistries/TagBot@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
ssh: ${{ secrets.DOCUMENTER_KEY }}
132 changes: 132 additions & 0 deletions PERF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# MsgPack pack performance — Bonito workloads

Tracks per-message pack performance for the shapes Bonito actually sends
(SerializedMessage with caching, observable updates, typed arrays). Update
when changing `src/pack.jl` or anything in Bonito's serialization layer.

Environment: Julia 1.12.6, x86_64 linux, Sonnet 4.7 worker.

## Methodology

Run with `julia_eval`, env_path = project root. After each scenario, `GC.gc(true)` and `@timed` over a fixed iteration count. `serialize_binary(sm)` benches the MsgPack round (re-packing an already-built `SerializedMessage`); `full(...)` is the `Bonito.send` hot path — `SerializedMessage(session, msg)` + `serialize_binary(sm)`.

To re-measure: stash any MsgPack changes (`cd dev/MsgPack && git stash`), restart Julia (force fresh precompile), run the bench block at the bottom of this file. `git stash pop`, restart, run again.

## Workloads

| Name | Shape |
|---|---|
| `small` | 3-key Dict, Float64 payload (typical slider feedback, ~145 B on wire) |
| `vec_4K` | `Vector{Float32}` of 4096 elements (graphics-y, ~16.5 KB) |
| `batch_50` | 50 inner observable-update Dicts in one outer Dict (batched updates, ~2 KB) |
| `nested` | mixed Float32 vec / String / Int vec inside nested Dict (~1.2 KB) |

## Results — 2026-05-12

### `serialize_binary(sm)` (5000 iters)

| Workload | BEFORE μs | AFTER μs | Δ | BEFORE allocs | AFTER allocs | Δ |
|---|---:|---:|---:|---:|---:|---:|
| small | 1.48 | 1.33 | -10% | 51 | 41 | -20% |
| vec_4K | 5.05 | 4.93 | -2% | 50 | 42 | -16% |
| batch_50 | 8.22 | 8.74 | +6% | 106 | 48 | -55% |
| nested | 2.94 | 2.84 | -3% | 52 | 42 | -19% |

### `full` pipeline (2000 iters): `SerializedMessage(...)` + `serialize_binary(...)`

| Workload | BEFORE μs | AFTER μs | Δ | BEFORE allocs | AFTER allocs | Δ |
|---|---:|---:|---:|---:|---:|---:|
| small | 0.91 | 0.81 | -11% | 56 | 46 | -18% |
| vec_4K | 7.84 | 6.86 | -13% | 65 | 57 | -12% |
| batch_50 | 9.72 | 9.97 | +3% | 123 | 65 | -47% |
| nested | 3.76 | 3.79 | +1% | 77 | 67 | -13% |

### Micro: 1000-elt arrays packed into reused IOBuffer (zero-alloc target)

| Workload | BEFORE | AFTER |
|--- |--- |--- |
| pack 1000 small Ints | 745 allocs | 0 allocs |
| pack 1000 Float64 | 1000 allocs | 0 allocs |

## What changed (commit summary)

`dev/MsgPack/src/pack.jl`:

1. **`write_be(io, ::Primitive)` helper** — bypasses the `Base.RefValue{T}` allocation that Julia stdlib's `write(io::IO, ::Real)` incurs for multi-byte primitives (the call chain `write` → `write(io, Ref(x), n)` → `unsafe_write` crosses enough indirection that escape analysis ≤1.12 can't elide the Ref). Inlined as one block here, the compiler proves `r` doesn't escape and SROAs it. Generic IO fallback keeps stdlib semantics.
2. **`pack(x; sizehint=64)`** — kwarg so callers that know the output size can pre-size the buffer (avoids ~3-4 geometric resizes for typical small payloads).
3. **Idiomatic `return nothing`** on all `pack_format` / `pack_type` methods — they were leaking the `Int` byte count from `write` up the call chain. No perf effect (discarded), pure tidy.

`Primitive = Union{Base.BitInteger, Base.IEEEFloat}` so one generic method handles all primitive sizes.

## Protocol-level — `bench/bench.jl` end-to-end through Electron

Real WS round-trip (echo via JS observable callback) and burst throughputs.
Raw JSON: [bench/results/msgpack_before.json](../../bench/results/msgpack_before.json), [bench/results/msgpack_after.json](../../bench/results/msgpack_after.json), [bench/results/msgpack_sessionio.json](../../bench/results/msgpack_sessionio.json).
24 threads, single Electron window, same machine, same session.

Stages compared:
* **BL** — true baseline (no opts).
* **+B** — Bonito's `caching.jl` fast-path + `Dict{String,Any}` switch (commit 5547834).
* **+M** — `MsgPack.write_be` + `pack` sizehint kwarg.
* **+SP** — initial stream-pack via per-call buffers in `pack_type(::ExtensionType, ::SerializedMessage)`.
* **+SIO** — `SessionIO` on `Session`, scratch buffers reused across messages.

| Metric | BL | +B | +M | +SP | **+SIO** | Δ vs BL |
|--- |---: |---: |---: |---: |---: |---: |
| RTT median (μs) | 53.6 | 51.6 | 49.0 | 48.7 | **48.0**| **−10%** |
| RTT p95 (μs) | 97.7 | 85.3 | 82.9 | 72.6 | **79.5**| **−19%** |
| RTT p99 (μs) | 254.3 | 116.9 | 111.7 | 101.8 | **103.8**| **−59%** |
| burst s→j (msg/s) | 88.9k | 105.9k| 103.8k| 97.9k | **112.8k**| **+27%** |
| burst j→s (msg/s) | 93.1k | 98.5k | 93.6k | 97.9k | **95.4k**| +2.4% |

Where each layer contributed:
* The bulk of the throughput win came from **Bonito's fast-path** + **SessionIO buffer reuse**. The SessionIO step alone added +9% s→j over the no-stream-pack baseline.
* Tail latency win is dominated by **MsgPack write_be** (Ref alloc elimination) and **stream-pack** structure (fewer per-message allocs → fewer GC pause spikes).
* SessionIO matched stream-pack on p99 latency while *also* boosting throughput because the reused scratches eliminate the per-call IOBuffer allocation that hurt stream-pack v1's burst numbers.

The `Per-message serialize_binary` micro reflects this dramatically: small message went 41 → 7 allocs and 1.33 → 0.50 μs (−83% allocs, −62% time). The j→s direction is unaffected by any of these changes — that path is dominated by JS→server WS framing on the JavaScript side.

## Where the wins come from

The `batch_50` allocs going 106 → 48 is the strongest signal: dozens of small UInt16/UInt32 writes per message all skip the Ref alloc. The headline absolute time changes are modest because the bigger bottlenecks for Bonito serialization are upstream of MsgPack (`SerializedMessage`'s ctx-locking, the per-cache hashing, JSCode interpolation, etc.). MsgPack itself was never the dominant cost — the win is removing it as a steady source of GC pressure on every Bonito message.

The `+6% time on batch_50` (`serialize_binary` only) is within noise; full pipeline shows it flat. Worth re-checking if it persists.

## Bench code

```julia
using Bonito, MsgPack
using Bonito: Session, NoConnection, NoServer, SerializedMessage, serialize_binary

session = Session(NoConnection(); asset_server=NoServer())
msg_small = Dict{String,Any}("payload" => 0.42, "id" => "obs-12345", "msg_type" => Bonito.UpdateObservable)
msg_vec = Dict{String,Any}("payload" => rand(Float32, 4096), "id" => "obs-vec", "msg_type" => Bonito.UpdateObservable)
msg_batch = Dict{String,Any}("payload" => [Dict{String,Any}("payload" => i*0.1, "id" => "o-$i", "msg_type" => Bonito.UpdateObservable) for i in 1:50],
"id" => "batch", "msg_type" => Bonito.UpdateObservable)
msg_nested = Dict{String,Any}(
"payload" => Dict{String,Any}("a" => rand(Float32, 256), "b" => "hello world", "c" => [1,2,3,4,5]),
"id" => "nested", "msg_type" => Bonito.UpdateObservable)

# serialize_binary only
for (name, m) in (("small", msg_small), ("vec_4K", msg_vec), ("batch_50", msg_batch), ("nested", msg_nested))
sm = SerializedMessage(session, m); serialize_binary(sm)
GC.gc(true)
s = @timed (for _ in 1:5000; serialize_binary(sm); end)
println(rpad("serialize_binary($name)", 28), " ",
lpad(round(s.time*1e6/5000, digits=2), 8), " μs/op ",
lpad(Int(round(s.bytes/5000)), 8), " B/op ",
lpad(Int(round((s.gcstats.poolalloc + s.gcstats.malloc)/5000)), 6), " allocs/op")
end

# full pipeline
full_pipeline(s, m) = serialize_binary(SerializedMessage(s, m))
for (name, m) in (("small", msg_small), ("vec_4K", msg_vec), ("batch_50", msg_batch), ("nested", msg_nested))
full_pipeline(session, m); full_pipeline(session, m)
GC.gc(true)
s = @timed (for _ in 1:2000; full_pipeline(session, m); end)
println(rpad("full($name)", 28), " ",
lpad(round(s.time*1e6/2000, digits=2), 8), " μs/op ",
lpad(Int(round(s.bytes/2000)), 8), " B/op ",
lpad(Int(round((s.gcstats.poolalloc + s.gcstats.malloc)/2000)), 6), " allocs/op")
end
```
4 changes: 2 additions & 2 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "MsgPack"
uuid = "99f44e22-a591-53d1-9472-aa23ef4bd671"
version = "1.2.1"
version = "1.3.0"

[deps]
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
Expand All @@ -9,7 +9,7 @@ Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
Aqua = "0.8"
Serialization = "<0.0.1, 1"
Test = "<0.0.1, 1"
julia = "1"
julia = "1.10"

[extras]
Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# MsgPack.jl

[![CI](https://github.com/JuliaIO/MsgPack.jl/actions/workflows/CI.yml/badge.svg)](https://github.com/JuliaIO/MsgPack.jl/actions/workflows/CI.yml)
[![codecov](https://codecov.io/gh/JuliaIO/MsgPack.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/JuliaIO/MsgPack.jl)

MsgPack.jl is a MessagePack implementation in pure Julia, inspired by [JSON3.jl](https://github.com/quinnj/JSON3.jl). This package supports:

- (de)serialization of Julia values to/from MessagePack (see `pack` and `unpack`)
Expand Down
4 changes: 2 additions & 2 deletions docs/Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[deps]
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
MsgPack = "f3bf5812-ae52-11e9-182b-097c550858cf"
MsgPack = "99f44e22-a591-53d1-9472-aa23ef4bd671"

[compat]
Documenter = "~0.22"
Documenter = "1"
Loading
Loading