Skip to content

Replace Blaze with direct ByteString Builder for 2-4x faster HSX rendering#2563

Open
mpscholten wants to merge 33 commits intomasterfrom
perf/direct-builder-hsx
Open

Replace Blaze with direct ByteString Builder for 2-4x faster HSX rendering#2563
mpscholten wants to merge 33 commits intomasterfrom
perf/direct-builder-hsx

Conversation

@mpscholten
Copy link
Copy Markdown
Member

Summary

  • Replaces Blaze's intermediate MarkupM tree with a direct ByteString.Builder approach, eliminating tree allocation and traversal overhead
  • New IHP.HSX.Markup module: newtype MarkupM a = Markup { getBuilder :: Builder } with Functor/Applicative/Monad instances for forEach compatibility
  • New IHP.HSX.MarkupQQ quasiquoter: simplified code generation that handles static and dynamic attributes uniformly (no Parent/Leaf/AddAttribute machinery)
  • Updated all IHP core modules (View/Types, ViewSupport, ViewPrelude, Controller/Render, CSS frameworks, form helpers, modals, etc.) to use Markup instead of Blaze.Html
  • Also benchmarked bytestring-strict-builder (used by hasql) — lazy Builder wins on realistic web workloads

Performance

Benchmark Blaze Direct Builder Speedup
static page 81 ns 79 ns 1.0x
dynamic text 212 ns 114 ns 1.9x
many dynamic 509 ns 238 ns 2.1x
list 100 6.87 μs 2.24 μs 3.1x
list 1000 67.0 μs 21.9 μs 3.1x
table 100 19.8 μs 5.89 μs 3.4x
dyn attrs 100 19.2 μs 4.73 μs 4.1x

Breaking changes

  • Html type is now MarkupM () (from IHP.HSX.Markup) instead of MarkupM () (from Text.Blaze.Internal)
  • (!) operator and onClick/onLoad helpers removed from ViewPrelude (use HSX attributes instead)
  • Code using Blaze functions directly (Html5.div, attribute, etc.) needs updating to use HSX

Test plan

  • All 468 IHP tests pass
  • All 106 HSX tests pass
  • Output verified byte-identical to Blaze for static, dynamic, escaped, leaf, comment, multi-root, dynamic attrs, boolean attrs, Maybe attrs, and mixed attrs
  • Test with a real IHP application

🤖 Generated with Claude Code

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8fb32876b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +193 to +195
instance {-# OVERLAPPABLE #-} Show a => ApplyAttribute a where
{-# INLINE applyAttribute #-}
applyAttribute name prefix value = applyAttribute name prefix (show value)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add a raw-attribute path for preEscapedTextValue

IHP.ViewPrelude still re-exports preEscapedTextValue/stringValue for Blaze compatibility, but attribute splices using those helpers now hit this generic Show fallback because ApplyAttribute has no Markup instance. That means class={preEscapedTextValue "md:[&>*]:underline"} is rendered with the & re-escaped to &, breaking the documented Tailwind/arbitrary-variant use case and any existing templates that relied on raw attribute values.

Useful? React with 👍 / 👎.

Comment on lines +151 to +153
instance {-# OVERLAPPABLE #-} Show a => ToHtml a where
{-# INLINE toHtml #-}
toHtml = toHtml . show
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve legacy IHP.HSX.ToHtml instances

The new backend introduces a second ToHtml class here, but existing instances are still defined against IHP.HSX.ToHtml; for example IHP.QueryBuilder.Types still imports the old class and provides instance KnownSymbol table => ToHtml (QueryBuilder table) at line 214. Because HSX splices now resolve through this fallback Show instance instead, {queryBuilder} and downstream custom IHP.HSX.ToHtml instances silently lose their intended HTML rendering.

Useful? React with 👍 / 👎.

@mpscholten
Copy link
Copy Markdown
Member Author

Alternative Builder Benchmark Results

Benchmarked 6 different HTML rendering backends to compare approaches:

Benchmark Blaze (tree) Direct (BS Builder) Strict (strict-builder) Text (TLB) Short (SBS DList) Lazy (LBS concat)
static page 82ns 83ns 29ns 41ns 39ns 6ns*
dynamic text 207ns 133ns 134ns 186ns 145ns 119ns*
many dynamic 527ns 298ns 167ns 487ns 253ns 19ns*
list 10 901ns 405ns 668ns 1.21μs 1.03μs 948ns
list 100 7.43μs 3.04μs 6.04μs 10.8μs 9.41μs 8.56μs
list 1000 72.0μs 28.7μs 60.3μs 106μs 98.2μs 85.9μs
table 100 21.8μs 8.82μs 16.8μs 26.7μs 25.6μs 31.5μs
dyn attrs 100 20.4μs 6.17μs 10.7μs 19.5μs 21.9μs 25.2μs

*Lazy numbers for static/few-dynamic are misleadingly fast because renderMarkup is identity — the LBS is already constructed.

Approaches tested

  1. Blaze — baseline, intermediate MarkupM tree then render
  2. Direct (Data.ByteString.Builder) — lazy builder, encodes UTF-8 directly via BoundedPrim
  3. Strict (bytestring-strict-builder) — single-alloc strict builder
  4. Text (Data.Text.Lazy.Builder) — builds Text then encodeUtf8 (double pass)
  5. Short (ShortByteString DList + SBS.concat) — accumulates unpinned fragments
  6. Lazy (LBS.ByteString direct concat) — no builder abstraction, just <> on lazy bytestrings

Conclusions

  • Direct ByteString Builder is the clear winner across all real workloads (2-3x faster than Blaze)
  • Strict builder wins for static pages (29ns) due to single allocation, but falls behind Direct for dynamic/list workloads
  • Text builder is consistently slowest due to the double pass (build Text → encode UTF-8)
  • ShortByteString DList is competitive for small templates but scales poorly (many list cells + SBS fragments)
  • Lazy ByteString concat scales worst at high element counts due to O(n) chunk-list overhead

The current Direct (Data.ByteString.Builder) approach remains the best choice.

mpscholten added a commit that referenced this pull request Mar 22, 2026
Add 3 additional HSX rendering backends for benchmarking comparison:
- TextMarkup: Data.Text.Lazy.Builder (builds Text, then encodes UTF-8)
- ShortMarkup: ShortByteString DList + SBS.concat
- LazyMarkup: Direct LBS.ByteString concatenation (no Builder)

Results confirm Direct (Data.ByteString.Builder) remains the best approach.
See PR #2563 comment for full benchmark table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mpscholten and others added 21 commits March 28, 2026 19:48
…ster HSX rendering

HSX previously rendered through Blaze's MarkupM tree (Append/Content/Parent nodes)
which was then traversed to produce output. This replaces it with a direct
ByteString Builder approach that eliminates the intermediate tree entirely.

New modules:
- IHP.HSX.Markup: Builder-backed Markup type with Functor/Applicative/Monad
  instances for forEach compatibility
- IHP.HSX.MarkupQQ: Simplified quasiquoter that handles static and dynamic
  attributes uniformly via flattenNode (no Parent/Leaf/AddAttribute machinery)

Performance (lazy Builder vs Blaze):
- static page:    1.0x (both pre-render to single ByteString)
- dynamic text:   1.9x faster
- many dynamic:   2.1x faster
- list 100:       3.1x faster, 67% fewer allocations
- list 1000:      3.1x faster, 64% fewer allocations
- table 100:      3.4x faster, 62% fewer allocations
- dyn attrs 100:  4.1x faster, 79% fewer allocations

Also benchmarked bytestring-strict-builder (used by hasql) — it wins on
small static content but is 2-3x slower than lazy Builder on list/table
workloads due to eager size tracking overhead.

All 574 tests pass (468 IHP + 106 HSX).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…accept String

- StatusServer.hs: replace Blaze imports with IHP.HSX.Markup
- preEscapedToHtml: accept any ConvertibleStrings type (String, Text, etc.)
  to match Blaze's polymorphic ToMarkup-based signature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The integration test imported Text.Blaze.Html directly, causing a type
mismatch with IHP's new Markup-based Html type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ihp-ssc: replace Blaze.renderHtml with renderMarkup
- ihp-ssc: use IHP.HSX.MarkupQQ instead of IHP.HSX.QQ
- ihp-pagehead: use IHP.HSX.MarkupQQ and Markup type
- integration-test: use Markup type for defaultLayout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GHC was getting OOM-killed during -O1 compilation. Increase max heap
to 8G and nursery to 128M.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The 2>ghc-rts-stats.txt was hiding GHC compilation errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Html→Markup type change requires all dependent packages to be
recompiled from source. Using hackageOrLocal would fetch pre-built
nixpkgs versions compiled against the old Blaze Html type, causing
ABI mismatches.

Also fix core-size benchmark to show GHC errors (stderr was redirected).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ihp-mail/IHP/Mail.hs still imported Text.Blaze.Html5 and used
Blaze.renderHtml, causing type mismatches in downstream apps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ihp-mail now imports IHP.HSX.Markup instead of Text.Blaze.Html5,
so it needs ihp-hsx in build-depends.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e-mail

renderMarkup returns LBS.ByteString, but simpleMailInMemory expects LText
for the HTML body. Added cs conversion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The htmlEscapedW8 BoundedPrim (condB chains + fixed4/5/6) was being
inlined at every toHtml/escapeHtml call site due to INLINE pragma,
duplicating ~70 Core terms per dynamic HSX interpolation. Marking
escapeHtml as NOINLINE keeps it as a single function call with no
measurable runtime impact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Avoids allocating an error thunk in (>>=). The phantom value is never
inspected by forM_/forEach continuations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Builder internals (BufferRange, BuildStep) were being inlined at every
HSX splice point, causing 35% Core size regression. Making rawByteString,
escapeHtml, applyAttribute, and textComment NOINLINE keeps Builder
operations as opaque function calls. Result: Core is now 12% SMALLER
than Blaze baseline, while remaining 2-3x faster at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 3 additional HSX rendering backends for benchmarking comparison:
- TextMarkup: Data.Text.Lazy.Builder (builds Text, then encodes UTF-8)
- ShortMarkup: ShortByteString DList + SBS.concat
- LazyMarkup: Direct LBS.ByteString concatenation (no Builder)

Results confirm Direct (Data.ByteString.Builder) remains the best approach.
See PR #2563 comment for full benchmark table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clean up Text, Short, and Lazy benchmark variants — kept only in git
history for reference. Direct ByteString Builder confirmed as best approach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move static tree analysis (isStaticTree, isStaticAttribute, isNonTrivialStaticNode,
renderStaticHtml, renderStaticAttribute) into Parser.hs and extract shared QQ
compilation helpers (Part, emitParts, compileChildList, compileChildren, flatten*,
hsxQuasiQuoter, quoteHsxExpression) into a new QQCompiler module. This eliminates
~300 lines of triplicated code across QQ.hs, MarkupQQ.hs, and StrictMarkupQQ.hs,
so changes to shared logic only need to be made in one place.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ByteString Builder defaults to 4KB safeStrategy which creates many small chunks
for typical HTML pages. Switch renderMarkup and renderMarkupBS to use 32KB
untrimmed buffers:

- Fewer, larger chunks reduce overhead in warp's response sending and in
  respondHtml's `evaluate (length bs)` force step
- Pages under 32KB (most real-world pages) fit in a single chunk, making
  LBS.toStrict zero-copy in renderMarkupBS
- untrimmedStrategy skips the trim step since we consume output immediately

Benchmarked on a real-world forum page (20 threads with layout):
- renderMarkup (lazy): ~same speed, fewer chunks for warp
- renderMarkupBS (strict): 20-40% faster (fewer chunks to re-copy in toStrict)

Also adds real-world forum page benchmarks (modeled after ihp-forum) and
render-to-handle benchmarks that simulate actual response writing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@mpscholten mpscholten force-pushed the perf/direct-builder-hsx branch from 262e758 to c7a58c0 Compare March 28, 2026 18:48
mpscholten and others added 5 commits March 28, 2026 20:05
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Exports Html from IHP.HSX.Markup so user code like
`defaultLayout :: Html -> Html` keeps compiling without changes.
Removes redundant local aliases from ihp-mail and ihp-pagehead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Now that IHP.HSX.Markup exports `type Html = Markup`, revert type
signatures back to Html so downstream app code doesn't need to change
from e.g. `defaultLayout :: Html -> Html`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mpscholten and others added 7 commits March 28, 2026 21:26
IHP.ViewSupport exports its own `type Html = HtmlWithContext ControllerContext`
which conflicts with `Html` from IHP.HSX.Markup in any module that imports
both. Keep using Markup in internal type signatures; the Html alias in
IHP.HSX.Markup is available for user app code and external packages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These files don't import IHP.ViewSupport so can safely use the
Html alias from IHP.HSX.Markup. This keeps `type Layout = Html -> Html`
and `defaultLayout :: Html -> Html` matching existing user code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ules

These files don't import IHP.ViewSupport so can safely use the Html
alias, reducing the diff vs master to just the import line change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These files import IHP.ViewSupport which has its own Html type,
so they must use Markup from IHP.HSX.Markup to avoid ambiguity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pass the Builder directly to WAI instead of materializing an
intermediate lazy ByteString via renderMarkup + responseLBS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…er.hs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

Core Size & Compile Allocations Benchmark

Metric Baseline (master) This PR Change
Core size 13866948 bytes 11960966 bytes -13.7%
Compile allocations 34047715440 bytes 29460352416 bytes -13.5%

Improvement: Core size -13.7% reduction
Improvement: Compile allocations -13.5% reduction

HTTP Latency (GET /, 5000 reqs, 10 concurrent)

Metric Baseline (master) This PR Change
Mean 4.44ms 3.30ms -25.7%
p50 2.80ms 2.90ms
p99 46.10ms 20.60ms
Min 0.60ms 0.60ms
Max 102.10ms 45.60ms
Req/s 2199 2940

Improvement: Mean latency decreased -25.7%

Top 10 modules (this PR)

Module Size (bytes)
Web.Types.thr 547347
Web.Controller.Comments.thr 518026
Web.Controller.Users.thr 453494
Web.Controller.Threads.thr 435775
Web.FrontController.thr 431113
Web.Routes.thr 423681
Admin.Controller.UserBadges.thr 282646
Admin.FrontController.thr 278407
Admin.Types.thr 264263
Admin.Controller.Admins.thr 263961

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.

1 participant