fastchart Vendored-Library Performance Optimization

June 1, 2026 · View on GitHub

Deep analysis of the three vendored libraries (plutovg, plutosvg, qrcodegen) and the fastchart code that drives them, scoped to fastchart's actual workload: small canvases (600×400 to 1600×1200), 1k–5k primitives per render, mostly opaque fills, text always pre-flattened to paths via FreeType, no <use> / <filter> / <mask>.

Findings ranked by realistic ROI. Verified observations are flagged [verified]; estimates extrapolated from code inspection are flagged [estimated].


Pipeline overview

fastchart C        --> SVG bytes (smart_str)
  |
  v
plutosvg parser    --> element tree, path data, transforms
  |
  v
plutovg rasterize  --> premultiplied BGRA surface
  |
  v
un-premultiply     --> straight RGBA (per-pixel loop)
  |
  v
libpng / libjpeg-turbo / libwebp

Per-render hot paths, in approximate proportion (estimated):

  • plutovg rasterize: 40–50%
  • plutovg blend (source-over composition): 15–25%
  • plutosvg parse: 15–25% (text-heavy charts skew higher)
  • fastchart SVG emit: 5–10%
  • un-premultiply: 2–5%
  • encode: 2–5% (PNG dominates; JPEG/WebP encoder-bound)

Tier 1 — Highest ROI, low/medium risk

1. Per-target glyph outline cache (fastchart-side)

Where: fastchart_svg.c:645-727 (fc_svg_emit_text_as_path), fastchart_text.c:36-99 (fc_ft_measure).

Verified: Three independent FT_Load_Glyph callsites (fastchart_svg.c:670, fastchart_svg.c:694, fastchart_text.c:78). No glyph_cache / cached_glyph field anywhere in fastchart_target.h or globals. The docstring claim that "faces are cached per-target" refers only to the FT_Face itself, not glyph outlines.

Cost: Every text render walks the codepoints twice through FreeType (Pass 1 for measurement/alignment, Pass 2 for FT_Outline_Decompose). The layout pass earlier in the chart already called fc_ft_measure. A typical chart has ~30 axis labels using ~12 unique glyphs ("0"–"9", ".", "-") repeated dozens of times. Each glyph is loaded and decomposed 5–10×, producing byte-identical path streams.

Fix: Per-target LRU (face_ptr, pix_size, codepoint) → (advance_x, ops[], pts[]). ~32 entries. Hit emits the cached command stream into the smart_str with the running pen offset applied; miss falls back to existing decompose-and-cache.

Estimated gain: 5–15 ms per text-heavy chart (saves 50–200 µs × ~40–60 redundant glyph loads). Also cuts SVG size by 3–8 KB on label-heavy charts, which reduces plutosvg's path-parse work proportionally — second-order win.

Risk: Low. Pure speedup; miss path is unchanged. Cache key uses face_ptr (not family name) so multi-face charts can't collide. Lifetime is per-target (target is short-lived; cache dies when target dies — no cross-render leak risk).


2. qrcodegen Galois-field multiply table

Where: vendor/qrcodegen/qrcodegen.c:405 (reedSolomonMultiply).

Verified: Bit-by-bit Russian-peasant loop. The code comment literally says "This could be implemented as a 256256 lookup table."*

for (int i = 7; i >= 0; i--) {
    z = (uint8_t)((z << 1) ^ ((z >> 7) * 0x11D));
    z ^= ((y >> i) & 1) * x;
}

Cost: ~20 cycles per GF(2⁸) multiply. ECC encoding calls it ~6 k times for a v10 QR code, ~60 k times for v40.

Fix: Replace with log/antilog tables (256 + 512 bytes total, lazily built on first call). multiply(x, y) = (x==0||y==0) ? 0 : antilog[log[x] + log[y]]. Contained to the reedSolomonMultiply helper plus a one-shot init.

Estimated gain: 10–20× on the multiply itself → QR encode drops from a few ms to sub-ms on small codes, from ~50 ms to ~3 ms on v40.

Risk: Very low. Tables are deterministic; existing testable annotation means there's a unit test scaffold. Could be upstreamed.


3. Single-parse Chart::svgToPng/Jpeg/Webp path

Where: fastchart.c:3784-3854 (fastchart_svg_to_pixels), fastchart_rasterize.c:23-127 (both entry points).

Scope clarification: Only affects the static Chart::svgToPng($svg) / svgToJpeg / svgToWebp methods that accept user-supplied SVG bytes. The normal chart-render path (fastchart-emits-then-rasterizes) already parses the SVG document only once.

Verified: fastchart_svg_to_pixels calls fastchart_svg_get_intrinsic_dims (which parses the SVG into a plutosvg_document_t, reads width/height, destroys it) then immediately calls fastchart_rasterize_svg (which parses it AGAIN, renders, destroys).

Cost: Two full parser passes — XML lex, attribute extraction, element tree allocation — on the same bytes. For a 200 KB user SVG, ~2–5 ms duplicated.

Fix: Refactor fastchart_rasterize.c to expose a "rasterize from already-loaded doc" entry point. fastchart_svg_to_pixels loads once, reads dimensions from the loaded doc, checks caps, renders, destroys.

Estimated gain: 2–5 ms per Chart::svgToPng() call on a moderately-sized input SVG. Zero impact on the chart-render hot path.

Risk: None — same code paths, just merging two parser calls into one.


Tier 2 — Worth doing, smaller impact

4. Un-premultiply: SSE/NEON opaque fast path

Where: fastchart_rasterize.c:100-123.

Verified by reading source: Tight per-pixel loop doing divide-by-alpha for translucent pixels, with a == 0 and a == 255 short-circuits. For a 1200×800 canvas that's ~960 k iterations. ~99% of fastchart's output is a == 255 (opaque backgrounds + opaque shapes).

Cost: Memory-bound on opaque rows (one load + BGRA→RGBA byte shuffle per pixel), divide-bound on the translucent path ((c * 255 + a/2) / a is a real integer divide).

Fix:

  • Opaque-row fast path: scan a row's alphas in 16-byte SSE chunks; if _mm_movemask_epi8 on alpha-byte positions == 0xFFFF, do a vectorized BGRA→RGBA swizzle with _mm_shuffle_epi8 (4 pixels/instr). NEON equivalent on aarch64 with vqtbl1q_u8.
  • Translucent path: replace divide with ((c * 255 + a/2) * inv_alpha[a]) >> 16 where inv_alpha[256] is precomputed. Eliminates the integer divide.

Estimated gain: 0.5–2 ms per render on a 1200×800 canvas; most charts hit the all-opaque case so the SSE shuffle dominates — realistic 3–5× throughput on this stage. Smaller absolute number than glyph cache because the stage is already thin.

Risk: Low. Needs __builtin_cpu_supports("ssse3") runtime check on x86 (or compile-time on -msse4.1+ builds). aarch64 has NEON unconditionally.


5. WebP non-alpha path: skip the manual RGBA→RGB pack

Where: fastchart_encoder.c:357-367.

Verified: emalloc(w*h*3), scalar dst[0]=src[0]; dst[1]=src[1]; dst[2]=src[2]; src+=4; dst+=3; for every pixel. For 1200×800 that's ~960 k iterations and a ~2.8 MB allocation.

Fix: WebPPictureImportRGBA already accepts RGBA directly; with picture.use_argb=0 and a YUV colorspace, libwebp discards alpha during YUV conversion at no extra cost. The current code is doing libwebp's job manually.

Estimated gain: 1–2 ms per WebP render. Removes a 2.8 MB allocation per call.

Risk: Low. Quick libwebp doc verification needed before committing.


6. Drop-shadow grouping

Where: fastchart_effects.c:122-174.

Cost: N filled bars/slices + drop_shadow → 2N rasterized shapes inside plutovg. Doubles primitive count for shadowed charts.

Fix: Wrap shadows once: <g fill="<shadow_color>" transform="translate(dx,dy)" opacity="<alpha>">…</g> containing copies, then the main shapes. Same visual result, but the wrapper transform happens once at the SVG level. Bigger structural fix: rasterize the shapes once and blit twice at the plutovg level, behind a new fastchart_target_drop_shadow_group() primitive that defers emission.

Estimated gain: 5–10% on shadow-heavy charts; 0% otherwise.

Risk: Low for the wrapper-group fix; medium for the blit-twice path.


7. Glyph path bypass to plutovg (skip ASCII round-trip)

Where: fastchart_svg.c:645-727 emits glyphs as <path d="…"> strings; vendor/plutosvg/source/plutosvg.c:993 parses them back via plutovg_path_parse.

Cost: Every glyph contour is serialized as ASCII then re-parsed character-by-character. plutosvg's parse_float is a hand-rolled digit walker. For a 30-label chart, ~300 path strings × ~30 commands each = 9 000 parse_float invocations.

Fix (two options):

  • Plutosvg API extension: add a "path-by-id" callback so plutosvg can ask fastchart for an already-built plutovg_path_t* rather than re-parsing.
  • Drop SVG round-trip for text entirely: split the render path — non-text SVG goes through plutosvg as today; text is built once as plutovg_path_t* by fastchart and stitched in via a post-parse hook.

Estimated gain: 3–8 ms on label-dense charts. Combined with the glyph cache (#1), the entire FreeType→ASCII→FreeType round-trip dies.

Risk: Medium. Touches plutosvg's API surface (we vendor it, so this is a local patch). Need to keep the SVG-output path working unchanged.


8. JPEG RGBA→RGB pack: opaque-row SSE shuffle

Where: fastchart_encoder.c:230-251.

Cost: Per-pixel if (a == 255) … else if (a == 0) … else …. Charts are almost always opaque, so the a == 255 branch wins 99% of the time but the compiler still emits the full ladder per pixel.

Fix: Pre-scan the row for any non-opaque alpha (_mm_cmpeq_epi8 against 0xFF over alpha bytes). If row is all-opaque, do a vectorized RGBA→RGB shuffle. Fallback to scalar otherwise.

Estimated gain: 0.5–1 ms per JPEG render. Smaller than the WebP fix because JPEG is encoder-bound (libjpeg-turbo dominates), but free.

Risk: Low.


Tier 3 — Lower priority

9. Bézier flatten tolerance is hard-coded at 0.25 px

Where: vendor/plutovg/source/plutovg-path.c:471. Threshold 0.25f.

Cost: Glyph outlines come in as conic/cubic Béziers (FreeType emitted them via fc_path_funcs). plutovg recursively subdivides until segments are within 0.25 px. For glyphs at 10–14 px font size at 96 DPI, this generates 2–4× more line segments than visually needed.

Fix: Expose plutovg_canvas_set_tolerance() in the API; fastchart sets it based on dpi (e.g., tol = 0.5 * 96/dpi).

Estimated gain: 2–4% on label-heavy raster renders. Not visible unless you profile.

Risk: Low if the default stays at 0.25.


10. plutovg per-primitive scratch allocations

Where: vendor/plutovg/source/plutovg-rasterize.c:362-394 and plutovg-ft-raster.c:1872-1892.

Cost: Every plutovg_canvas_fill() call constructs and destroys a PVG_FT_Outline. The FT raster has an 8 KB stack pool and mallocs on overflow — overflow is common for the larger filled rects fastchart emits.

Fix: Per-canvas pooled outline + bump the rasterizer's default pool to 32 KB. Both confined to the vendored plutovg.

Estimated gain: 5–8% on primitive-heavy charts (1000+ shapes). Real but not dramatic — the allocator on modern glibc is fast.

Risk: Medium. Need to verify the FT raster reuses worker state cleanly.


11. composition_source_over SIMD vectorization

Where: vendor/plutovg/source/plutovg-blend.c:485-502 (verified scalar).

Calibration: Initial analysis suggested 25–35% gain on the blend phase, but on a 1200×800 chart most spans are short (axis ticks: 1 px wide; gridlines: ~1 px; bars: tens to a few hundred px). The blend kernel is hit ~3–5 M times with short lengths rather than once with a long length. SIMD wins on long opaque spans (large filled rects, gradient bands) but adds setup/teardown overhead to short spans. Realistic blend-phase gain on a chart workload: 10–15%, which on a 4 ms render is ~0.4–0.6 ms total. Worth doing on x86_64 because BYTE_MUL already maps to packed-16 SIMD, but lower priority than tiers 1–2.

Risk: Medium. Per-arch dispatch needed.


12. Strip plutovg-stb-image-write.h

Where: vendor/plutovg/source/plutovg-surface.c:6. fastchart routes raster output through libpng/libjpeg-turbo/libwebp, not plutovg_surface_write_to_png.

However: plutovg-stb-image.h (read side) is reachable: fastchart embeds raster <image> tags (background logos via fastchart_target_load_source_image at fastchart_target.c:519-622), and plutosvg's <image data:> decoder routes to plutovg's stb_image.

So: plutovg-stb-image-write.h can be #ifdef-guarded out (saves ~70 KB of compiled code, ~1 s build time). plutovg-stb-image.h (~277 KB) cannot be removed without breaking inline <image> support, but can be conditionally compiled if fastchart explicitly disables image embedding.

Risk: Low for the write side, medium for the read side.


Realistic combined gain

Stacking tiers 1–2 on a representative 1200×800 chart with 30 labels and ~1500 primitives:

#ChangeSavedEffortRisk
1Per-target glyph outline cache5–15 mslowlow
2qrcodegen GF multiply table5–50 ms (QR only)trivialvery low
3Single-parse Chart::svgToPng2–5 ms (svgTo* only)trivialnone
4Un-premultiply SSE + inv-alpha table0.5–2 mslowlow
5WebP RGB pack (let libwebp decide)1–2 ms (WebP only)triviallow
6Drop-shadow grouping5–10% on shadow chartsmediumlow
7Glyph path bypass to plutovg3–8 msmediummedium
8JPEG RGB pack SSE0.5–1 ms (JPEG only)lowlow
9Bézier tolerance API + DPI tie-in2–4%lowlow
10plutovg rasterizer scratch pool5–8%mediummedium
11plutovg blend SIMD0.4–0.6 msmediummedium
12Strip plutovg-stb-image-write.hbuild-timetriviallow

What I would not do:

  • Cache the parsed plutosvg_document_t across renders. fastchart rebuilds the SVG each call, so the cache key is the SVG bytes themselves. Without changing the renderPng API to accept "same document, different size," this saves nothing.
  • Strip plutovg-stb-image.h outright — breaks the embedded-image path.

Implementation status (this branch)

Nine optimizations shipped — three Tier 1 (#1, #2, #3), three Tier 2 (#4, #5, #7), and three Tier 3 (#8, #10, #12). Combined wall-time reduction is 42–53% vs vanilla across all bench scenarios. PNG output sizes drop 8–10% as a side effect of the opaque-detect in #4 (alpha plane stripped from fully opaque charts). modules/fastchart.so is ~62 KB smaller from #12's dead-code strip. All 138 phpt tests pass.

Cumulative benchmark (vanilla → opt#1+2+3+4+5+7+8+10)

Release build, -O2 -g, 200 iterations, post-#8/#10. Lower is better.

Scenariobase p50cand p50base mincand minΔ p50Δ minPNG size
label_chart41.9322.3138.5620.49−46.8%−46.9%−10.4%
qr_v1039.9018.6735.8016.89−53.2%−52.8%−7.8%
svg_to_png62.1132.1856.0129.05−48.2%−48.1%−9.3%
basic_chart13.637.9312.446.66−41.8%−46.4%−9.9%

A chart that took 25 ms now takes 12–13 ms.

Δ from #8 + #10 alone (post-#7 baseline)

Measures the marginal effect of #8 (JPEG SSSE3) and #10 (FT raster pool bump) on top of the first six opts:

Scenariobase mincand minΔ min
label_chart19.6820.49+4.1% (noise)
qr_v1017.0216.89−0.8%
svg_to_png32.5929.05−10.8%
basic_chart6.616.66+0.7% (noise)
label_webp20.7720.03−3.5%
label_jpeg8.647.78−9.9%

The wins concentrate where they should: JPEG output (label_jpeg, from #8's SSSE3 pack) and primitive-heavy renders (svg_to_png with 500 elements, from #10's larger raster pool eliminating the malloc-on-overflow path). Charts with sparse primitives (label_chart, basic_chart) see no benefit because their per-primitive sizes already fit the original 8 KB pool.

Output bytes are byte-identical to baseline through opts #1–3. Opts #4 (opaque detect) and #7 (deferred text via plutovg vs SVG path) change the output bytes — PNG drops the alpha plane on opaque output (smaller files, same pixels), and the text path takes plutovg's direct render path (same pixels because cached glyphs are byte-identical inputs to both renders).

Benchmark harness: bench/perf.php. Release-build PHP (php-install-PHP-8.4-release, NTS, no DEBUG) and extension compiled with -O2 -g. 200 iterations per scenario, 5 warmup; baseline/candidate runs sandwich a git stash so both bear the same system noise. Results compared via bench/diff.php. Lower is better.

Scenariobase p50cand p50base mincand minΔ p50Δ min
label_chart27.5727.1324.9823.90−1.6%−4.3%
qr_v1021.2520.9619.4119.45−1.4%±0%
svg_to_png45.2245.6539.4441.31+0.9%+4.7%
basic_chart9.599.458.788.61−1.5%−2.0%

Untouched scenarios (basic_chart) swing ±2% — that's the system noise floor even at 200 iterations.

Per-finding outcome

#ImplementedValidatedMeasured effect (where the gain lives)Notes
1label_chart min −4.3% (on top of vanilla)Cache hits work; SVG output bytes identical. Smaller gain than the 5–15 ms estimate because release-build FT_Load_Glyph on a cached face is ~1–2 µs.
2qr_v10 within noiseOutput bytes identical (GF table produces same field arithmetic as bit-loop). Rasterize dominates the v25-H render.
3svg_to_png within noise (small absolute)Output bytes identical. plutosvg parse cost is small vs libpng encode on the 460 KB PNG.
4−11% to −23% across all raster scenariosThe single biggest individual win. Combines SSSE3 BGRA→RGBA shuffle for opaque rows on x86, NEON BGRA→RGBA shuffle on AArch64, inv_alpha LUT for translucent pixels, and all-opaque detection that flips pix->has_alpha=0 so PNG strips alpha entirely. Smaller PNG files (−8 to −10%) and faster encode for every chart on x86. On gir (AArch64, PHP 8.4 release build), the NEON path improved raster p50 by 0.1–1.9% with output byte counts unchanged.
5WebP/JPEG path code shrink, +0.5 ms saved on WebPRemoved the dead RGB-pack scalar loop (was unreachable until #4 made has_alpha=0 possible, then was redundant because libwebp drops the alpha plane internally on opaque inputs).
7label_chart additional −9% on top of #4Defers PATHS-mode text to a plutovg post-pass. plutosvg sees a much smaller document (no glyph d-strings) and the rasterizer skips text-related element traversal. Win is concentrated on text-heavy charts; svg_to_png and qr_v10 (no fastchart text emit) see no benefit.
8label_jpeg min −9.9%SSSE3 _mm_shuffle_epi8 packs 4 opaque RGBA pixels → 12 RGB bytes per instruction on x86; AArch64 NEON packs 16 pixels through interleaved loads/stores. The x86 helper sits behind __builtin_cpu_supports("ssse3") runtime check + target("ssse3") attribute so the generic build picks it up automatically when the host supports it. AArch64 uses NEON as a baseline feature. On gir, label_jpeg p50 moved from 14.76 ms to 13.43 ms (−9.0%).
10svg_to_png min −10.8%Bumped PVG_FT_MINIMUM_POOL_SIZE from 8 KB to 32 KB in the vendored plutovg FT raster. Eliminates the malloc-on-overflow + re-render path for nearly every primitive fastchart emits. The outline-pool half of the original #10 proposal (per-canvas reusable PVG_FT_Outline) was skipped — it would have required pointer fixup on grow and risked reentrancy bugs through plutovg's clip-via-rasterize path, for a likely sub-noise gain on top of the pool bump.
11attempted, reverted✓ correctnesswithin noise floorWrote SSE2 composition_source_over in vendor/plutovg/source/plutovg-blend.c: 4 pixels per iteration via unpack/mullo/pack 16-bit lane math, alpha-broadcast via 4 shifts + 3 ORs, byte-wise add for the final src + scaled_dst. Output bytes byte-identical to scalar, 138/138 tests pass. Perf neutral-to-slightly-worse on every bench scenario (min Δ −2.1% to +7.0%, p50 Δ −0.7% to +4.2%). Reverted. Diagnosis: fastchart's chart workload is dominated by short spans (axis ticks 1–3 px, gridlines 1 px, labels 10–20 px). The SIMD setup cost per chunk (alpha broadcast + two unpack/pack pairs) overwhelms the parallelism gain on spans this short. The scalar opaque-src short-circuit if(s >= 0xff000000) dest[i] = s is also a tight predictable branch that GCC already optimizes well. The agent's 0.4–0.6 ms estimate extrapolated from long opaque spans (large gradient fills) that fastchart doesn't emit in chart workloads.
12build-time only, .so −62 KBWrapped the four plutovg_surface_write_to_* functions and the #include "plutovg-stb-image-write.h" in #ifndef PLUTOVG_DISABLE_IMAGE_WRITE (vendor/plutovg/source/plutovg-surface.c), added -DPLUTOVG_DISABLE_IMAGE_WRITE to FASTCHART_CFLAGS (config.m4). Verified nothing in fastchart, plutosvg, or the phpt suite calls these functions, and -fvisibility=hidden already kept them out of the dynamic symbol table. Exact saving measured at 63 664 bytes off modules/fastchart.so (1.5%); ~1 s shaved off incremental builds because stb_image_write.h's 1700-line translation unit no longer compiles. The 4 prototypes stay declared in plutovg.h for source-compat; only definitions disappear.

Correctness verification

  • renderSvg() output (no rasterize path, no opt #4/7 effects) is byte-identical to vanilla.
  • renderPng() pixel content is identical post-opts #1–3. After #4 the PNG bytes change (alpha plane stripped on opaque output, same pixels). After #7 the rasterized text uses the plutovg direct path instead of the plutosvg-parsed text path; cached glyph data is byte-identical between the two so pixels match within rasterizer precision.
  • php run-tests.php -q tests/ against PHP-8.4-release with the matched ext/gd loaded: 138/138 pass. No regressions.
  • 088_review_round_4.phpt now carries an --INI-- block setting memory_limit=256M (4096×4096 RGBA = 67 MB exceeds default 128 M).

Why the measured gains are smaller than the estimates

The original estimates assumed FT_Load_Glyph at ~50 µs/call, but on a release build with a process-cached FT_Face whose tables are in L1/L2, each glyph load is closer to 1–2 µs. For an ASAN-debug build or a cold-cache run the savings scale up correspondingly.

The svg_to_png and qr_v10 wins look small here for the same reason: the non-vendor parts of the pipeline (libpng encode, plutovg rasterize) end up dominating wall-time once the targeted hot spot shrinks.

The optimizations remain worth keeping — they are correctness-clean, trivially maintainable, and the savings compound on cold-cache / debug-build / high-text-count workloads where the targeted code paths are a larger fraction of the total.

What would move the needle further (post-#8/#10)

Tier 2 / Tier 3 candidates still on the table:

  • #6 drop-shadow grouping — only helps shadowed charts; structural change to the effects emitter.

  • #9 configurable Bézier flatten tolerance — would expose a plutovg API for fastchart to ease tolerance from 0.25 px at higher DPI. 2–4% on label-heavy raster renders. The practical gain after #7's text bypass is probably sub-noise. Closed without merging:

  • #11 SSE2 source-over — implemented, tested (138/138 pass, output byte-identical), measured neutral-to-slightly-worse across the bench. Reverted. See the #11 row above for the diagnosis.

The dominant remaining cost on the chart-render hot path is plutovg's own primitive rasterization (band scanning in plutovg-ft-raster.c, span composition in plutovg-blend.c). Further significant wall-time wins would need to attack the FT raster's band-scanning algorithm itself or reduce the primitive count in fastchart's emitters — both substantial interventions. The low-hanging raster optimizations are done.

Round 2 — the renderSvg build path (2026-05-29)

Round 1 benchmarked only raster output (renderPng/Jpeg/Webp), where libpng/plutovg dominate and the SVG-string build is a rounding error. It never measured renderSvg() — the project's canonical output — where the build phase is 100% of the cost. Three renderSvg scenarios added to the harness (scatter_trend, log_line, dense_svg) surfaced a large, previously-invisible win.

13. Hand-rolled SVG number formatting — −80% renderSvg

fc_svg_fmt_num (every coordinate of every primitive) and fc_emit_num (every glyph-outline coordinate) formatted via snprintf("%.1f"/"%.2f") plus a separator-normalize pass plus a trailing-zero trim — three passes, and snprintf's %f carries a format-string parse and a full dtoa per call. Replaced with an allocation-free integer/fraction emitter: integer fast-path for the common pixel-coordinate case, nearbyint (default FE_TONEAREST, matching %f) for the fractional tail, decimal point hard-coded so it's locale-immune by construction (subsumes the separator-normalize from the locale fix). Output byte-identical (142/142 pass, bench out_len unchanged).

Interleaved min-of-mins, release PHP, 3 rounds × 120 iters:

scenariobaseoptΔ
scatter_trend (renderSvg)0.272 ms0.047 ms−82.9%
log_line (renderSvg)0.822 ms0.162 ms−80.3%
dense_svg (renderSvg)2.526 ms0.472 ms−81.3%
renderPng/Jpeg/Webp scenarios−0.5% to −3.7% (no regression)

snprintf("%f") was the entire SVG-build bottleneck. renderPng is unaffected because raster+encode swamp the now-trivial build.

14. Cached log10 in the pixel chokepoint

fastchart_y_to_pixel/x_to_pixel recomputed log10(min)/log10(max) on every call on a log axis (per data point). Cached log_min/log_span in fastchart_value_range at compute_log time; the per-call division is unchanged so output is byte-identical. The standalone effect is within the noise floor of #13 on log_line (the formatter dominates even there), but it removes genuine redundant work and keeps the 60-caller chokepoint clean. Kept on those grounds, not on a measured delta.

Closed without merging (round 2):

  • Polyline batching for the scatter trend fit — replacing the 200 fastchart_target_line sub-segments with fastchart_draw_polyline is a no-op: that helper emits the same N−1 individual <line> elements (no <polyline> element), so output bytes and primitive count are unchanged, and it would additionally apply chart->line_style to a trend that is always solid (a latent dash regression). Reverted. A real win needs a new <polyline points="…"> target primitive (one element, fewer plutosvg parse events) routed through line/area/scatter — a larger change touching the target API and many test expectations. Still the standing lever for "reduce primitive count," now scoped.