Skip to content
Closed
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
10 changes: 5 additions & 5 deletions apps/benchmarks/src/generated/package-sizes.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@
"label": "Shaper Wasm",
"status": "measured",
"format": "wasm",
"sha256": "f798cae5df8aaf379b59b1d81cdd19068143611532dd1c37f4b8ec19c9fc7d11",
"rawBytes": 1106070,
"minifiedBytes": 1106070,
"gzipBytes": 428166,
"brotliBytes": 338258
"sha256": "05a12365942118a52b1682dfeffa9659b8e13a2dafe239f3ac4e345d668f9a93",
"rawBytes": 1106204,
"minifiedBytes": 1106204,
"gzipBytes": 428282,
"brotliBytes": 338809
},
{
"id": "three-runtime-js",
Expand Down
9 changes: 9 additions & 0 deletions docs/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@

## 2026-08-12

- **SIMD kernels reach their consumers (11.14, layer 4 / D-245)** — Two lab-admitted kernels graduated into
`engine/line_kernels.rs` production consumers: transition masks now drive the bidi run scan (sixteen levels per
step instead of a per-unit compare) and flag masks drive the justification space scan over the new
`CLUSTER_SPACE` flag, which also replaced per-cluster text lookups everywhere justification detects spaces. Each
kernel is one scalar function with a `cfg(simd128)` sixteen-lane prefix and a scalar tail, proven equal by
boundary-sweeping parity tests natively and exercised on the SIMD path by every layout integration test against
the shipped simd128 Wasm. Chunk-64 advance summaries stay lab-admitted with the reason recorded in D-245: exact
parity for whole-chunk width arithmetic needs a fixed-point cluster-advance representation first.

- **Justification controls (11.14, layer 3)** — Justify grows professional bounds. Word spaces expand uniformly up
to the declared maximum ratio of their natural advance sum; the remaining deficit spills into inter-cluster
letter gaps bounded per gap, and any residue reads as an under-full line. A declared minimum ratio makes spaces
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur
resource: ../../apps/benchmarks
workspace_package: '@pmndrs/glyph-benchmarks'
documentation_type: reference
source_digest: 'sha256:b771d35e801acf08e10cd2b7e7da0452c9ec1a1845bfa90e65b1aa295ff4675b'
source_digest: 'sha256:defe604551e49dcd3d34cdf17e31c1b448a5b4b20efd62ee42c71cfc38c7afac'
tags: [package, benchmarks, react, vite, product-e2e]
sources:
- id: manifest
Expand Down
2 changes: 1 addition & 1 deletion docs/packages/glyph.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout,
resource: ../../packages/glyph
workspace_package: '@pmndrs/glyph'
documentation_type: reference
source_digest: 'sha256:af3db3822c6edecccdb336677a1067b1435035549b45712e6be4b0caabb95b1f'
source_digest: 'sha256:31d96bf7e6de7586b3fd2f7ece097c90ac759d3c992b4ff329660a403d0fcc1d'
tags: [package, public-api, rust, wasm, threejs, typography]
sources:
- id: manifest
Expand Down
2 changes: 1 addition & 1 deletion docs/planning/decision-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules.
| D-248 | Text decoration rendering lands as the first 11.18 slice, pulled forward for visual proof. Spans declare `decoration` (underline, overline, line-through; solid only — other line styles are rejected at the boundary rather than silently rendered solid). The engine cascade stamps the CSS decorating box: the declaring span's resolved font size rides the resolved decoration group, positioning derives one continuous line per decorating box from the baked `post`/`OS/2` metrics (top-of-stroke semantics), and adjacent runs with one decoration identity merge across nested font-size changes. Decoration records flow as a reserved resource-free `pmndrs.decoration` technique: plan programs declare a primitive kind in the former reserved wire field, planners admit resource-free rows and emit `PRIMITIVE_DECORATION` with zero-resource draws, underline/overline rows append before the paragraph's glyphs and line-through after so draw order matches CSS paint order, and Three realizes one shared flat-quad TSL material with no texture, decoding the packed sRGB paint through the sRGB EOTF so a text-colored line is byte-identical to its glyph ink at the framebuffer. Decorated sessions rebuild their gather output; the undecorated fast path is unchanged, verified by a same-window interleaved A/B against the pre-decoration checkpoint (−0.3%/+0.6%/−0.7%/+1.6% on cold/font-size/suffix-edit/splice; earlier apparent regressions reproduced on the checkpoint under ambient load). Retained decoration diffing and patterned line styles remain 11.18 work. | Accepted |
| D-247 | The shaping and layout contract represents a break-inserted hyphen glyph without a source cluster, proven by an exact positioning test rather than a contract change. Any flow fragment — not only an ellipsized final line — may reference a boundary-shape record whose source span is empty and whose inserted span holds independently shaped glyphs: the inserted glyph positions after the fragment's retained clusters, carries its own stable glyph identity from the boundary arena, publishes the boundary text position as its semantic cluster (`SemanticGlyph.cluster = text_end`, the "no source cluster" representation), inherits style and paint from the neighbor cluster, participates in alignment through the composed line advance, and receives a content revision; the following line is unaffected. Nothing in the path is ellipsis-specific, so 11.14's hyphenation-adjacent work composes onto this record shape. Language patterns, break selection, and justification quality controls remain later work. | Accepted |
| D-246 | Baked font artifacts carry text-decoration metrics from bake time: `metrics` gains required `underlinePosition`/`underlineThickness` from `post` and `strikeoutPosition`/`strikeoutSize` from `OS/2`, with a conservative derived fallback (thickness em/14, position −em/10) when a source font omits `post`. The canonical and packaged extension schemas stay byte-identical, the loader decodes the four values into public `FontMetrics`, and the rich-text conformance lane probes every font it loads for finite, positive-thickness values. Because the fields are required, every checked-in baked fixture, contract font, benchmark asset, and pinned artifact identity regenerated in the same change — the pre-v1 re-bake this decision exists to avoid after publication; shaping payload hashes are byte-identical throughout, so only the JSON metadata grew (for Inter, 172,144 → 172,240 bytes). Decoration rendering remains an additive renderer feature and is not part of this change. | Accepted |
| D-245 | Explicit `simd128` expansion is admitted kernel by kernel from recorded lab evidence, not speculatively. The complete kernel-lab run over real paragraph arrays ([evidence](../../packages/glyph/rust/shaper/evidence/kernel-lab-v0.json)) measures explicit break-opportunity masks at 7.6×, bidi transition masks at 4.8×, and chunk-64 advance summaries at 2.2× the auto-vectorized builds, while confirming two prior decisions: the shared scalar pack loop matches the rejected hand shuffle, and the production policy interpreter's explicit kernel is 2.9× its auto-vectorized form. No production path consumes mask or chunk lanes today, so these kernels remain lab-admitted with named consumers: break masks and chunk summaries wire into the 11.14 justification/line-planner tier, and bidi transition masks into analysis run segmentation, each behind `cfg(simd128)` with exact scalar-parity tests and end-to-end lane gates. | Accepted |
| D-245 | Explicit `simd128` expansion is admitted kernel by kernel from recorded lab evidence, not speculatively. The complete kernel-lab run over real paragraph arrays ([evidence](../../packages/glyph/rust/shaper/evidence/kernel-lab-v0.json)) measures explicit break-opportunity masks at 7.6×, bidi transition masks at 4.8×, and chunk-64 advance summaries at 2.2× the auto-vectorized builds, while confirming two prior decisions: the shared scalar pack loop matches the rejected hand shuffle, and the production policy interpreter's explicit kernel is 2.9× its auto-vectorized form. The 11.14 tier graduated two kernels into `engine/line_kernels.rs` production consumers: transition masks drive the bidi run scan and flag masks drive the justification space scan, each a scalar function with a `cfg(simd128)` sixteen-lane prefix, exact scalar-parity tests, and the shipped simd128 Wasm exercising the SIMD path through every layout integration test. Chunk-64 advance summaries remain lab-admitted: their line-planner consumer needs a fixed-point cluster-advance representation before whole-chunk arithmetic can match sequential f64 rounding bit-for-bit, and that representation is scheduled with the editorial performance pass. | Accepted |
| D-244 | The Wasm optimizer pipeline for every distributed artifact is `--merge-similar-functions -Oz --merge-similar-functions -Oz`. The merge pass finds no candidates after a completed `-Oz` pipeline, but running it before each `-Oz` pass removes 8,248 raw / 4,106 gzip bytes from the shaper and 4,396 / 16,838 / 4,127 / 3,853 raw bytes from the Bitmap, font, MTSDF, and Slug bakers, with the 22k-glyph benchmark lanes unchanged within noise and byte-identical bake goldens. Explicit `#[inline(never)]` seams on the fourteen update-path stage functions measured size-neutral (+241 raw, −741 gzip) and are rejected: the large `text_update` export body is stage aggregation under single-caller inlining, not duplicated code, so outlining only adds call overhead. | Accepted |
| D-243 | Engine orderings sort through one shared kernel. Each call site lowers its key into a `u64` — packed integer fields via `pack2`, or the order-preserving bit image of an `f64` under `total_cmp` — and sorts retained `(key, source index)` pairs through a single `sort_unstable` instantiation, applying the permutation by in-place cycle walking; raw `f64` buffers sort through one `u64`-slice instantiation viewed in place, and the four-field style-cascade key runs as two stable passes over the same pair kernel. The index tiebreak makes equal-key order total, deterministic, and sort-algorithm independent, strictly refining the previous unspecified `sort_unstable` equal-key order; every accepting call site either forbids equal keys, merges them order-insensitively, or collapses them. Scratch pairs are retained per owner and reserved with the session envelope. Optimized Wasm falls from 1,160,223 / 442,808 / 348,415 to 1,109,644 / 428,350 / 337,447 raw/gzip/Brotli bytes; measured sort bodies fall from 115.5 KiB in 64 functions to 79.5 KiB in 45 (45.5 KiB HarfRust-internal). All 165 unit and integration tests pass unchanged, bake goldens are byte-identical, and the eight-warmup/31-sample 22k-glyph lanes are within noise. | Accepted |
| D-242 | Cargo `opt-level` stays per-crate evidence-pinned: the shaper keeps default `3`, the Bitmap/MTSDF/Slug bakers keep `3`, and the font baker keeps `z`. A four-variant shaper matrix (whole-`z` 890,381, dependency-`z` 1,025,289, HarfRust-family-`s` 1,076,427, whole-`s` 977,753 raw bytes vs the 1,160,223 baseline) regressed shaping-bound lanes beyond acceptance on the 22k-glyph eight-warmup/31-sample benchmark: whole-`z` roughly doubled all five lanes, dependency-`z` cost +33% cold / +40% suffix-edit, and HarfRust-`s` still cost +22% cold / +27% suffix-edit, because the HarfRust bytes that dominate size are the shaping hot path; warm planner lanes stayed within noise whenever the engine crate kept `3`. The inverse holds for parser-generic bakers: `z`/`s` inflate Bitmap +26/+53 KB, MTSDF +99/+123 KB, and Slug +76/+104 KB over `-O3` because `-O3` inlining enables constant-folding elimination that size levels forgo, and the font baker at `3`/`s` inflates +192/+96 KB over its current `z`. Wasm size reduction therefore proceeds through code-shape changes (shared sort kernel, update outlining seams) rather than optimizer flags; `wasm-opt -Oz` remains per the recorded Binaryen evidence. | Accepted |
Expand Down
7 changes: 3 additions & 4 deletions packages/glyph/rust/shaper/src/bidi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,9 @@ pub fn analyze_into(
let mut start = 0usize;
while start < output.levels.len() {
let level = output.levels[start];
let mut end = start + 1;
while end < output.levels.len() && output.levels[end] == level {
end += 1;
}
// Level runs end at the next transition; the D-245 transition-mask
// kernel scans sixteen levels per step on simd128 builds.
let end = crate::engine::line_kernels::next_transition(&output.levels, start);
output.runs.push(BidiRun {
text_start: u32::try_from(start).map_err(|_| BidiError::ResultTooLarge)?,
text_end: u32::try_from(end).map_err(|_| BidiError::ResultTooLarge)?,
Expand Down
16 changes: 3 additions & 13 deletions packages/glyph/rust/shaper/src/engine/layout_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ pub(crate) fn append_measurement(
semantic_line_glyph_starts: &[u32],
semantic_line_glyph_counts: &[u32],
semantic_line_inline_extents: Option<&[f64]>,
text: &[u16],
clusters: &ClusterArena,
intrinsic_extents: Option<LayoutExtents>,
include_glyphs: bool,
Expand Down Expand Up @@ -110,7 +109,6 @@ pub(crate) fn append_measurement(
flow,
line,
index,
text,
clusters,
constraint_typography(constraint),
)?
Expand Down Expand Up @@ -218,7 +216,6 @@ pub(crate) fn append_measurement(
pub(crate) fn flow_extents(
flow_thread_id: u32,
flow: &FlowLayoutArena,
text: &[u16],
clusters: &ClusterArena,
typography: ThreadTypography,
) -> Result<LayoutExtents, EngineError> {
Expand All @@ -234,9 +231,9 @@ pub(crate) fn flow_extents(
let Some(last) = fragments.last() else {
continue;
};
extents.width = extents.width.max(line_inline_extent(
flow, line, index, text, clusters, typography,
)?);
extents.width = extents
.width
.max(line_inline_extent(flow, line, index, clusters, typography)?);
extents.height = extents.height.max(line.block_start + line.height);
extents.consumed_clusters = extents
.consumed_clusters
Expand All @@ -249,7 +246,6 @@ fn line_inline_extent(
flow: &FlowLayoutArena,
line: FlowLine,
index: usize,
text: &[u16],
clusters: &ClusterArena,
typography: ThreadTypography,
) -> Result<f64, EngineError> {
Expand Down Expand Up @@ -278,7 +274,6 @@ fn line_inline_extent(
line,
fragment,
final_line,
text,
clusters,
indent,
typography.justify,
Expand Down Expand Up @@ -389,7 +384,6 @@ mod tests {
&[0],
&[2],
None,
&[],
&ClusterArena::default(),
None,
false,
Expand Down Expand Up @@ -449,7 +443,6 @@ mod tests {
&[0],
&[2],
Some(&[7.0]),
&[],
&ClusterArena::default(),
None,
false,
Expand Down Expand Up @@ -485,7 +478,6 @@ mod tests {
&[0],
&[2],
Some(&[7.0]),
&[],
&ClusterArena::default(),
None,
true,
Expand Down Expand Up @@ -546,7 +538,6 @@ mod tests {
&[0],
&[0],
Some(&[7.0]),
&[],
&ClusterArena::default(),
Some(LayoutExtents {
width: 9.0,
Expand Down Expand Up @@ -610,7 +601,6 @@ mod tests {
&[0],
&[0],
Some(&[140.64]),
&[],
&ClusterArena::default(),
None,
false,
Expand Down
138 changes: 138 additions & 0 deletions packages/glyph/rust/shaper/src/engine/line_kernels.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//! Production SIMD lanes for segmentation and line planning (D-245).
//!
//! Each entry point is a scalar function with an explicit `simd128` prefix that
//! computes the identical integer result sixteen lanes at a time; the scalar
//! tail finishes every call, so native builds and Wasm builds agree exactly.
//! Consumers: the bidi run scan (`transition masks`) and the justification
//! space scan (`flag masks`). Chunk-64 advance summaries remain lab-admitted
//! until cluster advances gain a fixed-point representation (see D-245).

/// The first index after `start` whose level differs from `levels[start]`,
/// or `levels.len()` when the run extends to the end.
pub(crate) fn next_transition(levels: &[u8], start: usize) -> usize {
let level = levels[start];
#[allow(unused_mut)]
let mut index = start + 1;
#[cfg(all(target_arch = "wasm32", feature = "simd128"))]
{
index = next_transition_simd(levels, index, level);
}
while index < levels.len() && levels[index] == level {
index += 1;
}
index
}

#[cfg(all(target_arch = "wasm32", feature = "simd128"))]
fn next_transition_simd(levels: &[u8], mut index: usize, level: u8) -> usize {
use core::arch::wasm32::{i8x16_bitmask, i8x16_ne, u8x16_splat, v128, v128_load};

let expected = u8x16_splat(level);
while index + 16 <= levels.len() {
// SAFETY: the loop bound keeps every sixteen-byte load inside `levels`.
let block = unsafe { v128_load(levels.as_ptr().add(index).cast::<v128>()) };
let mask = i8x16_bitmask(i8x16_ne(block, expected));
if mask != 0 {
return index + mask.trailing_zeros() as usize;
}
index += 16;
}
index
}

/// Invoke `visit` for every index in `[start, end)` whose flags carry `flag`.
pub(crate) fn for_each_flagged(
flags: &[u8],
start: usize,
end: usize,
flag: u8,
mut visit: impl FnMut(usize),
) {
#[allow(unused_mut)]
let mut index = start;
#[cfg(all(target_arch = "wasm32", feature = "simd128"))]
{
use core::arch::wasm32::{i8x16_bitmask, i8x16_ne, u8x16_splat, v128, v128_and, v128_load};

let selected = u8x16_splat(flag);
let zero = u8x16_splat(0);
while index + 16 <= end {
// SAFETY: the loop bound keeps every sixteen-byte load inside `flags`.
let block = unsafe { v128_load(flags.as_ptr().add(index).cast::<v128>()) };
let mut mask = i8x16_bitmask(i8x16_ne(v128_and(block, selected), zero));
while mask != 0 {
let lane = mask.trailing_zeros() as usize;
visit(index + lane);
mask &= mask - 1;
}
index += 16;
}
}
while index < end {
if flags[index] & flag != 0 {
visit(index);
}
index += 1;
}
}

#[cfg(test)]
mod tests {
use super::*;
use alloc::vec::Vec;

#[test]
fn transitions_match_the_naive_scan_across_block_boundaries() {
let mut levels = [0_u8; 40];
for (index, level) in levels.iter_mut().enumerate() {
*level = match index {
0..=14 => 1,
15..=16 => 2,
17..=32 => 1,
_ => 3,
};
}
let naive = |start: usize| {
let mut end = start + 1;
while end < levels.len() && levels[end] == levels[start] {
end += 1;
}
end
};
for start in 0..levels.len() {
assert_eq!(
next_transition(&levels, start),
naive(start),
"start {start}"
);
}
let uniform = [7_u8; 33];
assert_eq!(next_transition(&uniform, 0), 33);
}

#[test]
fn flagged_visits_match_the_naive_filter_for_every_alignment() {
let mut flags = [0_u8; 50];
for (index, flag) in flags.iter_mut().enumerate() {
if index % 3 == 0 {
*flag |= 0b1_0000;
}
if index % 7 == 0 {
*flag |= 0b0_1000;
}
}
for start in [0, 1, 15, 16, 17] {
for end in [start, 31, 32, 33, 50] {
if end < start {
continue;
}
let mut visited = Vec::new();
for_each_flagged(&flags, start, end, 0b1_0000, |index| visited.push(index));
let naive: Vec<usize> = (start..end)
.filter(|index| flags[*index] & 0b1_0000 != 0)
.collect();
assert_eq!(visited, naive, "range {start}..{end}");
}
}
}
}
Loading
Loading