Skip to content

Splat run-end bool decode into 64-bit words - #9021

Open
joseph-isaacs wants to merge 5 commits into
claude/runend-bool-decode-benchesfrom
claude/runend-decompress-bools-1t2re2
Open

Splat run-end bool decode into 64-bit words#9021
joseph-isaacs wants to merge 5 commits into
claude/runend-bool-decode-benchesfrom
claude/runend-decompress-bools-1t2re2

Conversation

@joseph-isaacs

@joseph-isaacs joseph-isaacs commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Stacked on #9020 (benchmarks only) — review that first; this PR's base is that branch, so its diff is just the five commits here.

Rationale for this change

The bool decoder prefilled the output with the majority value and then fill_ranged every run whose value differed. Per run that costs a data-dependent branch on the run's value — which mispredicts about half the time on 50/50 data — plus a range fill that masks a partial byte at each end around its memset. Both costs are constant per run, so when runs are short the loop is O(n · k) with nothing amortising k.

A run of bools is a bit pattern, so it can be built rather than patched: OR a mask of the run's value into a 64-bit accumulator and store each output word exactly once.

Result

On #9020's corpus benchmark, by fastest sample:

case before after speedup
128x16k_run4_even 4.93 ms 1.38 ms 3.57x
128x16k_run4_even_nullable 6.71 ms 2.39 ms 2.81x
128x16k_run4_skewed 2.06 ms 1.40 ms 1.47x
128x16k_run16_even 1.31 ms 339 µs 3.87x
128x16k_run16_even_nullable 1.78 ms 751 µs 2.36x
128x16k_run16_skewed 537 µs 338 µs 1.59x
128x16k_run64_even 315 µs 143 µs 2.20x
128x16k_run64_even_nullable 443 µs 259 µs 1.71x
128x16k_run64_skewed 148 µs 130 µs 1.14x
4x100k_run4_even 943 µs 260 µs 3.62x
4x100k_run4_even_nullable 1125 µs 433 µs 2.60x
4x100k_run4_skewed 381 µs 256 µs 1.49x
4x100k_run16_even 234 µs 62.8 µs 3.72x
4x100k_run16_even_nullable 293 µs 110 µs 2.67x
4x100k_run16_skewed 91.1 µs 60.9 µs 1.50x
4x100k_run64_even 45.9 µs 17.2 µs 2.67x
4x100k_run64_even_nullable 56.4 µs 27.1 µs 2.08x
4x100k_run64_skewed 20.5 µs 14.7 µs 1.39x

Where the win comes from

Each commit measured on this same benchmark, at that commit. Every column is the speedup over the column to its left, so the five multiply to the total.

case splat overwrite fuse blend refit total
128x16k_run4_even 3.55 1.06 0.99 0.96 1.04 3.68
128x16k_run4_even_nullable 2.03 1.03 1.27 0.86 1.13 2.57
128x16k_run4_skewed 1.49 1.06 0.99 0.97 1.03 1.56
128x16k_run16_even 2.64 1.18 0.97 1.14 1.18 4.05
128x16k_run16_even_nullable 1.74 1.11 1.23 1.04 0.99 2.44
128x16k_run16_skewed 1.05 1.16 0.98 1.14 1.19 1.62
128x16k_run64_even 1.48 1.28 1.02 1.19 1.01 2.35
128x16k_run64_even_nullable 1.03 0.98 1.51 1.15 1.02 1.79
128x16k_run64_skewed 1.18 1.02 0.99 0.95 1.07 1.21
4x100k_run4_even 3.71 1.07 0.98 0.98 1.01 3.86
4x100k_run4_even_nullable 2.02 1.03 1.30 0.94 1.07 2.70
4x100k_run4_skewed 1.49 1.07 0.98 0.98 1.01 1.55
4x100k_run16_even 2.88 1.24 0.94 1.16 1.02 3.99
4x100k_run16_even_nullable 1.69 1.14 1.26 1.03 1.09 2.72
4x100k_run16_skewed 1.06 1.24 0.96 1.15 1.01 1.47
4x100k_run64_even 1.59 1.79 0.99 0.98 0.96 2.66
4x100k_run64_even_nullable 0.99 0.98 2.00 1.07 1.00 2.06
4x100k_run64_skewed 1.24 1.08 1.00 0.95 1.06 1.35

Reading it honestly: the splat itself is most of the win, and the rest is worth having but is not where the headline comes from. fuse is nullable-only by construction, so its non-nullable column is run-to-run noise around 1.0 and should be read as nothing rather than as a small loss. blend and refit have to be read as a pair — the blend was landed with thresholds still fitted to the old uniform data, which cost up to 14% at 4-element runs, and the refit gives it back; together they are 0.94–1.36x, clearly positive at 16-element runs and neutral at 4.

What changes are included in this PR?

1. The splat kernel — 1.03–3.71x, the bulk of the change. Accumulate into a u64, flush whole words. Nothing is read back, and the run's value only feeds an AND mask, so the value branch is gone. Biggest where runs are short and values even, which is where the old kernel's per-run branch mispredicted most and its range fills were smallest; near nothing at 64-element nullable runs, where the prefill was already close to a memset.

Both kernels are kept, because the prefill is not strictly worse: it skips majority runs entirely, so once patches are rare enough it approaches a bare memset and wins. prefer_splat dispatches on the count its advantage depends on. The prefill also now takes run ends as a slice and trims by offset inline rather than through trimmed_ends_iter plus a bit-at-a-time BitBuffer iterator.

2. Overwrite a pre-sized word buffer — 0.98–1.79x, broadly 1.03–1.28. Instead of appending with BufferMut::push/push_n. The two obvious reasons to expect a win are both wrong, and the disassembly says so: push_n and slice::fill lower to the identical inlined vector store loop (neither is a memset — the splat value is a runtime value, not a byte pattern), and the per-word capacity check trades places with a slice bounds check. The win is second-order: without reserve's allocation slow path, the spanning path fits LLVM's inline budget, so &mut self stops escaping and the accumulator stays in registers.

3. Fuse the nullable pair onto one bit counter — 1.23–2.00x on nullable cases, nothing elsewhere. The nullable path drove two independent sinks that are appended to in lockstep, so every run did the bookkeeping twice, and the doubled live state spilled: ten memory operations per run against the non-nullable loop's zero.

4. A branch-free splat form for word-scale runs — 0.86–1.19x on its own. The remaining per-run branch — does this run reach the end of the word — is data-dependent, and its predictability tracks the run length: for runs averaging r bits it fires about r/64 of the time. The new form paints the run from the current offset to the top of the word and stores unconditionally, relying on the bits above the offset being don't-care. It is not strictly better, so both forms stay, picked by average run length.

5. Refit both constants for normally distributed run lengths — 0.96–1.19x, recovering commit 4's losses at short runs. Matches #9020's generator, and the ablation generator to it, so the constants are fitted on the data CI measures. The kernels separate far more cleanly on this data. Splat over prefill:

run length      1     2     8    16    32    48    64   128  1024
non-nullable
  50/50      3.57  3.67  3.71  3.75  3.45  3.02  2.91  1.52  0.85
  90/10         -  1.38  1.35     -  1.14     -  0.90  0.74  0.76

Every win has at least 205 patched runs and every loss at most 102, so one threshold separates them with margin where the previous fit had borderline points either side. The nullable side splits at the same place — it doubles the work on both sides of the comparison, since the prefill memsets and patches two buffers exactly where the splat builds two — so prefer_splat loses its buffers parameter and becomes one patched run per eight output words.

prefer_blend gains an upper bound, because the band has two edges:

run length      1     2     8    16    32    48    64   128  1024
non-nullable 0.91  0.99  1.18  1.31  1.41  1.43  1.48  1.18  0.93
nullable     0.87  0.90  1.03  1.15  1.27  1.52  1.25  0.94  0.89

Far above a word the boundary test always fires and predicts again, leaving the blend paying its extra store against a whole-word fill that dominates either way. Without the bound the nullable 1024 cases came out 13% behind the original kernel, reproducibly across two runs.

New bench target run_end_decode_bool_ablation, which A/Bs the kernel structures in one process against bench-local mirrors of the previous kernel and of the append-based splat. Both constants can be re-derived from it by pinning a kernel and re-running the grid.

Reviewer notes

  • The blend path is #[inline(never)], and that is load-bearing. With both loops inlined into one function, the branching loop lost 20% with its own text unchanged, measured against an in-process anchor that did not move. Expressing the choice as a const generic over a single loop did the same. Only keeping the second loop out of line leaves the first alone. Similarly, the spanning path needs #[inline(always)] and not #[inline] — the crate build leaves it out of line where an identical bench-local copy is inlined, which costs most of commit 2's win.
  • The blend form needs one word of slack past the output, because a zero-length run arriving when the buffer is exactly full still stores, and run ends clamped to the logical length produce exactly that. finish must also mask the last partial word or the paint survives past the logical length where true_count would read it. Handling the last word by a separate slower path instead was measured and does not pay: the only per-run cost it could remove is the store's bounds check, which an unchecked-store experiment puts inside the noise.
  • Dropping the branching form entirely was considered, on the grounds that the compressor gates run-end at an average run length of 4 (RUN_END_THRESHOLD), so very short runs are unreachable. Measured at that floor, always blending is free on the non-nullable path but costs 14% on the nullable one, which is reachable through comparisons pushed into a nullable run-end column. The dispatch is free, so it stays.

What APIs are changed? Are there any user-facing changes?

runend_decode_typed_bool changes signature: run ends as &[E] plus an offset, instead of an impl Iterator<Item = usize> of already-trimmed ends. No callers outside this crate.

No behaviour change. Covered by a differential test against a naive element-at-a-time decode across run lengths 1–300, nullable and not, at offsets 0/1/63/64/100; a separate differential test pinning the blend form at eleven run lengths and three lengths chosen to land on and off the word grid; and word-boundary cases at every alignment.

Run position advances by the clamped run length rather than jumping to the run end, so a malformed array cannot run the output position backwards and overrun the sink. Run ends are only checked for sortedness under debug_assertions, so a release build does see unsorted ends; decode_bools_non_monotonic_ends covers it. All indexing stays bounds-checked — this is a panic-vs-correct-output question, not a soundness one. The only unsafe added is a contained BufferMut::set_len per output buffer, after the sink has provably initialised every word.

Checks run: cargo test -p vortex-runend (242 pass), cargo test -p vortex-btrblocks -p vortex-layout -p vortex-file, cargo clippy -p vortex-runend --all-targets --all-features, cargo +nightly fmt --all, RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items, cargo test --doc, git diff --check, plus objdump on the built binaries to confirm the inlining claims above.

@codspeed-hq

codspeed-hq Bot commented Jul 27, 2026

Copy link
Copy Markdown

Merging this PR will regress 5 benchmarks

⚡ 39 improved benchmarks
❌ 5 regressed benchmarks
✅ 1823 untouched benchmarks
🆕 90 new benchmarks
⏩ 46 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation add_decimal_i64_nonnull 1.5 ms 1.9 ms -23.3%
Simulation div_i64_nonnull 1.1 ms 1.4 ms -21.25%
Simulation mul_u8_nonnull 385.2 µs 440 µs -12.46%
Simulation decompress[u64, (10000, 1024)] 51.2 µs 58 µs -11.71%
Simulation mul_u16_nonnull 438.4 µs 493.2 µs -11.11%
Simulation decode_bool_nullable[10000_10_mostly_true_mostly_null] 64 µs 36.5 µs +75.34%
Simulation decode_bool_nullable[10000_10_mostly_true_half_valid] 64.4 µs 38.5 µs +67.48%
Simulation decode_bool_nullable[10000_10_alternating_half_valid] 62.8 µs 38.5 µs +62.96%
Simulation decode_bool_corpus[4x100k_run4_even_nullable] 3.7 ms 2.4 ms +54.37%
Simulation decode_bool_corpus[4x100k_run16_even] 786.6 µs 517.8 µs +51.91%
Simulation decode_bool_corpus[4x100k_run64_even] 252.6 µs 166.4 µs +51.85%
Simulation decode_bool_corpus[128x16k_run4_even_nullable] 19.4 ms 12.8 ms +50.95%
Simulation decode_bool_corpus[128x16k_run16_even] 4.2 ms 2.8 ms +46.87%
Simulation decode_bool_corpus[4x100k_run4_even] 2.4 ms 1.7 ms +44.42%
Simulation decode_bool_corpus[128x16k_run4_even] 12.8 ms 9 ms +43.29%
Simulation decode_bool_corpus[4x100k_run16_even_nullable] 1,178.2 µs 825.8 µs +42.67%
Simulation decode_bool_nullable[10000_2_alternating_mostly_valid] 185.3 µs 130.6 µs +41.89%
Simulation decode_bool_corpus[128x16k_run16_even_nullable] 6.2 ms 4.4 ms +41.32%
Simulation decode_bool_corpus[4x100k_run64_even_nullable] 401.1 µs 285.5 µs +40.48%
Simulation decode_bool_corpus[128x16k_run64_even] 1.4 ms 1 ms +38.35%
... ... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/runend-decompress-bools-1t2re2 (48ddd8d) with claude/runend-bool-decode-benches (dbda9ee)

Open in CodSpeed

Footnotes

  1. 46 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@joseph-isaacs
joseph-isaacs marked this pull request as ready for review July 27, 2026 18:13
@joseph-isaacs
joseph-isaacs force-pushed the claude/runend-decompress-bools-1t2re2 branch 2 times, most recently from 5edb616 to 13cedf9 Compare July 28, 2026 11:27
claude added 2 commits July 28, 2026 17:11
The bool decoder prefilled the output with the majority value and then
`fill_range`d every run whose value differed. That pays, per run, a
data-dependent branch on the value plus a range fill that masks a partial
byte at each end -- work that is constant per run and so never amortized
when runs are short.

Add a splat kernel that accumulates runs into a 64-bit word and stores each
output word exactly once: the run's value only feeds an AND mask, so the
value branch disappears, and head/tail masking is paid per output word
instead of per run.

The two structures are complementary, so keep both and dispatch on the
number of runs the prefill would have to patch, which is what its skipping
buys it. Splat over prefill, by fastest sample on
`run_end_decode_bool_ablation` (65536 elements, randomised run lengths and
values, rotating datasets):

    run length      1     2     8    32    64   128  1024
    non-nullable
      50/50      3.70  3.46  2.86  1.25  1.26  0.87  0.61
      90/10         -  1.28  0.93  0.44  0.48  0.49  0.61
    nullable
      50/50      1.97  2.09  1.92  1.21  0.74  0.50  0.63
      90/10         -  1.20  1.14  0.64  0.46  0.47  0.63

`prefer_splat` reproduces this table's sign at every point but non-nullable
8/90-10, where it gives up 7%.

The prefill kernel also now takes run ends as a slice and trims by offset
inline, rather than through `trimmed_ends_iter` plus a bit-at-a-time
`BitBuffer` iterator; per-run work here is a handful of instructions, so
that chain is not amortized by anything either.

Net against the previously shipped kernel, fastest sample: 3.9x at run
length 1, 3.0x at 8, 1.3-1.4x at 32-64, parity at 1024, and 2.0x for
nullable short runs.

Signed-off-by: Claude <noreply@anthropic.com>
The splat appended its output words to a `BufferMut<u64>` with `push` and
`push_n`. Size the buffer up front instead -- the final word count is known
exactly -- and have the loop overwrite it by index.

The two obvious reasons to expect a win are both wrong, and the disassembly
is what says so. `push_n` and `slice::fill` lower to the identical inlined
vector store loop; neither becomes a `memset`, because the splat value is a
runtime value rather than a byte pattern. And the per-word capacity check
does not disappear so much as trade places with a slice bounds check.

The win is second-order. `reserve`'s allocation slow path kept
`append_spanning` above LLVM's inline budget, so `&mut self` escaped into a
call and the accumulator lived on the stack: a read-modify-write of `word`
and a store-plus-reload of `bits` on every short run, on the loop's critical
dependency chain. Without that slow path the spanning path inlines and both
stay in registers. `#[inline]` is needed to get this in the crate build --
the bench mirror is a smaller function and inlines without it, which is
exactly the sort of gap a bench-only check would hide.

In-process, candidate over baseline by median sample:

    run length      1     2     8    32    64   128  1024
    non-nullable
      50/50      0.94  0.94  0.87  0.79  0.68  0.74  0.84
      90/10         -  0.94  0.87  0.78  0.63  0.71  0.80
    nullable
      50/50      0.96  0.97  0.91  0.81  0.78  0.59  0.82
      90/10         -  0.98  0.94  0.79  0.79  0.53  0.88

Nullable 1- and 2-element runs are a wash rather than a win; everything else
is real, and one of the two nullable accumulators still spills under the
register pressure of driving two sinks.

Position is now advanced with `pos = end` rather than `pos = pos.max(end)`.
The guarded form put a `cmova` on the loop-carried position where a plain
`mov` breaks the chain, and at run length 1 that alone cost 15-25%. Bounds
-checked indexing keeps a malformed run-end array to a panic either way.

`prefer_splat`'s threshold was derived against the slower splat, so it is now
conservative in the sense that it hands the prefill grid points the splat
would win. Recorded in its doc comment; retuning it means re-deriving the
whole ablation table.

Adds `push_splat` to the ablation bench so the append-versus-overwrite
comparison stays reproducible.

Signed-off-by: Claude <noreply@anthropic.com>
@joseph-isaacs
joseph-isaacs force-pushed the claude/runend-bool-decode-benches branch from bd674b8 to dbda9ee Compare July 28, 2026 17:11
claude added 3 commits July 28, 2026 17:11
The nullable splat drove two independent `BitSplat`s, one for the decoded
bits and one for validity. They are appended to in lockstep -- every run
contributes the same number of bits to both -- so all of the bookkeeping was
being done twice: two run lengths, two word-boundary tests, two head masks,
two spanning paths. The disassembly showed the doubled live state spilling as
well: ten memory operations per run against the non-nullable loop's zero.

Fuse them. One `bits` counter, one boundary test, one mask feeding both
accumulators. Measured in-process against the two-sink form, this is worth
0.86-0.70 on its own, and it is the largest single contributor on the
nullable side.

Also settles the inline attribute on the spanning paths. `#[inline]` is not
enough: the crate build leaves the function out of line even where an
identical bench-local copy is inlined, and out of line means `&mut self`
escapes and the accumulator lives on the stack for the whole loop. With
`#[inline(always)]` no call to either spanning path survives in the object
file, which is the property the comment now claims and `objdump` confirms.

Run position now advances by the clamped run length rather than jumping to
the run end. The sinks are sized to exactly `length` bits, so a position that
could move backwards would let the total appended exceed the buffer and turn
a malformed array into an index-out-of-bounds panic -- and run ends are only
checked for sortedness under `debug_assertions`, so a release build does see
them. `decode_bools_non_monotonic_ends` covers it.

With the splat 1.3-1.9x faster than when `prefer_splat`'s constant was fitted,
that constant is re-derived. The crossover moved out: the splat now wins at
8-element runs with 90/10 values, and at 32- and 64-element runs with even
values, all of which the old threshold handed to the prefill. A second output
buffer also no longer doubles the splat's cost, since the fused pair shares
the per-run bookkeeping, so the weight is `buffers + 1` rather than `buffers`.
The new constant picks the faster kernel at 25 of the 26 grid points, against
24 before; the miss is non-nullable 1024-element runs at 50/50, worth 6%.

Net against the kernel this PR started from, by fastest sample:

    run length      1     2     8    32    64   128  1024
    non-nullable
      50/50      4.14  3.93  3.35  1.87  1.20  1.02  1.39
      90/10         -  1.60  1.24  1.14  1.37  1.38  1.68
    nullable
      50/50      2.51  2.61  2.51  1.59  1.22  1.23  1.00
      90/10         -  1.46  1.36  1.05  1.11  1.08  1.04

Signed-off-by: Claude <noreply@anthropic.com>
The splat tests, per run, whether the run reaches the end of the current
word. That test is data-dependent, and its predictability tracks the run
length: for runs averaging r bits it fires about r/64 of the time. Well below
a word it almost never fires and predicts perfectly. Around half a word it is
a coin flip, and the mispredictions dominate the loop -- which is why the
per-run cost peaked at run length 32 rather than falling monotonically.

Add a form with no such test. It paints the run from the current bit offset
all the way to the top of the word and stores unconditionally:

    word = (word & low_mask(bits)) | (splat << bits);
    words[idx] = word;
    let completed = (bits + n) / 64;
    ...
    idx += completed;
    word = if completed > 0 { splat } else { word };
    bits = (bits + n) % 64;

Bits above `bits` are don't-care, so painting past the run's end is free: the
next run blends over them. Two conditions make it correct. The output needs
one word of slack, because a zero-length run arriving once the buffer is
exactly full still stores -- and run ends clamped to the logical length
produce exactly that. And `finish` must mask the last partial word, or the
paint survives past the logical length where `true_count` would read it.
`blend_form_matches_naive` covers both, at three lengths chosen to land on
and off the word grid.

It is not strictly better, so both forms stay, picked by average run length,
which is O(1) from the run count. Blend over branching, by fastest sample:

    run length      1     2     8    16    32    48    64   128  1024
    non-nullable 0.75  0.79  1.02  1.35  1.71  1.39  1.15  0.86  0.74
    nullable     0.82  0.88  1.15  1.37  1.71  1.79  1.74  1.89  1.16

Below a quarter of a word the redundant stores -- up to 64 per word of output
-- cost more than the branch. The nullable kernel crosses over earlier: it
does about twice the work per run, so the extra stores are proportionally
cheaper while a misprediction costs the same.

The blend path is `#[inline(never)]`. This is not a micro-preference: with
the two loops inlined into one function, the branching loop lost 20% with its
own text unchanged, measured against an in-process anchor that did not move.
Expressing the choice as a const generic over one loop did the same. Only
keeping the second loop out of line leaves the first one alone.

On the corpus benchmark, against the previous commit: run16 cases 1.16-1.37x,
run64 cases up to 1.50x, run4 cases unchanged.

Signed-off-by: Claude <noreply@anthropic.com>
The corpus and ablation generators now draw run lengths from a normal
distribution rather than a uniform band. The spread of run lengths, not the
mean, is what sets how often a run crosses a word boundary and how many runs
a prefill has to patch, so both dispatch constants were conditioned on the
old shape and had to be re-derived. Matching the ablation generator to the
corpus one also means the constants are fitted on the data CI measures.

The kernels separate far more cleanly on this data than on the uniform band.
Splat over prefill, by fastest sample:

    run length      1     2     8    16    32    48    64   128  1024
    non-nullable
      50/50      3.57  3.67  3.71  3.75  3.45  3.02  2.91  1.52  0.85
      90/10         -  1.38  1.35     -  1.14     -  0.90  0.74  0.76

Every win has at least 205 patched runs and every loss at most 102, so one
threshold separates them with margin, where the old fit had borderline points
either side. The nullable side splits at the same place: it doubles the work
on both sides of the comparison, since the prefill memsets and patches two
buffers exactly where the splat builds two. So `prefer_splat` loses its
`buffers` parameter entirely and becomes one patched run per eight output
words.

`prefer_blend` gains an upper bound. Blend over branching now reads

    run length      1     2     8    16    32    48    64   128  1024
    non-nullable 0.91  0.99  1.18  1.31  1.41  1.43  1.48  1.18  0.93
    nullable     0.87  0.90  1.03  1.15  1.27  1.52  1.25  0.94  0.89

so the band has two edges, not one: far above a word the boundary test always
fires and predicts again, leaving the blend paying its extra store against a
whole-word fill that dominates either way. Without the bound the nullable
1024 cases came out 13% behind the original kernel, reproducibly across two
runs.

Against the original kernel, in-process, every point of the grid is now at or
above parity: non-nullable 1.01-4.05x, nullable 0.98-2.54x, the 0.98 being a
one-microsecond case. On the corpus benchmark, 1.14-3.87x across all 18.

Signed-off-by: Claude <noreply@anthropic.com>
@joseph-isaacs
joseph-isaacs force-pushed the claude/runend-decompress-bools-1t2re2 branch from 13cedf9 to 48ddd8d Compare July 28, 2026 17:11
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