Replace Blaze with direct ByteString Builder for 2-4x faster HSX rendering#2563
Replace Blaze with direct ByteString Builder for 2-4x faster HSX rendering#2563mpscholten wants to merge 33 commits intomasterfrom
Conversation
There was a problem hiding this comment.
💡 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".
| instance {-# OVERLAPPABLE #-} Show a => ApplyAttribute a where | ||
| {-# INLINE applyAttribute #-} | ||
| applyAttribute name prefix value = applyAttribute name prefix (show value) |
There was a problem hiding this comment.
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 👍 / 👎.
| instance {-# OVERLAPPABLE #-} Show a => ToHtml a where | ||
| {-# INLINE toHtml #-} | ||
| toHtml = toHtml . show |
There was a problem hiding this comment.
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 👍 / 👎.
Alternative Builder Benchmark ResultsBenchmarked 6 different HTML rendering backends to compare approaches:
*Lazy numbers for static/few-dynamic are misleadingly fast because Approaches tested
Conclusions
The current Direct ( |
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>
…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>
262e758 to
c7a58c0
Compare
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>
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>
Core Size & Compile Allocations Benchmark
HTTP Latency (GET /, 5000 reqs, 10 concurrent)
Top 10 modules (this PR)
|
Summary
MarkupMtree with a directByteString.Builderapproach, eliminating tree allocation and traversal overheadIHP.HSX.Markupmodule:newtype MarkupM a = Markup { getBuilder :: Builder }withFunctor/Applicative/Monadinstances forforEachcompatibilityIHP.HSX.MarkupQQquasiquoter: simplified code generation that handles static and dynamic attributes uniformly (noParent/Leaf/AddAttributemachinery)View/Types,ViewSupport,ViewPrelude,Controller/Render, CSS frameworks, form helpers, modals, etc.) to useMarkupinstead ofBlaze.Htmlbytestring-strict-builder(used by hasql) — lazyBuilderwins on realistic web workloadsPerformance
Breaking changes
Htmltype is nowMarkupM ()(fromIHP.HSX.Markup) instead ofMarkupM ()(fromText.Blaze.Internal)(!)operator andonClick/onLoadhelpers removed fromViewPrelude(use HSX attributes instead)Html5.div,attribute, etc.) needs updating to use HSXTest plan
🤖 Generated with Claude Code